@foldrun/core 9.5.0 → 10.1.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 +2 -0
- package/dist/index.js +2 -0
- package/dist/src/oauth-assertion.d.ts +77 -0
- package/dist/src/oauth-assertion.js +223 -0
- package/dist/src/oauth-capabilities.js +8 -4
- package/dist/src/oauth-device.d.ts +53 -0
- package/dist/src/oauth-device.js +112 -0
- 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 +2 -0
- package/package.json +1 -1
- package/src/oauth-assertion.ts +234 -0
- package/src/oauth-capabilities.ts +8 -4
- package/src/oauth-device.ts +149 -0
- package/src/oauth-token.ts +31 -4
- package/src/secrets.ts +17 -5
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ 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";
|
|
36
|
+
export * from "./src/oauth-device.ts";
|
|
35
37
|
export * from "./src/secret-files.ts";
|
|
36
38
|
export * from "./src/notify.ts";
|
|
37
39
|
export * from "./src/webhook.ts";
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,8 @@ 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";
|
|
47
|
+
export * from "./src/oauth-device.js";
|
|
46
48
|
export * from "./src/secret-files.js";
|
|
47
49
|
export * from "./src/notify.js";
|
|
48
50
|
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,11 +83,13 @@ 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",
|
|
90
|
-
status: "
|
|
91
|
+
status: "supported", note: "Connect with a code on any device: the Connections form, or `foldrun connect --device`. PKCE is sent with the device grant.",
|
|
92
|
+
proof: [core("tests/oauth-device.test.ts", "pending, then slow_down"), platform("tests/oauth-connect.test.ts", "device authorization (RFC 8628): started for a saved app"), platform(KC, "device authorization (RFC 8628): started for a saved app, approved by the person at Keycloak"), web("tests/oauth-device-route.test.ts", "start → pending → approved → stored"), cli("tests/cli-connect.test.ts", "connect --device (RFC 8628)")],
|
|
91
93
|
},
|
|
92
94
|
{
|
|
93
95
|
id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
|
|
@@ -148,11 +150,13 @@ export const OAUTH_CAPABILITIES = [
|
|
|
148
150
|
},
|
|
149
151
|
{
|
|
150
152
|
id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
|
|
151
|
-
status: "
|
|
153
|
+
status: "supported", note: "An admin registers a machine client (Settings, or `foldrun mcp clients add`) with a role, workspaces and access; no refresh token; disconnecting it makes it `invalid_client`.",
|
|
154
|
+
proof: [web("tests/mcp-oauth.test.ts", "machine clients (client credentials, part G)"), web("tests/mcp-oauth.test.ts", "a machine client can sign in with private_key_jwt"), cli("tests/cli-mcp.test.ts", "mcp clients add")],
|
|
152
155
|
},
|
|
153
156
|
{
|
|
154
157
|
id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
155
|
-
status: "
|
|
158
|
+
status: "supported", note: "Apps that register `jwks` or `jwks_uri` sign in with assertions; each `jti` is used once.",
|
|
159
|
+
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
160
|
},
|
|
157
161
|
// ------------------------------------------------------------ refused, both roles
|
|
158
162
|
{
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type TokenClient, type TokenResponse } from "./oauth-token.ts";
|
|
2
|
+
export declare const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
3
|
+
export interface DeviceStart {
|
|
4
|
+
device_code: string;
|
|
5
|
+
user_code: string;
|
|
6
|
+
verification_uri: string;
|
|
7
|
+
/** The page with the code already in it, when the provider gives one. */
|
|
8
|
+
verification_uri_complete?: string;
|
|
9
|
+
/** Seconds the codes live. */
|
|
10
|
+
expires_in: number;
|
|
11
|
+
/** Seconds to wait between polls (default 5, RFC 8628 §3.2). */
|
|
12
|
+
interval: number;
|
|
13
|
+
/** PKCE for the device grant: sent with every poll. Keycloak requires it
|
|
14
|
+
* of an S256 client; a server that does not know it ignores both halves
|
|
15
|
+
* (found by the conformance suite). Secret: never shown to the person. */
|
|
16
|
+
code_verifier: string;
|
|
17
|
+
}
|
|
18
|
+
type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
19
|
+
/** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
|
|
20
|
+
export declare function startDeviceAuthorization(client: TokenClient & {
|
|
21
|
+
device_url: string;
|
|
22
|
+
token_url: string;
|
|
23
|
+
issuer?: string;
|
|
24
|
+
}, scopes: string, fetcher: Fetcher, extra?: Record<string, string>): Promise<DeviceStart>;
|
|
25
|
+
/** What one poll came to (RFC 8628 §3.5). */
|
|
26
|
+
export type DevicePoll = {
|
|
27
|
+
status: "approved";
|
|
28
|
+
token: TokenResponse;
|
|
29
|
+
} | {
|
|
30
|
+
status: "pending";
|
|
31
|
+
}
|
|
32
|
+
/** The provider asked for a longer wait: add 5 seconds to the interval. */
|
|
33
|
+
| {
|
|
34
|
+
status: "slow_down";
|
|
35
|
+
} | {
|
|
36
|
+
status: "denied";
|
|
37
|
+
reason: string;
|
|
38
|
+
} | {
|
|
39
|
+
status: "expired";
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
/** One token request with the device code. */
|
|
43
|
+
export declare function pollDeviceToken(client: TokenClient & {
|
|
44
|
+
token_url: string;
|
|
45
|
+
}, deviceCode: string, fetcher: Fetcher, codeVerifier?: string): Promise<DevicePoll>;
|
|
46
|
+
/** Poll until approved, denied or expired, honouring interval and slow_down. */
|
|
47
|
+
export declare function waitForDeviceToken(client: TokenClient & {
|
|
48
|
+
token_url: string;
|
|
49
|
+
}, start: DeviceStart, fetcher: Fetcher, opts?: {
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}): Promise<TokenResponse>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Device authorization (RFC 8628): connect an account from somewhere with no
|
|
2
|
+
// browser of its own — a terminal over ssh, a server. foldrun asks the
|
|
3
|
+
// provider for a device code, a person opens the provider's page on any
|
|
4
|
+
// device and types the short user code, and foldrun polls the token endpoint
|
|
5
|
+
// until they have said yes (or no, or the code ran out). The grant that
|
|
6
|
+
// comes back is stored like any other: an oauth2 secret.
|
|
7
|
+
//
|
|
8
|
+
// One request each, so a caller can poll its own way: the CLI in a loop,
|
|
9
|
+
// the platform once per dashboard poll.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { oauthTokenRequest, readTokenResponse } from "./oauth-token.js";
|
|
12
|
+
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
13
|
+
/** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
|
|
14
|
+
export async function startDeviceAuthorization(client, scopes, fetcher, extra = {}) {
|
|
15
|
+
// The client authenticates as at the token endpoint (§3.1); a signed
|
|
16
|
+
// assertion's aud is the issuer when known, else the token URL.
|
|
17
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
18
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
19
|
+
const request = oauthTokenRequest(client, { ...extra, ...(scopes.trim() ? { scope: scopes.trim() } : {}), code_challenge: challenge, code_challenge_method: "S256" }, { clientIdInBody: true, audience: client.issuer ?? client.token_url });
|
|
20
|
+
const res = await fetcher(client.device_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
|
|
21
|
+
const text = await res.text().catch(() => "");
|
|
22
|
+
let body = {};
|
|
23
|
+
try {
|
|
24
|
+
body = JSON.parse(text);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
body = Object.fromEntries(new URLSearchParams(text));
|
|
28
|
+
}
|
|
29
|
+
if (!res.ok || typeof body.device_code !== "string" || typeof body.user_code !== "string") {
|
|
30
|
+
const why = (body.error_description ?? body.error ?? "no device code in the reply");
|
|
31
|
+
throw new Error(`the provider refused the device authorization (${res.status}): ${why}`);
|
|
32
|
+
}
|
|
33
|
+
// Some providers (Google's older endpoint) say verification_url.
|
|
34
|
+
const uri = (body.verification_uri ?? body.verification_url);
|
|
35
|
+
if (typeof uri !== "string" || !/^https?:\/\//.test(uri))
|
|
36
|
+
throw new Error("the provider gave no verification_uri to send the person to");
|
|
37
|
+
const num = (v, d) => {
|
|
38
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
39
|
+
return Number.isFinite(n) && n > 0 ? n : d;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
device_code: body.device_code,
|
|
43
|
+
user_code: body.user_code,
|
|
44
|
+
verification_uri: uri,
|
|
45
|
+
...(typeof body.verification_uri_complete === "string" ? { verification_uri_complete: body.verification_uri_complete } : {}),
|
|
46
|
+
expires_in: num(body.expires_in, 600),
|
|
47
|
+
interval: num(body.interval, 5),
|
|
48
|
+
code_verifier: verifier,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** One token request with the device code. */
|
|
52
|
+
export async function pollDeviceToken(client, deviceCode, fetcher, codeVerifier) {
|
|
53
|
+
const request = oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: deviceCode, ...(codeVerifier ? { code_verifier: codeVerifier } : {}) }, { audience: client.token_url });
|
|
54
|
+
const res = await fetcher(client.token_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
|
|
55
|
+
// A body cut off mid-read throws here, so the caller retries it as the
|
|
56
|
+
// transport failure it is — never read as an empty refusal (Codex review).
|
|
57
|
+
const token = readTokenResponse(await res.text(), res.headers.get("content-type") ?? "");
|
|
58
|
+
if (res.ok && token.access_token)
|
|
59
|
+
return { status: "approved", token };
|
|
60
|
+
switch (token.error) {
|
|
61
|
+
case "authorization_pending": return { status: "pending" };
|
|
62
|
+
case "slow_down": return { status: "slow_down" };
|
|
63
|
+
case "access_denied": return { status: "denied", reason: token.error_description ?? "the person said no" };
|
|
64
|
+
case "expired_token": return { status: "expired", reason: token.error_description ?? "the code ran out before anyone approved it" };
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`the provider refused the device token request (${res.status}): ${token.error_description ?? token.error ?? "no token in the reply"}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** A failure of the wire, not of the provider: fetch's TypeError, an abort
|
|
70
|
+
* or a timeout. */
|
|
71
|
+
function isTransient(err) {
|
|
72
|
+
if (err instanceof TypeError)
|
|
73
|
+
return true;
|
|
74
|
+
const name = err?.name;
|
|
75
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
76
|
+
}
|
|
77
|
+
/** Poll until approved, denied or expired, honouring interval and slow_down. */
|
|
78
|
+
export async function waitForDeviceToken(client, start, fetcher, opts = {}) {
|
|
79
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
80
|
+
const now = opts.now ?? Date.now;
|
|
81
|
+
const deadline = now() + start.expires_in * 1000;
|
|
82
|
+
let interval = start.interval;
|
|
83
|
+
// A client that cannot make the request (no key, a bad alg) says so now,
|
|
84
|
+
// not after ten minutes of retries.
|
|
85
|
+
oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: start.device_code }, { audience: client.token_url });
|
|
86
|
+
for (;;) {
|
|
87
|
+
await sleep(interval * 1000);
|
|
88
|
+
if (now() > deadline)
|
|
89
|
+
throw new Error("the code ran out before anyone approved it — run it again");
|
|
90
|
+
let r;
|
|
91
|
+
try {
|
|
92
|
+
r = await pollDeviceToken(client, start.device_code, fetcher, start.code_verifier);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// Only a transport failure — a timeout, a dropped connection — is
|
|
96
|
+
// retried, less often (RFC 8628 §3.5); anything else is final. The
|
|
97
|
+
// backoff never polls faster than the interval the provider set.
|
|
98
|
+
if (!isTransient(err))
|
|
99
|
+
throw err;
|
|
100
|
+
interval = interval >= 60 ? interval : Math.min(interval * 2, 60);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (r.status === "approved")
|
|
104
|
+
return r.token;
|
|
105
|
+
if (r.status === "slow_down")
|
|
106
|
+
interval += 5;
|
|
107
|
+
else if (r.status === "denied")
|
|
108
|
+
throw new Error(`the provider says no: ${r.reason}`);
|
|
109
|
+
else if (r.status === "expired")
|
|
110
|
+
throw new Error(`${r.reason} — run it again`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -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. */
|