Files
TREK/server/src/nest/oidc/oidc.controller.ts
T
Maurice 0dde01baa2 fix(oidc): sign in after SSO on a success flag, not the deprecated token
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.

The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
2026-10-08 18:44:00 +02:00

290 lines
14 KiB
TypeScript

import { Body, Controller, Get, HttpException, Put, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { oidcLoginQuerySchema } from '@trek/shared';
import { readEnv } from '../../app-config';
import { OidcService, OIDC_STATE_TTL_MS, OIDC_AUTH_CODE_TTL_MS } from './oidc.service';
import { cookieOptions } from '../common/cookie';
import { AdminGuard } from '../auth/admin.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { User } from '../../types';
import { AuditService } from '../audit/audit.service';
import { getClientIp } from '../audit/client-ip';
import { AdminOidcUpdateDto } from '../admin/admin.dto';
import { Public } from '../auth/public.decorator';
import { ManagedForbidden } from '../common/managed';
import { sessionClientFrom } from '../sessions/sessions.service';
const OIDC_STATE_COOKIE = 'trek_oidc_state';
const OIDC_EXCHANGE_COOKIE = 'trek_oidc_exchange';
/**
* /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE).
*
* Carried over from the legacy Express route (server/src/routes/oidc.ts):
* unauthenticated, the sso-disabled / not-configured / HTTPS-issuer guards, the
* strict id_token + userinfo.sub cross-check, all the frontend redirect error
* codes, and the auth-code → cookie hand-off on /exchange. Uses @Res directly
* because the flow mixes provider redirects with JSON error bodies.
*
* Two cookies bind the flow to one browser, and both are single use: the state
* cookie set at /login is required at /callback, and the binding cookie set at
* /callback is required at /exchange. Neither the provider state nor the auth
* code is a credential on its own — both travel in URLs.
*/
@Public('the OIDC handshake happens before a TREK session exists; the provider state is the credential')
@Controller('api/auth/oidc')
export class OidcController {
constructor(
private readonly oidc: OidcService,
private readonly audit: AuditService,
) {}
@Get('login')
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
if (!(await this.oidc.oidcLoginEnabled())) {
res.status(403).json({ error: 'SSO login is disabled.' });
return;
}
const config = await this.oidc.getOidcConfig();
if (!config) {
res.status(400).json({ error: 'OIDC not configured' });
return;
}
if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) {
res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
return;
}
try {
const doc = await this.oidc.discover(config.issuer, config.discoveryUrl);
const appUrl = this.oidc.getAppUrl();
if (!appUrl) {
res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
return;
}
// `(?<!\/)` as in oidc.service.ts: it pins the run to its own start so a trailing
// run that does not end the string is not rescanned from every slash in it.
const redirectUri = `${appUrl.replace(/(?<!\/)\/+$/, '')}/api/auth/oidc/callback`;
// Lenient parse: a malformed query must never 400 a login redirect, so
// unknown values simply fall back to the raw invite + default duration.
const query = oidcLoginQuerySchema.safeParse(req.query);
const inviteToken = query.success ? query.data.invite : (req.query.invite as string | undefined);
const remember = query.success && query.data.remember !== undefined ? query.data.remember === '1' : undefined;
const { state, codeChallenge } = await this.oidc.createState(redirectUri, inviteToken, remember);
// Bind the state to THIS browser. The callback requires a matching cookie,
// so an attacker-initiated login (whose callback URL carries a valid state
// from the shared server map) cannot be replayed in a victim's browser to
// log them into the attacker's account (OIDC login CSRF / session fixation).
// maxAge matches the server-side pending-state TTL — a cookie that
// outlives its state entry could only ever produce invalid_state.
res.cookie(OIDC_STATE_COOKIE, state, { ...cookieOptions(false, req), maxAge: OIDC_STATE_TTL_MS });
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: readEnv().oidc.scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`${doc.authorization_endpoint}?${params}`);
} catch (err: unknown) {
console.error('[OIDC] Login error:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'OIDC login failed' });
}
}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('error') oidcError: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const f = (p: string) => res.redirect(this.oidc.frontendUrl(p));
// The state cookie is single-use — clear it regardless of the outcome.
const boundState = (req.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req));
if (!(await this.oidc.oidcLoginEnabled())) return f('/login?oidc_error=sso_disabled');
if (oidcError) {
console.error('[OIDC] Provider error:', oidcError);
return f('/login?oidc_error=' + encodeURIComponent(oidcError));
}
if (!code || !state) return f('/login?oidc_error=missing_params');
// Require the callback to come from the browser that started the flow.
if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state');
const pending = await this.oidc.consumeState(state);
if (!pending) return f('/login?oidc_error=invalid_state');
const config = await this.oidc.getOidcConfig();
if (!config) return f('/login?oidc_error=not_configured');
if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) {
return f('/login?oidc_error=issuer_not_https');
}
try {
const doc = await this.oidc.discover(config.issuer, config.discoveryUrl);
const tokenData = await this.oidc.exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier);
if (!tokenData._ok || !tokenData.access_token) {
console.error('[OIDC] Token exchange failed: status', tokenData._status);
return f('/login?oidc_error=token_failed');
}
if (!tokenData.id_token) {
console.error('[OIDC] Token response missing id_token — refusing login');
return f('/login?oidc_error=no_id_token');
}
const idVerify = await this.oidc.verifyIdToken(
tokenData.id_token,
doc,
config.clientId,
// Same trim as oidc.service.ts, and the same reason for the lookbehind — this one
// runs over an issuer read out of a fetched discovery document.
(doc.issuer ?? '').replace(/(?<!\/)\/+$/, '') || config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
console.error('[OIDC] id_token verification failed:', reason);
return f('/login?oidc_error=id_token_invalid');
}
const userInfo = await this.oidc.getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
if (!userInfo.email) return f('/login?oidc_error=no_email');
const tokenSub = idVerify.claims.sub;
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
return f('/login?oidc_error=subject_mismatch');
}
// Some IdPs put `picture` only in the id_token, not the userinfo response —
// fall back to it so the avatar (#1399) still populates.
if (!userInfo.picture && typeof idVerify.claims.picture === 'string') {
userInfo.picture = idVerify.claims.picture;
}
const result = await this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
if ('error' in result) return f('/login?oidc_error=' + result.error);
if (result.created) {
// An account the callback just made is a registration, the way a password
// signup is (auth-public.controller): the same row, plus the way in. Without
// it an admin reading user.register for who got an account never sees the
// SSO ones, and the log looks complete while it is not.
await this.audit.writeAudit({
userId: result.user.id,
action: 'user.register',
ip: getClientIp(req),
details: { username: result.user.username, email: result.user.email, role: result.user.role, method: 'oidc' },
});
}
if (result.roleChange) {
// The claim mapping changing someone's privileges is a security event, and
// the row is written here because this is where the client IP is. The claim
// NAME goes in the details, never its value: that column is readable by
// every admin and a claim can carry group memberships and worse.
await this.audit.writeAudit({
userId: result.user.id,
action: 'oidc.role_change',
resource: String(result.user.id),
ip: getClientIp(req),
details: { from: result.roleChange.from, to: result.roleChange.to, claim: result.roleChange.claim },
});
}
await this.oidc.touchLastLogin(result.user.id);
// The login row every other method writes (#2417). Here rather than at
// /exchange, because this is where the provider has vouched for the user
// and where the client IP is, the same place the role change is recorded;
// `method` names the way in, as the passkey login does.
await this.audit.writeAudit({ userId: result.user.id, action: 'user.login', ip: getClientIp(req), details: { method: 'oidc' } });
// Pass the flag through untouched: `undefined` must reach the token as
// "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927).
const jwtToken = await this.oidc.generateToken(result.user, pending.remember, sessionClientFrom(req));
const { code: authCode, binding } = await this.oidc.createAuthCode(jwtToken, pending.remember);
// Bind the code to THIS browser, the way the state cookie binds the callback.
// The code rides home in a URL, so it is readable from history, from a
// referrer and from anything that logs URLs; without a second half nobody
// else holds, /exchange would hand a session to whoever presents it — either
// stealing this login, or (a code the attacker minted for their own account,
// fed to a victim's browser) fixing the victim into the attacker's session.
res.cookie(OIDC_EXCHANGE_COOKIE, binding, { ...cookieOptions(false, req), maxAge: OIDC_AUTH_CODE_TTL_MS });
return f('/login?oidc_code=' + authCode);
} catch (err: unknown) {
console.error('[OIDC] Callback error:', err);
return f('/login?oidc_error=server_error');
}
}
@Get('exchange')
async exchange(@Query('code') code: string | undefined, @Req() req: Request, @Res() res: Response): Promise<void> {
// The binding cookie is single-use like the state cookie: one redemption
// attempt per callback, whatever its outcome.
const binding = (req.cookies as Record<string, string> | undefined)?.[OIDC_EXCHANGE_COOKIE];
res.clearCookie(OIDC_EXCHANGE_COOKIE, cookieOptions(true, req));
if (!code) {
res.status(400).json({ error: 'Code required' });
return;
}
const result = await this.oidc.consumeAuthCode(code, binding);
if ('error' in result) {
res.status(400).json({ error: result.error });
return;
}
this.oidc.setAuthCookie(res, result.token, req, result.remember);
// `success` is what the web app reads. `token` is deprecated like the one
// in the login bodies: kept for API clients, the cookie is the session.
res.json({ success: true, token: result.token });
}
}
/**
* /api/admin/oidc — the SSO configuration.
*
* Two routes that sat on AdminController with their SQL in AdminService, for a domain
* that already had a module. They read and write the same five app_settings keys the
* login flow next door consumes, so they belong here; the lockout guard that refuses
* to remove the config while password login is off lives with them in OidcService.
*
* The path, the {error,status} envelope and the audit action are unchanged.
*/
@Controller('api/admin/oidc')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminOidcController {
constructor(
private readonly oidc: OidcService,
private readonly audit: AuditService,
) {}
// Both halves, not just the write: reading back the issuer, client id and the
// discovery URL tells a caller how the identity of this install is wired, and
// on a managed install that wiring is not theirs.
@ManagedForbidden('the identity provider is part of what the operator runs')
@Get()
get() {
return this.oidc.getOidcSettings();
}
@ManagedForbidden('an instance-supplied issuer could assert any address as verified')
@Put()
async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
const result = await this.oidc.updateOidcSettings(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
// Only whether an issuer was set, never the value: the details column is readable
// by every admin and the issuer identifies the customer's IdP tenant.
await this.audit.writeAudit({
userId: user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { issuer_set: !!body.issuer },
});
return { success: true };
}
}