Files
admin 7d8ed9bfba
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
fix(auth): request the resourceowner scope so the tenant claim is present
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.
2026-11-02 16:07:22 +01:00

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