T-HUB/apps/api/src/oidc/oidc.controller.ts
glass beadian 643fbb94bc feat: OIDC phase 2 — session-bound refresh tokens, server-side logout
Hub sessions gain an optional sid backed by auth_sessions (login/reissue
write the row; POST /auth/logout revokes it; pre-phase2 tokens are
refused at reissue so revocation applies from the next epoch bump).
The OIDC provider issues rotating refresh tokens (oidc_refresh_tokens,
SHA-256 at rest, family-wide revocation on reuse detection) that live
and die with the consenting Hub session — an oCIS session can no longer
outlive the sign-in that minted it, and Hub logout propagates at the
next refresh instead of the next silent-authorize. Discovery advertises
refresh_token. Migration 034; RLS via the audit_append definer pattern
for the unauthenticated token-endpoint lookup.
2026-10-14 11:33:47 +00:00

72 lines
2.8 KiB
TypeScript

import { Body, Controller, Get, Header, Headers, HttpCode, Inject, Post, Query, Redirect, Req, UnauthorizedException, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireSession } from "../http/validation.js";
import { OidcService, type TokenResponse, type UserInfo } from "./oidc.service.js";
import type { PublicJwk } from "./oidc.keys.js";
/**
* The Hub's OpenID Connect provider (issuer https://api.occupymedia.cc/oidc).
*
* GET /oidc/.well-known/openid-configuration discovery
* GET /oidc/jwks public ES256 keys
* GET /oidc/authorize validate, then 302 → Hub SPA consent page
* POST /oidc/authorize/consent (Bearer session) issue the single-use code
* POST /oidc/token code + PKCE verifier → tokens
* GET /oidc/userinfo Bearer access_token → claims
*
* Only /authorize/consent is session-guarded: every active member may sign in
* to files, so it needs no permission. See OidcService for the cookie-less
* authorize flow (a deliberate deviation from the design doc).
*/
@Controller("oidc")
export class OidcController {
constructor(@Inject(OidcService) private readonly oidc: OidcService) {}
@Get(".well-known/openid-configuration")
@Header("Cache-Control", "public, max-age=300")
discovery(): Record<string, unknown> {
return this.oidc.discovery();
}
@Get("jwks")
@Header("Cache-Control", "public, max-age=300")
jwks(): Promise<{ keys: PublicJwk[] }> {
return this.oidc.jwks();
}
@Get("authorize")
@Redirect()
@Header("Cache-Control", "no-store")
authorize(@Query() query: unknown): { url: string; statusCode: number } {
return { url: this.oidc.authorizeRedirect(query), statusCode: 302 };
}
@Post("authorize/consent")
@HttpCode(200)
@UseGuards(RbacGuard)
@Header("Cache-Control", "no-store")
consent(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ redirectTo: string }> {
const session = requireSession(request);
return this.oidc.consent(session.workspaceId, session.userId, body, session.sid ?? null);
}
@Post("token")
@HttpCode(200)
@Header("Cache-Control", "no-store")
@Header("Pragma", "no-cache")
token(@Body() body: unknown): Promise<TokenResponse> {
return this.oidc.token(body);
}
@Get("userinfo")
@Header("Cache-Control", "no-store")
async userinfo(@Req() request: Request, @Headers("authorization") authorization: string | undefined): Promise<UserInfo> {
try {
return await this.oidc.userinfo(authorization);
} catch (err) {
if (err instanceof UnauthorizedException) request.res?.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"');
throw err;
}
}
}