Wrap discovery + token-exchange fetch in try/catch so a DNS/network failure returns BadRequest/Unauthorized instead of a bare 500. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.2 KiB
TypeScript
201 lines
7.2 KiB
TypeScript
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';
|
|
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<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,
|
|
});
|
|
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);
|
|
}
|
|
}
|