@foldrun/core 9.5.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/oauth-assertion.d.ts +77 -0
- package/dist/src/oauth-assertion.js +223 -0
- package/dist/src/oauth-capabilities.js +4 -2
- package/dist/src/oauth-token.d.ts +16 -2
- package/dist/src/oauth-token.js +24 -3
- package/dist/src/secrets.d.ts +8 -0
- package/dist/src/secrets.js +9 -5
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/oauth-assertion.ts +234 -0
- package/src/oauth-capabilities.ts +4 -2
- package/src/oauth-token.ts +31 -4
- package/src/secrets.ts +17 -5
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from "./src/secrets.ts";
|
|
|
32
32
|
export * from "./src/oauth-presets.ts";
|
|
33
33
|
export * from "./src/oauth-token.ts";
|
|
34
34
|
export * from "./src/oauth-discovery.ts";
|
|
35
|
+
export * from "./src/oauth-assertion.ts";
|
|
35
36
|
export * from "./src/secret-files.ts";
|
|
36
37
|
export * from "./src/notify.ts";
|
|
37
38
|
export * from "./src/webhook.ts";
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,7 @@ export * from "./src/secrets.js";
|
|
|
43
43
|
export * from "./src/oauth-presets.js";
|
|
44
44
|
export * from "./src/oauth-token.js";
|
|
45
45
|
export * from "./src/oauth-discovery.js";
|
|
46
|
+
export * from "./src/oauth-assertion.js";
|
|
46
47
|
export * from "./src/secret-files.js";
|
|
47
48
|
export * from "./src/notify.js";
|
|
48
49
|
export * from "./src/webhook.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
/** A JSON Web Key, as node's crypto reads and writes it. */
|
|
3
|
+
export interface Jwk {
|
|
4
|
+
kty?: string;
|
|
5
|
+
crv?: string;
|
|
6
|
+
x?: string;
|
|
7
|
+
y?: string;
|
|
8
|
+
n?: string;
|
|
9
|
+
e?: string;
|
|
10
|
+
d?: string;
|
|
11
|
+
kid?: string;
|
|
12
|
+
alg?: string;
|
|
13
|
+
use?: string;
|
|
14
|
+
[k: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare const ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
17
|
+
/** The algorithms foldrun signs and accepts. Never `none`, never HMAC. */
|
|
18
|
+
export declare const ASSERTION_ALGS: readonly ["ES256", "ES384", "RS256", "PS256", "EdDSA"];
|
|
19
|
+
export type AssertionAlg = (typeof ASSERTION_ALGS)[number];
|
|
20
|
+
/** The longest an assertion may live (proposal: ≤ 5 minutes). */
|
|
21
|
+
export declare const ASSERTION_MAX_SECONDS = 300;
|
|
22
|
+
/** The alg a key signs with when none is said: from its type. */
|
|
23
|
+
export declare function algForKey(key: crypto.KeyObject): AssertionAlg;
|
|
24
|
+
/** A private key from PEM (PKCS#8 or traditional) or a private JWK (JSON). */
|
|
25
|
+
export declare function readPrivateKey(material: string): crypto.KeyObject;
|
|
26
|
+
/** RFC 7638 thumbprint of a public JWK — the default `kid`. */
|
|
27
|
+
export declare function jwkThumbprint(jwk: Jwk): string;
|
|
28
|
+
/** The public JWK a provider needs to register for a private key. */
|
|
29
|
+
export declare function publicJwkOf(privateKey: string, opts?: {
|
|
30
|
+
kid?: string;
|
|
31
|
+
alg?: AssertionAlg;
|
|
32
|
+
}): Jwk & {
|
|
33
|
+
kid: string;
|
|
34
|
+
alg: AssertionAlg;
|
|
35
|
+
use: "sig";
|
|
36
|
+
};
|
|
37
|
+
/** A new signing key for an app: the private PEM to keep sealed, and the
|
|
38
|
+
* public JWK to register with the provider. EC P-256 (ES256) unless asked. */
|
|
39
|
+
export declare function generateClientKey(alg?: "ES256" | "RS256" | "EdDSA"): {
|
|
40
|
+
privateKey: string;
|
|
41
|
+
publicJwk: ReturnType<typeof publicJwkOf>;
|
|
42
|
+
};
|
|
43
|
+
export interface AssertionSigner {
|
|
44
|
+
client_id: string;
|
|
45
|
+
/** The private key, PEM or JWK. */
|
|
46
|
+
client_assertion_key: string;
|
|
47
|
+
client_assertion_kid?: string;
|
|
48
|
+
client_assertion_alg?: AssertionAlg;
|
|
49
|
+
/** The certificate for the key (PEM), when the provider identifies keys by
|
|
50
|
+
* it (Microsoft Entra: x5t / x5t#S256 in the header). */
|
|
51
|
+
client_assertion_cert?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Sign a client assertion for `audience` (the endpoint it is sent to). */
|
|
54
|
+
export declare function signClientAssertion(signer: AssertionSigner, audience: string, nowMs?: number): string;
|
|
55
|
+
/** Why a signer's key material cannot work, or null. */
|
|
56
|
+
export declare function assertionKeyProblem(signer: {
|
|
57
|
+
client_assertion_key?: string;
|
|
58
|
+
client_assertion_alg?: unknown;
|
|
59
|
+
client_assertion_cert?: string;
|
|
60
|
+
}): string | null;
|
|
61
|
+
export interface VerifiedAssertion {
|
|
62
|
+
jti: string;
|
|
63
|
+
exp: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Check a client assertion presented to `audiences` (the endpoint URL, and
|
|
67
|
+
* the issuer — RFC 7523 §3 and its successor accept either). `keys` is the
|
|
68
|
+
* client's public JWKS. Throws with why; returns the jti and exp for the
|
|
69
|
+
* caller's replay store, which must refuse a jti it has seen before.
|
|
70
|
+
*/
|
|
71
|
+
export declare function verifyClientAssertion(assertion: string, opts: {
|
|
72
|
+
clientId: string;
|
|
73
|
+
audiences: string[];
|
|
74
|
+
keys: Jwk[];
|
|
75
|
+
nowMs?: number;
|
|
76
|
+
skewSeconds?: number;
|
|
77
|
+
}): VerifiedAssertion;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Signed client assertions (private_key_jwt, RFC 7523 §2.2 and §3; OIDC Core
|
|
2
|
+
// §9): a client proves itself at a token endpoint by signing a short JWT with
|
|
3
|
+
// its private key instead of sending a secret. What Microsoft Entra
|
|
4
|
+
// certificate credentials, Okta and FAPI providers ask for — and what the MCP
|
|
5
|
+
// server (foldrun-web server/mcp-oauth.ts) accepts from apps that publish a
|
|
6
|
+
// JWKS. Both halves are here so the client and the server read one rule.
|
|
7
|
+
//
|
|
8
|
+
// The assertion: header { alg, typ: "JWT", kid?, x5t?, x5t#S256? }, claims
|
|
9
|
+
// { iss = sub = client_id, aud = the endpoint, jti = random, iat, exp ≤ 5 min }.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
export const ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
12
|
+
/** The algorithms foldrun signs and accepts. Never `none`, never HMAC. */
|
|
13
|
+
export const ASSERTION_ALGS = ["ES256", "ES384", "RS256", "PS256", "EdDSA"];
|
|
14
|
+
/** The longest an assertion may live (proposal: ≤ 5 minutes). */
|
|
15
|
+
export const ASSERTION_MAX_SECONDS = 300;
|
|
16
|
+
const b64url = (b) => Buffer.from(b).toString("base64url");
|
|
17
|
+
/** The alg a key signs with when none is said: from its type. */
|
|
18
|
+
export function algForKey(key) {
|
|
19
|
+
const t = key.asymmetricKeyType;
|
|
20
|
+
if (t === "ec") {
|
|
21
|
+
const crv = key.asymmetricKeyDetails?.namedCurve;
|
|
22
|
+
if (crv === "prime256v1" || crv === "P-256")
|
|
23
|
+
return "ES256";
|
|
24
|
+
if (crv === "secp384r1" || crv === "P-384")
|
|
25
|
+
return "ES384";
|
|
26
|
+
throw new Error(`an EC key on ${crv} cannot sign a client assertion — use P-256 or P-384`);
|
|
27
|
+
}
|
|
28
|
+
if (t === "rsa")
|
|
29
|
+
return "RS256";
|
|
30
|
+
// An RSA-PSS-only key cannot be exported as a JWK (no kid, no public JWK
|
|
31
|
+
// for the provider): refused here, not at the first token exchange. A
|
|
32
|
+
// plain RSA key signs PS256 when client_assertion_alg says so.
|
|
33
|
+
if (t === "rsa-pss")
|
|
34
|
+
throw new Error("an RSA-PSS-only key is not supported — use a plain RSA key with client_assertion_alg PS256");
|
|
35
|
+
if (t === "ed25519")
|
|
36
|
+
return "EdDSA";
|
|
37
|
+
throw new Error(`a ${t} key cannot sign a client assertion — use EC P-256, RSA or Ed25519`);
|
|
38
|
+
}
|
|
39
|
+
/** A private key from PEM (PKCS#8 or traditional) or a private JWK (JSON). */
|
|
40
|
+
export function readPrivateKey(material) {
|
|
41
|
+
const text = material.trim();
|
|
42
|
+
try {
|
|
43
|
+
if (text.startsWith("{"))
|
|
44
|
+
return crypto.createPrivateKey({ key: JSON.parse(text), format: "jwk" });
|
|
45
|
+
return crypto.createPrivateKey(text);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new Error(`the client assertion key is not a private key (PEM or JWK): ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** RFC 7638 thumbprint of a public JWK — the default `kid`. */
|
|
52
|
+
export function jwkThumbprint(jwk) {
|
|
53
|
+
const members = jwk.kty === "EC" ? { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }
|
|
54
|
+
: jwk.kty === "RSA" ? { e: jwk.e, kty: jwk.kty, n: jwk.n }
|
|
55
|
+
: { crv: jwk.crv, kty: jwk.kty, x: jwk.x };
|
|
56
|
+
return b64url(crypto.createHash("sha256").update(JSON.stringify(members)).digest());
|
|
57
|
+
}
|
|
58
|
+
/** The public JWK a provider needs to register for a private key. */
|
|
59
|
+
export function publicJwkOf(privateKey, opts = {}) {
|
|
60
|
+
const key = readPrivateKey(privateKey);
|
|
61
|
+
const pub = crypto.createPublicKey(key).export({ format: "jwk" });
|
|
62
|
+
const alg = opts.alg ?? algForKey(key);
|
|
63
|
+
return { ...pub, kid: opts.kid ?? ownKid(privateKey) ?? jwkThumbprint(pub), alg, use: "sig" };
|
|
64
|
+
}
|
|
65
|
+
/** A new signing key for an app: the private PEM to keep sealed, and the
|
|
66
|
+
* public JWK to register with the provider. EC P-256 (ES256) unless asked. */
|
|
67
|
+
export function generateClientKey(alg = "ES256") {
|
|
68
|
+
const pair = alg === "RS256" ? crypto.generateKeyPairSync("rsa", { modulusLength: 2048 })
|
|
69
|
+
: alg === "EdDSA" ? crypto.generateKeyPairSync("ed25519")
|
|
70
|
+
: crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
71
|
+
const privateKey = pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
72
|
+
return { privateKey, publicJwk: publicJwkOf(privateKey, { alg }) };
|
|
73
|
+
}
|
|
74
|
+
/** Whether a key can sign (and its public half verify) an alg. */
|
|
75
|
+
function algFits(alg, key) {
|
|
76
|
+
return algForKey(key) === alg || (alg === "PS256" && key.asymmetricKeyType === "rsa");
|
|
77
|
+
}
|
|
78
|
+
/** The kid a private JWK carries itself (the provider registered it by it). */
|
|
79
|
+
function ownKid(material) {
|
|
80
|
+
const text = material.trim();
|
|
81
|
+
if (!text.startsWith("{"))
|
|
82
|
+
return undefined;
|
|
83
|
+
try {
|
|
84
|
+
const kid = JSON.parse(text).kid;
|
|
85
|
+
return typeof kid === "string" && kid ? kid : undefined;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function signBytes(alg, data, key) {
|
|
92
|
+
switch (alg) {
|
|
93
|
+
case "ES256": return crypto.sign("sha256", data, { key, dsaEncoding: "ieee-p1363" });
|
|
94
|
+
case "ES384": return crypto.sign("sha384", data, { key, dsaEncoding: "ieee-p1363" });
|
|
95
|
+
case "RS256": return crypto.sign("sha256", data, key);
|
|
96
|
+
case "PS256": return crypto.sign("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 });
|
|
97
|
+
case "EdDSA": return crypto.sign(null, data, key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function verifyBytes(alg, data, sig, key) {
|
|
101
|
+
switch (alg) {
|
|
102
|
+
case "ES256": return crypto.verify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
103
|
+
case "ES384": return crypto.verify("sha384", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
104
|
+
case "RS256": return crypto.verify("sha256", data, key, sig);
|
|
105
|
+
case "PS256": return crypto.verify("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, sig);
|
|
106
|
+
case "EdDSA": return crypto.verify(null, data, key, sig);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Sign a client assertion for `audience` (the endpoint it is sent to). */
|
|
110
|
+
export function signClientAssertion(signer, audience, nowMs = Date.now()) {
|
|
111
|
+
const key = readPrivateKey(signer.client_assertion_key);
|
|
112
|
+
const alg = signer.client_assertion_alg ?? algForKey(key);
|
|
113
|
+
if (!ASSERTION_ALGS.includes(alg))
|
|
114
|
+
throw new Error(`client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`);
|
|
115
|
+
if (!algFits(alg, key))
|
|
116
|
+
throw new Error(`client_assertion_alg ${alg} does not fit this key`);
|
|
117
|
+
const header = { alg, typ: "JWT" };
|
|
118
|
+
if (signer.client_assertion_cert) {
|
|
119
|
+
const der = new crypto.X509Certificate(signer.client_assertion_cert).raw;
|
|
120
|
+
header["x5t#S256"] = b64url(crypto.createHash("sha256").update(der).digest());
|
|
121
|
+
header.x5t = b64url(crypto.createHash("sha1").update(der).digest());
|
|
122
|
+
}
|
|
123
|
+
header.kid = signer.client_assertion_kid ?? ownKid(signer.client_assertion_key) ?? jwkThumbprint(crypto.createPublicKey(key).export({ format: "jwk" }));
|
|
124
|
+
const iat = Math.floor(nowMs / 1000);
|
|
125
|
+
const claims = { iss: signer.client_id, sub: signer.client_id, aud: audience, jti: b64url(crypto.randomBytes(18)), iat, nbf: iat, exp: iat + 120 };
|
|
126
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
127
|
+
return `${input}.${b64url(signBytes(alg, Buffer.from(input), key))}`;
|
|
128
|
+
}
|
|
129
|
+
/** Why a signer's key material cannot work, or null. */
|
|
130
|
+
export function assertionKeyProblem(signer) {
|
|
131
|
+
if (!signer.client_assertion_key?.trim())
|
|
132
|
+
return "token_auth private_key_jwt needs a client_assertion_key (a private key, PEM or JWK)";
|
|
133
|
+
let key;
|
|
134
|
+
try {
|
|
135
|
+
key = readPrivateKey(signer.client_assertion_key);
|
|
136
|
+
algForKey(key);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return err instanceof Error ? err.message : String(err);
|
|
140
|
+
}
|
|
141
|
+
if (signer.client_assertion_alg !== undefined) {
|
|
142
|
+
const alg = String(signer.client_assertion_alg);
|
|
143
|
+
if (!ASSERTION_ALGS.includes(alg))
|
|
144
|
+
return `client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`;
|
|
145
|
+
// The alg must be one this key signs: an RSA key also signs PS256.
|
|
146
|
+
if (!algFits(alg, key))
|
|
147
|
+
return `client_assertion_alg ${alg} does not fit this key (a ${key.asymmetricKeyType} key signs ${algForKey(key)})`;
|
|
148
|
+
}
|
|
149
|
+
if (signer.client_assertion_cert) {
|
|
150
|
+
try {
|
|
151
|
+
const cert = new crypto.X509Certificate(signer.client_assertion_cert);
|
|
152
|
+
if (!cert.checkPrivateKey(key))
|
|
153
|
+
return "client_assertion_cert is not the certificate of client_assertion_key";
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return "client_assertion_cert is not a PEM certificate";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Check a client assertion presented to `audiences` (the endpoint URL, and
|
|
163
|
+
* the issuer — RFC 7523 §3 and its successor accept either). `keys` is the
|
|
164
|
+
* client's public JWKS. Throws with why; returns the jti and exp for the
|
|
165
|
+
* caller's replay store, which must refuse a jti it has seen before.
|
|
166
|
+
*/
|
|
167
|
+
export function verifyClientAssertion(assertion, opts) {
|
|
168
|
+
const parts = assertion.split(".");
|
|
169
|
+
if (parts.length !== 3)
|
|
170
|
+
throw new Error("the client assertion is not a JWT");
|
|
171
|
+
let header;
|
|
172
|
+
let claims;
|
|
173
|
+
try {
|
|
174
|
+
header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
175
|
+
claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new Error("the client assertion is not a JWT");
|
|
179
|
+
}
|
|
180
|
+
const alg = header.alg;
|
|
181
|
+
if (!ASSERTION_ALGS.includes(alg))
|
|
182
|
+
throw new Error(`the client assertion's alg ${JSON.stringify(header.alg)} is not accepted (${ASSERTION_ALGS.join(", ")})`);
|
|
183
|
+
// A key registered with an alg signs only that alg (RFC 7517 §4.4).
|
|
184
|
+
const candidates = opts.keys.filter((k) => (!header.kid || k.kid === header.kid) && k.use !== "enc" && (!k.alg || k.alg === alg));
|
|
185
|
+
if (!candidates.length)
|
|
186
|
+
throw new Error(`no key${header.kid ? ` with kid ${header.kid}` : ""} in the client's JWKS`);
|
|
187
|
+
const input = Buffer.from(`${parts[0]}.${parts[1]}`);
|
|
188
|
+
const sig = Buffer.from(parts[2], "base64url");
|
|
189
|
+
const good = candidates.some((jwk) => {
|
|
190
|
+
try {
|
|
191
|
+
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
192
|
+
// The key's type must be the alg's: an RSA key cannot be presented as ES256.
|
|
193
|
+
if (!algFits(alg, key))
|
|
194
|
+
return false;
|
|
195
|
+
return verifyBytes(alg, input, sig, key);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
if (!good)
|
|
202
|
+
throw new Error("the client assertion's signature does not verify against the client's keys");
|
|
203
|
+
if (claims.iss !== opts.clientId || claims.sub !== opts.clientId)
|
|
204
|
+
throw new Error("the client assertion's iss and sub must be the client_id");
|
|
205
|
+
// Plain string comparison (RFC 7519 §4.1.3): a trailing slash is another audience.
|
|
206
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
207
|
+
if (!aud.some((a) => typeof a === "string" && opts.audiences.includes(a)))
|
|
208
|
+
throw new Error(`the client assertion is for ${JSON.stringify(claims.aud)}, not this server`);
|
|
209
|
+
const now = Math.floor((opts.nowMs ?? Date.now()) / 1000);
|
|
210
|
+
const skew = opts.skewSeconds ?? 60;
|
|
211
|
+
if (typeof claims.exp !== "number")
|
|
212
|
+
throw new Error("the client assertion has no exp");
|
|
213
|
+
if (claims.exp + skew < now)
|
|
214
|
+
throw new Error("the client assertion has expired");
|
|
215
|
+
if (typeof claims.nbf === "number" && claims.nbf - skew > now)
|
|
216
|
+
throw new Error("the client assertion is not valid yet");
|
|
217
|
+
const issued = typeof claims.iat === "number" ? claims.iat : now;
|
|
218
|
+
if (claims.exp - issued > ASSERTION_MAX_SECONDS || claims.exp - now > ASSERTION_MAX_SECONDS + skew)
|
|
219
|
+
throw new Error(`the client assertion lives longer than ${ASSERTION_MAX_SECONDS} seconds`);
|
|
220
|
+
if (typeof claims.jti !== "string" || !claims.jti)
|
|
221
|
+
throw new Error("the client assertion has no jti");
|
|
222
|
+
return { jti: claims.jti, exp: claims.exp };
|
|
223
|
+
}
|
|
@@ -83,7 +83,8 @@ export const OAUTH_CAPABILITIES = [
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
id: "client.private-key-jwt", role: "client", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
86
|
-
status: "
|
|
86
|
+
status: "supported", note: "`token_auth: private_key_jwt` with a key you paste or foldrun makes; a certificate adds Entra's thumbprint. `--token-auth jwt`.",
|
|
87
|
+
proof: [core("tests/oauth-assertion.test.ts", "an oauth2 secret with private_key_jwt refreshes"), platform("tests/oauth-connect.test.ts", "private_key_jwt: a saved app can have its key made"), platform(KC, "private_key_jwt (RFC 7523): code + PKCE + PAR signed"), cli("tests/cli-connect.test.ts", "connect --token-auth jwt --key-file")],
|
|
87
88
|
},
|
|
88
89
|
{
|
|
89
90
|
id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
|
|
@@ -152,7 +153,8 @@ export const OAUTH_CAPABILITIES = [
|
|
|
152
153
|
},
|
|
153
154
|
{
|
|
154
155
|
id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
155
|
-
status: "
|
|
156
|
+
status: "supported", note: "Apps that register `jwks` or `jwks_uri` sign in with assertions; each `jti` is used once.",
|
|
157
|
+
proof: [web("tests/mcp-oauth.test.ts", "private_key_jwt (RFC 7523): an app with a JWKS signs in"), core("tests/oauth-assertion.test.ts", "the verifier refuses")],
|
|
156
158
|
},
|
|
157
159
|
// ------------------------------------------------------------ refused, both roles
|
|
158
160
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { type AssertionAlg } from "./oauth-assertion.ts";
|
|
2
|
+
export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"];
|
|
2
3
|
export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
|
|
3
4
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
4
5
|
export declare function parseTokenAuth(value: string): TokenAuth | null;
|
|
@@ -6,11 +7,20 @@ export interface TokenClient {
|
|
|
6
7
|
client_id: string;
|
|
7
8
|
client_secret?: string;
|
|
8
9
|
token_auth?: TokenAuth;
|
|
10
|
+
/** private_key_jwt: the private key (PEM or JWK), its kid and alg, and
|
|
11
|
+
* its certificate when the provider names keys by it (oauth-assertion.ts). */
|
|
12
|
+
client_assertion_key?: string;
|
|
13
|
+
client_assertion_kid?: string;
|
|
14
|
+
client_assertion_alg?: AssertionAlg;
|
|
15
|
+
client_assertion_cert?: string;
|
|
9
16
|
}
|
|
10
17
|
/** Why a client's token_auth cannot work, or null. */
|
|
11
18
|
export declare function tokenAuthProblem(client: {
|
|
12
19
|
client_secret?: string;
|
|
13
20
|
token_auth?: unknown;
|
|
21
|
+
client_assertion_key?: string;
|
|
22
|
+
client_assertion_alg?: unknown;
|
|
23
|
+
client_assertion_cert?: string;
|
|
14
24
|
}): string | null;
|
|
15
25
|
/**
|
|
16
26
|
* The headers and body of one token request. `params` is the grant's own
|
|
@@ -21,8 +31,12 @@ export declare function tokenAuthProblem(client: {
|
|
|
21
31
|
* pushed authorization request carries it as a parameter of the request it
|
|
22
32
|
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
23
33
|
*/
|
|
24
|
-
export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string>,
|
|
34
|
+
export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string>,
|
|
35
|
+
/** `audience`: the endpoint the request goes to — the `aud` of a
|
|
36
|
+
* private_key_jwt assertion (required for that method). */
|
|
37
|
+
opts?: {
|
|
25
38
|
clientIdInBody?: boolean;
|
|
39
|
+
audience?: string;
|
|
26
40
|
}): {
|
|
27
41
|
headers: Record<string, string>;
|
|
28
42
|
body: string;
|
package/dist/src/oauth-token.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
// base64 of the raw pair, which breaks on a secret
|
|
14
14
|
// holding ':' or '%'
|
|
15
15
|
// none a public client: client_id in the body, no secret
|
|
16
|
+
// private_key_jwt a JWT signed with the client's private key, no
|
|
17
|
+
// secret at all (RFC 7523 §2.2, oauth-assertion.ts)
|
|
16
18
|
//
|
|
17
19
|
// Absent means the old behaviour: the body, with the secret if there is one.
|
|
18
|
-
|
|
20
|
+
import { ASSERTION_TYPE, assertionKeyProblem, signClientAssertion } from "./oauth-assertion.js";
|
|
21
|
+
export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"];
|
|
19
22
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
20
23
|
export function parseTokenAuth(value) {
|
|
21
24
|
const v = value.trim().toLowerCase();
|
|
@@ -25,6 +28,8 @@ export function parseTokenAuth(value) {
|
|
|
25
28
|
return "client_secret_post";
|
|
26
29
|
if (v === "basic")
|
|
27
30
|
return "client_secret_basic";
|
|
31
|
+
if (v === "jwt" || v === "private-key-jwt" || v === "key")
|
|
32
|
+
return "private_key_jwt";
|
|
28
33
|
return null;
|
|
29
34
|
}
|
|
30
35
|
/** Why a client's token_auth cannot work, or null. */
|
|
@@ -42,6 +47,12 @@ export function tokenAuthProblem(client) {
|
|
|
42
47
|
return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
|
|
43
48
|
if (auth === "none" && hasSecret)
|
|
44
49
|
return "token_auth none is a public client — it sends no client_secret; leave the secret empty or pick client_secret_post or client_secret_basic";
|
|
50
|
+
if (auth === "private_key_jwt") {
|
|
51
|
+
// One method per request: a signed assertion, never a secret beside it.
|
|
52
|
+
if (hasSecret)
|
|
53
|
+
return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
|
|
54
|
+
return assertionKeyProblem(client);
|
|
55
|
+
}
|
|
45
56
|
return null;
|
|
46
57
|
}
|
|
47
58
|
/** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
|
|
@@ -55,13 +66,16 @@ const formEncode = (s) => new URLSearchParams({ x: s }).toString().slice(2);
|
|
|
55
66
|
* pushed authorization request carries it as a parameter of the request it
|
|
56
67
|
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
57
68
|
*/
|
|
58
|
-
export function oauthTokenRequest(client, params,
|
|
69
|
+
export function oauthTokenRequest(client, params,
|
|
70
|
+
/** `audience`: the endpoint the request goes to — the `aud` of a
|
|
71
|
+
* private_key_jwt assertion (required for that method). */
|
|
72
|
+
opts = {}) {
|
|
59
73
|
const problem = tokenAuthProblem(client);
|
|
60
74
|
if (problem)
|
|
61
75
|
throw new Error(problem);
|
|
62
76
|
const form = new URLSearchParams();
|
|
63
77
|
for (const [k, v] of Object.entries(params)) {
|
|
64
|
-
if (k === "client_id" || k === "client_secret")
|
|
78
|
+
if (k === "client_id" || k === "client_secret" || k === "client_assertion" || k === "client_assertion_type")
|
|
65
79
|
continue;
|
|
66
80
|
form.set(k, v);
|
|
67
81
|
}
|
|
@@ -79,6 +93,13 @@ export function oauthTokenRequest(client, params, opts = {}) {
|
|
|
79
93
|
if (opts.clientIdInBody)
|
|
80
94
|
form.set("client_id", client.client_id);
|
|
81
95
|
}
|
|
96
|
+
else if (auth === "private_key_jwt") {
|
|
97
|
+
if (!opts.audience)
|
|
98
|
+
throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
|
|
99
|
+
form.set("client_id", client.client_id);
|
|
100
|
+
form.set("client_assertion_type", ASSERTION_TYPE);
|
|
101
|
+
form.set("client_assertion", signClientAssertion(client, opts.audience));
|
|
102
|
+
}
|
|
82
103
|
else {
|
|
83
104
|
form.set("client_id", client.client_id);
|
|
84
105
|
if (auth === "client_secret_post")
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type TokenAuth } from "./oauth-token.ts";
|
|
2
|
+
import type { AssertionAlg } from "./oauth-assertion.ts";
|
|
2
3
|
/** Which store a secret came from. */
|
|
3
4
|
export type SecretScope = "account" | "workspace";
|
|
4
5
|
/**
|
|
@@ -248,6 +249,13 @@ export interface OAuth2Config {
|
|
|
248
249
|
/** The provider's revocation endpoint (RFC 7009): deleting or replacing
|
|
249
250
|
* this secret revokes its tokens there (revokeOAuth2). */
|
|
250
251
|
revocation_url?: string;
|
|
252
|
+
/** token_auth private_key_jwt: the private key (PEM or JWK) the client
|
|
253
|
+
* signs its assertions with, sealed with the rest of the recipe; its kid,
|
|
254
|
+
* alg, and certificate when the provider names keys by it (Entra). */
|
|
255
|
+
client_assertion_key?: string;
|
|
256
|
+
client_assertion_kid?: string;
|
|
257
|
+
client_assertion_alg?: AssertionAlg;
|
|
258
|
+
client_assertion_cert?: string;
|
|
251
259
|
}
|
|
252
260
|
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
253
261
|
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
package/dist/src/secrets.js
CHANGED
|
@@ -817,8 +817,8 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
|
817
817
|
}
|
|
818
818
|
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
819
819
|
// client credentials are nothing without the secret.
|
|
820
|
-
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
821
|
-
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
820
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim() && config.token_auth !== "private_key_jwt") {
|
|
821
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials (or token_auth private_key_jwt with a key)");
|
|
822
822
|
}
|
|
823
823
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
824
824
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
@@ -908,11 +908,14 @@ async function exchange(config, cacheKey) {
|
|
|
908
908
|
const flight = (async () => {
|
|
909
909
|
// One builder for every token request (oauth-token.ts): the client's
|
|
910
910
|
// identity goes where its token_auth says, and an `extra` cannot set it.
|
|
911
|
-
|
|
911
|
+
// Built per try: a private_key_jwt assertion's jti is good once, so a
|
|
912
|
+
// retry after a lost answer must carry a new one (Codex review).
|
|
913
|
+
const build = () => oauthTokenRequest(config, {
|
|
912
914
|
grant_type: config.grant_type ?? "refresh_token",
|
|
913
915
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
|
|
914
916
|
...(config.extra ?? {}),
|
|
915
|
-
});
|
|
917
|
+
}, { audience: config.token_url });
|
|
918
|
+
build(); // a config that cannot make a request says so before any network
|
|
916
919
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
917
920
|
// (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
|
|
918
921
|
// — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
|
|
@@ -924,6 +927,7 @@ async function exchange(config, cacheKey) {
|
|
|
924
927
|
if (attempt)
|
|
925
928
|
await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
|
|
926
929
|
try {
|
|
930
|
+
const request = build();
|
|
927
931
|
res = await fetchUntrusted(config.token_url, {
|
|
928
932
|
method: "POST",
|
|
929
933
|
headers: request.headers,
|
|
@@ -1062,7 +1066,7 @@ export async function revokeOAuth2Value(value, opts = {}) {
|
|
|
1062
1066
|
const out = { revoked: [] };
|
|
1063
1067
|
for (const [hint, token] of tokens) {
|
|
1064
1068
|
try {
|
|
1065
|
-
const request = oauthTokenRequest(config, { token, token_type_hint: hint });
|
|
1069
|
+
const request = oauthTokenRequest(config, { token, token_type_hint: hint }, { audience: config.token_url });
|
|
1066
1070
|
const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
1067
1071
|
if (res.ok) {
|
|
1068
1072
|
await res.body?.cancel().catch(() => { });
|
package/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export * from "./src/secrets.ts";
|
|
|
44
44
|
export * from "./src/oauth-presets.ts";
|
|
45
45
|
export * from "./src/oauth-token.ts";
|
|
46
46
|
export * from "./src/oauth-discovery.ts";
|
|
47
|
+
export * from "./src/oauth-assertion.ts";
|
|
47
48
|
export * from "./src/secret-files.ts";
|
|
48
49
|
export * from "./src/notify.ts";
|
|
49
50
|
export * from "./src/webhook.ts";
|
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Signed client assertions (private_key_jwt, RFC 7523 §2.2 and §3; OIDC Core
|
|
2
|
+
// §9): a client proves itself at a token endpoint by signing a short JWT with
|
|
3
|
+
// its private key instead of sending a secret. What Microsoft Entra
|
|
4
|
+
// certificate credentials, Okta and FAPI providers ask for — and what the MCP
|
|
5
|
+
// server (foldrun-web server/mcp-oauth.ts) accepts from apps that publish a
|
|
6
|
+
// JWKS. Both halves are here so the client and the server read one rule.
|
|
7
|
+
//
|
|
8
|
+
// The assertion: header { alg, typ: "JWT", kid?, x5t?, x5t#S256? }, claims
|
|
9
|
+
// { iss = sub = client_id, aud = the endpoint, jti = random, iat, exp ≤ 5 min }.
|
|
10
|
+
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/** A JSON Web Key, as node's crypto reads and writes it. */
|
|
14
|
+
export interface Jwk { kty?: string; crv?: string; x?: string; y?: string; n?: string; e?: string; d?: string; kid?: string; alg?: string; use?: string; [k: string]: unknown }
|
|
15
|
+
|
|
16
|
+
export const ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
17
|
+
/** The algorithms foldrun signs and accepts. Never `none`, never HMAC. */
|
|
18
|
+
export const ASSERTION_ALGS = ["ES256", "ES384", "RS256", "PS256", "EdDSA"] as const;
|
|
19
|
+
export type AssertionAlg = (typeof ASSERTION_ALGS)[number];
|
|
20
|
+
/** The longest an assertion may live (proposal: ≤ 5 minutes). */
|
|
21
|
+
export const ASSERTION_MAX_SECONDS = 300;
|
|
22
|
+
|
|
23
|
+
const b64url = (b: Buffer | string) => Buffer.from(b).toString("base64url");
|
|
24
|
+
|
|
25
|
+
/** The alg a key signs with when none is said: from its type. */
|
|
26
|
+
export function algForKey(key: crypto.KeyObject): AssertionAlg {
|
|
27
|
+
const t = key.asymmetricKeyType;
|
|
28
|
+
if (t === "ec") {
|
|
29
|
+
const crv = key.asymmetricKeyDetails?.namedCurve;
|
|
30
|
+
if (crv === "prime256v1" || crv === "P-256") return "ES256";
|
|
31
|
+
if (crv === "secp384r1" || crv === "P-384") return "ES384";
|
|
32
|
+
throw new Error(`an EC key on ${crv} cannot sign a client assertion — use P-256 or P-384`);
|
|
33
|
+
}
|
|
34
|
+
if (t === "rsa") return "RS256";
|
|
35
|
+
// An RSA-PSS-only key cannot be exported as a JWK (no kid, no public JWK
|
|
36
|
+
// for the provider): refused here, not at the first token exchange. A
|
|
37
|
+
// plain RSA key signs PS256 when client_assertion_alg says so.
|
|
38
|
+
if (t === "rsa-pss") throw new Error("an RSA-PSS-only key is not supported — use a plain RSA key with client_assertion_alg PS256");
|
|
39
|
+
if (t === "ed25519") return "EdDSA";
|
|
40
|
+
throw new Error(`a ${t} key cannot sign a client assertion — use EC P-256, RSA or Ed25519`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A private key from PEM (PKCS#8 or traditional) or a private JWK (JSON). */
|
|
44
|
+
export function readPrivateKey(material: string): crypto.KeyObject {
|
|
45
|
+
const text = material.trim();
|
|
46
|
+
try {
|
|
47
|
+
if (text.startsWith("{")) return crypto.createPrivateKey({ key: JSON.parse(text), format: "jwk" });
|
|
48
|
+
return crypto.createPrivateKey(text);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`the client assertion key is not a private key (PEM or JWK): ${err instanceof Error ? err.message : String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** RFC 7638 thumbprint of a public JWK — the default `kid`. */
|
|
55
|
+
export function jwkThumbprint(jwk: Jwk): string {
|
|
56
|
+
const members: Record<string, unknown> =
|
|
57
|
+
jwk.kty === "EC" ? { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }
|
|
58
|
+
: jwk.kty === "RSA" ? { e: jwk.e, kty: jwk.kty, n: jwk.n }
|
|
59
|
+
: { crv: jwk.crv, kty: jwk.kty, x: jwk.x };
|
|
60
|
+
return b64url(crypto.createHash("sha256").update(JSON.stringify(members)).digest());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The public JWK a provider needs to register for a private key. */
|
|
64
|
+
export function publicJwkOf(privateKey: string, opts: { kid?: string; alg?: AssertionAlg } = {}): Jwk & { kid: string; alg: AssertionAlg; use: "sig" } {
|
|
65
|
+
const key = readPrivateKey(privateKey);
|
|
66
|
+
const pub = crypto.createPublicKey(key).export({ format: "jwk" }) as Jwk;
|
|
67
|
+
const alg = opts.alg ?? algForKey(key);
|
|
68
|
+
return { ...pub, kid: opts.kid ?? ownKid(privateKey) ?? jwkThumbprint(pub), alg, use: "sig" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A new signing key for an app: the private PEM to keep sealed, and the
|
|
72
|
+
* public JWK to register with the provider. EC P-256 (ES256) unless asked. */
|
|
73
|
+
export function generateClientKey(alg: "ES256" | "RS256" | "EdDSA" = "ES256"): { privateKey: string; publicJwk: ReturnType<typeof publicJwkOf> } {
|
|
74
|
+
const pair =
|
|
75
|
+
alg === "RS256" ? crypto.generateKeyPairSync("rsa", { modulusLength: 2048 })
|
|
76
|
+
: alg === "EdDSA" ? crypto.generateKeyPairSync("ed25519")
|
|
77
|
+
: crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
78
|
+
const privateKey = pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
79
|
+
return { privateKey, publicJwk: publicJwkOf(privateKey, { alg }) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether a key can sign (and its public half verify) an alg. */
|
|
83
|
+
function algFits(alg: AssertionAlg, key: crypto.KeyObject): boolean {
|
|
84
|
+
return algForKey(key) === alg || (alg === "PS256" && key.asymmetricKeyType === "rsa");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The kid a private JWK carries itself (the provider registered it by it). */
|
|
88
|
+
function ownKid(material: string): string | undefined {
|
|
89
|
+
const text = material.trim();
|
|
90
|
+
if (!text.startsWith("{")) return undefined;
|
|
91
|
+
try {
|
|
92
|
+
const kid = (JSON.parse(text) as { kid?: unknown }).kid;
|
|
93
|
+
return typeof kid === "string" && kid ? kid : undefined;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function signBytes(alg: AssertionAlg, data: Buffer, key: crypto.KeyObject): Buffer {
|
|
100
|
+
switch (alg) {
|
|
101
|
+
case "ES256": return crypto.sign("sha256", data, { key, dsaEncoding: "ieee-p1363" });
|
|
102
|
+
case "ES384": return crypto.sign("sha384", data, { key, dsaEncoding: "ieee-p1363" });
|
|
103
|
+
case "RS256": return crypto.sign("sha256", data, key);
|
|
104
|
+
case "PS256": return crypto.sign("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 });
|
|
105
|
+
case "EdDSA": return crypto.sign(null, data, key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function verifyBytes(alg: AssertionAlg, data: Buffer, sig: Buffer, key: crypto.KeyObject): boolean {
|
|
110
|
+
switch (alg) {
|
|
111
|
+
case "ES256": return crypto.verify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
112
|
+
case "ES384": return crypto.verify("sha384", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
113
|
+
case "RS256": return crypto.verify("sha256", data, key, sig);
|
|
114
|
+
case "PS256": return crypto.verify("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, sig);
|
|
115
|
+
case "EdDSA": return crypto.verify(null, data, key, sig);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface AssertionSigner {
|
|
120
|
+
client_id: string;
|
|
121
|
+
/** The private key, PEM or JWK. */
|
|
122
|
+
client_assertion_key: string;
|
|
123
|
+
client_assertion_kid?: string;
|
|
124
|
+
client_assertion_alg?: AssertionAlg;
|
|
125
|
+
/** The certificate for the key (PEM), when the provider identifies keys by
|
|
126
|
+
* it (Microsoft Entra: x5t / x5t#S256 in the header). */
|
|
127
|
+
client_assertion_cert?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Sign a client assertion for `audience` (the endpoint it is sent to). */
|
|
131
|
+
export function signClientAssertion(signer: AssertionSigner, audience: string, nowMs = Date.now()): string {
|
|
132
|
+
const key = readPrivateKey(signer.client_assertion_key);
|
|
133
|
+
const alg = signer.client_assertion_alg ?? algForKey(key);
|
|
134
|
+
if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) throw new Error(`client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`);
|
|
135
|
+
if (!algFits(alg, key)) throw new Error(`client_assertion_alg ${alg} does not fit this key`);
|
|
136
|
+
const header: Record<string, string> = { alg, typ: "JWT" };
|
|
137
|
+
if (signer.client_assertion_cert) {
|
|
138
|
+
const der = new crypto.X509Certificate(signer.client_assertion_cert).raw;
|
|
139
|
+
header["x5t#S256"] = b64url(crypto.createHash("sha256").update(der).digest());
|
|
140
|
+
header.x5t = b64url(crypto.createHash("sha1").update(der).digest());
|
|
141
|
+
}
|
|
142
|
+
header.kid = signer.client_assertion_kid ?? ownKid(signer.client_assertion_key) ?? jwkThumbprint(crypto.createPublicKey(key).export({ format: "jwk" }) as Jwk);
|
|
143
|
+
const iat = Math.floor(nowMs / 1000);
|
|
144
|
+
const claims = { iss: signer.client_id, sub: signer.client_id, aud: audience, jti: b64url(crypto.randomBytes(18)), iat, nbf: iat, exp: iat + 120 };
|
|
145
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
146
|
+
return `${input}.${b64url(signBytes(alg, Buffer.from(input), key))}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Why a signer's key material cannot work, or null. */
|
|
150
|
+
export function assertionKeyProblem(signer: { client_assertion_key?: string; client_assertion_alg?: unknown; client_assertion_cert?: string }): string | null {
|
|
151
|
+
if (!signer.client_assertion_key?.trim()) return "token_auth private_key_jwt needs a client_assertion_key (a private key, PEM or JWK)";
|
|
152
|
+
let key: crypto.KeyObject;
|
|
153
|
+
try {
|
|
154
|
+
key = readPrivateKey(signer.client_assertion_key);
|
|
155
|
+
algForKey(key);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return err instanceof Error ? err.message : String(err);
|
|
158
|
+
}
|
|
159
|
+
if (signer.client_assertion_alg !== undefined) {
|
|
160
|
+
const alg = String(signer.client_assertion_alg);
|
|
161
|
+
if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) return `client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`;
|
|
162
|
+
// The alg must be one this key signs: an RSA key also signs PS256.
|
|
163
|
+
if (!algFits(alg as AssertionAlg, key)) return `client_assertion_alg ${alg} does not fit this key (a ${key.asymmetricKeyType} key signs ${algForKey(key)})`;
|
|
164
|
+
}
|
|
165
|
+
if (signer.client_assertion_cert) {
|
|
166
|
+
try {
|
|
167
|
+
const cert = new crypto.X509Certificate(signer.client_assertion_cert);
|
|
168
|
+
if (!cert.checkPrivateKey(key)) return "client_assertion_cert is not the certificate of client_assertion_key";
|
|
169
|
+
} catch {
|
|
170
|
+
return "client_assertion_cert is not a PEM certificate";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ------------------------------------------------------------ the server's half
|
|
177
|
+
|
|
178
|
+
export interface VerifiedAssertion {
|
|
179
|
+
jti: string;
|
|
180
|
+
exp: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check a client assertion presented to `audiences` (the endpoint URL, and
|
|
185
|
+
* the issuer — RFC 7523 §3 and its successor accept either). `keys` is the
|
|
186
|
+
* client's public JWKS. Throws with why; returns the jti and exp for the
|
|
187
|
+
* caller's replay store, which must refuse a jti it has seen before.
|
|
188
|
+
*/
|
|
189
|
+
export function verifyClientAssertion(
|
|
190
|
+
assertion: string,
|
|
191
|
+
opts: { clientId: string; audiences: string[]; keys: Jwk[]; nowMs?: number; skewSeconds?: number },
|
|
192
|
+
): VerifiedAssertion {
|
|
193
|
+
const parts = assertion.split(".");
|
|
194
|
+
if (parts.length !== 3) throw new Error("the client assertion is not a JWT");
|
|
195
|
+
let header: { alg?: string; kid?: string; typ?: string };
|
|
196
|
+
let claims: { iss?: unknown; sub?: unknown; aud?: unknown; exp?: unknown; iat?: unknown; nbf?: unknown; jti?: unknown };
|
|
197
|
+
try {
|
|
198
|
+
header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
199
|
+
claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("the client assertion is not a JWT");
|
|
202
|
+
}
|
|
203
|
+
const alg = header.alg as AssertionAlg;
|
|
204
|
+
if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) throw new Error(`the client assertion's alg ${JSON.stringify(header.alg)} is not accepted (${ASSERTION_ALGS.join(", ")})`);
|
|
205
|
+
// A key registered with an alg signs only that alg (RFC 7517 §4.4).
|
|
206
|
+
const candidates = opts.keys.filter((k) => (!header.kid || k.kid === header.kid) && k.use !== "enc" && (!k.alg || k.alg === alg));
|
|
207
|
+
if (!candidates.length) throw new Error(`no key${header.kid ? ` with kid ${header.kid}` : ""} in the client's JWKS`);
|
|
208
|
+
const input = Buffer.from(`${parts[0]}.${parts[1]}`);
|
|
209
|
+
const sig = Buffer.from(parts[2], "base64url");
|
|
210
|
+
const good = candidates.some((jwk) => {
|
|
211
|
+
try {
|
|
212
|
+
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
213
|
+
// The key's type must be the alg's: an RSA key cannot be presented as ES256.
|
|
214
|
+
if (!algFits(alg, key)) return false;
|
|
215
|
+
return verifyBytes(alg, input, sig, key);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
if (!good) throw new Error("the client assertion's signature does not verify against the client's keys");
|
|
221
|
+
if (claims.iss !== opts.clientId || claims.sub !== opts.clientId) throw new Error("the client assertion's iss and sub must be the client_id");
|
|
222
|
+
// Plain string comparison (RFC 7519 §4.1.3): a trailing slash is another audience.
|
|
223
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
224
|
+
if (!aud.some((a) => typeof a === "string" && opts.audiences.includes(a))) throw new Error(`the client assertion is for ${JSON.stringify(claims.aud)}, not this server`);
|
|
225
|
+
const now = Math.floor((opts.nowMs ?? Date.now()) / 1000);
|
|
226
|
+
const skew = opts.skewSeconds ?? 60;
|
|
227
|
+
if (typeof claims.exp !== "number") throw new Error("the client assertion has no exp");
|
|
228
|
+
if (claims.exp + skew < now) throw new Error("the client assertion has expired");
|
|
229
|
+
if (typeof claims.nbf === "number" && claims.nbf - skew > now) throw new Error("the client assertion is not valid yet");
|
|
230
|
+
const issued = typeof claims.iat === "number" ? claims.iat : now;
|
|
231
|
+
if (claims.exp - issued > ASSERTION_MAX_SECONDS || claims.exp - now > ASSERTION_MAX_SECONDS + skew) throw new Error(`the client assertion lives longer than ${ASSERTION_MAX_SECONDS} seconds`);
|
|
232
|
+
if (typeof claims.jti !== "string" || !claims.jti) throw new Error("the client assertion has no jti");
|
|
233
|
+
return { jti: claims.jti, exp: claims.exp };
|
|
234
|
+
}
|
|
@@ -109,7 +109,8 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
id: "client.private-key-jwt", role: "client", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
112
|
-
status: "
|
|
112
|
+
status: "supported", note: "`token_auth: private_key_jwt` with a key you paste or foldrun makes; a certificate adds Entra's thumbprint. `--token-auth jwt`.",
|
|
113
|
+
proof: [core("tests/oauth-assertion.test.ts", "an oauth2 secret with private_key_jwt refreshes"), platform("tests/oauth-connect.test.ts", "private_key_jwt: a saved app can have its key made"), platform(KC, "private_key_jwt (RFC 7523): code + PKCE + PAR signed"), cli("tests/cli-connect.test.ts", "connect --token-auth jwt --key-file")],
|
|
113
114
|
},
|
|
114
115
|
{
|
|
115
116
|
id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
|
|
@@ -178,7 +179,8 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
|
|
|
178
179
|
},
|
|
179
180
|
{
|
|
180
181
|
id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
181
|
-
status: "
|
|
182
|
+
status: "supported", note: "Apps that register `jwks` or `jwks_uri` sign in with assertions; each `jti` is used once.",
|
|
183
|
+
proof: [web("tests/mcp-oauth.test.ts", "private_key_jwt (RFC 7523): an app with a JWKS signs in"), core("tests/oauth-assertion.test.ts", "the verifier refuses")],
|
|
182
184
|
},
|
|
183
185
|
// ------------------------------------------------------------ refused, both roles
|
|
184
186
|
{
|
package/src/oauth-token.ts
CHANGED
|
@@ -13,10 +13,14 @@
|
|
|
13
13
|
// base64 of the raw pair, which breaks on a secret
|
|
14
14
|
// holding ':' or '%'
|
|
15
15
|
// none a public client: client_id in the body, no secret
|
|
16
|
+
// private_key_jwt a JWT signed with the client's private key, no
|
|
17
|
+
// secret at all (RFC 7523 §2.2, oauth-assertion.ts)
|
|
16
18
|
//
|
|
17
19
|
// Absent means the old behaviour: the body, with the secret if there is one.
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
import { ASSERTION_TYPE, assertionKeyProblem, signClientAssertion, type AssertionAlg } from "./oauth-assertion.ts";
|
|
22
|
+
|
|
23
|
+
export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"] as const;
|
|
20
24
|
export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
|
|
21
25
|
|
|
22
26
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
@@ -25,6 +29,7 @@ export function parseTokenAuth(value: string): TokenAuth | null {
|
|
|
25
29
|
if ((TOKEN_AUTH_METHODS as readonly string[]).includes(v)) return v as TokenAuth;
|
|
26
30
|
if (v === "post" || v === "body") return "client_secret_post";
|
|
27
31
|
if (v === "basic") return "client_secret_basic";
|
|
32
|
+
if (v === "jwt" || v === "private-key-jwt" || v === "key") return "private_key_jwt";
|
|
28
33
|
return null;
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -32,10 +37,16 @@ export interface TokenClient {
|
|
|
32
37
|
client_id: string;
|
|
33
38
|
client_secret?: string;
|
|
34
39
|
token_auth?: TokenAuth;
|
|
40
|
+
/** private_key_jwt: the private key (PEM or JWK), its kid and alg, and
|
|
41
|
+
* its certificate when the provider names keys by it (oauth-assertion.ts). */
|
|
42
|
+
client_assertion_key?: string;
|
|
43
|
+
client_assertion_kid?: string;
|
|
44
|
+
client_assertion_alg?: AssertionAlg;
|
|
45
|
+
client_assertion_cert?: string;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** Why a client's token_auth cannot work, or null. */
|
|
38
|
-
export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown }): string | null {
|
|
49
|
+
export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown; client_assertion_key?: string; client_assertion_alg?: unknown; client_assertion_cert?: string }): string | null {
|
|
39
50
|
const auth = client.token_auth;
|
|
40
51
|
if (auth === undefined) return null;
|
|
41
52
|
if (typeof auth !== "string" || !(TOKEN_AUTH_METHODS as readonly string[]).includes(auth)) {
|
|
@@ -45,6 +56,11 @@ export function tokenAuthProblem(client: { client_secret?: string; token_auth?:
|
|
|
45
56
|
if (auth === "client_secret_basic" && !hasSecret) return "token_auth client_secret_basic needs a client_secret";
|
|
46
57
|
if (auth === "client_secret_post" && !hasSecret) return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
|
|
47
58
|
if (auth === "none" && hasSecret) return "token_auth none is a public client — it sends no client_secret; leave the secret empty or pick client_secret_post or client_secret_basic";
|
|
59
|
+
if (auth === "private_key_jwt") {
|
|
60
|
+
// One method per request: a signed assertion, never a secret beside it.
|
|
61
|
+
if (hasSecret) return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
|
|
62
|
+
return assertionKeyProblem(client);
|
|
63
|
+
}
|
|
48
64
|
return null;
|
|
49
65
|
}
|
|
50
66
|
|
|
@@ -60,12 +76,18 @@ const formEncode = (s: string) => new URLSearchParams({ x: s }).toString().slice
|
|
|
60
76
|
* pushed authorization request carries it as a parameter of the request it
|
|
61
77
|
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
62
78
|
*/
|
|
63
|
-
export function oauthTokenRequest(
|
|
79
|
+
export function oauthTokenRequest(
|
|
80
|
+
client: TokenClient,
|
|
81
|
+
params: Record<string, string>,
|
|
82
|
+
/** `audience`: the endpoint the request goes to — the `aud` of a
|
|
83
|
+
* private_key_jwt assertion (required for that method). */
|
|
84
|
+
opts: { clientIdInBody?: boolean; audience?: string } = {},
|
|
85
|
+
): { headers: Record<string, string>; body: string } {
|
|
64
86
|
const problem = tokenAuthProblem(client);
|
|
65
87
|
if (problem) throw new Error(problem);
|
|
66
88
|
const form = new URLSearchParams();
|
|
67
89
|
for (const [k, v] of Object.entries(params)) {
|
|
68
|
-
if (k === "client_id" || k === "client_secret") continue;
|
|
90
|
+
if (k === "client_id" || k === "client_secret" || k === "client_assertion" || k === "client_assertion_type") continue;
|
|
69
91
|
form.set(k, v);
|
|
70
92
|
}
|
|
71
93
|
const headers: Record<string, string> = {
|
|
@@ -80,6 +102,11 @@ export function oauthTokenRequest(client: TokenClient, params: Record<string, st
|
|
|
80
102
|
const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
|
|
81
103
|
headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
|
|
82
104
|
if (opts.clientIdInBody) form.set("client_id", client.client_id);
|
|
105
|
+
} else if (auth === "private_key_jwt") {
|
|
106
|
+
if (!opts.audience) throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
|
|
107
|
+
form.set("client_id", client.client_id);
|
|
108
|
+
form.set("client_assertion_type", ASSERTION_TYPE);
|
|
109
|
+
form.set("client_assertion", signClientAssertion(client as TokenClient & { client_assertion_key: string }, opts.audience));
|
|
83
110
|
} else {
|
|
84
111
|
form.set("client_id", client.client_id);
|
|
85
112
|
if (auth === "client_secret_post") form.set("client_secret", client.client_secret!);
|
package/src/secrets.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { platform } from "./platform.ts";
|
|
|
26
26
|
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
27
27
|
import { oauthTokenRequest, readTokenResponse, tokenAuthProblem, type TokenAuth } from "./oauth-token.ts";
|
|
28
28
|
import { isSecureUrl } from "./oauth-discovery.ts";
|
|
29
|
+
import type { AssertionAlg } from "./oauth-assertion.ts";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
@@ -945,6 +946,13 @@ export interface OAuth2Config {
|
|
|
945
946
|
/** The provider's revocation endpoint (RFC 7009): deleting or replacing
|
|
946
947
|
* this secret revokes its tokens there (revokeOAuth2). */
|
|
947
948
|
revocation_url?: string;
|
|
949
|
+
/** token_auth private_key_jwt: the private key (PEM or JWK) the client
|
|
950
|
+
* signs its assertions with, sealed with the rest of the recipe; its kid,
|
|
951
|
+
* alg, and certificate when the provider names keys by it (Entra). */
|
|
952
|
+
client_assertion_key?: string;
|
|
953
|
+
client_assertion_kid?: string;
|
|
954
|
+
client_assertion_alg?: AssertionAlg;
|
|
955
|
+
client_assertion_cert?: string;
|
|
948
956
|
}
|
|
949
957
|
|
|
950
958
|
const OAUTH2_PREFIX = "@oauth2 ";
|
|
@@ -970,8 +978,8 @@ export function setOAuth2Secret(
|
|
|
970
978
|
}
|
|
971
979
|
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
972
980
|
// client credentials are nothing without the secret.
|
|
973
|
-
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
974
|
-
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
981
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim() && config.token_auth !== "private_key_jwt") {
|
|
982
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials (or token_auth private_key_jwt with a key)");
|
|
975
983
|
}
|
|
976
984
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
977
985
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
@@ -1068,11 +1076,14 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
|
|
|
1068
1076
|
const flight = (async () => {
|
|
1069
1077
|
// One builder for every token request (oauth-token.ts): the client's
|
|
1070
1078
|
// identity goes where its token_auth says, and an `extra` cannot set it.
|
|
1071
|
-
|
|
1079
|
+
// Built per try: a private_key_jwt assertion's jti is good once, so a
|
|
1080
|
+
// retry after a lost answer must carry a new one (Codex review).
|
|
1081
|
+
const build = () => oauthTokenRequest(config, {
|
|
1072
1082
|
grant_type: config.grant_type ?? "refresh_token",
|
|
1073
1083
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
|
|
1074
1084
|
...(config.extra ?? {}),
|
|
1075
|
-
});
|
|
1085
|
+
}, { audience: config.token_url });
|
|
1086
|
+
build(); // a config that cannot make a request says so before any network
|
|
1076
1087
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
1077
1088
|
// (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
|
|
1078
1089
|
// — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
|
|
@@ -1083,6 +1094,7 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
|
|
|
1083
1094
|
for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
|
|
1084
1095
|
if (attempt) await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
|
|
1085
1096
|
try {
|
|
1097
|
+
const request = build();
|
|
1086
1098
|
res = await fetchUntrusted(config.token_url, {
|
|
1087
1099
|
method: "POST",
|
|
1088
1100
|
headers: request.headers,
|
|
@@ -1231,7 +1243,7 @@ export async function revokeOAuth2Value(
|
|
|
1231
1243
|
const out: OAuthRevokeResult = { revoked: [] };
|
|
1232
1244
|
for (const [hint, token] of tokens) {
|
|
1233
1245
|
try {
|
|
1234
|
-
const request = oauthTokenRequest(config, { token, token_type_hint: hint });
|
|
1246
|
+
const request = oauthTokenRequest(config, { token, token_type_hint: hint }, { audience: config.token_url });
|
|
1235
1247
|
const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
1236
1248
|
if (res.ok) {
|
|
1237
1249
|
await res.body?.cancel().catch(() => {});
|