import { ValidationPipe } from '@nestjs/common'; import { AuthSessionService } from '../src/modules/auth/auth-session.service'; import { OidcSessionRepository } from '../src/modules/auth/oidc/oidc-session.repository'; import type { NativeAuthResponse, NativeCredentials } from '@bookorbit/types'; import { createHash, randomUUID } from 'crypto'; import fastifyCookie from '@fastify/cookie'; 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 { AppModule } from '../src/app.module'; 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 { SystemMailService } from '../src/modules/email/system-mail.service'; import { makeMetadataNoopMock, type Db } from './e2e/app-harness'; type CookieJar = Map; interface AuthRecoveryContext { app: NestFastifyApplication; db: Db; systemMailMock: { isConfigured: ReturnType; sendPasswordReset: ReturnType; }; oidcDiscoveryMock: { getDiscoveryDoc: ReturnType; }; } interface LocalUserCredentials { userId: number; username: string; password: string; email: string; } interface LoginResult { sessionId: number; accessToken: string; refreshToken: string; jar: CookieJar; } const ADMIN_SETUP_DTO = { username: 'recovery-e2e-admin', name: 'Recovery E2E Admin', email: 'recovery-e2e-admin@example.com', password: 'RecoveryAdmin123', }; function sha256(value: string): string { return createHash('sha256').update(value).digest('hex'); } 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 cookieValue(setCookieLines: string[], cookieName: string): string | null { const line = setCookieLines.find((entry) => entry.startsWith(`${cookieName}=`)); if (!line) return null; const parsed = parseCookiePair(line); return parsed?.value ?? 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 cookieHeader(jar: CookieJar): string { return Array.from(jar.entries()) .map(([name, value]) => `${name}=${value}`) .join('; '); } function defaultDiscoveryDoc() { return { issuer: 'https://issuer.example', authorizationEndpoint: 'https://issuer.example/auth', tokenEndpoint: 'https://issuer.example/token', jwksUri: 'https://issuer.example/jwks', userinfoEndpoint: 'https://issuer.example/userinfo', endSessionEndpoint: 'https://issuer.example/logout', backchannelLogoutSupported: true, }; } async function createAuthRecoveryContext(): Promise { const systemMailMock = { isConfigured: vi.fn().mockResolvedValue(true), sendPasswordReset: vi.fn().mockResolvedValue(undefined), }; const oidcDiscoveryMock = { getDiscoveryDoc: vi.fn().mockResolvedValue(defaultDiscoveryDoc()), }; const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(MetadataService) .useValue(makeMetadataNoopMock()) .overrideProvider(SystemMailService) .useValue(systemMailMock) .overrideProvider(OidcDiscoveryService) .useValue(oidcDiscoveryMock) .compile(); const app = moduleFixture.createNestApplication(new FastifyAdapter()); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); await app.register(fastifyCookie as never); await app.init(); await app.getHttpAdapter().getInstance().ready(); const db = app.get(DB); return { app, db, systemMailMock, oidcDiscoveryMock }; } async function ensureInitialSetup(app: NestFastifyApplication): Promise { const statusResponse = await app.inject({ method: 'GET', url: '/api/v1/auth/setup-status', }); expect(statusResponse.statusCode).toBe(200); const statusBody = statusResponse.json() as { needsSetup: boolean }; if (!statusBody.needsSetup) return; const setupResponse = await app.inject({ method: 'POST', url: '/api/v1/auth/setup', payload: ADMIN_SETUP_DTO, }); expect(setupResponse.statusCode).toBe(201); } async function createUser( db: Db, options?: { password?: string; provisioningMethod?: 'local' | 'oidc'; active?: boolean; }, ): Promise { const suffix = randomUUID().replace(/-/g, ''); const password = options?.password ?? 'AuthUser123'; const username = `auth-recovery-${suffix}`; const email = `${username}@example.com`; const passwordHash = await hash(password, 12); const [created] = await db .insert(schema.users) .values({ username, name: `Auth Recovery ${suffix}`, email, passwordHash, active: options?.active ?? true, provisioningMethod: options?.provisioningMethod ?? 'local', isDefaultPassword: false, }) .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; sessionId: number }; const setCookieLines = getSetCookieLines(response.headers); const refreshToken = cookieValue(setCookieLines, 'refresh_token'); expect(refreshToken).toBeTruthy(); const jar: CookieJar = new Map(); mergeCookieJar(jar, setCookieLines); return { accessToken: body.accessToken, sessionId: body.sessionId, refreshToken: refreshToken!, jar, }; } async function waitForExpectation(assertion: () => void, timeoutMs = 4_000): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; while (Date.now() < deadline) { try { assertion(); return; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 50)); } } throw lastError; } async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } function latestResetTokenFromMail(context: AuthRecoveryContext): string { const token = context.systemMailMock.sendPasswordReset.mock.calls.at(-1)?.[2]; if (typeof token !== 'string' || token.length === 0) { throw new Error('Password reset token was not captured by the mail mock'); } return token; } async function passwordResetTokenCount(db: Db): Promise { const rows = await db.select({ id: schema.passwordResetTokens.id }).from(schema.passwordResetTokens); return rows.length; } async function waitForStablePasswordResetTokenCount(db: Db, expected: number, stableMs = 300, timeoutMs = 4_000): Promise { const deadline = Date.now() + timeoutMs; let stableSince: number | null = null; let latestCount = -1; while (Date.now() < deadline) { latestCount = await passwordResetTokenCount(db); if (latestCount === expected) { if (stableSince === null) { stableSince = Date.now(); } else if (Date.now() - stableSince >= stableMs) { return; } } else { stableSince = null; } await sleep(50); } throw new Error(`Password reset token count was not stable at ${expected}. Last count: ${latestCount}`); } 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; } async function setOidcEnabledConfig(db: Db, issuerUri = 'https://issuer.example'): Promise { const value = JSON.stringify({ enabled: true, providerName: 'Example OIDC', issuerUri, clientId: 'client-id', clientSecret: 'client-secret', scopes: 'openid profile email', claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups', }, autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [], }, }); await db.insert(schema.appSettings).values({ key: 'oidc_config', value }).onConflictDoUpdate({ target: schema.appSettings.key, set: { value } }); } describe('Auth recovery and OIDC logout hardening (e2e)', () => { let context: AuthRecoveryContext; beforeAll(async () => { context = await createAuthRecoveryContext(); await ensureInitialSetup(context.app); }); beforeEach(() => { context.systemMailMock.isConfigured.mockReset(); context.systemMailMock.isConfigured.mockResolvedValue(true); context.systemMailMock.sendPasswordReset.mockClear(); context.oidcDiscoveryMock.getDiscoveryDoc.mockReset(); context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(defaultDiscoveryDoc()); }); afterAll(async () => { await context.app.close(); }); describe('forgot/reset password', () => { it('issues reset token, resets password, and revokes active sessions', async () => { const user = await createUser(context.db, { password: 'OldPass123' }); const session = await login(context.app, user.username, user.password); const forgotResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: user.email }, }); expect(forgotResponse.statusCode).toBe(200); await waitForExpectation(() => { expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1); }); const rawToken = latestResetTokenFromMail(context); const tokenRowBeforeReset = await context.db.query.passwordResetTokens.findFirst({ where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)), }); expect(tokenRowBeforeReset?.userId).toBe(user.userId); expect(tokenRowBeforeReset?.usedAt).toBeNull(); const resetResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: rawToken, newPassword: 'NewPass123', }, }); expect(resetResponse.statusCode).toBe(204); const oldPasswordLogin = await context.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: user.username, password: user.password, }, }); expect(oldPasswordLogin.statusCode).toBe(401); const newPasswordLogin = await context.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: user.username, password: 'NewPass123', }, }); expect(newPasswordLogin.statusCode).toBe(200); expect(await activeSessionCount(context.db, user.userId)).toBe(1); const newAccessToken = (newPasswordLogin.json() as { accessToken: string }).accessToken; const refreshAfterReset = await context.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', headers: { cookie: `refresh_token=${session.refreshToken}`, }, }); expect(refreshAfterReset.statusCode).toBe(401); expect(await activeSessionCount(context.db, user.userId)).toBe(1); const meAfterRevokedReuse = await context.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${newAccessToken}`, }, }); expect(meAfterRevokedReuse.statusCode).toBe(200); const tokenRowAfterReset = await context.db.query.passwordResetTokens.findFirst({ where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)), }); expect(tokenRowAfterReset?.usedAt).not.toBeNull(); }); it('rejects reset token reuse after successful password reset', async () => { const user = await createUser(context.db); const forgotResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: user.email }, }); expect(forgotResponse.statusCode).toBe(200); await waitForExpectation(() => { expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1); }); const rawToken = latestResetTokenFromMail(context); const firstReset = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: rawToken, newPassword: 'ReuseCheck123', }, }); expect(firstReset.statusCode).toBe(204); const secondReset = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: rawToken, newPassword: 'ReuseCheck456', }, }); expect(secondReset.statusCode).toBe(400); }); it('returns success but does not issue token for unknown or OIDC users', async () => { const tokenCountBefore = await passwordResetTokenCount(context.db); const unknownEmailResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: `missing-${randomUUID()}@example.com` }, }); expect(unknownEmailResponse.statusCode).toBe(200); const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' }); const oidcForgotResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: oidcUser.email }, }); expect(oidcForgotResponse.statusCode).toBe(200); await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore); expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled(); const tokenCountAfter = await passwordResetTokenCount(context.db); expect(tokenCountAfter).toBe(tokenCountBefore); }); it('returns success but does not issue token for inactive local users', async () => { const tokenCountBefore = await passwordResetTokenCount(context.db); const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: inactiveUser.email }, }); expect(response.statusCode).toBe(200); await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore); expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled(); const tokenRowsForUser = await context.db .select({ id: schema.passwordResetTokens.id }) .from(schema.passwordResetTokens) .where(eq(schema.passwordResetTokens.userId, inactiveUser.userId)); expect(tokenRowsForUser).toHaveLength(0); }); it('returns 503 when self-service mail is not configured', async () => { context.systemMailMock.isConfigured.mockResolvedValueOnce(false); const response = await context.app.inject({ method: 'POST', url: '/api/v1/auth/forgot-password', payload: { email: `config-check-${randomUUID()}@example.com` }, }); expect(response.statusCode).toBe(503); }); it('rejects invalid and expired reset tokens', async () => { const invalidTokenResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: `invalid-${randomUUID()}`, newPassword: 'NewPass123', }, }); expect(invalidTokenResponse.statusCode).toBe(400); const user = await createUser(context.db); const expiredToken = `expired-${randomUUID()}`; await context.db.insert(schema.passwordResetTokens).values({ userId: user.userId, tokenHash: sha256(expiredToken), createdAt: new Date(Date.now() - 120_000), expiresAt: new Date(Date.now() - 60_000), }); const expiredTokenResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: expiredToken, newPassword: 'NewPass123', }, }); expect(expiredTokenResponse.statusCode).toBe(400); }); it('rejects reset tokens for inactive and OIDC users', async () => { const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' }); const inactiveUserToken = `inactive-${randomUUID()}`; await context.db.insert(schema.passwordResetTokens).values({ userId: inactiveUser.userId, tokenHash: sha256(inactiveUserToken), expiresAt: new Date(Date.now() + 60_000), }); const inactiveResetResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: inactiveUserToken, newPassword: 'NewPass123', }, }); expect(inactiveResetResponse.statusCode).toBe(400); const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' }); const oidcUserToken = `oidc-${randomUUID()}`; await context.db.insert(schema.passwordResetTokens).values({ userId: oidcUser.userId, tokenHash: sha256(oidcUserToken), expiresAt: new Date(Date.now() + 60_000), }); const oidcResetResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: oidcUserToken, newPassword: 'NewPass123', }, }); expect(oidcResetResponse.statusCode).toBe(400); const inactiveTokenRow = await context.db.query.passwordResetTokens.findFirst({ where: eq(schema.passwordResetTokens.tokenHash, sha256(inactiveUserToken)), }); expect(inactiveTokenRow?.usedAt).toBeNull(); const oidcTokenRow = await context.db.query.passwordResetTokens.findFirst({ where: eq(schema.passwordResetTokens.tokenHash, sha256(oidcUserToken)), }); expect(oidcTokenRow?.usedAt).toBeNull(); }); }); describe('native device sessions', () => { async function nativeLogin(user: LocalUserCredentials) { const response = await context.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: user.username, password: user.password, clientKind: 'native', deviceLabel: 'iOS test' }, }); expect(response.statusCode).toBe(200); expect(getSetCookieLines(response.headers)).toEqual([]); expect(response.headers['cache-control']).toBe('no-store'); return response.json(); } function refresh(refreshToken: string) { return context.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', payload: { refreshToken } }); } function me(accessToken: string) { return context.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${accessToken}` } }); } it('serializes concurrent rotation and recovers the same replacement after a lost response', async () => { const user = await createUser(context.db); const first = await nativeLogin(user); const results = await Promise.all(Array.from({ length: 8 }, () => refresh(first.refreshToken))); expect(results.map((result) => result.statusCode)).toEqual(Array(8).fill(200)); const replacements = results.map((result) => result.json()); expect(new Set(replacements.map((replacement) => replacement.refreshToken)).size).toBe(1); expect(replacements.every((replacement) => replacement.sessionId === first.sessionId)).toBe(true); const retry = await refresh(first.refreshToken); expect(retry.json().refreshToken).toBe(replacements[0].refreshToken); const rows = await context.db.select().from(schema.refreshTokens).where(eq(schema.refreshTokens.sessionId, first.sessionId)); expect(rows).toHaveLength(2); expect(rows.filter((row) => !row.revokedAt)).toHaveLength(1); expect(JSON.stringify(rows)).not.toContain(replacements[0].refreshToken); expect((await refresh(replacements[0].refreshToken)).statusCode).toBe(200); const list = await context.app.inject({ method: 'GET', url: '/api/v1/auth/sessions', headers: { authorization: `Bearer ${first.accessToken}` }, }); expect(list.json()).toEqual([expect.objectContaining({ id: first.sessionId, clientKind: 'native', deviceLabel: 'iOS test' })]); }); it('limits replay outside the grace window to one device', async () => { const user = await createUser(context.db); const first = await nativeLogin(user); const second = await nativeLogin(user); const rotated = await refresh(first.refreshToken); expect(rotated.statusCode).toBe(200); await context.db .update(schema.refreshTokens) .set({ rotatedAt: new Date(Date.now() - 60_000) }) .where(eq(schema.refreshTokens.tokenHash, sha256(first.refreshToken))); expect((await refresh(first.refreshToken)).statusCode).toBe(401); expect((await me(first.accessToken)).statusCode).toBe(401); expect((await refresh(rotated.json().refreshToken)).statusCode).toBe(401); expect((await me(second.accessToken)).statusCode).toBe(200); expect((await refresh(second.refreshToken)).statusCode).toBe(200); }); it('logout revokes current access and refresh credentials without affecting another device', async () => { const user = await createUser(context.db); const first = await nativeLogin(user); const second = await nativeLogin(user); const response = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', payload: { refreshToken: first.refreshToken } }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({}); expect((await me(first.accessToken)).statusCode).toBe(401); expect((await refresh(first.refreshToken)).statusCode).toBe(401); expect((await me(second.accessToken)).statusCode).toBe(200); const userAfter = await context.db.query.users.findFirst({ where: eq(schema.users.id, user.userId) }); expect(userAfter?.tokenVersion).toBe(1); }); it('checks ownership before device removal and rejects credentials immediately after removal', async () => { const user = await createUser(context.db); const other = await createUser(context.db); const first = await nativeLogin(user); const second = await nativeLogin(user); const outsider = await nativeLogin(other); const remove = (token: string) => context.app.inject({ method: 'DELETE', url: `/api/v1/auth/sessions/${first.sessionId}`, headers: { authorization: `Bearer ${token}` } }); expect((await remove(outsider.accessToken)).statusCode).toBe(403); expect((await me(first.accessToken)).statusCode).toBe(200); expect((await remove(second.accessToken)).statusCode).toBe(204); expect((await me(first.accessToken)).statusCode).toBe(401); expect((await me(second.accessToken)).statusCode).toBe(200); }); it('validates native DTOs and rejects conflicting credential transports', async () => { const user = await createUser(context.db); const native = await nativeLogin(user); const web = await login(context.app, user.username, user.password); const response = await context.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', headers: { cookie: `refresh_token=${web.refreshToken}` }, payload: { refreshToken: native.refreshToken }, }); expect(response.statusCode).toBe(400); expect((await refresh('invalid')).statusCode).toBe(400); const invalid = await context.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: user.username, password: user.password, clientKind: 'unknown' }, }); expect(invalid.statusCode).toBe(400); expect((await me(web.accessToken)).statusCode).toBe(200); expect((await me(native.accessToken)).statusCode).toBe(200); }); it('isolates provider logout by issuer and sid, leaving password and other provider sessions active', async () => { const user = await createUser(context.db); const password = await nativeLogin(user); const sessions = context.app.get(AuthSessionService); const first = await sessions.issue( user.userId, 1, 'oidc', { clientKind: 'native' }, { oidcSubject: 'subject', oidcIssuer: 'https://first.example', oidcSessionId: 'shared-sid' }, ); const second = await sessions.issue( user.userId, 1, 'oidc', { clientKind: 'native' }, { oidcSubject: 'subject', oidcIssuer: 'https://second.example', oidcSessionId: 'shared-sid' }, ); const repository = context.app.get(OidcSessionRepository); expect(await repository.revokeProviderSessions('https://first.example', { sid: 'shared-sid' })).toBe(1); expect((await me(first.accessToken)).statusCode).toBe(401); expect((await refresh(first.refreshToken)).statusCode).toBe(401); expect((await me(second.accessToken)).statusCode).toBe(200); expect((await me(password.accessToken)).statusCode).toBe(200); expect(await repository.revokeProviderSessions('https://second.example', { subject: 'subject' })).toBe(1); expect((await me(second.accessToken)).statusCode).toBe(401); }); }); describe('OIDC logout', () => { it('clears cookies and revokes session for local user without OIDC session', async () => { const user = await createUser(context.db); const session = await login(context.app, user.username, user.password); const logoutResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: cookieHeader(session.jar) }, }); expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.json()).toEqual({}); const logoutCookies = getSetCookieLines(logoutResponse.headers); expect(cookieValue(logoutCookies, 'refresh_token')).toBe(''); expect(cookieValue(logoutCookies, 'access_token')).toBe(''); const refreshAfterLogout = await context.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', headers: { cookie: `refresh_token=${session.refreshToken}` }, }); expect(refreshAfterLogout.statusCode).toBe(401); }); it('revokes the local OIDC session without signing out of the identity provider', async () => { const user = await createUser(context.db); const session = await login(context.app, user.username, user.password); await setOidcEnabledConfig(context.db); const [createdOidcSession] = await context.db .insert(schema.oidcSessions) .values({ userId: user.userId, sessionId: session.sessionId, oidcSubject: `sub-${randomUUID()}`, oidcIssuer: 'https://issuer.example', oidcSessionId: `sid-${randomUUID()}`, idTokenHint: `id-token-${randomUUID()}`, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning({ id: schema.oidcSessions.id, idTokenHint: schema.oidcSessions.idTokenHint }); const logoutResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: cookieHeader(session.jar), origin: 'http://localhost:6263', }, }); expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.json()).toEqual({}); expect(context.oidcDiscoveryMock.getDiscoveryDoc).not.toHaveBeenCalled(); const logoutCookies = getSetCookieLines(logoutResponse.headers); expect(cookieValue(logoutCookies, 'refresh_token')).toBe(''); expect(cookieValue(logoutCookies, 'access_token')).toBe(''); const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.id, createdOidcSession.id), }); expect(oidcSessionAfter?.revoked).toBe(true); const refreshAfterLogout = await context.app.inject({ method: 'POST', url: '/api/v1/auth/refresh', headers: { cookie: `refresh_token=${session.refreshToken}`, }, }); expect(refreshAfterLogout.statusCode).toBe(401); const meAfterLogout = await context.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${session.accessToken}`, }, }); expect(meAfterLogout.statusCode).toBe(401); }); it('revokes local OIDC session even when id token hint is missing', async () => { const user = await createUser(context.db); const session = await login(context.app, user.username, user.password); await setOidcEnabledConfig(context.db); const [createdOidcSession] = await context.db .insert(schema.oidcSessions) .values({ userId: user.userId, sessionId: session.sessionId, oidcSubject: `sub-${randomUUID()}`, oidcIssuer: 'https://issuer.example', oidcSessionId: `sid-${randomUUID()}`, idTokenHint: null, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning({ id: schema.oidcSessions.id }); const logoutResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: cookieHeader(session.jar), }, }); expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.json()).toEqual({}); const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.id, createdOidcSession.id), }); expect(oidcSessionAfter?.revoked).toBe(true); }); it('still revokes local OIDC session when end-session endpoint is unavailable', async () => { const user = await createUser(context.db); const session = await login(context.app, user.username, user.password); await setOidcEnabledConfig(context.db); const [createdOidcSession] = await context.db .insert(schema.oidcSessions) .values({ userId: user.userId, sessionId: session.sessionId, oidcSubject: `sub-${randomUUID()}`, oidcIssuer: 'https://issuer.example', oidcSessionId: `sid-${randomUUID()}`, idTokenHint: `id-token-${randomUUID()}`, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning({ id: schema.oidcSessions.id }); context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue({ ...defaultDiscoveryDoc(), endSessionEndpoint: undefined, }); const logoutResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: cookieHeader(session.jar), }, }); expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.json()).toEqual({}); const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.id, createdOidcSession.id), }); expect(oidcSessionAfter?.revoked).toBe(true); }); it('still revokes local OIDC session when discovery lookup fails', async () => { const user = await createUser(context.db); const session = await login(context.app, user.username, user.password); await setOidcEnabledConfig(context.db); const [createdOidcSession] = await context.db .insert(schema.oidcSessions) .values({ userId: user.userId, sessionId: session.sessionId, oidcSubject: `sub-${randomUUID()}`, oidcIssuer: 'https://issuer.example', oidcSessionId: `sid-${randomUUID()}`, idTokenHint: `id-token-${randomUUID()}`, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }) .returning({ id: schema.oidcSessions.id }); context.oidcDiscoveryMock.getDiscoveryDoc.mockRejectedValueOnce(new Error('discovery unavailable')); const logoutResponse = await context.app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: cookieHeader(session.jar), }, }); expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.json()).toEqual({}); const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.id, createdOidcSession.id), }); expect(oidcSessionAfter?.revoked).toBe(true); }); }); });