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

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);
}
}