mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-24 02:11:10 +01:00
Co-authored-by: Davide Becker <github@reg.davide.me> Co-authored-by: Gregory Schier <gschier1990@gmail.com>
170 lines
4.4 KiB
TypeScript
170 lines
4.4 KiB
TypeScript
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<typeof fetchAccessToken>[1],
|
||
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
|
||
> = {
|
||
grantType: 'client_credentials',
|
||
accessTokenUrl,
|
||
audience,
|
||
clientId,
|
||
scope,
|
||
params: [],
|
||
};
|
||
|
||
const fetchParams: Parameters<typeof fetchAccessToken>[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);
|
||
}
|