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.
76 lines
3.1 KiB
TypeScript
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") }),
|
|
};
|
|
}
|