Files
bookorbit/server/test/auth-recovery-oidc-logout.e2e-spec.ts
T
neon 4b86ff73cf build: move local development off port 3000
Run the dev API on 6262 and Vite on 6263 with strictPort; production
defaults stay on 3000. Define the dev client origin once and read the
typed appConfig instead of repeating its fallback in every gateway.
2026-09-23 18:18:51 +00:00

918 lines
33 KiB
TypeScript

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<string, string>;
interface AuthRecoveryContext {
app: NestFastifyApplication;
db: Db;
systemMailMock: {
isConfigured: ReturnType<typeof vi.fn>;
sendPasswordReset: ReturnType<typeof vi.fn>;
};
oidcDiscoveryMock: {
getDiscoveryDoc: ReturnType<typeof vi.fn>;
};
}
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, string | string[] | undefined>): 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<AuthRecoveryContext> {
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<NestFastifyApplication>(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>(DB);
return { app, db, systemMailMock, oidcDiscoveryMock };
}
async function ensureInitialSetup(app: NestFastifyApplication): Promise<void> {
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<LocalUserCredentials> {
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<LoginResult> {
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<void> {
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<void> {
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<number> {
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<void> {
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<number> {
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<void> {
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<NativeAuthResponse>();
}
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<NativeCredentials>());
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<NativeCredentials>().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<NativeCredentials>().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);
});
});
});