Files
TREK/server/tests/unit/nest/oidc.service.test.ts
T
Maurice e7ab0fc21e fix(users): delete a user who recorded a payment between two other members
budget_settlements.created_by_user_id has no ON DELETE action and the
account cleanup never cleared it, so deleting such a user failed on the
foreign key and rolled the whole deletion back. The cleanup now clears
the recorder; the payment itself belongs to the two members and stays.
2026-10-07 16:05:02 +02:00

1403 lines
66 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 ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
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 mock;
});
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() }));
// The four provider calls go through the SSRF guard now (link-local and the
// cloud-metadata range are refused, every redirect hop re-checked). These tests
// exercise OIDC logic, not the guard, so the guard delegates to whatever fetch
// the case stubbed — and OIDC-SVC-SSRF-001 below pins that it is really there.
vi.mock('../../../src/utils/ssrfGuard', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/utils/ssrfGuard')>();
return {
...actual,
safeFetchAdminConfigured: vi.fn((url: string, init?: RequestInit) => fetch(url, init)),
};
});
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 { db as testDb } from '../../../src/db/database';
import type { Request, Response } from 'express';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
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 { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { OidcService } from '../../../src/nest/oidc/oidc.service';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
sharedTestOrm,
createTestUsersRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestWebauthnCredentialsRepo,
createTestPasswordResetTokensRepo,
createTestTripsRepo,
createTestTripMembersRepo,
} from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
// 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;
// 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.
let membership: TripMembershipService;
let auth: AuthService;
let svc: OidcService;
beforeAll(async () => {
membership = new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb));
auth = new AuthService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membership,
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService((await sharedTestOrm(testDb)).em, new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb),
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
);
svc = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb));
});
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
displayName: 'SSO',
discoveryUrl: null,
};
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
delete process.env.OIDC_ADMIN_CLAIM;
delete process.env.OIDC_USERNAME_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-055: createState stores the remember flag and consumeState returns it', () => {
const { state: sTrue } = svc.createState('https://example.com/cb', undefined, true);
const { state: sFalse } = svc.createState('https://example.com/cb', undefined, false);
const { state: sAbsent } = svc.createState('https://example.com/cb');
expect(svc.consumeState(sTrue)!.remember).toBe(true);
expect(svc.consumeState(sFalse)!.remember).toBe(false);
expect(svc.consumeState(sAbsent)!.remember).toBeUndefined();
});
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 code and the secret that redeems it', () => {
const { code, binding } = svc.createAuthCode('my.jwt.token');
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
// 32 random bytes as base64url; the code alone must never be enough.
expect(binding).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(binding).not.toBe(code);
});
it('OIDC-SVC-005b: every code gets its own binding secret', () => {
const a = svc.createAuthCode('t');
const b = svc.createAuthCode('t');
expect(a.binding).not.toBe(b.binding);
expect(a.code).not.toBe(b.code);
});
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
const { code, binding } = svc.createAuthCode('real.jwt.here');
const result = svc.consumeAuthCode(code, binding);
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, binding } = svc.createAuthCode('single.use.token');
svc.consumeAuthCode(code, binding); // first use
const second = svc.consumeAuthCode(code, binding);
expect('error' in second).toBe(true);
});
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
const result = svc.consumeAuthCode('not-a-real-code', 'whatever');
expect('error' in result).toBe(true);
});
it('OIDC-SVC-008b: a valid code without its binding is refused, and indistinguishable from an unknown one', () => {
const { code } = svc.createAuthCode('bound.token');
expect(svc.consumeAuthCode(code, undefined)).toEqual({ error: 'Invalid or expired code' });
expect(svc.consumeAuthCode('not-a-real-code', undefined)).toEqual({ error: 'Invalid or expired code' });
});
it('OIDC-SVC-008c: another browser binding is refused', () => {
const mine = svc.createAuthCode('mine');
const theirs = svc.createAuthCode('theirs');
expect('error' in svc.consumeAuthCode(mine.code, theirs.binding)).toBe(true);
});
it('OIDC-SVC-008d: a wrong binding burns the code, so it cannot be retried', () => {
const { code, binding } = svc.createAuthCode('burn.me');
svc.consumeAuthCode(code, 'not-the-binding');
expect('error' in svc.consumeAuthCode(code, binding)).toBe(true);
});
it('OIDC-SVC-008e: a binding that is not even base64 is refused instead of throwing', () => {
const { code } = svc.createAuthCode('t');
expect('error' in svc.consumeAuthCode(code, '!!!!')).toBe(true);
});
it('OIDC-SVC-056: auth code round-trips the remember flag', () => {
const cTrue = svc.createAuthCode('t1', true);
const cFalse = svc.createAuthCode('t2', false);
const cAbsent = svc.createAuthCode('t3');
expect((svc.consumeAuthCode(cTrue.code, cTrue.binding) as { remember?: boolean }).remember).toBe(true);
expect((svc.consumeAuthCode(cFalse.code, cFalse.binding) as { remember?: boolean }).remember).toBe(false);
expect((svc.consumeAuthCode(cAbsent.code, cAbsent.binding) as { remember?: boolean }).remember).toBeUndefined();
});
});
// ── generateToken ─────────────────────────────────────────────────────────────
describe('generateToken', () => {
it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', async () => {
const { user } = createUser(testDb, { email: 'remember@example.com' });
const token = await svc.generateToken({ id: user.id }, true);
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
expect(decoded.exp - decoded.iat).toBe(2592000);
});
it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', async () => {
const { user } = createUser(testDb, { email: 'default@example.com' });
for (const token of [await svc.generateToken({ id: user.id }, false), await svc.generateToken({ id: user.id })]) {
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
expect(decoded.exp - decoded.iat).toBe(86400);
}
});
});
// ── 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', async () => {
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 = await 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', async () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = await 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', async () => {
const result = await 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', async () => {
// DB is empty after resetTestDb
const result = await 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-022b: without OIDC_USERNAME_CLAIM the username comes from name, as before (#1677)', async () => {
createUser(testDb, { email: 'someone@example.com' });
const result = await svc.findOrCreateUser(
{ sub: 'sub-jane-1', email: 'jane@example.com', name: 'Jane Doe', preferred_username: 'jane' },
MOCK_CONFIG
);
expect((result as { user: { username: string } }).user.username).toBe('JaneDoe');
});
it('OIDC-SVC-022c: OIDC_USERNAME_CLAIM picks the claim the username is built from (#1677)', async () => {
createUser(testDb, { email: 'someone@example.com' });
process.env.OIDC_USERNAME_CLAIM = 'preferred_username';
const result = await svc.findOrCreateUser(
{ sub: 'sub-jane-2', email: 'jane2@example.com', name: 'Jane Doe', preferred_username: 'jane.d' },
MOCK_CONFIG
);
expect((result as { user: { username: string } }).user.username).toBe('jane.d');
});
it('OIDC-SVC-022d: a named claim the provider leaves empty falls back to name, then the email (#1677)', async () => {
createUser(testDb, { email: 'someone@example.com' });
process.env.OIDC_USERNAME_CLAIM = 'nickname';
const withName = await svc.findOrCreateUser(
{ sub: 'sub-jane-3', email: 'jane3@example.com', name: 'Jane Three', nickname: ' ' },
MOCK_CONFIG
);
expect((withName as { user: { username: string } }).user.username).toBe('JaneThree');
const bare = await svc.findOrCreateUser({ sub: 'sub-jane-4', email: 'j.four@example.com' }, MOCK_CONFIG);
expect((bare as { user: { username: string } }).user.username).toBe('j.four');
});
it('OIDC-SVC-022e: a later login never renames the account to the claim', async () => {
const { user } = createUser(testDb, { email: 'kept@example.com', username: 'chosen' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-kept', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_USERNAME_CLAIM = 'preferred_username';
const result = await svc.findOrCreateUser(
{ sub: 'sub-kept', email: 'kept@example.com', name: 'Kept', preferred_username: 'idp-name' },
MOCK_CONFIG
);
expect((result as { user: { username: string } }).user.username).toBe('chosen');
});
it('OIDC-SVC-024: returns registration_disabled error when registration is off', async () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = await 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)', async () => {
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);
await 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', async () => {
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 = await 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', async () => {
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 = await 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', async () => {
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 = await 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', async () => {
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 = await 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', async () => {
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 = await 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', async () => {
const result = await 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', async () => {
await 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', async () => {
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);
await 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', async () => {
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);
await 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', async () => {
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);
await 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');
});
// #2110 — the admin repoints the instance at a different IdP. The user still logs in,
// because the verified-email lookup finds the row, but everything the old provider put
// on it used to survive: an avatar URL on a host this instance no longer talks to, and
// a sub/issuer pair pinning the account to a provider that is gone.
it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', async () => {
const { user } = createUser(testDb, { email: 'switch1@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-old-1', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id);
await svc.findOrCreateUser(
{ sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBeNull();
});
it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => {
const { user } = createUser(testDb, { email: 'switch2@example.com' });
// A local upload is a bare filename, not a URL, and belongs to the user.
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-old-2', 'https://old-idp.example.com', 'uploaded-abc.jpg', user.id);
await svc.findOrCreateUser(
{ sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
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-062: the account is relinked to the new provider sub and issuer', async () => {
const { user } = createUser(testDb, { email: 'switch3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-old-3', 'https://old-idp.example.com', user.id);
await svc.findOrCreateUser(
{ sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' },
);
const row = testDb.prepare('SELECT oidc_sub, oidc_issuer FROM users WHERE id = ?').get(user.id) as any;
expect(row.oidc_sub).toBe('sub-new-3');
expect(row.oidc_issuer).toBe('https://new-idp.example.com');
});
// Plan 3b Task 6 (O5/O6, `linkOidcIdentity`) — the repository write names only
// `oidc_sub`/`oidc_issuer`; pinning the WHOLE row (not just those two columns,
// as OIDC-SVC-025/062 already do) proves no other column moved — the exact
// identity-map-stale-write-back shape the program's disableIdentityMap ruling
// exists to foreclose (task-1-fix-report.md).
it('OIDC-SVC-090: identity linking (O5) leaves the rest of the users row byte-identical', async () => {
const { user } = createUser(testDb, { email: 'linkrow@example.com', username: 'linkrowuser' });
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
// Task 6 review L1 / Task 7 review, Task 6 L1: pin updated_at to a fixed
// past value before the snapshot so a future `linkOidcIdentity` that
// starts stamping updated_at (a CURRENT_TIMESTAMP drift) makes this test
// fail loudly, instead of the before/after snapshot racing the mutation
// inside the same second and passing by accident.
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser(
{ sub: 'sub-linkrow', email: 'linkrow@example.com', name: 'Link Row', email_verified: true },
MOCK_CONFIG,
);
const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
expect(after).toEqual({ ...before, oidc_sub: 'sub-linkrow', oidc_issuer: MOCK_CONFIG.issuer });
});
it('OIDC-SVC-091: the provider-switch branch (O6) leaves the rest of the users row byte-identical', async () => {
const { user } = createUser(testDb, { email: 'switchrow@example.com', username: 'switchrowuser' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-old-row', 'https://old-idp.example.com', user.id);
// Task 6 review L1 / Task 7 review, Task 6 L1: see OIDC-SVC-090's comment.
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
await svc.findOrCreateUser(
{ sub: 'sub-new-row', email: 'switchrow@example.com', name: 'Switch Row', email_verified: true },
{ ...MOCK_CONFIG, issuer: 'https://new-idp-row.example.com' },
);
const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
expect(after).toEqual({ ...before, oidc_sub: 'sub-new-row', oidc_issuer: 'https://new-idp-row.example.com' });
});
// Plan 3b Task 6 (O13, `InviteTokensRepository.incrementUsedCount`) — the
// ruling: a `null` result inside `uow.transactional` throws the same
// reference-compared `inviteRaceError` sentinel the legacy `changes === 0`
// branch threw, mapped to the same `registration_disabled` outcome. Mirrors
// `WebauthnChallenges.repository.test.ts`'s `Promise.all` concurrent-claim
// proof (WEBAUTHN-CHAL-REPO-011) — same single-SQLite-connection
// serialization, one winner.
it('OIDC-SVC-092: two concurrent SSO registrations racing the same one-use invite — the loser gets registration_disabled, byte-identical to the legacy invite_exhausted outcome', async () => {
const { user: creator } = createUser(testDb, { email: 'racecreator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-race', 1, 0, ?)"
).run(creator.id);
const [first, second] = await Promise.all([
svc.findOrCreateUser({ sub: 'sub-race-1', email: 'racer1@example.com', name: 'Racer One' }, MOCK_CONFIG, 'tok-race'),
svc.findOrCreateUser({ sub: 'sub-race-2', email: 'racer2@example.com', name: 'Racer Two' }, MOCK_CONFIG, 'tok-race'),
]);
const outcomes = [first, second];
const winners = outcomes.filter((r) => 'user' in r);
const losers = outcomes.filter((r) => 'error' in r) as Array<{ error: string }>;
expect(winners).toHaveLength(1);
expect(losers).toHaveLength(1);
expect(losers[0]!.error).toBe('registration_disabled');
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-race'").get() as { used_count: number };
expect(token.used_count).toBe(1);
});
it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => {
const result = await 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', async () => {
const result = await 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)', async () => {
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 = await 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();
});
});
// ── claim-based role mapping on login (#2364) ────────────────────────────────
describe('findOrCreateUser role mapping', () => {
type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } };
function ssoUser(email: string, sub: string, role: 'admin' | 'user') {
const { user } = createUser(testDb, { email, role });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, MOCK_CONFIG.issuer, user.id);
return user;
}
const storedRole = (id: number) => (testDb.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string }).role;
it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
// A second admin row, so the #1274 last-admin guard cannot be what saves them.
createUser(testDb, { email: 'bootstrap@example.com', role: 'admin' });
const user = ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = (await svc.findOrCreateUser(
{ sub: 'sub-keep-admin', email: 'sso-admin@example.com', groups: ['authentik Admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(result.user.role).toBe('admin');
expect(storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-064: an absent claim does not promote a plain user either', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('plain@example.com', 'sub-keep-user', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = (await svc.findOrCreateUser(
{ sub: 'sub-keep-user', email: 'plain@example.com', groups: ['authentik Admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(result.user.role).toBe('user');
expect(storedRole(user.id)).toBe('user');
expect(result.roleChange).toBeUndefined();
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('promote@example.com', 'sub-promote', 'user');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(result.roleChange).toEqual({ from: 'user', to: 'admin', claim: 'entitlements' });
});
it('OIDC-SVC-066: a claim that arrives without the admin value still demotes', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' });
const user = ssoUser('demote@example.com', 'sub-demote', 'admin');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('user');
expect(result.roleChange).toEqual({ from: 'admin', to: 'user', claim: 'entitlements' });
});
it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('lonely@example.com', 'sub-lonely', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = (await svc.findOrCreateUser(
{ sub: 'sub-lonely', email: 'lonely@example.com', entitlements: ['trek-users'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('only admin');
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'trek_roles';
ssoUser('warned@example.com', 'sub-warned', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// The warning is deduped per claim name for the life of the process, so this
// case needs an instance that has not seen the claim yet.
const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb));
try {
const info = { sub: 'sub-warned', email: 'warned@example.com', groups: ['authentik Admins'] };
await fresh.findOrCreateUser(info, MOCK_CONFIG);
await fresh.findOrCreateUser(info, MOCK_CONFIG);
expect(warn).toHaveBeenCalledTimes(1);
const line = warn.mock.calls[0][0] as string;
expect(line).toContain('"trek_roles"');
expect(line).toContain('sub, email, groups');
expect(line).toContain('OIDC_SCOPE');
expect(line).not.toContain('authentik Admins');
expect(line).not.toContain('warned@example.com');
} finally {
fresh.onModuleDestroy();
warn.mockRestore();
}
});
it('OIDC-SVC-069: a new account is registered with the default role when the claim is absent', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'reg_entitlements';
createUser(testDb, { email: 'someone@example.com' }); // not the first user any more
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = (await svc.findOrCreateUser(
{ sub: 'sub-fresh', email: 'fresh@example.com', name: 'Fresh', groups: ['authentik Admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(result.user.id)).toBe('user');
expect(result.roleChange).toBeUndefined();
expect(warn.mock.calls[0][0]).toContain('during registration');
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const user = ssoUser('steady@example.com', 'sub-steady', 'admin');
const result = (await svc.findOrCreateUser(
{ sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] },
MOCK_CONFIG
)) as RoleResult;
expect(storedRole(user.id)).toBe('admin');
expect(result.roleChange).toBeUndefined();
});
it('OIDC-SVC-071: resolveOidcRoleDetailed only reports a missing claim where the mapping is actually consulted', () => {
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
const info = { sub: 'x', groups: ['authentik Admins'] };
delete process.env.OIDC_ADMIN_VALUE;
expect(svc.resolveOidcRoleDetailed(info, false)).toMatchObject({ role: 'user', claimMissing: false, claimKey: 'entitlements' });
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(svc.resolveOidcRoleDetailed(info, true)).toMatchObject({ role: 'admin', claimMissing: false });
expect(svc.resolveOidcRoleDetailed(info, false)).toMatchObject({ role: 'user', claimMissing: true, seenKeys: ['sub', 'groups'] });
expect(svc.resolveOidcRoleDetailed({ ...info, entitlements: ['trek-admins'] }, false)).toMatchObject({ role: 'admin', claimMissing: false });
});
it('OIDC-SVC-072: a stored admin whose claim never arrives is warned about on every login, by id, with the admin panel as the way out', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles';
createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' });
const user = ssoUser('kept@example.com', 'sub-kept-admin', 'admin');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const info = { sub: 'sub-kept-admin', email: 'kept@example.com', groups: ['authentik Admins'] };
await svc.findOrCreateUser(info, MOCK_CONFIG);
await svc.findOrCreateUser(info, MOCK_CONFIG);
// No dedup here: this is the login where an IdP-side revocation quietly fails.
expect(warn).toHaveBeenCalledTimes(2);
for (const call of warn.mock.calls) {
const line = call[0] as string;
expect(line).toContain(`User ${user.id} (${user.username}) is stored as an admin`);
expect(line).toContain('"kept_admin_roles"');
expect(line).toContain('admin panel');
expect(line).toContain('sub, email, groups');
expect(line).not.toContain('authentik Admins');
expect(line).not.toContain('kept@example.com');
}
expect(storedRole(user.id)).toBe('admin');
} finally {
warn.mockRestore();
}
});
it('OIDC-SVC-073: warning about a stored admin does not use up the once-per-claim warning for everybody else', async () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
process.env.OIDC_ADMIN_CLAIM = 'shared_roles';
createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' });
ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin');
ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb));
try {
await fresh.findOrCreateUser({ sub: 'sub-shared-admin', email: 'shared-admin@example.com' }, MOCK_CONFIG);
await fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG);
await fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0][0]).toContain('is stored as an admin');
expect(warn.mock.calls[1][0]).toContain('so their stored role is left unchanged');
} finally {
fresh.onModuleDestroy();
warn.mockRestore();
}
});
});
// ── 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', async () => {
const spy = vi.spyOn(auth, 'resolveAuthToggles');
spy.mockResolvedValue({ oidc_login: true } as Awaited<ReturnType<AuthService['resolveAuthToggles']>>);
expect(await svc.oidcLoginEnabled()).toBe(true);
spy.mockResolvedValue({ oidc_login: false } as Awaited<ReturnType<AuthService['resolveAuthToggles']>>);
expect(await 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, undefined);
});
it('OIDC-SVC-059: setAuthCookie forwards the remember option to the cookie helper', () => {
const res = {} as Response;
const req = {} as Request;
svc.setAuthCookie(res, 'jwt', req, true);
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req, true);
svc.setAuthCookie(res, 'jwt', req, false);
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req, false);
});
});
// 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', async () => {
const result = await 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', async () => {
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = await 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)', async () => {
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = await 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', async () => {
const result = await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any;
expect(result.success).toBe(true);
const settings = await 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', async () => {
// 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').mockResolvedValue({ password_login: false } as never);
expect(await svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 });
expect(await svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 });
expect(((await 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', async () => {
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never);
expect(await 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', async () => {
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never);
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' });
expect((await 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.
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' });
expect((await svc.getOidcSettings()).client_secret_set).toBe(true);
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' });
expect((await svc.getOidcSettings()).client_secret_set).toBe(false);
toggles.mockRestore();
});
});