mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
899 lines
40 KiB
TypeScript
899 lines
40 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native OidcService — OIDC-SVC-001 through OIDC-SVC-054
|
||
* (moved 1:1 from the legacy tests/unit/services/oidcService.test.ts with case
|
||
* IDs preserved; 046–048 carry over the wrapper cases from the superseded
|
||
* delegation-shim suite; 049–054 pin the post-fold `fix(server)` quirk fixes —
|
||
* fetch timeout/validation, per-URL discovery cache, userinfo ok-check, the
|
||
* no_email guard and the post-insert re-select). Covers state management,
|
||
* auth codes, role resolution, findOrCreateUser, discover caching, and the
|
||
* ReDoS-sensitive issuer trailing-slash regex. Constructed directly (no
|
||
* TestingModule, repo convention) over a real in-memory SQLite database.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||
import { generateKeyPairSync } from 'crypto';
|
||
import jwtLib from 'jsonwebtoken';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: () => null,
|
||
canAccessTrip: (tripId: any, userId: number) =>
|
||
db.prepare(`
|
||
SELECT t.id, t.user_id FROM trips t
|
||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||
`).get(userId, tripId, userId),
|
||
isOwner: (tripId: any, userId: number) =>
|
||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
SESSION_DURATION_SECONDS: 86400,
|
||
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
|
||
// Construction insurance for the injected AuthService's import graph (same set
|
||
// as auth.service.test.ts) — TripMembershipService is deliberately the real one,
|
||
// not a stub: OIDC-SVC-045 asserts the actual trip_members row a trip-bound
|
||
// invite creates.
|
||
vi.mock('../../../src/nest/common/crypto/mfaCrypto', () => ({
|
||
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
|
||
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
|
||
}));
|
||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||
decrypt_api_key: vi.fn((v) => v),
|
||
maybe_encrypt_api_key: vi.fn((v) => v),
|
||
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
|
||
encrypt_api_key: vi.fn((v) => v),
|
||
}));
|
||
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||
|
||
const { getAppUrlMock } = vi.hoisted(() => ({ getAppUrlMock: vi.fn() }));
|
||
vi.mock('../../../src/app-config', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
||
return { ...actual, getAppUrl: getAppUrlMock };
|
||
});
|
||
|
||
const { setAuthCookieMock } = vi.hoisted(() => ({ setAuthCookieMock: vi.fn() }));
|
||
vi.mock('../../../src/nest/common/cookie', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../../../src/nest/common/cookie')>();
|
||
return { ...actual, setAuthCookie: setAuthCookieMock };
|
||
});
|
||
|
||
import type { Request, Response } from 'express';
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser, createTrip } from '../../helpers/factories';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||
import { OidcService } from '../../../src/nest/oidc/oidc.service';
|
||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||
|
||
// MailerService is injected since the notifications fold — a stub instead of a
|
||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||
|
||
const membership = new TripMembershipService(new DatabaseService(testDb));
|
||
// Positional, and previously shifted by one: an AtlasService sat in the membership
|
||
// slot, which AuthService no longer takes at all, so webauthn/userCleanup/mailer
|
||
// each landed one place too late and the EphemeralTokenService was missing
|
||
// entirely. Nothing failed, because no case below reaches those collaborators.
|
||
const auth = new AuthService(
|
||
new DatabaseService(testDb),
|
||
new PermissionsService(new DatabaseService(testDb)),
|
||
membership,
|
||
new WebauthnConfigService(new DatabaseService(testDb)),
|
||
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())),
|
||
mailerStub,
|
||
new EphemeralTokenService(),
|
||
);
|
||
const svc = new OidcService(new DatabaseService(testDb), auth, membership);
|
||
|
||
const MOCK_CONFIG = {
|
||
issuer: 'https://oidc.example.com',
|
||
clientId: 'client-id',
|
||
clientSecret: 'client-secret',
|
||
displayName: 'SSO',
|
||
discoveryUrl: null,
|
||
};
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
delete process.env.OIDC_ADMIN_VALUE;
|
||
delete process.env.OIDC_ADMIN_CLAIM;
|
||
delete process.env.NODE_ENV;
|
||
});
|
||
|
||
afterAll(() => {
|
||
vi.unstubAllGlobals();
|
||
svc.onModuleDestroy();
|
||
testDb.close();
|
||
});
|
||
|
||
// ── createState / consumeState ────────────────────────────────────────────────
|
||
|
||
describe('createState / consumeState', () => {
|
||
it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => {
|
||
const { state, codeChallenge } = svc.createState('https://example.com/callback');
|
||
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding
|
||
});
|
||
|
||
it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => {
|
||
const { state } = svc.createState('https://example.com/callback', 'invite-abc');
|
||
const data = svc.consumeState(state);
|
||
expect(data).not.toBeNull();
|
||
expect(data!.redirectUri).toBe('https://example.com/callback');
|
||
expect(data!.inviteToken).toBe('invite-abc');
|
||
expect(typeof data!.codeVerifier).toBe('string');
|
||
expect(data!.codeVerifier.length).toBeGreaterThan(20);
|
||
// State is consumed — second call returns null
|
||
expect(svc.consumeState(state)).toBeNull();
|
||
});
|
||
|
||
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
|
||
expect(svc.consumeState('not-a-real-state')).toBeNull();
|
||
});
|
||
|
||
it('OIDC-SVC-004: two different states do not conflict', () => {
|
||
const { state: s1 } = svc.createState('http://a.example.com');
|
||
const { state: s2 } = svc.createState('http://b.example.com');
|
||
expect(s1).not.toBe(s2);
|
||
expect(svc.consumeState(s1)!.redirectUri).toBe('http://a.example.com');
|
||
expect(svc.consumeState(s2)!.redirectUri).toBe('http://b.example.com');
|
||
});
|
||
});
|
||
|
||
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
|
||
|
||
describe('createAuthCode / consumeAuthCode', () => {
|
||
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
|
||
const code = svc.createAuthCode('my.jwt.token');
|
||
expect(typeof code).toBe('string');
|
||
expect(code.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
|
||
const code = svc.createAuthCode('real.jwt.here');
|
||
const result = svc.consumeAuthCode(code);
|
||
expect('token' in result).toBe(true);
|
||
expect((result as { token: string }).token).toBe('real.jwt.here');
|
||
});
|
||
|
||
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
|
||
const code = svc.createAuthCode('single.use.token');
|
||
svc.consumeAuthCode(code); // first use
|
||
const second = svc.consumeAuthCode(code);
|
||
expect('error' in second).toBe(true);
|
||
});
|
||
|
||
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
|
||
const result = svc.consumeAuthCode('not-a-real-code');
|
||
expect('error' in result).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ── resolveOidcRole ───────────────────────────────────────────────────────────
|
||
|
||
describe('resolveOidcRole', () => {
|
||
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
|
||
expect(svc.resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
|
||
});
|
||
|
||
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
|
||
delete process.env.OIDC_ADMIN_VALUE;
|
||
expect(svc.resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
|
||
});
|
||
|
||
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
|
||
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||
expect(svc.resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
|
||
});
|
||
|
||
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
|
||
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||
expect(svc.resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
|
||
});
|
||
|
||
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
|
||
process.env.OIDC_ADMIN_VALUE = 'superadmin';
|
||
process.env.OIDC_ADMIN_CLAIM = 'roles';
|
||
expect(svc.resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
|
||
});
|
||
|
||
it('OIDC-SVC-014: handles string claim (exact match)', () => {
|
||
process.env.OIDC_ADMIN_VALUE = 'admin';
|
||
process.env.OIDC_ADMIN_CLAIM = 'role';
|
||
expect(svc.resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
|
||
expect(svc.resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
|
||
});
|
||
});
|
||
|
||
// ── frontendUrl ───────────────────────────────────────────────────────────────
|
||
|
||
describe('frontendUrl', () => {
|
||
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
|
||
delete process.env.NODE_ENV;
|
||
expect(svc.frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
|
||
});
|
||
|
||
it('OIDC-SVC-016: returns bare path in production', () => {
|
||
process.env.NODE_ENV = 'production';
|
||
expect(svc.frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
|
||
delete process.env.NODE_ENV;
|
||
});
|
||
});
|
||
|
||
// ── discover ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('discover', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
|
||
const doc = {
|
||
authorization_endpoint: 'https://oidc.example.com/auth',
|
||
token_endpoint: 'https://oidc.example.com/token',
|
||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => doc,
|
||
}));
|
||
|
||
// Use unique issuer to bypass the instance-level cache from other tests
|
||
const result = await svc.discover('https://unique-1.example.com');
|
||
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
|
||
expect(result.token_endpoint).toBe(doc.token_endpoint);
|
||
});
|
||
|
||
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
await expect(svc.discover('https://bad-issuer.example.com')).rejects.toThrow();
|
||
});
|
||
|
||
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
|
||
const doc = {
|
||
issuer: 'https://auth.example.com/application/o/myapp/',
|
||
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
|
||
token_endpoint: 'https://auth.example.com/application/o/token/',
|
||
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
|
||
const result = await svc.discover(
|
||
'https://auth.example.com',
|
||
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
|
||
);
|
||
|
||
expect(result.issuer).toBe(doc.issuer);
|
||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
|
||
warnSpy.mockRestore();
|
||
});
|
||
|
||
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
|
||
const doc = {
|
||
issuer: 'https://evil.example.com',
|
||
authorization_endpoint: 'https://unique-2.example.com/auth',
|
||
token_endpoint: 'https://unique-2.example.com/token',
|
||
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||
|
||
await expect(svc.discover('https://unique-2.example.com')).rejects.toThrow(
|
||
'OIDC discovery issuer mismatch',
|
||
);
|
||
});
|
||
|
||
it('OIDC-SVC-049: passes an abort-signal timeout to the discovery fetch', async () => {
|
||
const doc = {
|
||
authorization_endpoint: 'https://unique-t.example.com/auth',
|
||
token_endpoint: 'https://unique-t.example.com/token',
|
||
userinfo_endpoint: 'https://unique-t.example.com/userinfo',
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||
|
||
await svc.discover('https://unique-t.example.com');
|
||
|
||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(fetchCall[1].signal).toBeInstanceOf(AbortSignal);
|
||
});
|
||
|
||
it('OIDC-SVC-050: rejects a discovery document missing the required endpoints', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ foo: 'bar' }) }));
|
||
await expect(svc.discover('https://unique-bad.example.com')).rejects.toThrow(
|
||
'Invalid OIDC discovery document',
|
||
);
|
||
});
|
||
|
||
it('OIDC-SVC-051: caches per discovery URL — two issuers do not thrash each other', async () => {
|
||
const docFor = (base: string) => ({
|
||
authorization_endpoint: `${base}/auth`,
|
||
token_endpoint: `${base}/token`,
|
||
userinfo_endpoint: `${base}/userinfo`,
|
||
issuer: base,
|
||
});
|
||
const fetchMock = vi.fn((url: string) => {
|
||
const base = url.replace('/.well-known/openid-configuration', '');
|
||
return Promise.resolve({ ok: true, json: async () => docFor(base) });
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await svc.discover('https://unique-3.example.com');
|
||
await svc.discover('https://unique-4.example.com');
|
||
const again3 = await svc.discover('https://unique-3.example.com');
|
||
const again4 = await svc.discover('https://unique-4.example.com');
|
||
|
||
// One network round-trip per URL — the second pair served from cache.
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(again3.issuer).toBe('https://unique-3.example.com');
|
||
expect(again4.issuer).toBe('https://unique-4.example.com');
|
||
});
|
||
|
||
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
|
||
const doc = {
|
||
issuer: 'https://auth.example.com/',
|
||
authorization_endpoint: 'https://auth.example.com/auth',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
userinfo_endpoint: 'https://auth.example.com/userinfo',
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
|
||
await svc.discover(
|
||
'https://auth.example.com',
|
||
'https://auth.example.com/.well-known/openid-configuration',
|
||
);
|
||
|
||
expect(warnSpy).not.toHaveBeenCalled();
|
||
warnSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||
|
||
describe('getOidcConfig issuer trailing-slash regex', () => {
|
||
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
|
||
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
|
||
// Adversarial input: many trailing slashes — should not backtrack catastrophically
|
||
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
|
||
const start = Date.now();
|
||
const result = adversarial.replace(/\/+$/, '');
|
||
const elapsed = Date.now() - start;
|
||
expect(result).toBe('https://oidc.example.com');
|
||
expect(elapsed).toBeLessThan(100);
|
||
});
|
||
});
|
||
|
||
// ── findOrCreateUser ──────────────────────────────────────────────────────────
|
||
|
||
describe('findOrCreateUser', () => {
|
||
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
|
||
const { user } = createUser(testDb, { email: 'alice@example.com' });
|
||
// Link the sub manually
|
||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
expect((result as { user: any }).user.id).toBe(user.id);
|
||
});
|
||
|
||
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
|
||
const { user } = createUser(testDb, { email: 'bob@example.com' });
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob', email_verified: true },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
expect((result as { user: any }).user.id).toBe(user.id);
|
||
});
|
||
|
||
it('OIDC-SVC-022: creates new user when registration is open', () => {
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||
expect(newUser).toBeDefined();
|
||
});
|
||
|
||
it('OIDC-SVC-023: first user gets admin role', () => {
|
||
// DB is empty after resetTestDb
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
expect((result as { user: any }).user.role).toBe('admin');
|
||
});
|
||
|
||
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
|
||
createUser(testDb, { email: 'existing@example.com' });
|
||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('error' in result).toBe(true);
|
||
expect((result as { error: string }).error).toBe('registration_disabled');
|
||
});
|
||
|
||
it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', () => {
|
||
const { user } = createUser(testDb, { email: 'charlie@example.com' });
|
||
// Ensure no oidc_sub set
|
||
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
|
||
|
||
svc.findOrCreateUser(
|
||
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true },
|
||
MOCK_CONFIG
|
||
);
|
||
|
||
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(updated.oidc_sub).toBe('sub-charlie-linked');
|
||
});
|
||
|
||
it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', () => {
|
||
const { user } = createUser(testDb, { email: 'dora@example.com' });
|
||
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
|
||
|
||
// No email_verified claim — an IdP that lets users set arbitrary emails must
|
||
// not be able to take over a pre-existing password account.
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-dora-attacker', email: 'dora@example.com', name: 'Dora' },
|
||
MOCK_CONFIG
|
||
);
|
||
|
||
expect('error' in result).toBe(true);
|
||
expect((result as { error: string }).error).toBe('email_not_verified');
|
||
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked
|
||
});
|
||
|
||
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
|
||
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
|
||
// Link oidc_sub manually so the user is found by sub lookup
|
||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
|
||
|
||
process.env.OIDC_ADMIN_VALUE = 'admins';
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
|
||
MOCK_CONFIG
|
||
);
|
||
|
||
expect('user' in result).toBe(true);
|
||
expect((result as { user: any }).user.role).toBe('admin');
|
||
|
||
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(dbUser.role).toBe('admin');
|
||
});
|
||
|
||
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
|
||
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
|
||
testDb.prepare(
|
||
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
|
||
).run(creator.id);
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
|
||
MOCK_CONFIG,
|
||
'tok-valid'
|
||
);
|
||
|
||
expect('user' in result).toBe(true);
|
||
|
||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
|
||
expect(token.used_count).toBe(1);
|
||
});
|
||
|
||
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
|
||
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
|
||
testDb.prepare(
|
||
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
|
||
).run(creator.id);
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
|
||
MOCK_CONFIG,
|
||
'tok-expired'
|
||
);
|
||
|
||
// User is still created because open registration is allowed
|
||
expect('user' in result).toBe(true);
|
||
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
|
||
expect(newUser).toBeDefined();
|
||
|
||
// Invite used_count must remain 0 (token was treated as invalid)
|
||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
|
||
expect(token.used_count).toBe(0);
|
||
});
|
||
|
||
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
|
||
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
|
||
testDb.prepare(
|
||
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
|
||
).run(creator.id);
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
|
||
MOCK_CONFIG,
|
||
'tok-full'
|
||
);
|
||
|
||
// User is still created because open registration is allowed
|
||
expect('user' in result).toBe(true);
|
||
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
|
||
expect(newUser).toBeDefined();
|
||
|
||
// Invite used_count must remain 1 (token was treated as invalid)
|
||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
|
||
expect(token.used_count).toBe(1);
|
||
});
|
||
|
||
// ── OIDC picture claim → avatar (#1399) ──────────────────────────────────
|
||
|
||
it('OIDC-SVC-040: new user stores the https picture claim as their avatar', () => {
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-pic-1', email: 'pic1@example.com', name: 'Pic One', picture: 'https://idp.example.com/u/pic1.png' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any;
|
||
expect(row.avatar).toBe('https://idp.example.com/u/pic1.png');
|
||
});
|
||
|
||
it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', () => {
|
||
svc.findOrCreateUser(
|
||
{ sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' },
|
||
MOCK_CONFIG
|
||
);
|
||
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any;
|
||
expect(row.avatar).toBeNull();
|
||
});
|
||
|
||
it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', () => {
|
||
const { user } = createUser(testDb, { email: 'pic3@example.com' });
|
||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?')
|
||
.run('sub-pic-3', MOCK_CONFIG.issuer, user.id);
|
||
svc.findOrCreateUser(
|
||
{ sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' },
|
||
MOCK_CONFIG
|
||
);
|
||
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(row.avatar).toBe('https://idp.example.com/u/pic3.png');
|
||
});
|
||
|
||
it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', () => {
|
||
const { user } = createUser(testDb, { email: 'pic4@example.com' });
|
||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
|
||
.run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id);
|
||
svc.findOrCreateUser(
|
||
{ sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' },
|
||
MOCK_CONFIG
|
||
);
|
||
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(row.avatar).toBe('uploaded-abc.jpg');
|
||
});
|
||
|
||
it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', () => {
|
||
const { user } = createUser(testDb, { email: 'pic5@example.com' });
|
||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
|
||
.run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id);
|
||
svc.findOrCreateUser(
|
||
{ sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' },
|
||
MOCK_CONFIG
|
||
);
|
||
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
|
||
expect(row.avatar).toBe('https://idp.example.com/u/new.png');
|
||
});
|
||
|
||
it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', () => {
|
||
const result = svc.findOrCreateUser({ sub: 'sub-no-email', name: 'No Email' }, MOCK_CONFIG);
|
||
expect('error' in result).toBe(true);
|
||
expect((result as { error: string }).error).toBe('no_email');
|
||
});
|
||
|
||
it('OIDC-SVC-054: a new user is returned as the re-selected DB row, not a hand-built partial', () => {
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-full-row', email: 'fullrow@example.com', name: 'Full Row' },
|
||
MOCK_CONFIG
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
const user = (result as { user: any }).user;
|
||
// Columns the legacy hand-built object silently lacked.
|
||
expect(user.password_version).toBeDefined();
|
||
expect(user.is_guest).toBeDefined();
|
||
expect(user.created_at).toBeDefined();
|
||
expect(user.email).toBe('fullrow@example.com');
|
||
});
|
||
|
||
it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', () => {
|
||
const { user: admin } = createUser(testDb, { role: 'admin' });
|
||
const trip = createTrip(testDb, admin.id);
|
||
testDb.prepare(
|
||
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)'
|
||
).run('inv-trip-join', admin.id, trip.id);
|
||
|
||
const result = svc.findOrCreateUser(
|
||
{ sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' },
|
||
MOCK_CONFIG,
|
||
'inv-trip-join'
|
||
);
|
||
expect('user' in result).toBe(true);
|
||
const uid = (result as { user: any }).user.id;
|
||
const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid);
|
||
expect(member).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
|
||
|
||
describe('exchangeCodeForToken', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('OIDC-SVC-030: sends correct POST body and returns token data', async () => {
|
||
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => mockTokenData,
|
||
}));
|
||
|
||
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
|
||
const result = await svc.exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
|
||
|
||
expect(result.access_token).toBe('tok');
|
||
expect(result._ok).toBe(true);
|
||
expect(result._status).toBe(200);
|
||
|
||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(fetchCall[0]).toBe('https://oidc.example.com/token');
|
||
expect(fetchCall[1].method).toBe('POST');
|
||
});
|
||
|
||
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 400,
|
||
json: async () => ({ error: 'invalid_grant' }),
|
||
}));
|
||
|
||
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
|
||
const result = await svc.exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
|
||
|
||
expect(result._ok).toBe(false);
|
||
expect(result._status).toBe(400);
|
||
});
|
||
});
|
||
|
||
// ── getUserInfo ───────────────────────────────────────────────────────────────
|
||
|
||
describe('getUserInfo', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('OIDC-SVC-032: fetches userinfo with Bearer token and returns parsed JSON', async () => {
|
||
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => userInfoData,
|
||
}));
|
||
|
||
const result = await svc.getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
|
||
|
||
expect(result.sub).toBe('user-sub');
|
||
expect(result.email).toBe('user@example.com');
|
||
|
||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
||
});
|
||
|
||
it('OIDC-SVC-052: throws on a non-ok userinfo response instead of parsing the error body', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 401,
|
||
json: async () => ({ error: 'invalid_token' }),
|
||
}));
|
||
|
||
await expect(svc.getUserInfo('https://oidc.example.com/userinfo', 'expired-token')).rejects.toThrow(
|
||
'Userinfo fetch failed: HTTP 401',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── verifyIdToken ─────────────────────────────────────────────────────────────
|
||
|
||
describe('verifyIdToken', () => {
|
||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||
const ISSUER = 'https://auth.example.com/application/o/trek';
|
||
const CLIENT_ID = 'trek-client';
|
||
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
|
||
|
||
function mockJwks() {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ keys: [jwk] }),
|
||
}));
|
||
}
|
||
|
||
function makeToken(iss: string, overrides: object = {}) {
|
||
return jwtLib.sign(
|
||
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
|
||
privateKey,
|
||
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
|
||
);
|
||
}
|
||
|
||
const doc = { jwks_uri: JWKS_URI } as any;
|
||
|
||
afterEach(() => { vi.unstubAllGlobals(); });
|
||
|
||
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
|
||
mockJwks();
|
||
const token = makeToken(ISSUER);
|
||
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
|
||
mockJwks();
|
||
const token = makeToken(ISSUER + '/');
|
||
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
|
||
mockJwks();
|
||
const token = makeToken('https://evil.example.com');
|
||
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||
expect(result.ok).toBe(false);
|
||
expect((result as any).error).toMatch('jwt issuer invalid');
|
||
});
|
||
|
||
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
|
||
mockJwks();
|
||
const wrongAudToken = jwtLib.sign(
|
||
{ sub: 'user-sub', iss: ISSUER },
|
||
privateKey,
|
||
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
|
||
);
|
||
const result = await svc.verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── wrapper methods (carried over from the delegation-shim suite) ────────────
|
||
|
||
describe('wrapper methods', () => {
|
||
it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', () => {
|
||
const spy = vi.spyOn(auth, 'resolveAuthToggles');
|
||
spy.mockReturnValue({ oidc_login: true } as ReturnType<AuthService['resolveAuthToggles']>);
|
||
expect(svc.oidcLoginEnabled()).toBe(true);
|
||
spy.mockReturnValue({ oidc_login: false } as ReturnType<AuthService['resolveAuthToggles']>);
|
||
expect(svc.oidcLoginEnabled()).toBe(false);
|
||
spy.mockRestore();
|
||
});
|
||
|
||
it('OIDC-SVC-047: getAppUrl delegates to app-config getAppUrl', () => {
|
||
getAppUrlMock.mockReturnValue('https://app');
|
||
expect(svc.getAppUrl()).toBe('https://app');
|
||
});
|
||
|
||
it('OIDC-SVC-048: setAuthCookie forwards res, token and req to the cookie helper', () => {
|
||
const res = {} as Response;
|
||
const req = {} as Request;
|
||
svc.setAuthCookie(res, 'jwt', req);
|
||
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req);
|
||
});
|
||
});
|
||
|
||
// The SSO configuration read/write moved here from AdminService, which held the SQL
|
||
// for a domain that already had a module. Same cases, same service, new owner.
|
||
describe('OIDC settings', () => {
|
||
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => {
|
||
const result = svc.getOidcSettings() as any;
|
||
expect(result.issuer).toBe('');
|
||
expect(result.client_id).toBe('');
|
||
expect(result.oidc_only).toBe(false);
|
||
expect(result.client_secret_set).toBe(false);
|
||
expect(result.display_name).toBe('');
|
||
expect(result.discovery_url).toBe('');
|
||
});
|
||
|
||
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => {
|
||
svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||
const result = svc.getOidcSettings() as any;
|
||
expect(result.issuer).toBe('https://auth.example.com');
|
||
expect(result.client_id).toBe('my-client');
|
||
});
|
||
|
||
it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', () => {
|
||
svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||
const result = svc.getOidcSettings() as any;
|
||
// oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles
|
||
expect(result.oidc_only).toBe(false);
|
||
});
|
||
|
||
it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', () => {
|
||
const result = svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any;
|
||
expect(result.success).toBe(true);
|
||
const settings = svc.getOidcSettings();
|
||
expect(settings).toMatchObject({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' });
|
||
});
|
||
});
|
||
|
||
describe('OIDC settings — the lockout guard', () => {
|
||
it('OIDC-SETTINGS-050 refuses to clear the config while password login is off', () => {
|
||
// Clearing the issuer with password login disabled locks every user out of the
|
||
// instance: no SSO to log in through, and no password form either.
|
||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: false } as never);
|
||
expect(svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 });
|
||
expect(svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 });
|
||
expect((svc.updateOidcSettings({ issuer: '', client_id: '' }) as { error?: string }).error).toMatch(/password login/i);
|
||
toggles.mockRestore();
|
||
});
|
||
|
||
it('OIDC-SETTINGS-051 allows the same clear once password login is back on', () => {
|
||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never);
|
||
expect(svc.updateOidcSettings({ issuer: '', client_id: '' })).toEqual({ success: true });
|
||
toggles.mockRestore();
|
||
});
|
||
|
||
it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', () => {
|
||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never);
|
||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' });
|
||
expect(svc.getOidcSettings().client_secret_set).toBe(true);
|
||
// Omitted: the write skips the column entirely rather than blanking it, which is
|
||
// what lets the admin panel save the form without re-typing the secret.
|
||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' });
|
||
expect(svc.getOidcSettings().client_secret_set).toBe(true);
|
||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' });
|
||
expect(svc.getOidcSettings().client_secret_set).toBe(false);
|
||
toggles.mockRestore();
|
||
});
|
||
});
|