import { createPrivateKey, randomUUID } from 'node:crypto'; import type { Context } from '@yaakapp/api'; import jwt, { type Algorithm } from 'jsonwebtoken'; import { fetchAccessToken } from '../fetchAccessToken'; import type { TokenStoreArgs } from '../store'; import { getToken, storeToken } from '../store'; import { isTokenExpired } from '../util'; export const jwtAlgorithms = [ 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'none', ] as const; export const defaultJwtAlgorithm = jwtAlgorithms[0]; /** * Build a signed JWT for the client_assertion parameter (RFC 7523). * * The `secret` value is auto-detected as one of: * - **JWK** – a JSON string containing a private-key object (has a `kty` field). * - **PEM** – a string whose trimmed form starts with `-----`. * - **HMAC secret** – anything else, used as-is for HS* algorithms. */ function buildClientAssertionJwt(params: { clientId: string; accessTokenUrl: string; secret: string; algorithm: Algorithm; }): string { const { clientId, accessTokenUrl, secret, algorithm } = params; const isHmac = algorithm.startsWith('HS') || algorithm === 'none'; // Resolve the signing key depending on format let signingKey: jwt.Secret; let kid: string | undefined; const trimmed = secret.trim(); if (isHmac) { // HMAC algorithms use the raw secret (string or Buffer) signingKey = secret; } else if (trimmed.startsWith('{')) { // Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases. let jwk: any; try { jwk = JSON.parse(trimmed); } catch { throw new Error('Client Assertion secret looks like JSON but is not valid'); } kid = jwk?.kid; signingKey = createPrivateKey({ key: jwk, format: 'jwk' }); } else if (trimmed.startsWith('-----')) { // PEM-encoded key signingKey = createPrivateKey({ key: trimmed, format: 'pem' }); } else { throw new Error( 'Client Assertion secret must be a JWK JSON object, a PEM-encoded key ' + '(starting with -----), or a raw secret for HMAC algorithms.', ); } const now = Math.floor(Date.now() / 1000); const payload = { iss: clientId, sub: clientId, aud: accessTokenUrl, iat: now, exp: now + 300, // 5 minutes jti: randomUUID(), }; // Build the JWT header; include "kid" when available const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' }; if (kid) { header.kid = kid; } return jwt.sign(JSON.stringify(payload), signingKey, { algorithm: algorithm as jwt.Algorithm, header, }); } export async function getClientCredentials( ctx: Context, contextId: string, { accessTokenUrl, clientId, clientSecret, scope, audience, credentialsInBody, clientAssertionSecret, clientAssertionSecretBase64, clientCredentialsMethod, clientAssertionAlgorithm, }: { accessTokenUrl: string; clientId: string; clientSecret: string; scope: string | null; audience: string | null; credentialsInBody: boolean; clientAssertionSecret: string; clientAssertionSecretBase64: boolean; clientCredentialsMethod: string; clientAssertionAlgorithm: string; }, ) { const tokenArgs: TokenStoreArgs = { contextId, clientId, accessTokenUrl, authorizationUrl: null, }; const token = await getToken(ctx, tokenArgs); if (token && !isTokenExpired(token)) { return token; } const common: Omit< Parameters[1], 'clientAssertion' | 'clientSecret' | 'credentialsInBody' > = { grantType: 'client_credentials', accessTokenUrl, audience, clientId, scope, params: [], }; const fetchParams: Parameters[1] = clientCredentialsMethod === 'client_assertion' ? { ...common, clientAssertion: buildClientAssertionJwt({ clientId, algorithm: clientAssertionAlgorithm as Algorithm, accessTokenUrl, secret: clientAssertionSecretBase64 ? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8') : clientAssertionSecret, }), } : { ...common, clientSecret, credentialsInBody, }; const response = await fetchAccessToken(ctx, fetchParams); return storeToken(ctx, tokenArgs, response); }