T-HUB/apps/api/src/auth/auth.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

76 lines
3.1 KiB
TypeScript

import { Body, Controller, Get, HttpCode, Inject, Post, Req, UnauthorizedException, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { optionalEmail, optionalString, parseId, requireObject, requireString, requireUsername, requireSession } from "../http/validation.js";
import { AuthService, type AuthResult, type LoginInput, type MeResponse, type RegisterInput } from "./auth.service.js";
import { verifySession } from "./session.js";
@Controller("auth")
export class AuthController {
constructor(@Inject(AuthService) private readonly auth: AuthService) {}
@Post("register")
async register(@Body() body: unknown): Promise<AuthResult> {
return this.auth.register(parseRegister(body));
}
@Post("login")
@HttpCode(200)
async login(@Body() body: unknown): Promise<AuthResult> {
return this.auth.login(parseLogin(body));
}
/** Any valid, epoch-current session may read itself — no permission gate beyond authentication. */
@Get("me")
@UseGuards(RbacGuard)
async me(@Req() request: Request & AuthenticatedRequest): Promise<MeResponse> {
return this.auth.me(requireSession(request));
}
/** A signed, unexpired session can refresh its epoch and live role. */
@Post("reissue")
@HttpCode(200)
async reissue(@Req() request: Request): Promise<AuthResult> {
const authorization = request.headers.authorization;
if (authorization === undefined || !authorization.startsWith("Bearer ")) throw new UnauthorizedException();
const session = verifySession(authorization.slice("Bearer ".length));
if (session === null) throw new UnauthorizedException();
return this.auth.reissue(session);
}
/** Signed session present ⇒ its server-side row is revoked (phase 2). Legacy tokens: client-side only. */
@Post("logout")
@HttpCode(200)
logout(@Req() request: Request): { ok: true } {
const authorization = request.headers.authorization;
const session = authorization !== undefined && authorization.startsWith("Bearer ")
? verifySession(authorization.slice("Bearer ".length))
: null;
void this.auth.logout(session);
return { ok: true };
}
}
function parseRegister(value: unknown): RegisterInput {
const body = requireObject(value, "body");
const username = requireUsername(body.username);
const email = optionalEmail(body.email);
return {
// Collectives opt-in: no name => solo account (personal workspace).
workspaceName: optionalString(body.workspaceName, "workspaceName")?.trim().slice(0, 100),
username,
password: requireString(body.password, "password"),
displayName: (optionalString(body.displayName, "displayName") ?? username).trim().slice(0, 100),
...(email === undefined ? {} : { email }),
};
}
function parseLogin(value: unknown): LoginInput {
const body = requireObject(value, "body");
const workspaceId = optionalString(body.workspaceId, "workspaceId");
return {
username: requireUsername(body.username),
password: requireString(body.password, "password"),
...(workspaceId === undefined ? {} : { workspaceId: parseId(workspaceId, "workspaceId") }),
};
}