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
107 lines
3.7 KiB
TypeScript
107 lines
3.7 KiB
TypeScript
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
|
import type { Response } from 'express';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
|
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
|
|
|
import { AuthedRequest, Public, setSessionCookie } from './auth.guard';
|
|
import { OidcService } from './oidc.service';
|
|
import { sessionAbsoluteMs } from './sessions.service';
|
|
|
|
/** Carries state+nonce+PKCE verifier across the IdP round-trip — signed
|
|
* (purpose-derived key), HttpOnly, Lax so the top-level callback
|
|
* navigation still sends it, and 10 minutes short-lived. */
|
|
const OIDC_STATE_COOKIE = 'dt_oidc';
|
|
|
|
/**
|
|
* OIDC endpoints (issue #214, ADR 0021). Browser-navigation shaped: `login`
|
|
* and `link` answer 302 to the IdP, the callback lands back here and
|
|
* redirects into the SPA — errors become `/login?error=<code>` so the SPA
|
|
* can translate them.
|
|
*/
|
|
@AuthenticatedOnly()
|
|
@Controller('auth/oidc')
|
|
export class OidcController {
|
|
constructor(
|
|
private readonly oidc: OidcService,
|
|
private readonly config: AppConfig,
|
|
) {}
|
|
|
|
private stateCookie(response: Response, value: string): void {
|
|
response.cookie(OIDC_STATE_COOKIE, value, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: this.config.env.NODE_ENV === 'production',
|
|
maxAge: 10 * 60 * 1000,
|
|
path: '/',
|
|
});
|
|
}
|
|
|
|
@Public()
|
|
@Get('login')
|
|
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
|
|
async login(@Res() response: Response): Promise<void> {
|
|
this.oidc.assertEnabled();
|
|
const { url, stateToken } = await this.oidc.beginLogin();
|
|
this.stateCookie(response, stateToken);
|
|
response.redirect(url);
|
|
}
|
|
|
|
/** The deliberate account-linking flow (ADR 0021 §2): only a logged-in
|
|
* user attaches an IdP identity to their own account. */
|
|
@Get('link')
|
|
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
|
|
async link(@Req() request: AuthedRequest, @Res() response: Response): Promise<void> {
|
|
this.oidc.assertEnabled();
|
|
const { url, stateToken } = await this.oidc.beginLogin(request.user!.id);
|
|
this.stateCookie(response, stateToken);
|
|
response.redirect(url);
|
|
}
|
|
|
|
@Public()
|
|
@Get('callback')
|
|
@RateLimit({ scope: 'oidc-callback', limit: 30, windowSeconds: 60 })
|
|
async callback(
|
|
@Query('code') code: string | undefined,
|
|
@Query('state') state: string | undefined,
|
|
@Query('error') idpError: string | undefined,
|
|
@Req() request: AuthedRequest,
|
|
@Res() response: Response,
|
|
): Promise<void> {
|
|
this.oidc.assertEnabled();
|
|
const base = this.config.env.APP_BASE_URL;
|
|
response.clearCookie(OIDC_STATE_COOKIE, { path: '/' });
|
|
const stateToken = (request.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
|
|
if (idpError || !code || !state || !stateToken) {
|
|
response.redirect(`${base}/login?error=oidc_cancelled`);
|
|
return;
|
|
}
|
|
try {
|
|
const result = await this.oidc.completeLogin(
|
|
code,
|
|
state,
|
|
stateToken,
|
|
request.headers['user-agent'],
|
|
);
|
|
if (result.linked) {
|
|
response.redirect(`${base}/settings?oidc=linked`);
|
|
return;
|
|
}
|
|
setSessionCookie(
|
|
response,
|
|
result.sessionToken!,
|
|
this.config.env.NODE_ENV === 'production',
|
|
sessionAbsoluteMs(this.config.env),
|
|
);
|
|
response.redirect(`${base}/`);
|
|
} catch (error) {
|
|
const code_ =
|
|
typeof (error as { response?: { code?: string } })?.response?.code === 'string'
|
|
? (error as { response: { code: string } }).response.code
|
|
: 'oidc_failed';
|
|
response.redirect(`${base}/login?error=${encodeURIComponent(code_)}`);
|
|
}
|
|
}
|
|
}
|