Files
selfphoneng/apps/api/test/fake-oidc.ts
T
iamdoubz 97e67e347d
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
auth: security audit fixes, rate limits on every route
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00

179 lines
7.0 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
import { createServer, type IncomingMessage } from 'node:http';
import type { AddressInfo } from 'node:net';
export type FakeUser = {
sub: string;
email?: string;
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
/** Only in the ID token, as Entra's app roles. */
roles?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
const pair =
alg === 'RS256'
? generateKeyPairSync('rsa', { modulusLength: 2048 })
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
const clientId = 'selfphoneng';
const clientSecret = 'fake-secret';
const st = {
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
tamper: {} as {
nonce?: boolean;
iss?: boolean;
aud?: boolean;
expired?: boolean;
signature?: boolean;
userinfoSub?: boolean;
},
/** false: refresh tokens are refused, as for a user disabled at the provider. */
refreshOk: true,
/** true: the token endpoint answers 503, as a provider that's down. */
down: false,
refreshes: 0,
/** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
refreshIdToken: true,
};
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
const access = new Map<string, FakeUser>();
const refresh = new Map<string, FakeUser>();
const server = createServer(async (req, res) => {
const url = new URL(req.url!, issuer);
const json = (code: number, body: unknown) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration')
return json(200, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
end_session_endpoint: `${issuer}/logout`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [alg],
code_challenge_methods_supported: ['S256'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
});
if (url.pathname === '/jwks')
return json(200, {
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
});
if (url.pathname === '/authorize') {
const p = url.searchParams;
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
return json(400, { error: 'invalid_request' });
const code = b64u(randomBytes(16));
codes.set(code, {
nonce: p.get('nonce')!,
challenge: p.get('code_challenge')!,
user: { ...st.user },
});
const back = new URL(p.get('redirect_uri')!);
back.searchParams.set('code', code);
back.searchParams.set('state', p.get('state')!);
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
if (st.down) return json(503, { error: 'temporarily_unavailable' });
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
const [id, secret] = Buffer.from(
(req.headers.authorization ?? '').replace(/^Basic /, ''),
'base64',
)
.toString()
.split(':')
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
const form = new URLSearchParams(await body(req));
if (form.get('grant_type') === 'refresh_token') {
const user = refresh.get(form.get('refresh_token')!);
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
// Today's claims, as a real provider gives on refresh.
const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
const verifier = form.get('code_verifier') ?? '';
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
return json(400, { error: 'invalid_grant' });
return json(200, tokens(c.user, c.nonce));
}
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
const { roles: _, ...info } = user;
return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
});
function tokens(user: FakeUser, nonce: string | null) {
const now = Math.floor(Date.now() / 1000);
const t = st.tamper;
const claims = {
iss: t.iss ? 'https://evil.example' : issuer,
sub: user.sub,
aud: t.aud ? 'another-client' : clientId,
iat: now,
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
...(user.roles && { roles: user.roles }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
access.set(at, user);
refresh.set(rt, user);
return {
access_token: at,
token_type: 'Bearer',
expires_in: 300,
refresh_token: rt,
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
};
}
function jwt(claims: object, key: KeyObject) {
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
const sig =
alg === 'RS256'
? sign('sha256', Buffer.from(data), key)
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
return `${data}.${b64u(sig)}`;
}
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
return {
issuer,
clientId,
clientSecret,
st,
close: () => new Promise<void>((r) => server.close(() => r())),
};
}
async function body(req: IncomingMessage): Promise<string> {
let s = '';
for await (const chunk of req) s += chunk;
return s;
}