@foldrun/core 10.0.0 → 11.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 +2 -0
- package/dist/index.js +2 -0
- package/dist/src/oauth-capabilities.js +10 -5
- package/dist/src/oauth-device.d.ts +53 -0
- package/dist/src/oauth-device.js +112 -0
- package/dist/src/oauth-dpop.d.ts +33 -0
- package/dist/src/oauth-dpop.js +85 -0
- package/dist/src/oauth-token.d.ts +9 -1
- package/dist/src/oauth-token.js +25 -1
- package/dist/src/platform.d.ts +7 -0
- package/dist/src/secrets.d.ts +37 -2
- package/dist/src/secrets.js +148 -7
- package/dist/src/untrusted-fetch.d.ts +6 -0
- package/dist/src/untrusted-fetch.js +64 -3
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/oauth-capabilities.ts +10 -5
- package/src/oauth-device.ts +149 -0
- package/src/oauth-dpop.ts +93 -0
- package/src/oauth-token.ts +24 -2
- package/src/platform.ts +1 -1
- package/src/secrets.ts +178 -9
- package/src/untrusted-fetch.ts +63 -3
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export * from "./src/oauth-presets.ts";
|
|
|
33
33
|
export * from "./src/oauth-token.ts";
|
|
34
34
|
export * from "./src/oauth-discovery.ts";
|
|
35
35
|
export * from "./src/oauth-assertion.ts";
|
|
36
|
+
export * from "./src/oauth-device.ts";
|
|
37
|
+
export * from "./src/oauth-dpop.ts";
|
|
36
38
|
export * from "./src/secret-files.ts";
|
|
37
39
|
export * from "./src/notify.ts";
|
|
38
40
|
export * from "./src/webhook.ts";
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,8 @@ export * from "./src/oauth-presets.js";
|
|
|
44
44
|
export * from "./src/oauth-token.js";
|
|
45
45
|
export * from "./src/oauth-discovery.js";
|
|
46
46
|
export * from "./src/oauth-assertion.js";
|
|
47
|
+
export * from "./src/oauth-device.js";
|
|
48
|
+
export * from "./src/oauth-dpop.js";
|
|
47
49
|
export * from "./src/secret-files.js";
|
|
48
50
|
export * from "./src/notify.js";
|
|
49
51
|
export * from "./src/webhook.js";
|
|
@@ -88,19 +88,23 @@ export const OAUTH_CAPABILITIES = [
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
|
|
91
|
-
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)")],
|
|
92
93
|
},
|
|
93
94
|
{
|
|
94
95
|
id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
|
|
95
|
-
status: "
|
|
96
|
+
status: "supported", note: "A `token-exchange` secret: another secret's live token traded for a narrower one, cached. Cloud STS is covered by the keyless identity kind; a workload identity as the subject is not yet.",
|
|
97
|
+
proof: [core("tests/oauth-token-exchange.test.ts", "the subject's live token is traded for a narrower one"), platform(KC, "token exchange (RFC 8693)"), cli("tests/cli-connect.test.ts", "--kind token-exchange")],
|
|
96
98
|
},
|
|
97
99
|
{
|
|
98
100
|
id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
|
|
99
|
-
status: "
|
|
101
|
+
status: "supported", note: "`dpop: true` on the app (`--dpop`): each connection's own key; the egress proxy signs a proof for every call through a site binding; nonces honoured.",
|
|
102
|
+
proof: [core("tests/oauth-dpop-mtls.test.ts", "refresh with DPoP"), platform("tests/egress-dpop.test.ts", "a used DPoP connection gives its key"), platform(KC, "DPoP (RFC 9449): a connection made with DPoP gets a bound token"), cli("tests/cli-connect.test.ts", "connect --dpop")],
|
|
100
103
|
},
|
|
101
104
|
{
|
|
102
105
|
id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
|
|
103
|
-
status: "
|
|
106
|
+
status: "supported", note: "`token_auth: tls_client_auth` with the certificate and key; presented to the token URL and, through the egress proxy, on the calls the token is bound to.",
|
|
107
|
+
proof: [core("tests/oauth-dpop-mtls.test.ts", "tls_client_auth (RFC 8705)"), platform("tests/egress-dpop.test.ts", "a certificate-authenticated connection gives its certificate"), platform(KC, "mutual TLS (RFC 8705)"), cli("tests/cli-connect.test.ts", "--token-auth mtls stores the certificate")],
|
|
104
108
|
},
|
|
105
109
|
{
|
|
106
110
|
id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
|
|
@@ -149,7 +153,8 @@ export const OAUTH_CAPABILITIES = [
|
|
|
149
153
|
},
|
|
150
154
|
{
|
|
151
155
|
id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
|
|
152
|
-
status: "
|
|
156
|
+
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`.",
|
|
157
|
+
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")],
|
|
153
158
|
},
|
|
154
159
|
{
|
|
155
160
|
id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
|
|
2
|
+
export declare function generateDpopKey(): string;
|
|
3
|
+
/** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
|
|
4
|
+
export declare function dpopHtu(url: string): string;
|
|
5
|
+
/** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
|
|
6
|
+
export declare function dpopAth(accessToken: string): string;
|
|
7
|
+
/** A DPoP proof for one request. */
|
|
8
|
+
export declare function dpopProof(key: string, req: {
|
|
9
|
+
htm: string;
|
|
10
|
+
htu: string;
|
|
11
|
+
ath?: string;
|
|
12
|
+
nonce?: string;
|
|
13
|
+
}, nowMs?: number): string;
|
|
14
|
+
/** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
|
|
15
|
+
export declare function dpopJkt(key: string): string;
|
|
16
|
+
/** Remember a DPoP-Nonce a response to this connection carried. */
|
|
17
|
+
export declare function noteDpopNonce(url: string, res: {
|
|
18
|
+
headers: {
|
|
19
|
+
get(name: string): string | null;
|
|
20
|
+
};
|
|
21
|
+
}, key: string): void;
|
|
22
|
+
/** The nonce for this connection's next proof to this URL's server, if any. */
|
|
23
|
+
export declare function dpopNonceFor(url: string, key: string): string | undefined;
|
|
24
|
+
/** Whether an answer asks for a (new) nonce: a token endpoint's 400
|
|
25
|
+
* use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
|
|
26
|
+
export declare function wantsDpopNonce(res: {
|
|
27
|
+
status: number;
|
|
28
|
+
headers: {
|
|
29
|
+
get(name: string): string | null;
|
|
30
|
+
};
|
|
31
|
+
}, body?: {
|
|
32
|
+
error?: string;
|
|
33
|
+
}): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// DPoP (RFC 9449): sender-constrained tokens. A connection made with DPoP
|
|
2
|
+
// holds a key of its own; every request to the token endpoint, and every call
|
|
3
|
+
// that uses the access token, carries a fresh proof signed with it — so a
|
|
4
|
+
// token copied off the wire is useless without the key. Bluesky/atproto and
|
|
5
|
+
// Okta's DPoP mode require it.
|
|
6
|
+
//
|
|
7
|
+
// The proof: header { typ: "dpop+jwt", alg, jwk: the public key }, claims
|
|
8
|
+
// { jti, htm, htu (no query or fragment), iat, ath? (SHA-256 of the access
|
|
9
|
+
// token, on resource calls), nonce? (when the server asked for one) }.
|
|
10
|
+
//
|
|
11
|
+
// Where the proofs are made: core's refresh and the platform's code exchange
|
|
12
|
+
// (token requests), and the egress proxy (resource calls) — a DPoP-bound
|
|
13
|
+
// connection is used through a site binding, where the proxy can sign each
|
|
14
|
+
// request; the sandbox never holds the key.
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { algForKey, readPrivateKey, ASSERTION_ALGS } from "./oauth-assertion.js";
|
|
17
|
+
/** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
|
|
18
|
+
export function generateDpopKey() {
|
|
19
|
+
return crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
20
|
+
}
|
|
21
|
+
/** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
|
|
22
|
+
export function dpopHtu(url) {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
return `${u.origin}${u.pathname}`;
|
|
25
|
+
}
|
|
26
|
+
/** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
|
|
27
|
+
export function dpopAth(accessToken) {
|
|
28
|
+
return crypto.createHash("sha256").update(accessToken).digest("base64url");
|
|
29
|
+
}
|
|
30
|
+
const b64url = (s) => Buffer.from(s).toString("base64url");
|
|
31
|
+
/** A DPoP proof for one request. */
|
|
32
|
+
export function dpopProof(key, req, nowMs = Date.now()) {
|
|
33
|
+
const k = readPrivateKey(key);
|
|
34
|
+
const alg = algForKey(k);
|
|
35
|
+
if (!ASSERTION_ALGS.includes(alg) || alg === "PS256")
|
|
36
|
+
throw new Error("a DPoP key must be EC P-256/P-384, RSA or Ed25519");
|
|
37
|
+
const pub = crypto.createPublicKey(k).export({ format: "jwk" });
|
|
38
|
+
const header = { typ: "dpop+jwt", alg, jwk: pub };
|
|
39
|
+
const claims = {
|
|
40
|
+
jti: b64url(crypto.randomBytes(18)),
|
|
41
|
+
htm: req.htm.toUpperCase(),
|
|
42
|
+
htu: dpopHtu(req.htu),
|
|
43
|
+
iat: Math.floor(nowMs / 1000),
|
|
44
|
+
...(req.ath ? { ath: req.ath } : {}),
|
|
45
|
+
...(req.nonce ? { nonce: req.nonce } : {}),
|
|
46
|
+
};
|
|
47
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
48
|
+
const data = Buffer.from(input);
|
|
49
|
+
const sig = alg === "ES256" ? crypto.sign("sha256", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
50
|
+
: alg === "ES384" ? crypto.sign("sha384", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
51
|
+
: alg === "EdDSA" ? crypto.sign(null, data, k)
|
|
52
|
+
: crypto.sign("sha256", data, k);
|
|
53
|
+
return `${input}.${b64url(sig)}`;
|
|
54
|
+
}
|
|
55
|
+
/** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
|
|
56
|
+
export function dpopJkt(key) {
|
|
57
|
+
const pub = crypto.createPublicKey(readPrivateKey(key)).export({ format: "jwk" });
|
|
58
|
+
const members = pub.kty === "EC" ? { crv: pub.crv, kty: pub.kty, x: pub.x, y: pub.y } : pub.kty === "RSA" ? { e: pub.e, kty: pub.kty, n: pub.n } : { crv: pub.crv, kty: pub.kty, x: pub.x };
|
|
59
|
+
return crypto.createHash("sha256").update(JSON.stringify(members)).digest("base64url");
|
|
60
|
+
}
|
|
61
|
+
// Server-provided nonces (RFC 9449 §8, §9), kept per origin and per DPoP
|
|
62
|
+
// key: the last one a server sent to this connection is the one its next
|
|
63
|
+
// proof carries — two connections to one server never overwrite each other's
|
|
64
|
+
// (a server may issue nonces per client; Codex review).
|
|
65
|
+
const nonces = new Map();
|
|
66
|
+
const nonceKey = (url, key) => `${new URL(url).origin} ${dpopJkt(key)}`;
|
|
67
|
+
/** Remember a DPoP-Nonce a response to this connection carried. */
|
|
68
|
+
export function noteDpopNonce(url, res, key) {
|
|
69
|
+
const n = res.headers.get("dpop-nonce");
|
|
70
|
+
if (n)
|
|
71
|
+
nonces.set(nonceKey(url, key), n);
|
|
72
|
+
}
|
|
73
|
+
/** The nonce for this connection's next proof to this URL's server, if any. */
|
|
74
|
+
export function dpopNonceFor(url, key) {
|
|
75
|
+
return nonces.get(nonceKey(url, key));
|
|
76
|
+
}
|
|
77
|
+
/** Whether an answer asks for a (new) nonce: a token endpoint's 400
|
|
78
|
+
* use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
|
|
79
|
+
export function wantsDpopNonce(res, body) {
|
|
80
|
+
if (!res.headers.get("dpop-nonce"))
|
|
81
|
+
return false;
|
|
82
|
+
if (res.status === 400 && body?.error === "use_dpop_nonce")
|
|
83
|
+
return true;
|
|
84
|
+
return res.status === 401 && /use_dpop_nonce/.test(res.headers.get("www-authenticate") ?? "");
|
|
85
|
+
}
|
|
@@ -1,5 +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
|
+
export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"];
|
|
3
3
|
export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
|
|
4
4
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
5
5
|
export declare function parseTokenAuth(value: string): TokenAuth | null;
|
|
@@ -13,6 +13,11 @@ export interface TokenClient {
|
|
|
13
13
|
client_assertion_kid?: string;
|
|
14
14
|
client_assertion_alg?: AssertionAlg;
|
|
15
15
|
client_assertion_cert?: string;
|
|
16
|
+
/** tls_client_auth / self_signed_tls_client_auth (RFC 8705): the client
|
|
17
|
+
* certificate and key presented on the connection to the token URL. */
|
|
18
|
+
tls_client_cert?: string;
|
|
19
|
+
tls_client_key?: string;
|
|
20
|
+
tls_client_passphrase?: string;
|
|
16
21
|
}
|
|
17
22
|
/** Why a client's token_auth cannot work, or null. */
|
|
18
23
|
export declare function tokenAuthProblem(client: {
|
|
@@ -21,6 +26,9 @@ export declare function tokenAuthProblem(client: {
|
|
|
21
26
|
client_assertion_key?: string;
|
|
22
27
|
client_assertion_alg?: unknown;
|
|
23
28
|
client_assertion_cert?: string;
|
|
29
|
+
tls_client_cert?: string;
|
|
30
|
+
tls_client_key?: string;
|
|
31
|
+
tls_client_passphrase?: string;
|
|
24
32
|
}): string | null;
|
|
25
33
|
/**
|
|
26
34
|
* The headers and body of one token request. `params` is the grant's own
|
package/dist/src/oauth-token.js
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// secret at all (RFC 7523 §2.2, oauth-assertion.ts)
|
|
18
18
|
//
|
|
19
19
|
// Absent means the old behaviour: the body, with the secret if there is one.
|
|
20
|
+
import crypto from "node:crypto";
|
|
20
21
|
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"];
|
|
22
|
+
export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"];
|
|
22
23
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
23
24
|
export function parseTokenAuth(value) {
|
|
24
25
|
const v = value.trim().toLowerCase();
|
|
@@ -30,6 +31,10 @@ export function parseTokenAuth(value) {
|
|
|
30
31
|
return "client_secret_basic";
|
|
31
32
|
if (v === "jwt" || v === "private-key-jwt" || v === "key")
|
|
32
33
|
return "private_key_jwt";
|
|
34
|
+
if (v === "mtls" || v === "tls" || v === "cert")
|
|
35
|
+
return "tls_client_auth";
|
|
36
|
+
if (v === "self-signed-mtls" || v === "self-signed")
|
|
37
|
+
return "self_signed_tls_client_auth";
|
|
33
38
|
return null;
|
|
34
39
|
}
|
|
35
40
|
/** Why a client's token_auth cannot work, or null. */
|
|
@@ -53,6 +58,20 @@ export function tokenAuthProblem(client) {
|
|
|
53
58
|
return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
|
|
54
59
|
return assertionKeyProblem(client);
|
|
55
60
|
}
|
|
61
|
+
if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
|
|
62
|
+
if (hasSecret)
|
|
63
|
+
return `token_auth ${auth} proves the client with its certificate and sends no client_secret — leave the secret empty`;
|
|
64
|
+
if (!client.tls_client_cert?.trim() || !client.tls_client_key?.trim())
|
|
65
|
+
return `token_auth ${auth} needs tls_client_cert and tls_client_key (PEM)`;
|
|
66
|
+
try {
|
|
67
|
+
const key = crypto.createPrivateKey(client.tls_client_passphrase ? { key: client.tls_client_key, passphrase: client.tls_client_passphrase } : client.tls_client_key);
|
|
68
|
+
if (!new crypto.X509Certificate(client.tls_client_cert).checkPrivateKey(key))
|
|
69
|
+
return "tls_client_cert is not the certificate of tls_client_key";
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "tls_client_cert and tls_client_key must be a PEM certificate and its private key";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
return null;
|
|
57
76
|
}
|
|
58
77
|
/** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
|
|
@@ -93,6 +112,11 @@ opts = {}) {
|
|
|
93
112
|
if (opts.clientIdInBody)
|
|
94
113
|
form.set("client_id", client.client_id);
|
|
95
114
|
}
|
|
115
|
+
else if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
|
|
116
|
+
// The certificate on the connection is the proof (RFC 8705 §2): the
|
|
117
|
+
// caller presents tls_client_cert; the body names the client only.
|
|
118
|
+
form.set("client_id", client.client_id);
|
|
119
|
+
}
|
|
96
120
|
else if (auth === "private_key_jwt") {
|
|
97
121
|
if (!opts.audience)
|
|
98
122
|
throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -183,6 +183,13 @@ export interface PlatformHooks {
|
|
|
183
183
|
*/
|
|
184
184
|
fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
185
185
|
maxBytes?: number;
|
|
186
|
+
clientCert?: {
|
|
187
|
+
pool: string;
|
|
188
|
+
identity: string;
|
|
189
|
+
cert: string;
|
|
190
|
+
key: string;
|
|
191
|
+
passphrase?: string;
|
|
192
|
+
};
|
|
186
193
|
}): Promise<Response>;
|
|
187
194
|
/**
|
|
188
195
|
* May the host call this URL a tenant chose — a provider's base_url,
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function installKeyMaterial(): string;
|
|
|
17
17
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
18
18
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
19
19
|
* the run's scratch. Plain values have no kind. */
|
|
20
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
20
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity" | "token-exchange";
|
|
21
21
|
interface StoredSecret {
|
|
22
22
|
iv: string;
|
|
23
23
|
tag: string;
|
|
@@ -256,6 +256,15 @@ export interface OAuth2Config {
|
|
|
256
256
|
client_assertion_kid?: string;
|
|
257
257
|
client_assertion_alg?: AssertionAlg;
|
|
258
258
|
client_assertion_cert?: string;
|
|
259
|
+
/** DPoP (RFC 9449): this connection's own key; token requests and the
|
|
260
|
+
* calls that use its token carry proofs signed with it (oauth-dpop.ts). */
|
|
261
|
+
dpop_key?: string;
|
|
262
|
+
/** tls_client_auth (RFC 8705): the client certificate and key presented
|
|
263
|
+
* to the token URL — and, through the egress proxy, on the calls its
|
|
264
|
+
* certificate-bound token is used for. */
|
|
265
|
+
tls_client_cert?: string;
|
|
266
|
+
tls_client_key?: string;
|
|
267
|
+
tls_client_passphrase?: string;
|
|
259
268
|
}
|
|
260
269
|
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
261
270
|
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
@@ -273,7 +282,7 @@ export declare function isOAuth2Value(value: string): boolean;
|
|
|
273
282
|
* second account with the same names be handed the first one's token
|
|
274
283
|
* (Codex debate, 22 Oct 2026).
|
|
275
284
|
*/
|
|
276
|
-
export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string;
|
|
285
|
+
export declare function tokenCacheKey(kind: "oauth2" | "sa" | "tx", storedValue: string, tenant?: string): string;
|
|
277
286
|
/**
|
|
278
287
|
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
279
288
|
* with a new one: write it back where the old one is stored — the workspace
|
|
@@ -417,6 +426,32 @@ export declare function setClientCertSecret(tenant: string, name: string, config
|
|
|
417
426
|
export interface IdentityConfig {
|
|
418
427
|
audience: string;
|
|
419
428
|
}
|
|
429
|
+
export declare const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
430
|
+
export interface TokenExchangeConfig {
|
|
431
|
+
token_url: string;
|
|
432
|
+
client_id: string;
|
|
433
|
+
client_secret?: string;
|
|
434
|
+
token_auth?: TokenAuth;
|
|
435
|
+
client_assertion_key?: string;
|
|
436
|
+
client_assertion_kid?: string;
|
|
437
|
+
client_assertion_alg?: AssertionAlg;
|
|
438
|
+
client_assertion_cert?: string;
|
|
439
|
+
tls_client_cert?: string;
|
|
440
|
+
tls_client_key?: string;
|
|
441
|
+
tls_client_passphrase?: string;
|
|
442
|
+
/** The secret whose live token is the subject: an oauth2 or service-account
|
|
443
|
+
* secret, or a static token. Declared beside this one, or found in the
|
|
444
|
+
* vault at the same scope. */
|
|
445
|
+
subject_secret: string;
|
|
446
|
+
/** Default urn:ietf:params:oauth:token-type:access_token. */
|
|
447
|
+
subject_token_type?: string;
|
|
448
|
+
requested_token_type?: string;
|
|
449
|
+
audience?: string;
|
|
450
|
+
resource?: string;
|
|
451
|
+
scope?: string;
|
|
452
|
+
}
|
|
453
|
+
export declare function setTokenExchangeSecret(tenant: string, name: string, config: TokenExchangeConfig, workspace?: string, limit?: SecretLimit): void;
|
|
454
|
+
export declare function isTokenExchangeValue(value: string): boolean;
|
|
420
455
|
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
|
|
421
456
|
export declare function isIdentityValue(value: string): boolean;
|
|
422
457
|
export declare function identityOf(value: string): IdentityConfig;
|