chore: move oidc backend to proper DI

This commit is contained in:
jubnl
2026-08-01 18:25:28 +02:00
parent d892b61a59
commit ec0709741a
9 changed files with 1306 additions and 1379 deletions
+19 -22
View File
@@ -1,15 +1,17 @@
/**
* OIDC e2e — exercises the migrated /api/auth/oidc flow with the real cookie
* service. The OIDC service is mocked and the auth toggles are stubbed on the
* container's real AuthService instance (OidcService injects it since the auth
* DI fold — the old services/authService path mock would silently miss); this
* proves the flow is unauthenticated, the sso-disabled 403, the login redirect,
* and that /exchange sets the httpOnly trek_session cookie from a valid auth code.
* service. The OIDC methods and the auth toggles are stubbed with vi.spyOn on
* the container's real OidcService/AuthService instances (the domain is
* DI-native since the oidc fold — a services/oidcService path mock would
* silently miss); this proves the flow is unauthenticated, the sso-disabled
* 403, the login redirect, and that /exchange sets the httpOnly trek_session
* cookie from a valid auth code.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import type { MockInstance } from 'vitest';
import { Test } from '@nestjs/testing';
vi.mock('../../src/app-config', async (importOriginal) => {
@@ -20,9 +22,10 @@ vi.mock('../../src/app-config', async (importOriginal) => {
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
// Bare :memory: db — nothing in these flows queries it (the toggles are
// stubbed on the AuthService instance below), the container just needs a
// connection to construct the auth/permissions/atlas providers.
// Bare :memory: db — nothing in these flows queries it (the db-touching
// OidcService methods and the toggles are stubbed on the container
// instances below), the container just needs a connection to construct the
// oidc/auth/permissions/atlas providers.
return { db: new Database(':memory:') };
});
vi.mock('../../src/db/database', () => ({
@@ -38,16 +41,8 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', lo
const toggles = { oidc_login: true };
const { oidcSvc } = vi.hoisted(() => ({
oidcSvc: {
getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(),
consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(),
findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p,
},
}));
vi.mock('../../src/services/oidcService', () => oidcSvc);
import { OidcModule } from '../../src/nest/oidc/oidc.module';
import { OidcService } from '../../src/nest/oidc/oidc.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { AuthService } from '../../src/nest/auth/auth.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
@@ -55,6 +50,7 @@ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter
describe('OIDC e2e (real cookie service)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let consumeAuthCode: MockInstance;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OidcModule] }).compile();
@@ -70,10 +66,11 @@ describe('OIDC e2e (real cookie service)', () => {
app = await build();
server = app.getHttpServer();
vi.spyOn(app.get(AuthService), 'resolveAuthToggles').mockImplementation(() => toggles as never);
oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null });
oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' });
oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
const oidc = app.get(OidcService);
vi.spyOn(oidc, 'getOidcConfig').mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', displayName: 'SSO', discoveryUrl: null });
vi.spyOn(oidc, 'discover').mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' } as never);
vi.spyOn(oidc, 'createState').mockReturnValue({ state: 'st', codeChallenge: 'cc' });
consumeAuthCode = vi.spyOn(oidc, 'consumeAuthCode').mockReturnValue({ token: 'jwt.value' });
});
beforeEach(() => { toggles.oidc_login = true; });
@@ -102,7 +99,7 @@ describe('OIDC e2e (real cookie service)', () => {
});
it('GET /exchange sets the httpOnly trek_session cookie + returns the token', async () => {
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ token: 'jwt.value' });
+34 -34
View File
@@ -1,8 +1,11 @@
/**
* OIDC integration tests — OIDC-001 through OIDC-010.
* OIDC integration tests — OIDC-001 through OIDC-014.
* Covers /api/auth/oidc/login, /callback, /exchange.
* HTTP calls (discover, exchangeCodeForToken, getUserInfo) are mocked.
* State management, auth codes, and findOrCreateUser run against the real test DB.
* HTTP calls (discover, exchangeCodeForToken, getUserInfo, verifyIdToken) are
* stubbed with vi.spyOn on the container's OidcService instance (the domain is
* DI-native since the oidc fold — a services/oidcService path mock would
* silently miss). State management, auth codes, and findOrCreateUser run for
* real on that same instance against the real test DB.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
@@ -42,33 +45,13 @@ vi.mock('../../src/config', () => ({
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
vi.mock('../../src/services/oidcService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/oidcService')>();
return {
...actual,
discover: vi.fn(),
exchangeCodeForToken: vi.fn(),
getUserInfo: vi.fn(),
// Bypass real JWKS fetch + signature verification in tests. Callers
// that exercise the security of verifyIdToken should unit-test the
// function directly instead; integration tests here focus on the
// callback flow, not the crypto.
verifyIdToken: vi.fn(),
};
});
import type { MockInstance } from 'vitest';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import * as oidcService from '../../src/services/oidcService';
const mockDiscover = vi.mocked(oidcService.discover);
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
const mockVerifyIdToken = vi.mocked(oidcService.verifyIdToken);
import { OidcService } from '../../src/nest/oidc/oidc.service';
const MOCK_DISCOVERY_DOC = {
authorization_endpoint: 'https://oidc.example.com/auth',
@@ -78,12 +61,29 @@ const MOCK_DISCOVERY_DOC = {
let nestApp: INestApplication;
let app: Application;
let oidcSvc: OidcService;
let mockDiscover: MockInstance<OidcService['discover']>;
let mockExchangeCode: MockInstance<OidcService['exchangeCodeForToken']>;
let mockGetUserInfo: MockInstance<OidcService['getUserInfo']>;
let mockVerifyIdToken: MockInstance<OidcService['verifyIdToken']>;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
// Stub only the HTTP-calling methods on the container's OidcService — the
// default implementation resolves undefined so an unexpected call fails the
// flow (matching the old vi.fn() mocks) instead of hitting the network.
// Bypasses real JWKS fetch + signature verification: callers that exercise
// the security of verifyIdToken unit-test the method directly; integration
// tests here focus on the callback flow, not the crypto. State management,
// auth codes, and findOrCreateUser run for real on this same instance.
oidcSvc = nestApp.get(OidcService);
mockDiscover = vi.spyOn(oidcSvc, 'discover').mockImplementation(async () => undefined as never);
mockExchangeCode = vi.spyOn(oidcSvc, 'exchangeCodeForToken').mockImplementation(async () => undefined as never);
mockGetUserInfo = vi.spyOn(oidcSvc, 'getUserInfo').mockImplementation(async () => undefined as never);
mockVerifyIdToken = vi.spyOn(oidcSvc, 'verifyIdToken').mockImplementation(async () => undefined as never);
});
beforeEach(() => {
@@ -169,7 +169,7 @@ describe('GET /api/auth/oidc/callback', () => {
});
// Create a valid state token
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -187,7 +187,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'New User',
});
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -224,7 +224,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -236,7 +236,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -249,7 +249,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -267,7 +267,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'Alice',
});
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -290,7 +290,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'Blocked',
});
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
@@ -304,7 +304,7 @@ describe('GET /api/auth/oidc/callback', () => {
describe('GET /api/auth/oidc/exchange', () => {
it('OIDC-011: valid auth code returns JWT and sets cookie', async () => {
const fakeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.sig';
const code = oidcService.createAuthCode(fakeToken);
const code = oidcSvc.createAuthCode(fakeToken);
const res = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
@@ -331,7 +331,7 @@ describe('GET /api/auth/oidc/exchange', () => {
it('OIDC-014: auth code is single-use (second use returns 400)', async () => {
const fakeToken = 'test.token.here';
const code = oidcService.createAuthCode(fakeToken);
const code = oidcSvc.createAuthCode(fakeToken);
// First use: success
const res1 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
+689 -113
View File
@@ -1,163 +1,739 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
/**
* Unit tests for the DI-native OidcService — OIDC-SVC-001 through OIDC-SVC-048
* (moved 1:1 from the legacy tests/unit/services/oidcService.test.ts with case
* IDs preserved; 046–048 carry over the wrapper cases from the superseded
* delegation-shim suite). Covers state management, auth codes, role
* resolution, findOrCreateUser, discover caching, and the ReDoS-sensitive
* issuer trailing-slash regex. Constructed directly (no TestingModule, repo
* convention) over a real in-memory SQLite database.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
// The Nest service is a thin pass-through to the legacy OIDC helpers plus a few
// adjacent service modules. Mock each one and assert the wrapper forwards every
// argument and returns whatever the legacy function hands back.
const { oidc } = vi.hoisted(() => ({
oidc: {
getOidcConfig: vi.fn(),
discover: vi.fn(),
createState: vi.fn(),
consumeState: vi.fn(),
exchangeCodeForToken: vi.fn(),
verifyIdToken: vi.fn(),
getUserInfo: vi.fn(),
findOrCreateUser: vi.fn(),
touchLastLogin: vi.fn(),
generateToken: vi.fn(),
createAuthCode: vi.fn(),
consumeAuthCode: vi.fn(),
frontendUrl: vi.fn(),
},
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
SESSION_DURATION_SECONDS: 86400,
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/oidcService', () => oidc);
const { getAppUrl } = vi.hoisted(() => ({ getAppUrl: vi.fn() }));
// Construction insurance for the injected AuthService's import graph (same set
// as auth.service.test.ts) — services/tripMembership is deliberately NOT
// mocked: OIDC-SVC-045 asserts the real trip_members row a trip-bound invite
// creates.
vi.mock('../../../src/services/mfaCrypto', () => ({
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
vi.mock('../../../src/scheduler', () => ({
startTripReminders: vi.fn(),
buildCronExpression: vi.fn(),
loadSettings: vi.fn(() => ({ enabled: false })),
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
}));
const { getAppUrlMock } = vi.hoisted(() => ({ getAppUrlMock: vi.fn() }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getAppUrl };
return { ...actual, getAppUrl: getAppUrlMock };
});
// OidcService injects AuthService since the auth DI fold; a stub instance
// replaces the old services/authService path mock.
const resolveAuthToggles = vi.fn();
const { setAuthCookie } = vi.hoisted(() => ({ setAuthCookie: vi.fn() }));
vi.mock('../../../src/services/cookie', () => ({ setAuthCookie }));
const { setAuthCookieMock } = vi.hoisted(() => ({ setAuthCookieMock: vi.fn() }));
vi.mock('../../../src/services/cookie', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/services/cookie')>();
return { ...actual, setAuthCookie: setAuthCookieMock };
});
import type { Request, Response } from 'express';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { AuthService } from '../../../src/nest/auth/auth.service';
import { OidcService } from '../../../src/nest/oidc/oidc.service';
import type { AuthService } from '../../../src/nest/auth/auth.service';
let s: OidcService;
beforeEach(() => {
vi.clearAllMocks();
s = new OidcService({ resolveAuthToggles } as unknown as AuthService);
const auth = new AuthService(
new DatabaseService(testDb),
new PermissionsService(new DatabaseService(testDb)),
new AtlasService(new DatabaseService(testDb)),
);
const svc = new OidcService(new DatabaseService(testDb), auth);
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
displayName: 'SSO',
discoveryUrl: null,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
describe('OidcService', () => {
it('oidcLoginEnabled reads the resolved auth toggle', () => {
resolveAuthToggles.mockReturnValue({ oidc_login: true });
expect(s.oidcLoginEnabled()).toBe(true);
resolveAuthToggles.mockReturnValue({ oidc_login: false });
expect(s.oidcLoginEnabled()).toBe(false);
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
delete process.env.OIDC_ADMIN_CLAIM;
delete process.env.NODE_ENV;
});
afterAll(() => {
vi.unstubAllGlobals();
svc.onModuleDestroy();
testDb.close();
});
// ── createState / consumeState ────────────────────────────────────────────────
describe('createState / consumeState', () => {
it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => {
const { state, codeChallenge } = svc.createState('https://example.com/callback');
expect(state).toMatch(/^[0-9a-f]{64}$/);
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding
});
it('getOidcConfig delegates to the legacy helper', () => {
const cfg = { issuer: 'https://idp' };
oidc.getOidcConfig.mockReturnValue(cfg);
expect(s.getOidcConfig()).toBe(cfg);
it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => {
const { state } = svc.createState('https://example.com/callback', 'invite-abc');
const data = svc.consumeState(state);
expect(data).not.toBeNull();
expect(data!.redirectUri).toBe('https://example.com/callback');
expect(data!.inviteToken).toBe('invite-abc');
expect(typeof data!.codeVerifier).toBe('string');
expect(data!.codeVerifier.length).toBeGreaterThan(20);
// State is consumed — second call returns null
expect(svc.consumeState(state)).toBeNull();
});
it('getAppUrl delegates to app-config getAppUrl', () => {
getAppUrl.mockReturnValue('https://app');
expect(s.getAppUrl()).toBe('https://app');
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
expect(svc.consumeState('not-a-real-state')).toBeNull();
});
it('discover forwards the issuer and discovery url', () => {
const doc = { authorization_endpoint: 'https://idp/auth' };
oidc.discover.mockReturnValue(doc);
expect(s.discover('https://idp', 'https://idp/.well-known')).toBe(doc);
expect(oidc.discover).toHaveBeenCalledWith('https://idp', 'https://idp/.well-known');
it('OIDC-SVC-004: two different states do not conflict', () => {
const { state: s1 } = svc.createState('http://a.example.com');
const { state: s2 } = svc.createState('http://b.example.com');
expect(s1).not.toBe(s2);
expect(svc.consumeState(s1)!.redirectUri).toBe('http://a.example.com');
expect(svc.consumeState(s2)!.redirectUri).toBe('http://b.example.com');
});
});
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
describe('createAuthCode / consumeAuthCode', () => {
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
const code = svc.createAuthCode('my.jwt.token');
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
});
it('discover works without a discovery url', () => {
oidc.discover.mockReturnValue('doc');
expect(s.discover('https://idp')).toBe('doc');
expect(oidc.discover).toHaveBeenCalledWith('https://idp', undefined);
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
const code = svc.createAuthCode('real.jwt.here');
const result = svc.consumeAuthCode(code);
expect('token' in result).toBe(true);
expect((result as { token: string }).token).toBe('real.jwt.here');
});
it('createState forwards the redirect uri and invite token', () => {
const st = { state: 'st', codeChallenge: 'cc' };
oidc.createState.mockReturnValue(st);
expect(s.createState('https://app/cb', 'inv')).toBe(st);
expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', 'inv');
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
const code = svc.createAuthCode('single.use.token');
svc.consumeAuthCode(code); // first use
const second = svc.consumeAuthCode(code);
expect('error' in second).toBe(true);
});
it('createState works without an invite token', () => {
oidc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
s.createState('https://app/cb');
expect(oidc.createState).toHaveBeenCalledWith('https://app/cb', undefined);
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
const result = svc.consumeAuthCode('not-a-real-code');
expect('error' in result).toBe(true);
});
});
// ── resolveOidcRole ───────────────────────────────────────────────────────────
describe('resolveOidcRole', () => {
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
expect(svc.resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
});
it('consumeState forwards the state', () => {
oidc.consumeState.mockReturnValue({ redirectUri: 'r', codeVerifier: 'v' });
expect(s.consumeState('st')).toEqual({ redirectUri: 'r', codeVerifier: 'v' });
expect(oidc.consumeState).toHaveBeenCalledWith('st');
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
delete process.env.OIDC_ADMIN_VALUE;
expect(svc.resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
});
it('exchangeCodeForToken spreads all arguments through', () => {
oidc.exchangeCodeForToken.mockReturnValue({ _ok: true });
const doc = { token_endpoint: 'https://idp/token' } as never;
expect(s.exchangeCodeForToken(doc, 'code', 'redir', 'cid', 'secret', 'verifier')).toEqual({ _ok: true });
expect(oidc.exchangeCodeForToken).toHaveBeenCalledWith(doc, 'code', 'redir', 'cid', 'secret', 'verifier');
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(svc.resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
});
it('verifyIdToken spreads all arguments through', () => {
oidc.verifyIdToken.mockReturnValue({ ok: true });
const doc = { issuer: 'https://idp' } as never;
expect(s.verifyIdToken('id_token', doc, 'cid', 'https://idp')).toEqual({ ok: true });
expect(oidc.verifyIdToken).toHaveBeenCalledWith('id_token', doc, 'cid', 'https://idp');
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(svc.resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
});
it('getUserInfo forwards the endpoint and access token', () => {
oidc.getUserInfo.mockReturnValue({ email: 'a@b.c' });
expect(s.getUserInfo('https://idp/ui', 'at')).toEqual({ email: 'a@b.c' });
expect(oidc.getUserInfo).toHaveBeenCalledWith('https://idp/ui', 'at');
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
process.env.OIDC_ADMIN_VALUE = 'superadmin';
process.env.OIDC_ADMIN_CLAIM = 'roles';
expect(svc.resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
});
it('findOrCreateUser spreads all arguments through', () => {
const result = { user: { id: 1 } };
oidc.findOrCreateUser.mockReturnValue(result);
const info = { email: 'a@b.c' } as never;
const cfg = { issuer: 'https://idp' } as never;
expect(s.findOrCreateUser(info, cfg, 'inv')).toBe(result);
expect(oidc.findOrCreateUser).toHaveBeenCalledWith(info, cfg, 'inv');
it('OIDC-SVC-014: handles string claim (exact match)', () => {
process.env.OIDC_ADMIN_VALUE = 'admin';
process.env.OIDC_ADMIN_CLAIM = 'role';
expect(svc.resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
expect(svc.resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
});
});
// ── frontendUrl ───────────────────────────────────────────────────────────────
describe('frontendUrl', () => {
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
delete process.env.NODE_ENV;
expect(svc.frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
});
it('touchLastLogin forwards the user id', () => {
s.touchLastLogin(42);
expect(oidc.touchLastLogin).toHaveBeenCalledWith(42);
it('OIDC-SVC-016: returns bare path in production', () => {
process.env.NODE_ENV = 'production';
expect(svc.frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
delete process.env.NODE_ENV;
});
});
// ── discover ──────────────────────────────────────────────────────────────────
describe('discover', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('generateToken forwards the user', () => {
oidc.generateToken.mockReturnValue('jwt');
expect(s.generateToken({ id: 7 })).toBe('jwt');
expect(oidc.generateToken).toHaveBeenCalledWith({ id: 7 });
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
const doc = {
authorization_endpoint: 'https://oidc.example.com/auth',
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}));
// Use unique issuer to bypass the instance-level cache from other tests
const result = await svc.discover('https://unique-1.example.com');
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
expect(result.token_endpoint).toBe(doc.token_endpoint);
});
it('createAuthCode forwards the token', () => {
oidc.createAuthCode.mockReturnValue('ac');
expect(s.createAuthCode('jwt')).toBe('ac');
expect(oidc.createAuthCode).toHaveBeenCalledWith('jwt');
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
await expect(svc.discover('https://bad-issuer.example.com')).rejects.toThrow();
});
it('consumeAuthCode forwards the code', () => {
oidc.consumeAuthCode.mockReturnValue({ token: 'jwt' });
expect(s.consumeAuthCode('ac')).toEqual({ token: 'jwt' });
expect(oidc.consumeAuthCode).toHaveBeenCalledWith('ac');
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
const doc = {
issuer: 'https://auth.example.com/application/o/myapp/',
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
token_endpoint: 'https://auth.example.com/application/o/token/',
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await svc.discover(
'https://auth.example.com',
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
);
expect(result.issuer).toBe(doc.issuer);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
warnSpy.mockRestore();
});
it('frontendUrl forwards the path', () => {
oidc.frontendUrl.mockReturnValue('https://app/login');
expect(s.frontendUrl('/login')).toBe('https://app/login');
expect(oidc.frontendUrl).toHaveBeenCalledWith('/login');
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
const doc = {
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://unique-2.example.com/auth',
token_endpoint: 'https://unique-2.example.com/token',
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
await expect(svc.discover('https://unique-2.example.com')).rejects.toThrow(
'OIDC discovery issuer mismatch',
);
});
it('setAuthCookie forwards res, token and req to the cookie helper', () => {
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
const doc = {
issuer: 'https://auth.example.com/',
authorization_endpoint: 'https://auth.example.com/auth',
token_endpoint: 'https://auth.example.com/token',
userinfo_endpoint: 'https://auth.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await svc.discover(
'https://auth.example.com',
'https://auth.example.com/.well-known/openid-configuration',
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
describe('getOidcConfig issuer trailing-slash regex', () => {
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
// Adversarial input: many trailing slashes — should not backtrack catastrophically
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
const start = Date.now();
const result = adversarial.replace(/\/+$/, '');
const elapsed = Date.now() - start;
expect(result).toBe('https://oidc.example.com');
expect(elapsed).toBeLessThan(100);
});
});
// ── findOrCreateUser ──────────────────────────────────────────────────────────
describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = svc.findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = svc.findOrCreateUser(
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob', email_verified: true },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-022: creates new user when registration is open', () => {
const result = svc.findOrCreateUser(
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
});
it('OIDC-SVC-023: first user gets admin role', () => {
// DB is empty after resetTestDb
const result = svc.findOrCreateUser(
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
});
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = svc.findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('registration_disabled');
});
it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
svc.findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true },
MOCK_CONFIG
);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBe('sub-charlie-linked');
});
it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', () => {
const { user } = createUser(testDb, { email: 'dora@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
// No email_verified claim — an IdP that lets users set arbitrary emails must
// not be able to take over a pre-existing password account.
const result = svc.findOrCreateUser(
{ sub: 'sub-dora-attacker', email: 'dora@example.com', name: 'Dora' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('email_not_verified');
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked
});
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins';
const result = svc.findOrCreateUser(
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
expect(dbUser.role).toBe('admin');
});
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
const result = svc.findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
MOCK_CONFIG,
'tok-valid'
);
expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
expect(token.used_count).toBe(1);
});
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
const result = svc.findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
MOCK_CONFIG,
'tok-expired'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 0 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
expect(token.used_count).toBe(0);
});
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
const result = svc.findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
MOCK_CONFIG,
'tok-full'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 1 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
expect(token.used_count).toBe(1);
});
// ── OIDC picture claim → avatar (#1399) ──────────────────────────────────
it('OIDC-SVC-040: new user stores the https picture claim as their avatar', () => {
const result = svc.findOrCreateUser(
{ sub: 'sub-pic-1', email: 'pic1@example.com', name: 'Pic One', picture: 'https://idp.example.com/u/pic1.png' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any;
expect(row.avatar).toBe('https://idp.example.com/u/pic1.png');
});
it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', () => {
svc.findOrCreateUser(
{ sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' },
MOCK_CONFIG
);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any;
expect(row.avatar).toBeNull();
});
it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', () => {
const { user } = createUser(testDb, { email: 'pic3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?')
.run('sub-pic-3', MOCK_CONFIG.issuer, user.id);
svc.findOrCreateUser(
{ sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('https://idp.example.com/u/pic3.png');
});
it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', () => {
const { user } = createUser(testDb, { email: 'pic4@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id);
svc.findOrCreateUser(
{ sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('uploaded-abc.jpg');
});
it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', () => {
const { user } = createUser(testDb, { email: 'pic5@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id);
svc.findOrCreateUser(
{ sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('https://idp.example.com/u/new.png');
});
it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', () => {
const { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id);
testDb.prepare(
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)'
).run('inv-trip-join', admin.id, trip.id);
const result = svc.findOrCreateUser(
{ sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' },
MOCK_CONFIG,
'inv-trip-join'
);
expect('user' in result).toBe(true);
const uid = (result as { user: any }).user.id;
const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid);
expect(member).toBeTruthy();
});
});
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
describe('exchangeCodeForToken', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-030: sends correct POST body and returns token data', async () => {
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTokenData,
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await svc.exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
expect(result.access_token).toBe('tok');
expect(result._ok).toBe(true);
expect(result._status).toBe(200);
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toBe('https://oidc.example.com/token');
expect(fetchCall[1].method).toBe('POST');
});
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'invalid_grant' }),
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await svc.exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
expect(result._ok).toBe(false);
expect(result._status).toBe(400);
});
});
// ── getUserInfo ───────────────────────────────────────────────────────────────
describe('getUserInfo', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-032: fetches userinfo with Bearer token and returns parsed JSON', async () => {
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: async () => userInfoData,
}));
const result = await svc.getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
expect(result.sub).toBe('user-sub');
expect(result.email).toBe('user@example.com');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
});
});
// ── verifyIdToken ─────────────────────────────────────────────────────────────
describe('verifyIdToken', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const ISSUER = 'https://auth.example.com/application/o/trek';
const CLIENT_ID = 'trek-client';
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
function mockJwks() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}));
}
function makeToken(iss: string, overrides: object = {}) {
return jwtLib.sign(
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
privateKey,
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
);
}
const doc = { jwks_uri: JWKS_URI } as any;
afterEach(() => { vi.unstubAllGlobals(); });
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
mockJwks();
const token = makeToken(ISSUER);
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
mockJwks();
const token = makeToken(ISSUER + '/');
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
mockJwks();
const token = makeToken('https://evil.example.com');
const result = await svc.verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
expect((result as any).error).toMatch('jwt issuer invalid');
});
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
mockJwks();
const wrongAudToken = jwtLib.sign(
{ sub: 'user-sub', iss: ISSUER },
privateKey,
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
);
const result = await svc.verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
});
});
// ── wrapper methods (carried over from the delegation-shim suite) ────────────
describe('wrapper methods', () => {
it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', () => {
const spy = vi.spyOn(auth, 'resolveAuthToggles');
spy.mockReturnValue({ oidc_login: true } as ReturnType<AuthService['resolveAuthToggles']>);
expect(svc.oidcLoginEnabled()).toBe(true);
spy.mockReturnValue({ oidc_login: false } as ReturnType<AuthService['resolveAuthToggles']>);
expect(svc.oidcLoginEnabled()).toBe(false);
spy.mockRestore();
});
it('OIDC-SVC-047: getAppUrl delegates to app-config getAppUrl', () => {
getAppUrlMock.mockReturnValue('https://app');
expect(svc.getAppUrl()).toBe('https://app');
});
it('OIDC-SVC-048: setAuthCookie forwards res, token and req to the cookie helper', () => {
const res = {} as Response;
const req = {} as Request;
s.setAuthCookie(res, 'jwt', req);
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', req);
svc.setAuthCookie(res, 'jwt', req);
expect(setAuthCookieMock).toHaveBeenCalledWith(res, 'jwt', req);
});
});
@@ -1,676 +0,0 @@
/**
* Unit tests for oidcService — OIDC-SVC-001 through OIDC-SVC-025.
* Covers state management, auth codes, role resolution, findOrCreateUser,
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import {
createState,
consumeState,
createAuthCode,
consumeAuthCode,
resolveOidcRole,
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
displayName: 'SSO',
discoveryUrl: null,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
delete process.env.OIDC_ADMIN_CLAIM;
delete process.env.NODE_ENV;
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── createState / consumeState ────────────────────────────────────────────────
describe('createState / consumeState', () => {
it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => {
const { state, codeChallenge } = createState('https://example.com/callback');
expect(state).toMatch(/^[0-9a-f]{64}$/);
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding
});
it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => {
const { state } = createState('https://example.com/callback', 'invite-abc');
const data = consumeState(state);
expect(data).not.toBeNull();
expect(data!.redirectUri).toBe('https://example.com/callback');
expect(data!.inviteToken).toBe('invite-abc');
expect(typeof data!.codeVerifier).toBe('string');
expect(data!.codeVerifier.length).toBeGreaterThan(20);
// State is consumed — second call returns null
expect(consumeState(state)).toBeNull();
});
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
expect(consumeState('not-a-real-state')).toBeNull();
});
it('OIDC-SVC-004: two different states do not conflict', () => {
const { state: s1 } = createState('http://a.example.com');
const { state: s2 } = createState('http://b.example.com');
expect(s1).not.toBe(s2);
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');
});
});
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
describe('createAuthCode / consumeAuthCode', () => {
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
const code = createAuthCode('my.jwt.token');
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
});
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
const code = createAuthCode('real.jwt.here');
const result = consumeAuthCode(code);
expect('token' in result).toBe(true);
expect((result as { token: string }).token).toBe('real.jwt.here');
});
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
const code = createAuthCode('single.use.token');
consumeAuthCode(code); // first use
const second = consumeAuthCode(code);
expect('error' in second).toBe(true);
});
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
const result = consumeAuthCode('not-a-real-code');
expect('error' in result).toBe(true);
});
});
// ── resolveOidcRole ───────────────────────────────────────────────────────────
describe('resolveOidcRole', () => {
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
expect(resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
});
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
delete process.env.OIDC_ADMIN_VALUE;
expect(resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
});
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
});
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
});
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
process.env.OIDC_ADMIN_VALUE = 'superadmin';
process.env.OIDC_ADMIN_CLAIM = 'roles';
expect(resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
});
it('OIDC-SVC-014: handles string claim (exact match)', () => {
process.env.OIDC_ADMIN_VALUE = 'admin';
process.env.OIDC_ADMIN_CLAIM = 'role';
expect(resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
expect(resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
});
});
// ── frontendUrl ───────────────────────────────────────────────────────────────
describe('frontendUrl', () => {
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
delete process.env.NODE_ENV;
expect(frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
});
it('OIDC-SVC-016: returns bare path in production', () => {
process.env.NODE_ENV = 'production';
expect(frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
delete process.env.NODE_ENV;
});
});
// ── discover ──────────────────────────────────────────────────────────────────
describe('discover', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
const doc = {
authorization_endpoint: 'https://oidc.example.com/auth',
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}));
// Use unique issuer to bypass module-level cache from other tests
const result = await discover('https://unique-1.example.com');
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
expect(result.token_endpoint).toBe(doc.token_endpoint);
});
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
});
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
const doc = {
issuer: 'https://auth.example.com/application/o/myapp/',
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
token_endpoint: 'https://auth.example.com/application/o/token/',
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await discover(
'https://auth.example.com',
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
);
expect(result.issuer).toBe(doc.issuer);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
warnSpy.mockRestore();
});
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
const doc = {
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://unique-2.example.com/auth',
token_endpoint: 'https://unique-2.example.com/token',
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
'OIDC discovery issuer mismatch',
);
});
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
const doc = {
issuer: 'https://auth.example.com/',
authorization_endpoint: 'https://auth.example.com/auth',
token_endpoint: 'https://auth.example.com/token',
userinfo_endpoint: 'https://auth.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await discover(
'https://auth.example.com',
'https://auth.example.com/.well-known/openid-configuration',
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
describe('getOidcConfig issuer trailing-slash regex', () => {
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
// Adversarial input: many trailing slashes — should not backtrack catastrophically
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
const start = Date.now();
const result = adversarial.replace(/\/+$/, '');
const elapsed = Date.now() - start;
expect(result).toBe('https://oidc.example.com');
expect(elapsed).toBeLessThan(100);
});
});
// ── findOrCreateUser ──────────────────────────────────────────────────────────
describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = findOrCreateUser(
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob', email_verified: true },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-022: creates new user when registration is open', () => {
const result = findOrCreateUser(
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
});
it('OIDC-SVC-023: first user gets admin role', () => {
// DB is empty after resetTestDb
const result = findOrCreateUser(
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
});
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('registration_disabled');
});
it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true },
MOCK_CONFIG
);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBe('sub-charlie-linked');
});
it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', () => {
const { user } = createUser(testDb, { email: 'dora@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
// No email_verified claim — an IdP that lets users set arbitrary emails must
// not be able to take over a pre-existing password account.
const result = findOrCreateUser(
{ sub: 'sub-dora-attacker', email: 'dora@example.com', name: 'Dora' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('email_not_verified');
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked
});
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins';
const result = findOrCreateUser(
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
expect(dbUser.role).toBe('admin');
});
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
MOCK_CONFIG,
'tok-valid'
);
expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
expect(token.used_count).toBe(1);
});
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
MOCK_CONFIG,
'tok-expired'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 0 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
expect(token.used_count).toBe(0);
});
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
MOCK_CONFIG,
'tok-full'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 1 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
expect(token.used_count).toBe(1);
});
// ── OIDC picture claim → avatar (#1399) ──────────────────────────────────
it('OIDC-SVC-040: new user stores the https picture claim as their avatar', () => {
const result = findOrCreateUser(
{ sub: 'sub-pic-1', email: 'pic1@example.com', name: 'Pic One', picture: 'https://idp.example.com/u/pic1.png' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any;
expect(row.avatar).toBe('https://idp.example.com/u/pic1.png');
});
it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', () => {
findOrCreateUser(
{ sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' },
MOCK_CONFIG
);
const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any;
expect(row.avatar).toBeNull();
});
it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', () => {
const { user } = createUser(testDb, { email: 'pic3@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?')
.run('sub-pic-3', MOCK_CONFIG.issuer, user.id);
findOrCreateUser(
{ sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('https://idp.example.com/u/pic3.png');
});
it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', () => {
const { user } = createUser(testDb, { email: 'pic4@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id);
findOrCreateUser(
{ sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('uploaded-abc.jpg');
});
it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', () => {
const { user } = createUser(testDb, { email: 'pic5@example.com' });
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?')
.run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id);
findOrCreateUser(
{ sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' },
MOCK_CONFIG
);
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any;
expect(row.avatar).toBe('https://idp.example.com/u/new.png');
});
it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', () => {
const { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id);
testDb.prepare(
'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)'
).run('inv-trip-join', admin.id, trip.id);
const result = findOrCreateUser(
{ sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' },
MOCK_CONFIG,
'inv-trip-join'
);
expect('user' in result).toBe(true);
const uid = (result as { user: any }).user.id;
const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid);
expect(member).toBeTruthy();
});
});
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
describe('exchangeCodeForToken', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-030: sends correct POST body and returns token data', async () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTokenData,
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
expect(result.access_token).toBe('tok');
expect(result._ok).toBe(true);
expect(result._status).toBe(200);
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toBe('https://oidc.example.com/token');
expect(fetchCall[1].method).toBe('POST');
});
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'invalid_grant' }),
}));
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
expect(result._ok).toBe(false);
expect(result._status).toBe(400);
});
});
// ── getUserInfo ───────────────────────────────────────────────────────────────
describe('getUserInfo', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-032: fetches userinfo with Bearer token and returns parsed JSON', async () => {
const { getUserInfo } = await import('../../../src/services/oidcService');
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: async () => userInfoData,
}));
const result = await getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
expect(result.sub).toBe('user-sub');
expect(result.email).toBe('user@example.com');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
});
});
// ── verifyIdToken ─────────────────────────────────────────────────────────────
describe('verifyIdToken', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const ISSUER = 'https://auth.example.com/application/o/trek';
const CLIENT_ID = 'trek-client';
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
function mockJwks() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}));
}
function makeToken(iss: string, overrides: object = {}) {
return jwtLib.sign(
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
privateKey,
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
);
}
const doc = { jwks_uri: JWKS_URI } as any;
afterEach(() => { vi.unstubAllGlobals(); });
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
mockJwks();
const token = makeToken(ISSUER);
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
mockJwks();
const token = makeToken(ISSUER + '/');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
mockJwks();
const token = makeToken('https://evil.example.com');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
expect((result as any).error).toMatch('jwt issuer invalid');
});
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
mockJwks();
const token = makeToken(ISSUER, {});
const wrongAudToken = jwtLib.sign(
{ sub: 'user-sub', iss: ISSUER },
privateKey,
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
);
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
});
});