mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4b86ff73cf
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.
920 lines
30 KiB
TypeScript
920 lines
30 KiB
TypeScript
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<string, string>;
|
|
type InjectResponse = Awaited<ReturnType<NestFastifyApplication['inject']>>;
|
|
|
|
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<typeof vi.spyOn<typeof globalThis, 'fetch'>>;
|
|
oidcDiscoveryMock: {
|
|
getDiscoveryDoc: ReturnType<typeof vi.fn>;
|
|
};
|
|
oidcTokenClientMock: {
|
|
exchangeCode: ReturnType<typeof vi.fn>;
|
|
fetchUserInfo: ReturnType<typeof vi.fn>;
|
|
};
|
|
oidcTokenValidatorMock: {
|
|
validateIdToken: ReturnType<typeof vi.fn>;
|
|
validateLogoutToken: ReturnType<typeof vi.fn>;
|
|
};
|
|
}
|
|
|
|
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<string, string> {
|
|
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> = {}): 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, unknown>): 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, 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 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<Response> {
|
|
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<OidcContractContext> {
|
|
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<NestFastifyApplication>(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>(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>(DB);
|
|
return {
|
|
app,
|
|
db,
|
|
adminToken,
|
|
fetchMock,
|
|
oidcDiscoveryMock,
|
|
oidcTokenClientMock,
|
|
oidcTokenValidatorMock,
|
|
};
|
|
}
|
|
|
|
async function createLocalUser(
|
|
db: Db,
|
|
options: {
|
|
username?: string;
|
|
password?: string;
|
|
active?: boolean;
|
|
email?: string;
|
|
} = {},
|
|
): Promise<LocalUserCredentials> {
|
|
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<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; 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<void> {
|
|
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<string> {
|
|
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<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;
|
|
}
|
|
|
|
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:6263/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:6263/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:6263/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:6263/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:6263/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:6263/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();
|
|
});
|
|
});
|
|
});
|