mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
6d2dd37414
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID).
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
/**
|
|
* OIDC integration tests — OIDC-001 through OIDC-010.
|
|
* 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.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
import request from 'supertest';
|
|
import type { Application } from 'express';
|
|
|
|
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
|
|
|
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: () => {},
|
|
}));
|
|
|
|
// ── 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 { createApp } from '../../src/app';
|
|
import { createTables } from '../../src/db/schema';
|
|
import { runMigrations } from '../../src/db/migrations';
|
|
import { resetTestDb } from '../helpers/test-db';
|
|
import { createUser } from '../helpers/factories';
|
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
|
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);
|
|
|
|
const MOCK_DISCOVERY_DOC = {
|
|
authorization_endpoint: 'https://oidc.example.com/auth',
|
|
token_endpoint: 'https://oidc.example.com/token',
|
|
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
|
};
|
|
|
|
const app: Application = createApp();
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
loginAttempts.clear();
|
|
mfaAttempts.clear();
|
|
vi.clearAllMocks();
|
|
|
|
// Set OIDC environment variables for each test
|
|
process.env.OIDC_ISSUER = 'https://oidc.example.com';
|
|
process.env.OIDC_CLIENT_ID = 'test-client-id';
|
|
process.env.OIDC_CLIENT_SECRET = 'test-client-secret';
|
|
process.env.APP_URL = 'http://localhost:3001';
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OIDC_ISSUER;
|
|
delete process.env.OIDC_CLIENT_ID;
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
delete process.env.APP_URL;
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── /login ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('GET /api/auth/oidc/login', () => {
|
|
it('OIDC-001: redirects to OIDC authorization endpoint (302)', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
|
|
const res = await request(app).get('/api/auth/oidc/login');
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('https://oidc.example.com/auth');
|
|
expect(res.headers.location).toContain('client_id=test-client-id');
|
|
expect(res.headers.location).toContain('response_type=code');
|
|
expect(res.headers.location).toContain('redirect_uri=');
|
|
expect(res.headers.location).toContain('state=');
|
|
});
|
|
|
|
it('OIDC-002: returns 400 when OIDC is not configured', async () => {
|
|
delete process.env.OIDC_ISSUER;
|
|
delete process.env.OIDC_CLIENT_ID;
|
|
delete process.env.OIDC_CLIENT_SECRET;
|
|
|
|
const res = await request(app).get('/api/auth/oidc/login');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('OIDC-003: includes invite token in state when provided', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
|
|
const res = await request(app).get('/api/auth/oidc/login?invite=abc123');
|
|
expect(res.status).toBe(302);
|
|
// State is a hex token; the invite is embedded in pendingStates (internal)
|
|
// We just verify the redirect happened successfully
|
|
expect(res.headers.location).toContain('state=');
|
|
});
|
|
});
|
|
|
|
// ── /callback ────────────────────────────────────────────────────────────────
|
|
|
|
describe('GET /api/auth/oidc/callback', () => {
|
|
it('OIDC-004: valid code for existing user → redirects to frontend with oidc_code', async () => {
|
|
const { user } = createUser(testDb, { email: 'alice@example.com' });
|
|
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
mockExchangeCode.mockResolvedValueOnce({
|
|
access_token: 'test-access-token',
|
|
id_token: 'fake.id.token',
|
|
_ok: true,
|
|
_status: 200,
|
|
});
|
|
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-alice-123' } });
|
|
mockGetUserInfo.mockResolvedValueOnce({
|
|
sub: 'sub-alice-123',
|
|
email: 'alice@example.com',
|
|
name: 'Alice',
|
|
});
|
|
|
|
// Create a valid state token
|
|
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
|
|
|
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('/login?oidc_code=');
|
|
});
|
|
|
|
it('OIDC-005: new user gets created when registration is open', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
|
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-newuser-999' } });
|
|
mockGetUserInfo.mockResolvedValueOnce({
|
|
sub: 'sub-newuser-999',
|
|
email: 'newuser@example.com',
|
|
name: 'New User',
|
|
});
|
|
|
|
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
|
|
|
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('/login?oidc_code=');
|
|
|
|
// Verify user was created in DB
|
|
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
|
expect(newUser).toBeDefined();
|
|
});
|
|
|
|
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
|
const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=invalid-state-xyz');
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=invalid_state');
|
|
});
|
|
|
|
it('OIDC-007: provider error param → redirects with error', async () => {
|
|
const res = await request(app).get('/api/auth/oidc/callback?error=access_denied');
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=access_denied');
|
|
});
|
|
|
|
it('OIDC-008: missing code or state → redirects with missing_params error', async () => {
|
|
const res = await request(app).get('/api/auth/oidc/callback');
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=missing_params');
|
|
});
|
|
|
|
it('OIDC-009: token exchange failure → redirects with token_failed error', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
|
|
|
|
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
|
|
|
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=token_failed');
|
|
});
|
|
|
|
it('OIDC-010a: missing id_token in token response → redirects with no_id_token error', async () => {
|
|
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 res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=no_id_token');
|
|
});
|
|
|
|
it('OIDC-010b: verifyIdToken failure → redirects with id_token_invalid error', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
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 res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
|
|
});
|
|
|
|
it('OIDC-010c: userinfo.sub does not match id_token.sub → redirects with subject_mismatch error', async () => {
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
|
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-from-token' } });
|
|
mockGetUserInfo.mockResolvedValueOnce({
|
|
sub: 'sub-different-from-userinfo',
|
|
email: 'alice@example.com',
|
|
name: 'Alice',
|
|
});
|
|
|
|
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
|
|
|
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
|
|
});
|
|
|
|
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
|
|
// Need at least one existing user so isFirstUser=false
|
|
createUser(testDb, { email: 'existing@example.com' });
|
|
// Disable registration
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
|
|
|
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
|
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
|
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-blocked-user' } });
|
|
mockGetUserInfo.mockResolvedValueOnce({
|
|
sub: 'sub-blocked-user',
|
|
email: 'blocked@example.com',
|
|
name: 'Blocked',
|
|
});
|
|
|
|
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
|
|
|
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.location).toContain('oidc_error=registration_disabled');
|
|
});
|
|
});
|
|
|
|
// ── /exchange ─────────────────────────────────────────────────────────────────
|
|
|
|
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 res = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBe(fakeToken);
|
|
expect(res.headers['set-cookie']).toBeDefined();
|
|
const cookieHeader = Array.isArray(res.headers['set-cookie'])
|
|
? res.headers['set-cookie'].join(';')
|
|
: res.headers['set-cookie'];
|
|
expect(cookieHeader).toContain('trek_session');
|
|
});
|
|
|
|
it('OIDC-012: missing code returns 400', async () => {
|
|
const res = await request(app).get('/api/auth/oidc/exchange');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('OIDC-013: invalid/expired code returns 400', async () => {
|
|
const res = await request(app).get('/api/auth/oidc/exchange?code=not-a-real-code');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('OIDC-014: auth code is single-use (second use returns 400)', async () => {
|
|
const fakeToken = 'test.token.here';
|
|
const code = oidcService.createAuthCode(fakeToken);
|
|
|
|
// First use: success
|
|
const res1 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
|
expect(res1.status).toBe(200);
|
|
|
|
// Second use: rejected
|
|
const res2 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
|
expect(res2.status).toBe(400);
|
|
});
|
|
});
|