@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.
@@ -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
- const request = oauthTokenRequest(config, {
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,8 @@ 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";
48
+ export * from "./src/oauth-device.ts";
47
49
  export * from "./src/secret-files.ts";
48
50
  export * from "./src/notify.ts";
49
51
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.5.0",
3
+ "version": "10.1.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -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,11 +109,13 @@ 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: "planned", note: "Part E: Entra certificates, Okta, FAPI.",
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",
116
- status: "planned", note: "Part F: `foldrun connect --device`.",
117
+ status: "supported", note: "Connect with a code on any device: the Connections form, or `foldrun connect --device`. PKCE is sent with the device grant.",
118
+ 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)")],
117
119
  },
118
120
  {
119
121
  id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
@@ -174,11 +176,13 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
174
176
  },
175
177
  {
176
178
  id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
177
- status: "planned", note: "Part G: machine MCP clients an admin registers.",
179
+ 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`.",
180
+ 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")],
178
181
  },
179
182
  {
180
183
  id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
181
- status: "planned", note: "Part E: apps that declare `jwks` or `jwks_uri`.",
184
+ status: "supported", note: "Apps that register `jwks` or `jwks_uri` sign in with assertions; each `jti` is used once.",
185
+ 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
186
  },
183
187
  // ------------------------------------------------------------ refused, both roles
184
188
  {
@@ -0,0 +1,149 @@
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
+
11
+ import crypto from "node:crypto";
12
+ import { oauthTokenRequest, readTokenResponse, type TokenClient, type TokenResponse } from "./oauth-token.ts";
13
+
14
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
15
+
16
+ export interface DeviceStart {
17
+ device_code: string;
18
+ user_code: string;
19
+ verification_uri: string;
20
+ /** The page with the code already in it, when the provider gives one. */
21
+ verification_uri_complete?: string;
22
+ /** Seconds the codes live. */
23
+ expires_in: number;
24
+ /** Seconds to wait between polls (default 5, RFC 8628 §3.2). */
25
+ interval: number;
26
+ /** PKCE for the device grant: sent with every poll. Keycloak requires it
27
+ * of an S256 client; a server that does not know it ignores both halves
28
+ * (found by the conformance suite). Secret: never shown to the person. */
29
+ code_verifier: string;
30
+ }
31
+
32
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
33
+
34
+ /** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
35
+ export async function startDeviceAuthorization(
36
+ client: TokenClient & { device_url: string; token_url: string; issuer?: string },
37
+ scopes: string,
38
+ fetcher: Fetcher,
39
+ extra: Record<string, string> = {},
40
+ ): Promise<DeviceStart> {
41
+ // The client authenticates as at the token endpoint (§3.1); a signed
42
+ // assertion's aud is the issuer when known, else the token URL.
43
+ const verifier = crypto.randomBytes(32).toString("base64url");
44
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
45
+ 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 });
46
+ const res = await fetcher(client.device_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
47
+ const text = await res.text().catch(() => "");
48
+ let body: Record<string, unknown> = {};
49
+ try {
50
+ body = JSON.parse(text) as Record<string, unknown>;
51
+ } catch {
52
+ body = Object.fromEntries(new URLSearchParams(text));
53
+ }
54
+ if (!res.ok || typeof body.device_code !== "string" || typeof body.user_code !== "string") {
55
+ const why = (body.error_description ?? body.error ?? "no device code in the reply") as string;
56
+ throw new Error(`the provider refused the device authorization (${res.status}): ${why}`);
57
+ }
58
+ // Some providers (Google's older endpoint) say verification_url.
59
+ const uri = (body.verification_uri ?? body.verification_url) as unknown;
60
+ if (typeof uri !== "string" || !/^https?:\/\//.test(uri)) throw new Error("the provider gave no verification_uri to send the person to");
61
+ const num = (v: unknown, d: number) => {
62
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
63
+ return Number.isFinite(n) && n > 0 ? n : d;
64
+ };
65
+ return {
66
+ device_code: body.device_code,
67
+ user_code: body.user_code,
68
+ verification_uri: uri,
69
+ ...(typeof body.verification_uri_complete === "string" ? { verification_uri_complete: body.verification_uri_complete } : {}),
70
+ expires_in: num(body.expires_in, 600),
71
+ interval: num(body.interval, 5),
72
+ code_verifier: verifier,
73
+ };
74
+ }
75
+
76
+ /** What one poll came to (RFC 8628 §3.5). */
77
+ export type DevicePoll =
78
+ | { status: "approved"; token: TokenResponse }
79
+ | { status: "pending" }
80
+ /** The provider asked for a longer wait: add 5 seconds to the interval. */
81
+ | { status: "slow_down" }
82
+ | { status: "denied"; reason: string }
83
+ | { status: "expired"; reason: string };
84
+
85
+ /** One token request with the device code. */
86
+ export async function pollDeviceToken(
87
+ client: TokenClient & { token_url: string },
88
+ deviceCode: string,
89
+ fetcher: Fetcher,
90
+ codeVerifier?: string,
91
+ ): Promise<DevicePoll> {
92
+ const request = oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: deviceCode, ...(codeVerifier ? { code_verifier: codeVerifier } : {}) }, { audience: client.token_url });
93
+ const res = await fetcher(client.token_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
94
+ // A body cut off mid-read throws here, so the caller retries it as the
95
+ // transport failure it is — never read as an empty refusal (Codex review).
96
+ const token = readTokenResponse(await res.text(), res.headers.get("content-type") ?? "");
97
+ if (res.ok && token.access_token) return { status: "approved", token };
98
+ switch (token.error) {
99
+ case "authorization_pending": return { status: "pending" };
100
+ case "slow_down": return { status: "slow_down" };
101
+ case "access_denied": return { status: "denied", reason: token.error_description ?? "the person said no" };
102
+ case "expired_token": return { status: "expired", reason: token.error_description ?? "the code ran out before anyone approved it" };
103
+ default:
104
+ throw new Error(`the provider refused the device token request (${res.status}): ${token.error_description ?? token.error ?? "no token in the reply"}`);
105
+ }
106
+ }
107
+
108
+ /** A failure of the wire, not of the provider: fetch's TypeError, an abort
109
+ * or a timeout. */
110
+ function isTransient(err: unknown): boolean {
111
+ if (err instanceof TypeError) return true;
112
+ const name = (err as { name?: unknown })?.name;
113
+ return name === "AbortError" || name === "TimeoutError";
114
+ }
115
+
116
+ /** Poll until approved, denied or expired, honouring interval and slow_down. */
117
+ export async function waitForDeviceToken(
118
+ client: TokenClient & { token_url: string },
119
+ start: DeviceStart,
120
+ fetcher: Fetcher,
121
+ opts: { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
122
+ ): Promise<TokenResponse> {
123
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
124
+ const now = opts.now ?? Date.now;
125
+ const deadline = now() + start.expires_in * 1000;
126
+ let interval = start.interval;
127
+ // A client that cannot make the request (no key, a bad alg) says so now,
128
+ // not after ten minutes of retries.
129
+ oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: start.device_code }, { audience: client.token_url });
130
+ for (;;) {
131
+ await sleep(interval * 1000);
132
+ if (now() > deadline) throw new Error("the code ran out before anyone approved it — run it again");
133
+ let r: DevicePoll;
134
+ try {
135
+ r = await pollDeviceToken(client, start.device_code, fetcher, start.code_verifier);
136
+ } catch (err) {
137
+ // Only a transport failure — a timeout, a dropped connection — is
138
+ // retried, less often (RFC 8628 §3.5); anything else is final. The
139
+ // backoff never polls faster than the interval the provider set.
140
+ if (!isTransient(err)) throw err;
141
+ interval = interval >= 60 ? interval : Math.min(interval * 2, 60);
142
+ continue;
143
+ }
144
+ if (r.status === "approved") return r.token;
145
+ if (r.status === "slow_down") interval += 5;
146
+ else if (r.status === "denied") throw new Error(`the provider says no: ${r.reason}`);
147
+ else if (r.status === "expired") throw new Error(`${r.reason} — run it again`);
148
+ }
149
+ }
@@ -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
- export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none"] as const;
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(client: TokenClient, params: Record<string, string>, opts: { clientIdInBody?: boolean } = {}): { headers: Record<string, string>; body: string } {
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
- const request = oauthTokenRequest(config, {
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(() => {});