From 2a394969d2dd758e2817b984fd1d0104dfe99343 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 18 May 2026 09:44:49 +0100 Subject: [PATCH] feat(center): OIDC login (auto-provision by email) + CLI config-oidc - OidcConfig entity (single row) + DB registration. - OidcService: discovery, Authorization Code + PKCE, state/ticket as short-lived signed JWTs (no server session), userinfo/id_token claim resolution. Auto-provisions a passwordless user by email. - Public endpoints /auth/oidc/{status,start,callback,exchange}; callback bounces a one-time ticket to the SPA in the URL fragment. - AuthService.provisionOidcUser + issueSessionForUserId (login shape). - CLI: 'config oidc' upserts issuer/clientId/secret/callback/redirect/ scopes/enabled (secret masked on print). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/auth/auth.module.ts | 9 +- src/auth/auth.service.ts | 55 +++++++++ src/auth/oidc.controller.ts | 51 ++++++++ src/auth/oidc.service.ts | 190 +++++++++++++++++++++++++++++ src/cli.ts | 42 +++++++ src/database.config.ts | 3 +- src/entities/oidc-config.entity.ts | 37 ++++++ 7 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 src/auth/oidc.controller.ts create mode 100644 src/auth/oidc.service.ts create mode 100644 src/entities/oidc-config.entity.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d582944..7bdc62a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,14 +2,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller.js'; import { AuthService } from './auth.service.js'; +import { OidcController } from './oidc.controller.js'; +import { OidcService } from './oidc.service.js'; import { User } from '../entities/user.entity.js'; import { GuildNode } from '../entities/guild-node.entity.js'; import { GuildUser } from '../entities/guild-user.entity.js'; import { UserApiKey } from '../entities/user-api-key.entity.js'; +import { OidcConfig } from '../entities/oidc-config.entity.js'; @Module({ - imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey])], - controllers: [AuthController], - providers: [AuthService], + imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])], + controllers: [AuthController, OidcController], + providers: [AuthService, OidcService], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5018bf1..209ba2d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -116,6 +116,61 @@ export class AuthService { }; } + // OIDC: find a user by email or auto-provision one (no usable password — + // OIDC users never password-login). Returns the userId. + async provisionOidcUser(email: string, name?: string): Promise { + const e = String(email ?? '').trim(); + if (!e) throw new UnauthorizedException('oidc: missing email claim'); + let user = await this.userRepo.findOne({ where: { email: e } }); + if (!user) { + const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10); + user = await this.userRepo.save( + this.userRepo.create({ + email: e, + name: (name ?? '').trim() || e, + passwordHash, + refreshTokenHash: null, + }), + ); + await this.audit.write({ + action: 'auth.oidc.provision', + actorId: user.id, + targetType: 'user', + targetId: user.id, + detail: JSON.stringify({ email: e }), + }); + } + return user.id; + } + + // Issue a full session for an already-authenticated userId (same shape as + // login()). Used by the OIDC ticket exchange. + async issueSessionForUserId(userId: string) { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new UnauthorizedException('user not found'); + const accessToken = signAccessToken(user.id, user.email); + const refreshToken = signRefreshToken(user.id, user.email); + user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); + await this.userRepo.save(user); + await this.audit.write({ + action: 'auth.oidc.login', + actorId: user.id, + targetType: 'user', + targetId: user.id, + detail: null, + }); + const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); + return { + accessToken, + refreshToken, + tokenType: 'Bearer', + expiresIn: getAccessTokenExpiresInSeconds(), + user: { id: user.id, email: user.email, name: user.name ?? user.email }, + guilds, + guildAccessTokens, + }; + } + private async getUserGuildsAndTokens(userId: string, email: string) { const memberships = await this.guildUserRepo.find({ where: { userId, status: 'active' }, diff --git a/src/auth/oidc.controller.ts b/src/auth/oidc.controller.ts new file mode 100644 index 0000000..666c296 --- /dev/null +++ b/src/auth/oidc.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common'; +import type { Response } from 'express'; +import { Public } from '../common/public.decorator.js'; +import { OidcService } from './oidc.service.js'; + +@Public() +@Controller('auth/oidc') +export class OidcController { + constructor(private readonly oidc: OidcService) {} + + // Frontend asks whether to show the SSO button. + @Get('status') + async status() { + return { enabled: await this.oidc.isEnabled() }; + } + + // Browser hits this; we 302 to the IdP authorize endpoint. + @Get('start') + async start(@Res() res: Response) { + const url = await this.oidc.buildAuthorizeUrl(); + res.redirect(url); + } + + // IdP redirects back here with ?code&state. We mint a one-time ticket + // and bounce the browser to the SPA with it in the URL fragment (so the + // ticket never lands in server/proxy access logs). + @Get('callback') + async callback( + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response, + ) { + try { + const { ticket, redirect } = await this.oidc.handleCallback(code, state); + const sep = redirect.includes('#') ? '&' : '#'; + res.redirect(`${redirect}${sep}oidc_ticket=${encodeURIComponent(ticket)}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'oidc error'; + const c = await this.oidc.getConfig(); + const base = c?.postLoginRedirect || '/'; + const sep = base.includes('#') ? '&' : '#'; + res.redirect(`${base}${sep}oidc_error=${encodeURIComponent(msg)}`); + } + } + + // SPA exchanges the one-time ticket for a full Fabric session. + @Post('exchange') + async exchange(@Body() body: { ticket?: string }) { + return this.oidc.exchangeTicket(String(body?.ticket ?? '')); + } +} diff --git a/src/auth/oidc.service.ts b/src/auth/oidc.service.ts new file mode 100644 index 0000000..ea8e50c --- /dev/null +++ b/src/auth/oidc.service.ts @@ -0,0 +1,190 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import jwt from 'jsonwebtoken'; +import { createHash, randomBytes } from 'crypto'; +import { OidcConfig } from '../entities/oidc-config.entity.js'; +import { AuthService } from './auth.service.js'; + +const ROW_ID = 'default'; + +type Discovery = { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; +}; + +function b64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +@Injectable() +export class OidcService { + private discoveryCache: { issuer: string; doc: Discovery; at: number } | null = null; + + constructor( + @InjectRepository(OidcConfig) + private readonly repo: Repository, + private readonly auth: AuthService, + ) {} + + private stateSecret(): string { + return process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string; + } + + async getConfig(): Promise { + return this.repo.findOne({ where: { id: ROW_ID } }); + } + + async setConfig(patch: Partial>): Promise { + let row = await this.repo.findOne({ where: { id: ROW_ID } }); + if (!row) row = this.repo.create({ id: ROW_ID }); + Object.assign(row, patch); + return this.repo.save(row); + } + + async isEnabled(): Promise { + const c = await this.getConfig(); + return !!(c && c.enabled && c.issuer && c.clientId && c.clientSecret && c.redirectUri); + } + + private async requireEnabledConfig(): Promise { + const c = await this.getConfig(); + if (!c || !c.enabled || !c.issuer || !c.clientId || !c.clientSecret || !c.redirectUri) { + throw new BadRequestException('oidc is not configured/enabled'); + } + return c; + } + + private async discover(issuer: string): Promise { + const now = Date.now(); + if (this.discoveryCache && this.discoveryCache.issuer === issuer && now - this.discoveryCache.at < 3600_000) { + return this.discoveryCache.doc; + } + const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration'; + const res = await fetch(url); + if (!res.ok) throw new BadRequestException(`oidc discovery failed: ${res.status}`); + const doc = (await res.json()) as Discovery; + if (!doc.authorization_endpoint || !doc.token_endpoint) { + throw new BadRequestException('oidc discovery missing endpoints'); + } + this.discoveryCache = { issuer, doc, at: now }; + return doc; + } + + // Build the IdP authorize URL. PKCE verifier + nonce are sealed into a + // short-lived signed `state` JWT (no server-side session needed). + async buildAuthorizeUrl(): Promise { + const c = await this.requireEnabledConfig(); + const doc = await this.discover(c.issuer); + const codeVerifier = b64url(randomBytes(32)); + const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest()); + const nonce = b64url(randomBytes(16)); + const state = jwt.sign({ cv: codeVerifier, n: nonce, typ: 'oidc_state' }, this.stateSecret(), { + expiresIn: 600, + }); + const p = new URLSearchParams({ + response_type: 'code', + client_id: c.clientId, + redirect_uri: c.redirectUri, + scope: c.scopes || 'openid email profile', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + return `${doc.authorization_endpoint}?${p.toString()}`; + } + + // Handle the IdP callback: validate state, exchange the code, resolve the + // user's email/name, provision/find the user, and return a one-time + // ticket (JWT, 60s) plus the post-login redirect URL. + async handleCallback(code: string, state: string): Promise<{ ticket: string; redirect: string }> { + if (!code || !state) throw new BadRequestException('oidc: missing code/state'); + const c = await this.requireEnabledConfig(); + + let st: { cv?: string; typ?: string }; + try { + st = jwt.verify(state, this.stateSecret()) as { cv?: string; typ?: string }; + } catch { + throw new UnauthorizedException('oidc: invalid state'); + } + if (st.typ !== 'oidc_state' || !st.cv) throw new UnauthorizedException('oidc: invalid state'); + + const doc = await this.discover(c.issuer); + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: c.redirectUri, + client_id: c.clientId, + client_secret: c.clientSecret, + code_verifier: st.cv, + }); + const tokRes = await fetch(doc.token_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: body.toString(), + }); + if (!tokRes.ok) { + throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`); + } + const tok = (await tokRes.json()) as { access_token?: string; id_token?: string }; + + const claims = await this.resolveClaims(doc, tok); + const email = claims.email; + if (!email) throw new UnauthorizedException('oidc: no email claim'); + + const userId = await this.auth.provisionOidcUser(email, claims.name); + const ticket = jwt.sign({ sub: userId, typ: 'oidc_ticket' }, this.stateSecret(), { expiresIn: 60 }); + return { ticket, redirect: c.postLoginRedirect }; + } + + // Prefer userinfo (verified over TLS with the fresh access_token); fall + // back to decoding id_token payload for email/name/sub. + private async resolveClaims( + doc: Discovery, + tok: { access_token?: string; id_token?: string }, + ): Promise<{ email?: string; name?: string }> { + if (doc.userinfo_endpoint && tok.access_token) { + try { + const r = await fetch(doc.userinfo_endpoint, { + headers: { authorization: `Bearer ${tok.access_token}` }, + }); + if (r.ok) { + const u = (await r.json()) as { email?: string; name?: string; preferred_username?: string }; + if (u.email) return { email: u.email, name: u.name ?? u.preferred_username }; + } + } catch { + // fall through to id_token + } + } + if (tok.id_token) { + const part = tok.id_token.split('.')[1]; + if (part) { + try { + const p = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { + email?: string; + name?: string; + preferred_username?: string; + }; + return { email: p.email, name: p.name ?? p.preferred_username }; + } catch { + // ignore + } + } + } + return {}; + } + + // Redeem the one-time ticket for a full Fabric session (login shape). + async exchangeTicket(ticket: string) { + let t: { sub?: string; typ?: string }; + try { + t = jwt.verify(ticket, this.stateSecret()) as { sub?: string; typ?: string }; + } catch { + throw new UnauthorizedException('oidc: invalid or expired ticket'); + } + if (t.typ !== 'oidc_ticket' || !t.sub) throw new UnauthorizedException('oidc: invalid ticket'); + return this.auth.issueSessionForUserId(t.sub); + } +} diff --git a/src/cli.ts b/src/cli.ts index f9157d3..dfbdb03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; import { AuthService } from './auth/auth.service.js'; +import { OidcService } from './auth/oidc.service.js'; import { NodeAdminService } from './nodes/node-admin.service.js'; function getArg(flag: string): string | null { @@ -15,6 +16,11 @@ function printUsageAndExit(): never { console.error(' node dist/cli.js user create --email --password '); console.error(' node dist/cli.js user apikey --email [--label