Files
ApiCore/ApiCore-Backend/src/auth/sso/sso.controller.ts
T
Nick SzaunigandClaude Opus 5.5 0899576262 fix(api): tighter public lot endpoint, SSO callback from config, mapped 4xx errors
- Public lot lookup: 30 requests/min per IP and only well-formed lot
  numbers (1-64 chars) reach the database.
- SSO callback URL is built from OIDC_REDIRECT_URI instead of the Host
  header; SSO cookies are cleared with the attributes they were set with.
- Logout is rate-limited like the other auth endpoints.
- Middleware errors with a 4xx status (body too large, malformed JSON)
  answer with that status instead of a 500; error log lines carry the
  path without query string.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:20:33 +02:00

165 lines
5.0 KiB
TypeScript

import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier';
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
// Scoped to /complete only - the completion endpoint is the only place that
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
const SSO_CODE_COOKIE = 'oidcSsoCode';
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
interface PkceCookiePayload {
codeVerifier: string;
state: string;
}
@ApiTags('auth')
@Controller('auth/sso')
export class SsoController {
constructor(
private readonly ssoService: SsoService,
private readonly authService: AuthService,
) {}
private frontendLoginUrl(): string {
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
}
private cookieOptions(path: string, maxAge: number) {
return {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax' as const,
path,
maxAge,
};
}
@Public()
@Get('status')
status(): { enabled: boolean } {
return { enabled: this.ssoService.isConfigured() };
}
@Public()
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { redirectUrl, codeVerifier, state } =
await this.ssoService.buildAuthorizationRequest();
const payload: PkceCookiePayload = { codeVerifier, state };
res.cookie(
PKCE_COOKIE,
JSON.stringify(payload),
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
);
res.redirect(redirectUrl);
}
@Public()
@Get('callback')
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE
];
res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) {
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
return;
}
// Built from the configured redirect URI, not from the Host header a
// client sends; only the query (code, state, iss) comes from the request.
const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string);
callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search;
const result = await this.ssoService.handleCallback(
callbackUrl,
pkce.codeVerifier,
pkce.state,
);
if (result.status === 'error') {
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
return;
}
// The one-time completion code never touches the URL (history, server
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
// to the /complete endpoint, which the frontend calls right after.
res.cookie(
SSO_CODE_COOKIE,
result.ssoCode,
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
);
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('complete')
@HttpCode(HttpStatus.OK)
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto | MfaRequiredResponse> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
res.clearCookie(
SSO_CODE_COOKIE,
this.cookieOptions(SSO_CODE_COOKIE_PATH, 0),
);
if (!ssoCode) {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
if (
typeof parsed.codeVerifier !== 'string' ||
typeof parsed.state !== 'string'
) {
return null;
}
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
} catch {
return null;
}
}
}