import { randomUUID } from 'crypto'; // Mock DNS resolution so ensureSafeUrl passes for the fake issuer.example domain vi.mock('dns/promises', () => ({ lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }]), })); import fastifyCookie from '@fastify/cookie'; import { ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { and, eq, gt, isNull } from 'drizzle-orm'; import { hash } from 'bcryptjs'; import { Permission } from '@bookorbit/types'; import { AppModule } from '../src/app.module'; import { GlobalExceptionFilter } from '../src/common/filters/http-exception.filter'; import { DB } from '../src/db'; import * as schema from '../src/db/schema'; import { MetadataService } from '../src/modules/metadata/metadata.service'; import { OidcDiscoveryService } from '../src/modules/auth/oidc/oidc-discovery.service'; import { OidcTokenClientService } from '../src/modules/auth/oidc/oidc-token-client.service'; import { OidcTokenValidatorService } from '../src/modules/auth/oidc/oidc-token-validator.service'; import { makeMetadataNoopMock, type Db } from './e2e/app-harness'; type CookieJar = Map; type InjectResponse = Awaited>; const ADMIN_SETUP_DTO = { username: 'app-settings-oidc-e2e-admin', name: 'App Settings OIDC E2E Admin', email: 'app-settings-oidc-e2e-admin@example.com', password: 'AppSettingsOidcAdmin123', }; const SCENARIO_TIMEOUT_MS = 60_000; const DEFAULT_ISSUER = 'https://issuer.example'; const DEFAULT_SLUG = 'example-oidc'; interface OidcContractContext { app: NestFastifyApplication; db: Db; adminToken: string; fetchMock: ReturnType>; oidcDiscoveryMock: { getDiscoveryDoc: ReturnType; }; oidcTokenClientMock: { exchangeCode: ReturnType; fetchUserInfo: ReturnType; }; oidcTokenValidatorMock: { validateIdToken: ReturnType; validateLogoutToken: ReturnType; }; } type OidcConfigInput = { slug: string; displayName: string; enabled: boolean; issuerUri: string; clientId: string; clientSecret: string; scopes: string; claimMapping: { username: string; name: string; email: string; groups: string; }; autoProvision: { enabled: boolean; allowLocalLinking: boolean; defaultPermissionNames: string[]; }; }; interface LocalUserCredentials { userId: number; username: string; password: string; email: string; } interface LoginResult { userId: number; accessToken: string; refreshToken: string; jar: CookieJar; } function authHeader(token: string): Record { return { authorization: `Bearer ${token}` }; } function buildDiscoveryDoc(issuerUri = DEFAULT_ISSUER) { const normalized = issuerUri.replace(/\/$/, ''); return { issuer: normalized, authorizationEndpoint: `${normalized}/auth`, tokenEndpoint: `${normalized}/token`, jwksUri: `${normalized}/jwks`, userinfoEndpoint: `${normalized}/userinfo`, endSessionEndpoint: `${normalized}/logout`, backchannelLogoutSupported: true, }; } function buildDiscoveryResponse(issuerUri = DEFAULT_ISSUER) { const normalized = issuerUri.replace(/\/$/, ''); return { issuer: normalized, authorization_endpoint: `${normalized}/auth`, token_endpoint: `${normalized}/token`, jwks_uri: `${normalized}/jwks`, userinfo_endpoint: `${normalized}/userinfo`, end_session_endpoint: `${normalized}/logout`, backchannel_logout_supported: true, }; } function buildOidcConfig(overrides: Partial = {}): OidcConfigInput { return { slug: DEFAULT_SLUG, displayName: 'Example OIDC', enabled: true, issuerUri: DEFAULT_ISSUER, clientId: 'client-id', clientSecret: 'client-secret', scopes: 'openid profile email', claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups', ...(overrides.claimMapping ?? {}), }, autoProvision: { enabled: true, allowLocalLinking: true, defaultPermissionNames: [], ...(overrides.autoProvision ?? {}), }, ...overrides, }; } function makeFakeLogoutToken(claims: Record): string { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); return `${header}.${payload}.fakesig`; } function responseMessage(response: { message?: string | string[] }): string { if (Array.isArray(response.message)) return response.message.join(' '); return String(response.message ?? ''); } function expectError(response: InjectResponse, status: number, messageFragment?: string): void { expect(response.statusCode).toBe(status); if (!messageFragment) return; expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment); } function getSetCookieLines(headers: Record): string[] { const raw = headers['set-cookie']; if (!raw) return []; return Array.isArray(raw) ? raw : [raw]; } function parseCookiePair(setCookieLine: string): { name: string; value: string } | null { const firstSegment = setCookieLine.split(';', 1)[0]; if (!firstSegment) return null; const eqIndex = firstSegment.indexOf('='); if (eqIndex <= 0) return null; return { name: firstSegment.slice(0, eqIndex), value: firstSegment.slice(eqIndex + 1), }; } function findCookieLine(setCookieLines: string[], cookieName: string): string | undefined { return setCookieLines.find((line) => line.startsWith(`${cookieName}=`)); } function cookieValue(setCookieLines: string[], cookieName: string): string | null { const line = findCookieLine(setCookieLines, cookieName); if (!line) return null; return parseCookiePair(line)?.value ?? null; } function cookieAttribute(setCookieLine: string, attribute: string): string | null { const segments = setCookieLine.split(';').map((segment) => segment.trim()); const target = attribute.toLowerCase(); for (const segment of segments.slice(1)) { const [key, ...rest] = segment.split('='); if (key.toLowerCase() !== target) continue; return rest.length > 0 ? rest.join('=') : ''; } return null; } function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void { for (const line of setCookieLines) { const parsed = parseCookiePair(line); if (!parsed) continue; if (parsed.value === '') { jar.delete(parsed.name); } else { jar.set(parsed.name, parsed.value); } } } function mockDiscoveryFetch(input: string | URL | Request): Promise { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; if (url.endsWith('/.well-known/openid-configuration')) { const issuerUri = url.slice(0, -'/.well-known/openid-configuration'.length); return Promise.resolve( new Response(JSON.stringify(buildDiscoveryResponse(issuerUri)), { status: 200, headers: { 'content-type': 'application/json' }, }), ); } return Promise.reject(new Error(`Unexpected fetch call: ${url}`)); } async function createOidcContractContext(): Promise { const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(mockDiscoveryFetch); const oidcDiscoveryMock = { getDiscoveryDoc: vi.fn().mockResolvedValue(buildDiscoveryDoc()), }; const oidcTokenClientMock = { exchangeCode: vi.fn().mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' }), fetchUserInfo: vi.fn().mockResolvedValue({}), }; const oidcTokenValidatorMock = { validateIdToken: vi.fn().mockResolvedValue({ sub: 'oidc-default-subject', sid: 'oidc-default-sid', preferred_username: 'oidc-default-user', name: 'OIDC Default User', email: 'oidc-default-user@example.com', }), validateLogoutToken: vi.fn().mockResolvedValue({ sub: 'oidc-default-subject', sid: 'oidc-default-sid', jti: `logout-${randomUUID()}`, }), }; const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(MetadataService) .useValue(makeMetadataNoopMock()) .overrideProvider(OidcDiscoveryService) .useValue(oidcDiscoveryMock) .overrideProvider(OidcTokenClientService) .useValue(oidcTokenClientMock) .overrideProvider(OidcTokenValidatorService) .useValue(oidcTokenValidatorMock) .compile(); const app = moduleFixture.createNestApplication(new FastifyAdapter()); app.setGlobalPrefix('api/v1'); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); app.useGlobalFilters(new GlobalExceptionFilter()); await app.register(fastifyCookie as never); await app.init(); await app.getHttpAdapter().getInstance().ready(); const setupResponse = await app.inject({ method: 'POST', url: '/api/v1/auth/setup', payload: ADMIN_SETUP_DTO, }); let adminToken: string; if (setupResponse.statusCode === 201) { const body = setupResponse.json() as { accessToken: string }; adminToken = body.accessToken; } else if (setupResponse.statusCode === 409) { const db = app.get(DB); const loginResponse = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password }, }); if (loginResponse.statusCode === 200) { const body = loginResponse.json() as { accessToken: string }; adminToken = body.accessToken; } else { const suffix = randomUUID().replaceAll('-', ''); const fallbackUsername = `app-settings-oidc-e2e-admin-${suffix}`; const passwordHash = await hash(ADMIN_SETUP_DTO.password, 4); await db.insert(schema.users).values({ username: fallbackUsername, name: 'App Settings OIDC E2E Admin (fallback)', email: `${fallbackUsername}@example.com`, passwordHash, isSuperuser: true, isDefaultPassword: false, provisioningMethod: 'local', }); const fallbackResponse = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password }, }); if (fallbackResponse.statusCode !== 200) { throw new Error(`OIDC contract test: fallback admin login failed (${fallbackResponse.statusCode})`); } const body = fallbackResponse.json() as { accessToken: string }; adminToken = body.accessToken; } } else { throw new Error(`OIDC contract test: unexpected setup response ${setupResponse.statusCode}: ${setupResponse.body}`); } const db = app.get(DB); return { app, db, adminToken, fetchMock, oidcDiscoveryMock, oidcTokenClientMock, oidcTokenValidatorMock, }; } async function createLocalUser( db: Db, options: { username?: string; password?: string; active?: boolean; email?: string; } = {}, ): Promise { const suffix = randomUUID().replace(/-/g, ''); const username = options.username ?? `oidc-contract-${suffix}`; const password = options.password ?? 'OidcContractUser123'; const email = options.email ?? `${username}@example.com`; const passwordHash = await hash(password, 12); const [created] = await db .insert(schema.users) .values({ username, name: `OIDC Contract ${suffix}`, email, passwordHash, active: options.active ?? true, isDefaultPassword: false, provisioningMethod: 'local', }) .returning({ id: schema.users.id }); return { userId: created.id, username, password, email, }; } async function login(app: NestFastifyApplication, username: string, password: string): Promise { const response = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password }, }); expect(response.statusCode).toBe(200); const body = response.json() as { accessToken: string; user: { id: number } }; const setCookieLines = getSetCookieLines(response.headers); const refreshToken = cookieValue(setCookieLines, 'refresh_token'); expect(refreshToken).toBeTruthy(); const jar: CookieJar = new Map(); mergeCookieJar(jar, setCookieLines); return { userId: body.user.id, accessToken: body.accessToken, refreshToken: refreshToken!, jar, }; } async function putOidcConfig(ctx: OidcContractContext, config: OidcConfigInput): Promise { const createResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/app-settings/oidc/providers', headers: authHeader(ctx.adminToken), payload: config, }); if (createResponse.statusCode === 201) return; const { slug, ...updatePayload } = config; const updateResponse = await ctx.app.inject({ method: 'PUT', url: `/api/v1/app-settings/oidc/providers/${slug}`, headers: authHeader(ctx.adminToken), payload: updatePayload, }); expect(updateResponse.statusCode).toBe(200); } async function issueState(app: NestFastifyApplication, slug = DEFAULT_SLUG): Promise { const response = await app.inject({ method: 'POST', url: `/api/v1/auth/oidc/${slug}/state`, }); expect(response.statusCode).toBe(200); const body = response.json() as { state: string }; expect(body.state).toEqual(expect.any(String)); return body.state; } async function activeSessionCount(db: Db, userId: number): Promise { const rows = await db .select({ id: schema.refreshTokens.id }) .from(schema.refreshTokens) .where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date()))); return rows.length; } describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { let ctx!: OidcContractContext; beforeAll(async () => { ctx = await createOidcContractContext(); }); beforeEach(() => { ctx.fetchMock.mockReset(); ctx.fetchMock.mockImplementation(mockDiscoveryFetch); ctx.oidcDiscoveryMock.getDiscoveryDoc.mockReset(); ctx.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(buildDiscoveryDoc()); ctx.oidcTokenClientMock.exchangeCode.mockReset(); ctx.oidcTokenClientMock.exchangeCode.mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' }); ctx.oidcTokenClientMock.fetchUserInfo.mockReset(); ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValue({}); ctx.oidcTokenValidatorMock.validateIdToken.mockReset(); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValue({ sub: 'oidc-default-subject', sid: 'oidc-default-sid', preferred_username: 'oidc-default-user', name: 'OIDC Default User', email: 'oidc-default-user@example.com', }); ctx.oidcTokenValidatorMock.validateLogoutToken.mockReset(); ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValue({ sub: 'oidc-default-subject', sid: 'oidc-default-sid', jti: `logout-${randomUUID()}`, }); }); afterAll(async () => { ctx.fetchMock.mockRestore(); await ctx.app.close(); }); describe('OIDC app-settings contract', () => { it('stores admin config, exposes the public subset, masks the secret, and tests discovery', async () => { const limitedUser = await createLocalUser(ctx.db); const limitedSession = await login(ctx.app, limitedUser.username, limitedUser.password); const config = buildOidcConfig({ displayName: 'Contract OIDC', autoProvision: { enabled: true, allowLocalLinking: true, defaultPermissionNames: [Permission.KoboSync], }, }); await putOidcConfig(ctx, config); const publicResponse = await ctx.app.inject({ method: 'GET', url: '/api/v1/app-settings/oidc/providers/public', }); expect(publicResponse.statusCode).toBe(200); expect(publicResponse.json()).toEqual( expect.arrayContaining([ expect.objectContaining({ slug: DEFAULT_SLUG, displayName: 'Contract OIDC', enabled: true, clientId: 'client-id', scopes: 'openid profile email', }), ]), ); const forbiddenResponse = await ctx.app.inject({ method: 'GET', url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`, headers: authHeader(limitedSession.accessToken), }); expect(forbiddenResponse.statusCode).toBe(403); const adminResponse = await ctx.app.inject({ method: 'GET', url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`, headers: authHeader(ctx.adminToken), }); expect(adminResponse.statusCode).toBe(200); expect(adminResponse.json()).toMatchObject({ slug: DEFAULT_SLUG, displayName: 'Contract OIDC', enabled: true, issuerUri: DEFAULT_ISSUER, clientId: 'client-id', clientSecret: '***', scopes: 'openid profile email', }); const testResponse = await ctx.app.inject({ method: 'POST', url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}/test`, headers: authHeader(ctx.adminToken), }); expect(testResponse.statusCode).toBe(200); expect(testResponse.json()).toMatchObject({ success: true, issuer: DEFAULT_ISSUER, authorizationEndpoint: `${DEFAULT_ISSUER}/auth`, }); }); }); describe('OIDC callback contract', () => { it('issues state, provisions a new OIDC user, sets auth cookies, and consumes state once', async () => { const suffix = randomUUID().replace(/-/g, ''); const username = `oidc-provisioned-${suffix}`; const email = `${username}@example.com`; await putOidcConfig( ctx, buildOidcConfig({ autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: [Permission.KoboSync], }, }), ); ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({ accessToken: 'oidc-access-provisioned', idToken: 'oidc-id-provisioned', }); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sub: `oidc-provisioned-subject-${suffix}`, sid: `oidc-provisioned-sid-${suffix}`, preferred_username: username, name: 'OIDC Provisioned User', email, }); ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValueOnce({ picture: 'https://issuer.example/avatars/provisioned.png', }); const state = await issueState(ctx.app, DEFAULT_SLUG); const callbackPayload = { code: 'authorization-code-provisioned', codeVerifier: 'verifier-provisioned', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-provisioned', state, }; const callbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: callbackPayload, }); expect(callbackResponse.statusCode).toBe(200); const callbackBody = callbackResponse.json() as { accessToken: string; user: { id: number; username: string; email: string; provisioningMethod: string; avatarUrl: string | null; permissions: string[]; }; }; expect(callbackBody.user).toMatchObject({ username, email, provisioningMethod: 'oidc', avatarUrl: 'https://issuer.example/avatars/provisioned.png', }); expect(callbackBody.user.permissions).toContain(Permission.KoboSync); const callbackCookies = getSetCookieLines(callbackResponse.headers); const refreshCookieLine = findCookieLine(callbackCookies, 'refresh_token'); const accessCookieLine = findCookieLine(callbackCookies, 'access_token'); expect(refreshCookieLine).toBeDefined(); expect(accessCookieLine).toBeDefined(); expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth'); expect(cookieAttribute(refreshCookieLine!, 'httponly')).toBe(''); expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api'); expect(cookieAttribute(accessCookieLine!, 'httponly')).toBe(''); const createdUser = await ctx.db.query.users.findFirst({ where: eq(schema.users.id, callbackBody.user.id), }); expect(createdUser).toMatchObject({ username, email, oidcSubject: `oidc-provisioned-subject-${suffix}`, oidcIssuer: DEFAULT_ISSUER, provisioningMethod: 'oidc', }); const createdPermissions = await ctx.db.query.userPermissions.findMany({ where: eq(schema.userPermissions.userId, callbackBody.user.id), }); expect(createdPermissions.map((row) => row.permissionName)).toContain(Permission.KoboSync); const oidcSession = await ctx.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.userId, callbackBody.user.id), }); expect(oidcSession).toMatchObject({ oidcSubject: `oidc-provisioned-subject-${suffix}`, oidcIssuer: DEFAULT_ISSUER, oidcSessionId: `oidc-provisioned-sid-${suffix}`, idTokenHint: 'oidc-id-provisioned', revoked: false, }); const reusedState = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: callbackPayload, }); expectError(reusedState, 401, 'Your login session expired'); }); it('links an existing local user and backchannel logout revokes active sessions only for the matched OIDC identity', async () => { const suffix = randomUUID().replace(/-/g, ''); const linkedUser = await createLocalUser(ctx.db, { username: `oidc-linked-${suffix}`, }); await putOidcConfig( ctx, buildOidcConfig({ autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [], }, }), ); ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({ accessToken: 'oidc-access-linked', idToken: 'oidc-id-linked', }); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sub: `oidc-linked-subject-${suffix}`, sid: `oidc-linked-sid-${suffix}`, preferred_username: linkedUser.username, name: 'Linked Local User', email: linkedUser.email, }); const state = await issueState(ctx.app, DEFAULT_SLUG); const callbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: { code: 'authorization-code-linked', codeVerifier: 'verifier-linked', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-linked', state, }, }); expect(callbackResponse.statusCode).toBe(200); const callbackBody = callbackResponse.json() as { accessToken: string; user: { id: number; provisioningMethod: string; }; }; expect(callbackBody.user).toMatchObject({ id: linkedUser.userId, provisioningMethod: 'local', }); const setCookieLines = getSetCookieLines(callbackResponse.headers); const refreshToken = cookieValue(setCookieLines, 'refresh_token'); expect(refreshToken).toBeTruthy(); const linkedUserAfter = await ctx.db.query.users.findFirst({ where: eq(schema.users.id, linkedUser.userId), }); expect(linkedUserAfter).toMatchObject({ oidcSubject: `oidc-linked-subject-${suffix}`, oidcIssuer: DEFAULT_ISSUER, provisioningMethod: 'local', }); const sessionBeforeUnknownLogout = await activeSessionCount(ctx.db, linkedUser.userId); expect(sessionBeforeUnknownLogout).toBe(1); ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({ sub: `oidc-unknown-${randomUUID()}`, jti: `logout-unknown-${randomUUID()}`, }); const unknownLogoutResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/backchannel-logout', payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) }, }); expect(unknownLogoutResponse.statusCode).toBe(200); expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(1); const meBeforeMatchedLogout = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: authHeader(callbackBody.accessToken), }); expect(meBeforeMatchedLogout.statusCode).toBe(200); ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({ sub: `oidc-linked-subject-${suffix}`, sid: `oidc-linked-sid-${suffix}`, jti: `logout-linked-${randomUUID()}`, }); const matchedLogoutResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/backchannel-logout', payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) }, }); expect(matchedLogoutResponse.statusCode).toBe(200); const oidcSessionAfter = await ctx.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.userId, linkedUser.userId), }); expect(oidcSessionAfter?.revoked).toBe(true); expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(0); const refreshAfterLogout = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', headers: { cookie: `refresh_token=${refreshToken}`, }, }); expect(refreshAfterLogout.statusCode).toBe(401); const meAfterLogout = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: authHeader(callbackBody.accessToken), }); expect(meAfterLogout.statusCode).toBe(401); }); it('rejects invalid state and missing subject claims', async () => { await putOidcConfig( ctx, buildOidcConfig({ autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: [], }, }), ); const invalidStateResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: { code: 'authorization-code-invalid-state', codeVerifier: 'verifier-invalid-state', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-invalid-state', state: `invalid-${randomUUID()}`, }, }); expectError(invalidStateResponse, 401, 'Your login session expired'); expect(ctx.oidcTokenClientMock.exchangeCode).not.toHaveBeenCalled(); const state = await issueState(ctx.app, DEFAULT_SLUG); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sid: 'oidc-missing-subject-sid', preferred_username: `missing-subject-${randomUUID()}`, name: 'Missing Subject', email: 'missing-subject@example.com', }); const missingSubjectResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: { code: 'authorization-code-missing-subject', codeVerifier: 'verifier-missing-subject', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-missing-subject', state, }, }); expectError(missingSubjectResponse, 401, 'Could not complete sign-in with your provider'); }); it('rejects callback when OIDC is disabled or the linked account is deactivated', async () => { // Generate state while provider is enabled, then disable it so the callback is rejected. await putOidcConfig( ctx, buildOidcConfig({ enabled: true, autoProvision: { enabled: true, allowLocalLinking: true, defaultPermissionNames: [], }, }), ); const disabledState = await issueState(ctx.app, DEFAULT_SLUG); await putOidcConfig( ctx, buildOidcConfig({ enabled: false, autoProvision: { enabled: true, allowLocalLinking: true, defaultPermissionNames: [], }, }), ); const disabledCallbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: { code: 'authorization-code-disabled', codeVerifier: 'verifier-disabled', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-disabled', state: disabledState, }, }); expectError(disabledCallbackResponse, 401, 'OIDC provider is not enabled'); const inactiveUser = await createLocalUser(ctx.db, { username: `oidc-inactive-${randomUUID().replace(/-/g, '')}`, active: false, }); await putOidcConfig( ctx, buildOidcConfig({ enabled: true, autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [], }, }), ); const inactiveState = await issueState(ctx.app, DEFAULT_SLUG); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sub: 'oidc-inactive-subject', sid: 'oidc-inactive-sid', preferred_username: inactiveUser.username, name: 'Inactive Linked User', email: inactiveUser.email, }); const inactiveCallbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', payload: { code: 'authorization-code-inactive', codeVerifier: 'verifier-inactive', redirectUri: 'http://localhost:5173/oauth2-callback', nonce: 'nonce-inactive', state: inactiveState, }, }); expectError(inactiveCallbackResponse, 401, 'Your account has been deactivated'); const inactiveUserSession = await ctx.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.userId, inactiveUser.userId), }); expect(inactiveUserSession).toBeUndefined(); }); }); });