fix(oidc): bind the SSO auth code to the browser that earned it

The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.

The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
This commit is contained in:
Maurice
2026-09-13 22:03:24 +02:00
parent 9cb74e22f6
commit 803a7cb64a
5 changed files with 271 additions and 36 deletions
+22 -4
View File
@@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpException, Put, Query, Req, Res, UseGuards }
import type { Request, Response } from 'express';
import { oidcLoginQuerySchema } from '@trek/shared';
import { readEnv } from '../../app-config';
import { OidcService, OIDC_STATE_TTL_MS } from './oidc.service';
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';
@@ -15,15 +15,21 @@ import { Public } from '../auth/public.decorator';
import { ManagedForbidden } from '../common/managed';
const OIDC_STATE_COOKIE = 'trek_oidc_state';
const OIDC_EXCHANGE_COOKIE = 'trek_oidc_exchange';
/**
* /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE).
*
* Byte-identical to the legacy Express route (server/src/routes/oidc.ts):
* 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')
@@ -161,7 +167,14 @@ export class OidcController {
this.oidc.touchLastLogin(result.user.id);
const jwtToken = this.oidc.generateToken(result.user, pending.remember === true);
const authCode = this.oidc.createAuthCode(jwtToken, pending.remember);
const { code: authCode, binding } = 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);
@@ -171,11 +184,16 @@ export class OidcController {
@Get('exchange')
exchange(@Query('code') code: string | undefined, @Req() req: Request, @Res() res: Response): 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 = this.oidc.consumeAuthCode(code);
const result = this.oidc.consumeAuthCode(code, binding);
if ('error' in result) {
res.status(400).json({ error: result.error });
return;
+39 -6
View File
@@ -60,7 +60,9 @@ export interface OidcConfig {
// Constants / TTLs
// ---------------------------------------------------------------------------
const AUTH_CODE_TTL = 60000; // 1 minute
/** 1 minute — the auth-code lifetime AND the controller's binding-cookie maxAge. */
export const OIDC_AUTH_CODE_TTL_MS = 60000;
const AUTH_CODE_TTL = OIDC_AUTH_CODE_TTL_MS;
const AUTH_CODE_CLEANUP = 30000; // 30 seconds
/** 5 minutes — the server-side pending-state TTL AND the controller's state-cookie maxAge. */
export const OIDC_STATE_TTL_MS = 5 * 60 * 1000;
@@ -87,6 +89,19 @@ function base64url(buf: Buffer): string {
return buf.toString('base64url');
}
function sha256Hex(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
/** Constant-time compare of a presented binding secret against its stored hash. */
function bindingMatches(expectedHash: string, presented: string): boolean {
const expected = Buffer.from(expectedHash, 'hex');
const actual = crypto.createHash('sha256').update(presented, 'utf8').digest();
// Both are sha256 digests, so the lengths always agree; the guard is there
// because timingSafeEqual throws rather than returning false on a mismatch.
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -170,7 +185,11 @@ export class OidcService implements OnModuleDestroy {
// Auth code management – short-lived codes exchanged for JWT
// -------------------------------------------------------------------------
private readonly authCodes = new Map<string, { token: string; created: number; remember?: boolean }>();
// `bindingHash` is the sha256 of a secret that only the browser which finished
// the callback holds, in a cookie. The code itself travels in a URL — through
// history, referrers and any log in between — so on its own it is not a
// credential, and /exchange must not accept it as one.
private readonly authCodes = new Map<string, { token: string; created: number; remember?: boolean; bindingHash: string }>();
// Discovery document cache (1 h TTL), keyed by discovery URL so two
// configured issuers no longer thrash a single slot.
@@ -229,17 +248,31 @@ export class OidcService implements OnModuleDestroy {
return pending;
}
createAuthCode(token: string, remember?: boolean): string {
/**
* Mint a one-time login code plus the secret that redeems it.
*
* The caller puts `code` in the redirect URL and `binding` in an httpOnly
* cookie, so redeeming the code takes both halves and only the browser that
* completed the provider handshake has both.
*/
createAuthCode(token: string, remember?: boolean): { code: string; binding: string } {
const authCode: string = uuidv4();
this.authCodes.set(authCode, { token, created: Date.now(), remember });
return authCode;
const binding = crypto.randomBytes(32).toString('base64url');
this.authCodes.set(authCode, { token, created: Date.now(), remember, bindingHash: sha256Hex(binding) });
return { code: authCode, binding };
}
consumeAuthCode(code: string): { token: string; remember?: boolean } | { error: string } {
consumeAuthCode(code: string, binding?: string): { token: string; remember?: boolean } | { error: string } {
const entry = this.authCodes.get(code);
if (!entry) return { error: 'Invalid or expired code' };
// Single use, burnt on every outcome: a code seen by someone else must not
// survive their attempt for a second guess, and the browser that owns it can
// simply log in again.
this.authCodes.delete(code);
if (Date.now() - entry.created > AUTH_CODE_TTL) return { error: 'Code expired' };
// Same wording as the unknown-code case on purpose — whoever presents a code
// without its binding learns nothing about whether the code was real.
if (!binding || !bindingMatches(entry.bindingHash, binding)) return { error: 'Invalid or expired code' };
return { token: entry.token, remember: entry.remember };
}