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