/** * Zitadel OIDC Authorization Code + PKCE for the 0pt portal. * * The portal previously had no way to obtain a verified principal: the * `/api/0pt/*` routes require a Bearer token, but the browser only had the * dev-only HMAC session cookie. This module implements the standard web login * so a real user can reach the document workspace. * * Secrets (client id/secret, AUTH_SECRET) are server-only env values. */ import { createHash, randomBytes } from 'node:crypto'; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; import { deriveTenantId, zitadelIssuer } from '@/src/auth/zitadel'; export const OIDC_STATE_COOKIE = '0pt_oidc'; export const OIDC_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; export interface OidcConfig { issuer: string; clientId: string; clientSecret: string; redirectUri: string; } export function appBaseUrl(): string { const configured = process.env.NEXT_PUBLIC_APP_URL; if (configured && /^https?:\/\//.test(configured)) return configured.replace(/\/+$/, ''); return 'http://localhost:3000'; } export function readOidcConfig(): OidcConfig | undefined { const issuer = (process.env.ZITADEL_ISSUER || zitadelIssuer()).replace(/\/+$/, ''); const clientId = process.env.ZITADEL_OIDC_CLIENT_ID; const clientSecret = process.env.ZITADEL_OIDC_CLIENT_SECRET; if (!clientId || !clientSecret || !/^https:\/\//.test(issuer)) return undefined; return { issuer, clientId, clientSecret, redirectUri: `${appBaseUrl()}/api/auth/callback`, }; } function base64url(buffer: Buffer): string { return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } export function createPkce(): { verifier: string; challenge: string } { const verifier = base64url(randomBytes(48)); const challenge = base64url(createHash('sha256').update(verifier).digest()); return { verifier, challenge }; } export function createOidcState(): { state: string; nonce: string } { return { state: base64url(randomBytes(24)), nonce: base64url(randomBytes(24)) }; } export function authorizeUrl(config: OidcConfig, state: string, nonce: string, challenge: string): string { const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.redirectUri, response_type: 'code', scope: 'openid profile email urn:zitadel:iam:user:resourceowner', state, nonce, code_challenge: challenge, code_challenge_method: 'S256', }); return `${config.issuer}/oauth/v2/authorize?${params.toString()}`; } interface TokenResponse { id_token?: unknown; access_token?: unknown; token_type?: unknown; } export async function exchangeCode( config: OidcConfig, code: string, verifier: string, ): Promise<{ idToken: string; accessToken?: string }> { const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.redirectUri, client_id: config.clientId, client_secret: config.clientSecret, code_verifier: verifier, }); const response = await fetch(`${config.issuer}/oauth/v2/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, cache: 'no-store', signal: AbortSignal.timeout(10_000), }); if (!response.ok) throw new Error('token_exchange_failed'); const data = (await response.json()) as TokenResponse; if (typeof data.id_token !== 'string' || !data.id_token) { throw new Error('token_missing_id_token'); } return { idToken: data.id_token, ...(typeof data.access_token === 'string' ? { accessToken: data.access_token } : {}), }; } let jwks: ReturnType | undefined; let jwksIssuer: string | undefined; function keySet(issuer: string) { if (!jwks || jwksIssuer !== issuer) { jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`)); jwksIssuer = issuer; } return jwks; } export interface VerifiedIdToken { sub: string; tenantId: string; email?: string; claims: JWTPayload; } export async function verifyIdToken( config: OidcConfig, idToken: string, expectedNonce: string, accessToken?: string, ): Promise { const { payload } = await jwtVerify(idToken, keySet(config.issuer), { issuer: config.issuer, audience: config.clientId, }); if (typeof payload.sub !== 'string' || !payload.sub) { throw new Error('id_token_missing_sub'); } if (expectedNonce && payload.nonce !== expectedNonce) { throw new Error('id_token_nonce_mismatch'); } const tenantId = deriveTenantId(payload); if (!tenantId) throw new Error('id_token_missing_tenant'); return { sub: payload.sub, tenantId, ...(typeof payload.email === 'string' ? { email: payload.email } : {}), claims: payload, }; }