mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
/**
|
|
* The whole OIDC login against a real provider on this machine (#2506).
|
|
*
|
|
* oidc.test.ts stubs the four provider calls to test the flow around them. This
|
|
* file stubs nothing but the resolver: the provider is a small HTTP server on
|
|
* loopback that answers discovery, authorize, token, JWKS (with a real RS256
|
|
* signature) and userinfo, so every request the login makes goes through the
|
|
* SSRF guard to a socket.
|
|
*
|
|
* Its name resolves the way a LAN DNS server answers for a self-hosted IdP: an
|
|
* fe80:: record next to the reachable address. Since 4.3.0 that one record
|
|
* refused the name, and every login ended in `{"error":"OIDC login failed"}`.
|
|
* Loopback stands in for the LAN address, being the one address a test can
|
|
* reach; the admin lane OIDC rides on treats the two alike.
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { createUser } from '../helpers/factories';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import type { Application } from 'express';
|
|
import crypto from 'node:crypto';
|
|
import http from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
|
|
const { answers } = vi.hoisted(() => ({ answers: new Map<string, { address: string; family: number }[]>() }));
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
SESSION_DURATION: '24h',
|
|
SESSION_DURATION_MS: 86400000,
|
|
SESSION_DURATION_SECONDS: 86400,
|
|
SESSION_DURATION_REMEMBER: '30d',
|
|
SESSION_DURATION_REMEMBER_MS: 2592000000,
|
|
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
|
|
DEFAULT_LANGUAGE: 'en',
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
// The provider's names are answered here; every other name goes to the real resolver.
|
|
vi.mock('dns/promises', async (importOriginal) => {
|
|
const real = await importOriginal<typeof import('dns/promises')>();
|
|
const lookup = ((hostname: string, options?: { all?: boolean }) => {
|
|
const answer = answers.get(hostname);
|
|
if (!answer) return real.lookup(hostname, options as never);
|
|
return Promise.resolve(options?.all ? answer : answer[0]);
|
|
}) as typeof real.lookup;
|
|
return { ...real, default: { ...real, lookup }, lookup };
|
|
});
|
|
|
|
const CLIENT_ID = 'trek-local';
|
|
const CLIENT_SECRET = 'local-secret';
|
|
const EMAIL = 'lan-admin@home.test';
|
|
|
|
/** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */
|
|
function readCookie(res: request.Response, name: string): string | undefined {
|
|
const raw = res.headers['set-cookie'];
|
|
const all: string[] = Array.isArray(raw) ? raw : raw ? [raw as unknown as string] : [];
|
|
const value = all
|
|
.filter((c) => c.startsWith(`${name}=`))
|
|
.pop()
|
|
?.split(';')[0]
|
|
.slice(name.length + 1);
|
|
return value ? decodeURIComponent(value) : undefined;
|
|
}
|
|
|
|
// ── The provider ────────────────────────────────────────────────────────────
|
|
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256', use: 'sig' };
|
|
const codes = new Set<string>();
|
|
/** Every request the provider saw: method, path and the address the client dialled. */
|
|
const seen: { method: string; path: string; local: string }[] = [];
|
|
let provider: http.Server;
|
|
let port = 0;
|
|
|
|
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
function signIdToken(issuer: string): string {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const signingInput = `${b64({ alg: 'RS256', kid: 'k1', typ: 'JWT' })}.${b64({
|
|
iss: issuer,
|
|
sub: 'lan-sub-1',
|
|
aud: CLIENT_ID,
|
|
iat: now,
|
|
exp: now + 300,
|
|
email: EMAIL,
|
|
email_verified: true,
|
|
})}`;
|
|
return `${signingInput}.${crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url')}`;
|
|
}
|
|
|
|
function answerJson(res: http.ServerResponse, status: number, body: unknown) {
|
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function handle(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
// The Host header carries the name TREK asked for, so one server plays every issuer.
|
|
const issuer = `http://${req.headers.host}`;
|
|
const url = new URL(req.url ?? '/', issuer);
|
|
seen.push({ method: req.method ?? '', path: url.pathname, local: req.socket.localAddress ?? '' });
|
|
if (url.pathname === '/.well-known/openid-configuration') {
|
|
return answerJson(res, 200, {
|
|
issuer,
|
|
authorization_endpoint: `${issuer}/authorize`,
|
|
token_endpoint: `${issuer}/token`,
|
|
userinfo_endpoint: `${issuer}/userinfo`,
|
|
jwks_uri: `${issuer}/jwks`,
|
|
});
|
|
}
|
|
if (url.pathname === '/authorize') {
|
|
const code = crypto.randomBytes(12).toString('hex');
|
|
codes.add(code);
|
|
const back = new URL(url.searchParams.get('redirect_uri') ?? '');
|
|
back.searchParams.set('code', code);
|
|
back.searchParams.set('state', url.searchParams.get('state') ?? '');
|
|
res.writeHead(302, { location: back.toString() });
|
|
return res.end();
|
|
}
|
|
if (url.pathname === '/token' && req.method === 'POST') {
|
|
let raw = '';
|
|
req.on('data', (chunk) => (raw += chunk));
|
|
req.on('end', () => {
|
|
const form = new URLSearchParams(raw);
|
|
const code = form.get('code') ?? '';
|
|
if (!codes.delete(code) || form.get('client_secret') !== CLIENT_SECRET || !form.get('code_verifier')) {
|
|
return answerJson(res, 400, { error: 'invalid_grant' });
|
|
}
|
|
answerJson(res, 200, { access_token: 'lan-access', token_type: 'Bearer', id_token: signIdToken(issuer) });
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === '/userinfo') {
|
|
if (req.headers.authorization !== 'Bearer lan-access') return answerJson(res, 401, { error: 'invalid_token' });
|
|
return answerJson(res, 200, { sub: 'lan-sub-1', email: EMAIL, email_verified: true, name: 'LAN Admin' });
|
|
}
|
|
if (url.pathname === '/jwks') return answerJson(res, 200, { keys: [jwk] });
|
|
answerJson(res, 404, { error: 'not_found' });
|
|
}
|
|
|
|
// ── The app ─────────────────────────────────────────────────────────────────
|
|
|
|
let nestApp: INestApplication;
|
|
let app: Application;
|
|
|
|
beforeAll(async () => {
|
|
provider = http.createServer(handle);
|
|
await new Promise<void>((resolve) => provider.listen(0, '127.0.0.1', resolve));
|
|
port = (provider.address() as AddressInfo).port;
|
|
nestApp = await buildApp();
|
|
app = nestApp.getHttpAdapter().getInstance();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
resetRateLimits(nestApp);
|
|
seen.length = 0;
|
|
answers.clear();
|
|
process.env.OIDC_CLIENT_ID = CLIENT_ID;
|
|
process.env.OIDC_CLIENT_SECRET = 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;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
await new Promise((resolve) => provider.close(resolve));
|
|
testDb.close();
|
|
});
|
|
|
|
/** Point TREK at the provider under `host`, answering the name with `answer`. */
|
|
function useIssuer(host: string, answer: { address: string; family: number }[]): string {
|
|
answers.set(host, answer);
|
|
const issuer = `http://${host}:${port}`;
|
|
process.env.OIDC_ISSUER = issuer;
|
|
return issuer;
|
|
}
|
|
|
|
describe('OIDC login against a provider whose name has an fe80:: record (#2506)', () => {
|
|
it('OIDC-LAN-001: signs in end to end, and every provider request lands on the vetted address', async () => {
|
|
const { user } = createUser(testDb, { email: EMAIL });
|
|
// Link-local first, the order Windows and some LAN resolvers hand it out in.
|
|
const issuer = useIssuer('idp.home.test', [
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: 'fe80::f9f1:353:73:14a0', family: 6 },
|
|
{ address: '127.0.0.1', family: 4 },
|
|
]);
|
|
|
|
const login = await request(app).get('/api/auth/oidc/login');
|
|
expect(login.status).toBe(302);
|
|
const authorize = new URL(login.headers.location!);
|
|
expect(authorize.origin).toBe(issuer);
|
|
const state = readCookie(login, 'trek_oidc_state')!;
|
|
expect(authorize.searchParams.get('state')).toBe(state);
|
|
|
|
// The browser's leg: the provider sends it back to /callback with a code.
|
|
const back = await fetch(`http://127.0.0.1:${port}${authorize.pathname}${authorize.search}`, {
|
|
redirect: 'manual',
|
|
});
|
|
const callbackUrl = new URL(back.headers.get('location')!);
|
|
const cb = await request(app)
|
|
.get(`${callbackUrl.pathname}${callbackUrl.search}`)
|
|
.set('Cookie', `trek_oidc_state=${state}`);
|
|
expect(cb.status).toBe(302);
|
|
const landing = new URL(cb.headers.location!, 'http://localhost');
|
|
expect(landing.searchParams.get('oidc_error')).toBeNull();
|
|
const oidcCode = landing.searchParams.get('oidc_code')!;
|
|
expect(oidcCode).toBeTruthy();
|
|
|
|
const exchange = await request(app)
|
|
.get(`/api/auth/oidc/exchange?code=${oidcCode}`)
|
|
.set('Cookie', `trek_oidc_exchange=${readCookie(cb, 'trek_oidc_exchange')}`);
|
|
expect(exchange.status).toBe(200);
|
|
const session = readCookie(exchange, 'trek_session');
|
|
expect(session).toBeTruthy();
|
|
|
|
const me = await request(app).get('/api/auth/me').set('Cookie', `trek_session=${session}`);
|
|
expect(me.status).toBe(200);
|
|
expect(me.body.user).toMatchObject({ id: user.id, email: EMAIL });
|
|
|
|
// Discovery, token, JWKS and userinfo all went out, each over loopback.
|
|
const server = seen.filter((r) => r.path !== '/authorize');
|
|
expect(server.map((r) => `${r.method} ${r.path}`)).toEqual([
|
|
'GET /.well-known/openid-configuration',
|
|
'POST /token',
|
|
'GET /jwks',
|
|
'GET /userinfo',
|
|
]);
|
|
expect(new Set(server.map((r) => r.local))).toEqual(new Set(['127.0.0.1']));
|
|
});
|
|
|
|
it('OIDC-LAN-002: a name with nothing but link-local and metadata records is still refused', async () => {
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
useIssuer('metadata.home.test', [
|
|
{ address: 'fe80::1', family: 6 },
|
|
{ address: '169.254.169.254', family: 4 },
|
|
{ address: 'fd00:ec2::254', family: 6 },
|
|
]);
|
|
|
|
const login = await request(app).get('/api/auth/oidc/login');
|
|
|
|
expect(login.status).toBe(500);
|
|
expect(login.body).toEqual({ error: 'OIDC login failed' });
|
|
expect(errors).toHaveBeenCalledWith(
|
|
'[OIDC] Login error:',
|
|
'Requests to link-local / cloud-metadata addresses are not allowed',
|
|
);
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|