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'; let res: Response; try { res = await fetch(url); } catch { throw new BadRequestException('oidc: issuer unreachable (discovery failed)'); } 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, }); let tokRes: Response; try { tokRes = await fetch(doc.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: body.toString(), }); } catch { throw new UnauthorizedException('oidc: token endpoint unreachable'); } 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); } }