CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / security (pull_request) Successful in 20s
Security Scan (SIAX Cloud) / security (push) Successful in 30s
CI (SIAX Cloud) / integration (pull_request) Successful in 31s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
CI (SIAX Cloud) / quality (push) Successful in 54s
Zitadel omits urn:zitadel:iam:org:id from the id_token for the default scopes. Requesting urn:zitadel:iam:user:resourceowner yields the user's org id, which deriveTenantId already understands. Also log claim keys (never values) when the tenant is still absent.
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
/**
|
|
* 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<typeof createRemoteJWKSet> | 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<VerifiedIdToken> {
|
|
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,
|
|
};
|
|
}
|