mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-27 20:01:25 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
4.5 KiB
TypeScript
171 lines
4.5 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.
|
||
// oxlint-disable-next-line no-explicit-any
|
||
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);
|
||
}
|