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.
72 lines
2.8 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|