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) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-18 09:44:49 +01:00
parent 6afb935302
commit 2a394969d2
7 changed files with 383 additions and 4 deletions

View File

@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller.js'; import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.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 { User } from '../entities/user.entity.js';
import { GuildNode } from '../entities/guild-node.entity.js'; import { GuildNode } from '../entities/guild-node.entity.js';
import { GuildUser } from '../entities/guild-user.entity.js'; import { GuildUser } from '../entities/guild-user.entity.js';
import { UserApiKey } from '../entities/user-api-key.entity.js'; import { UserApiKey } from '../entities/user-api-key.entity.js';
import { OidcConfig } from '../entities/oidc-config.entity.js';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey])], imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])],
controllers: [AuthController], controllers: [AuthController, OidcController],
providers: [AuthService], providers: [AuthService, OidcService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -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<string> {
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) { private async getUserGuildsAndTokens(userId: string, email: string) {
const memberships = await this.guildUserRepo.find({ const memberships = await this.guildUserRepo.find({
where: { userId, status: 'active' }, where: { userId, status: 'active' },

View File

@@ -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 ?? ''));
}
}

190
src/auth/oidc.service.ts Normal file
View File

@@ -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<OidcConfig>,
private readonly auth: AuthService,
) {}
private stateSecret(): string {
return process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
}
async getConfig(): Promise<OidcConfig | null> {
return this.repo.findOne({ where: { id: ROW_ID } });
}
async setConfig(patch: Partial<Omit<OidcConfig, 'id' | 'updatedAt'>>): Promise<OidcConfig> {
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<boolean> {
const c = await this.getConfig();
return !!(c && c.enabled && c.issuer && c.clientId && c.clientSecret && c.redirectUri);
}
private async requireEnabledConfig(): Promise<OidcConfig> {
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<Discovery> {
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<string> {
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);
}
}

View File

@@ -2,6 +2,7 @@ import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js'; import { AppModule } from './app.module.js';
import { AuthService } from './auth/auth.service.js'; import { AuthService } from './auth/auth.service.js';
import { OidcService } from './auth/oidc.service.js';
import { NodeAdminService } from './nodes/node-admin.service.js'; import { NodeAdminService } from './nodes/node-admin.service.js';
function getArg(flag: string): string | null { function getArg(flag: string): string | null {
@@ -15,6 +16,11 @@ function printUsageAndExit(): never {
console.error(' node dist/cli.js user create --email <email> --password <password>'); console.error(' node dist/cli.js user create --email <email> --password <password>');
console.error(' node dist/cli.js user apikey --email <email> [--label <label>]'); console.error(' node dist/cli.js user apikey --email <email> [--label <label>]');
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>'); console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
console.error(
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
' [--enabled true|false] (sets provided fields; prints config with secret masked)',
);
process.exit(1); process.exit(1);
} }
@@ -58,6 +64,42 @@ async function main() {
return; return;
} }
if (subject === 'config' && action === 'oidc') {
const oidc = app.get(OidcService);
const patch: Record<string, unknown> = {};
const issuer = getArg('--issuer');
if (issuer !== null) patch.issuer = issuer;
const clientId = getArg('--client-id');
if (clientId !== null) patch.clientId = clientId;
const clientSecret = getArg('--client-secret');
if (clientSecret !== null) patch.clientSecret = clientSecret;
const callbackUrl = getArg('--callback-url');
if (callbackUrl !== null) patch.redirectUri = callbackUrl;
const postLogin = getArg('--post-login-redirect');
if (postLogin !== null) patch.postLoginRedirect = postLogin;
const scopes = getArg('--scopes');
if (scopes !== null) patch.scopes = scopes;
const enabled = getArg('--enabled');
if (enabled !== null) patch.enabled = enabled === 'true';
const saved = await oidc.setConfig(patch);
process.stdout.write(
JSON.stringify({
ok: true,
config: {
issuer: saved.issuer,
clientId: saved.clientId,
clientSecret: saved.clientSecret ? '***set***' : '',
redirectUri: saved.redirectUri,
postLoginRedirect: saved.postLoginRedirect,
scopes: saved.scopes,
enabled: saved.enabled,
},
}) + '\n',
);
return;
}
printUsageAndExit(); printUsageAndExit();
} finally { } finally {
await app.close(); await app.close();

View File

@@ -4,6 +4,7 @@ import { GuildNode } from './entities/guild-node.entity.js';
import { AuditLog } from './entities/audit-log.entity.js'; import { AuditLog } from './entities/audit-log.entity.js';
import { GuildUser } from './entities/guild-user.entity.js'; import { GuildUser } from './entities/guild-user.entity.js';
import { UserApiKey } from './entities/user-api-key.entity.js'; import { UserApiKey } from './entities/user-api-key.entity.js';
import { OidcConfig } from './entities/oidc-config.entity.js';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql', type: 'mysql',
@@ -12,7 +13,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric', username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric',
password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric', password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric',
database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center', database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center',
entities: [User, GuildNode, GuildUser, AuditLog, UserApiKey], entities: [User, GuildNode, GuildUser, AuditLog, UserApiKey, OidcConfig],
synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true', synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true',
logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true', logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true',
}); });

View File

@@ -0,0 +1,37 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
// Single-row OIDC provider configuration, managed via the CLI
// `config-oidc`. id is always 'default'.
@Entity('oidc_config')
export class OidcConfig {
@PrimaryColumn({ type: 'varchar', length: 16 })
id!: string; // always 'default'
@Column({ type: 'varchar', length: 512, default: '' })
issuer!: string;
@Column({ type: 'varchar', length: 256, default: '' })
clientId!: string;
@Column({ type: 'varchar', length: 512, default: '' })
clientSecret!: string;
// Center's own callback URL registered at the IdP, e.g.
// https://fabric-api.example/api/auth/oidc/callback
@Column({ type: 'varchar', length: 512, default: '' })
redirectUri!: string;
// Where to send the browser back after a successful login (the SPA),
// e.g. https://fabric.example/oidc
@Column({ type: 'varchar', length: 512, default: '' })
postLoginRedirect!: string;
@Column({ type: 'varchar', length: 256, default: 'openid email profile' })
scopes!: string;
@Column({ type: 'boolean', default: false })
enabled!: boolean;
@UpdateDateColumn()
updatedAt!: Date;
}