External authentication (ADR 0021) built on jose (#188's vetted library) plus fetch — no new dependency enters the supply chain for a security base function. Discovery-configured; ID tokens validate against the IdP's JWKS under an explicit RS256/ES256 allowlist with issuer, audience, expiry and nonce binding. State, nonce and the PKCE verifier travel in a signed HttpOnly Lax cookie keyed by a dedicated HKDF purpose (oidc-state, ADR 0020). Deploy-level configuration (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES/ PROVIDER_LABEL): who authenticates users is a platform decision. The login page discovers the provider via GET /auth/methods and renders the SSO button (i18n de+en). Identities use the existing slot (provider oidc:<issuer>, subject from the token). First login creates the account just-in-time — ACTIVE and mail-verified only when the IdP asserts a verified address. An existing local account is NEVER adopted silently by e-mail (account-takeover path): login refuses with oidc_link_required and the owner links explicitly via GET /auth/oidc/link (audited auth.identity_linked, catalogue v1.3). Sessions come from the one existing session service. Tests run the full flow against a protocol-faithful fake IdP: PKCE verifier at the token endpoint, JIT creation incl. personal pond, invalid state/nonce/signature/issuer/audience/expiry each rejected, the linking refusal and the explicit link flow. Verified end-to-end against a real Keycloak 26.0 (repeatable procedure documented in security.md §External authentication). Refs #214. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import { hkdfSync } from 'node:crypto';
|
|
|
|
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
|
|
import { z } from 'zod';
|
|
|
|
import {
|
|
collabTokenClaimsSchema,
|
|
type CollabTokenClaims,
|
|
type CollabTokenVerification,
|
|
} from './collab-token';
|
|
|
|
/**
|
|
* Purpose-bound token keys and collaboration-token signing (issue #34,
|
|
* ADR 0007; key separation and `jose` per ADR 0020, issue #188).
|
|
*
|
|
* The configured `COLLAB_TOKEN_SECRET` is a ROOT key: every purpose derives
|
|
* its own subkey via HKDF-SHA-256, and no code path signs with the root key
|
|
* directly. A token signed for one purpose cannot verify under another
|
|
* because the keys differ — the separation is structural, not a string
|
|
* prefix in the signed payload.
|
|
*
|
|
* Signing and verification go through `jose` (vetted, dependency-free)
|
|
* with HS256 as an explicit algorithm allowlist. The same module must work
|
|
* in the CommonJS api and the ESM collab server — proven by
|
|
* `token-crypto.crossruntime.test.ts` rather than assumed (jose v6 is
|
|
* ESM-only and reaches CJS via Node's `require(esm)`, available in the
|
|
* pinned Node 22 images). This module pulls in Node built-ins, so it lives
|
|
* outside the browser-safe package barrel and is imported via
|
|
* `@dorfteich/shared/token-crypto`.
|
|
*/
|
|
|
|
/** Every purpose a subkey is derived for. Add here — never reuse a key. */
|
|
export type TokenPurpose = 'collab' | 'unsubscribe' | 'oidc-state';
|
|
|
|
/** Fixed HKDF salt: domain-separates this application's key hierarchy. */
|
|
const HKDF_SALT = 'dorfteich-token-keys';
|
|
|
|
/**
|
|
* Derive the 32-byte subkey for `purpose` from the configured root secret.
|
|
* Deterministic: rotation of the root secret rotates every subkey at once,
|
|
* which is the intended behaviour (ADR 0020).
|
|
*/
|
|
export function deriveTokenKey(rootSecret: string, purpose: TokenPurpose): Uint8Array {
|
|
return new Uint8Array(hkdfSync('sha256', rootSecret, HKDF_SALT, `dorfteich/${purpose}/v1`, 32));
|
|
}
|
|
|
|
/** Full JWT payload: the claims plus standard `iat`/`exp` (seconds since epoch). */
|
|
const payloadSchema = collabTokenClaimsSchema.extend({
|
|
iat: z.number().int().nonnegative(),
|
|
exp: z.number().int(),
|
|
});
|
|
|
|
/** Sign a collaboration token that expires `ttlSeconds` from now. */
|
|
export async function signCollabToken(
|
|
claims: CollabTokenClaims,
|
|
rootSecret: string,
|
|
ttlSeconds: number,
|
|
): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return await new SignJWT({ ...collabTokenClaimsSchema.parse(claims) })
|
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
.setIssuedAt(now)
|
|
.setExpirationTime(now + ttlSeconds)
|
|
.sign(deriveTokenKey(rootSecret, 'collab'));
|
|
}
|
|
|
|
/** Map a `jose` verification error onto the stable rejection reasons. */
|
|
function reasonOf(error: unknown): Exclude<CollabTokenVerification, { valid: true }>['reason'] {
|
|
if (error instanceof joseErrors.JWTExpired) return 'expired';
|
|
if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'bad_algorithm';
|
|
if (error instanceof joseErrors.JWSSignatureVerificationFailed) return 'bad_signature';
|
|
if (error instanceof joseErrors.JWTClaimValidationFailed) return 'invalid_claims';
|
|
return 'malformed';
|
|
}
|
|
|
|
/** Verify signature, algorithm, claims, and expiry. Never throws. */
|
|
export async function verifyCollabToken(
|
|
token: string,
|
|
rootSecret: string,
|
|
now: number = Date.now(),
|
|
): Promise<CollabTokenVerification> {
|
|
let payload: unknown;
|
|
try {
|
|
({ payload } = await jwtVerify(token, deriveTokenKey(rootSecret, 'collab'), {
|
|
algorithms: ['HS256'],
|
|
currentDate: new Date(now),
|
|
clockTolerance: 0,
|
|
}));
|
|
} catch (error) {
|
|
return { valid: false, reason: reasonOf(error) };
|
|
}
|
|
// `jose` treats `exp` as optional; the schema makes it mandatory so a
|
|
// token without an expiry can never verify.
|
|
const parsed = payloadSchema.safeParse(payload);
|
|
if (!parsed.success) {
|
|
return { valid: false, reason: 'invalid_claims' };
|
|
}
|
|
if (parsed.data.exp * 1000 <= now) {
|
|
return { valid: false, reason: 'expired' };
|
|
}
|
|
|
|
const { userId, pageId, mode } = parsed.data;
|
|
return { valid: true, claims: { userId, pageId, mode } };
|
|
}
|