Files
TREK/server/tests/unit/nest/oidc.service.test.ts
T
jubnl 0ec611eb98 refactor(server)!: read the reminder gates per tick and drop the app-settings restart
The trip/todo reminder enable gates (notify_trip_reminder, notify_todo_due)
are now read inside each 9 AM tick instead of once at boot, so toggling them
takes effect at the next run without a restart. Saving notification settings
no longer re-arms anything: the auth-side startTripReminders() call and the
shouldRestartScheduler result field are gone (no controller consumed it, so
the PUT /api/auth/app-settings response shape is unchanged). The boot
'enabled/disabled' log lines are informational banners now.
2026-08-10 21:37:45 +02:00

896 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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)),
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();
});
});