@foldrun/core 9.3.0 → 9.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/oauth-discovery.d.ts +90 -0
- package/dist/src/oauth-discovery.js +168 -0
- package/dist/src/oauth-token.d.ts +6 -1
- package/dist/src/oauth-token.js +6 -1
- package/dist/src/secrets.d.ts +51 -0
- package/dist/src/secrets.js +128 -1
- package/dist/src/untrusted-fetch.d.ts +4 -0
- package/dist/src/untrusted-fetch.js +1 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/oauth-discovery.ts +208 -0
- package/src/oauth-token.ts +5 -1
- package/src/secrets.ts +137 -1
- package/src/untrusted-fetch.ts +5 -1
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export * from "./src/storage.ts";
|
|
|
31
31
|
export * from "./src/secrets.ts";
|
|
32
32
|
export * from "./src/oauth-presets.ts";
|
|
33
33
|
export * from "./src/oauth-token.ts";
|
|
34
|
+
export * from "./src/oauth-discovery.ts";
|
|
34
35
|
export * from "./src/secret-files.ts";
|
|
35
36
|
export * from "./src/notify.ts";
|
|
36
37
|
export * from "./src/webhook.ts";
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export * from "./src/storage.js";
|
|
|
42
42
|
export * from "./src/secrets.js";
|
|
43
43
|
export * from "./src/oauth-presets.js";
|
|
44
44
|
export * from "./src/oauth-token.js";
|
|
45
|
+
export * from "./src/oauth-discovery.js";
|
|
45
46
|
export * from "./src/secret-files.js";
|
|
46
47
|
export * from "./src/notify.js";
|
|
47
48
|
export * from "./src/webhook.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { TokenAuth } from "./oauth-token.ts";
|
|
2
|
+
/** What foldrun reads from a metadata document. */
|
|
3
|
+
export interface OAuthServerMetadata {
|
|
4
|
+
issuer: string;
|
|
5
|
+
authorization_endpoint?: string;
|
|
6
|
+
token_endpoint?: string;
|
|
7
|
+
revocation_endpoint?: string;
|
|
8
|
+
device_authorization_endpoint?: string;
|
|
9
|
+
pushed_authorization_request_endpoint?: string;
|
|
10
|
+
require_pushed_authorization_requests?: boolean;
|
|
11
|
+
token_endpoint_auth_methods_supported?: string[];
|
|
12
|
+
code_challenge_methods_supported?: string[];
|
|
13
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
14
|
+
dpop_signing_alg_values_supported?: string[];
|
|
15
|
+
scopes_supported?: string[];
|
|
16
|
+
jwks_uri?: string;
|
|
17
|
+
}
|
|
18
|
+
/** A discovered server, as the fields a saved app and an oauth2 secret take. */
|
|
19
|
+
export interface DiscoveredClientConfig {
|
|
20
|
+
issuer: string;
|
|
21
|
+
authorize_url?: string;
|
|
22
|
+
token_url: string;
|
|
23
|
+
revocation_url?: string;
|
|
24
|
+
device_url?: string;
|
|
25
|
+
par_url?: string;
|
|
26
|
+
/** The server requires PAR: an authorize URL without a request_uri fails. */
|
|
27
|
+
par_required?: boolean;
|
|
28
|
+
/** Whether the server sends `iss` with every authorization response
|
|
29
|
+
* (RFC 9207): when true, one that comes back without it is refused. */
|
|
30
|
+
iss_parameter: boolean;
|
|
31
|
+
/** How the client should authenticate at the token URL, given whether it
|
|
32
|
+
* has a secret and what the server supports; absent is the form body. */
|
|
33
|
+
token_auth?: TokenAuth;
|
|
34
|
+
/** Where the metadata was read from. */
|
|
35
|
+
metadata_url: string;
|
|
36
|
+
metadata: OAuthServerMetadata;
|
|
37
|
+
}
|
|
38
|
+
/** Loopback is exempt: a server on this machine (tests, a local Keycloak)
|
|
39
|
+
* crosses no wire for http to leak on. Judged on the parsed URL, never on
|
|
40
|
+
* how it starts: `http://localhost:80@evil.example` is evil.example
|
|
41
|
+
* (Codex review, 1 Nov 2026). */
|
|
42
|
+
export declare function isSecureUrl(url: string): boolean;
|
|
43
|
+
/** Every URL the metadata may be at, in the order to try. */
|
|
44
|
+
export declare function metadataUrls(issuer: string): string[];
|
|
45
|
+
/** Why an issuer cannot be discovered from, or null. */
|
|
46
|
+
export declare function issuerProblem(issuer: string): string | null;
|
|
47
|
+
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
48
|
+
/**
|
|
49
|
+
* Read the authorization server's metadata. `fetcher` is how the caller
|
|
50
|
+
* reaches the network (the platform's SSRF-guarded fetchPublic, the CLI's
|
|
51
|
+
* fetch). Throws with every place tried when none answers.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fetchOAuthMetadata(issuer: string, fetcher: Fetcher): Promise<{
|
|
54
|
+
url: string;
|
|
55
|
+
metadata: OAuthServerMetadata;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* What a client config should say for this server. `hasSecret`: the app
|
|
59
|
+
* has a client secret; `pkce`: false when the caller turned PKCE off;
|
|
60
|
+
* `tokenAuth`: one the caller chose, which wins over the server's list.
|
|
61
|
+
*/
|
|
62
|
+
export declare function clientConfigFromMetadata(found: {
|
|
63
|
+
url: string;
|
|
64
|
+
metadata: OAuthServerMetadata;
|
|
65
|
+
}, opts?: {
|
|
66
|
+
hasSecret: boolean;
|
|
67
|
+
pkce?: boolean;
|
|
68
|
+
tokenAuth?: TokenAuth;
|
|
69
|
+
needsAuthorize?: boolean;
|
|
70
|
+
}): DiscoveredClientConfig;
|
|
71
|
+
/**
|
|
72
|
+
* The token_auth to use. A choice the caller made is kept. Otherwise, with a
|
|
73
|
+
* secret: the form body when the server lists it (what every secret did
|
|
74
|
+
* before), else Basic — which is also the RFC 8414 default when the server
|
|
75
|
+
* lists nothing. Without a secret: none (a public client).
|
|
76
|
+
*/
|
|
77
|
+
export declare function pickTokenAuth(supported: unknown, opts: {
|
|
78
|
+
hasSecret: boolean;
|
|
79
|
+
tokenAuth?: TokenAuth;
|
|
80
|
+
}): {
|
|
81
|
+
token_auth?: TokenAuth;
|
|
82
|
+
};
|
|
83
|
+
/** Discover and turn into a client config in one call. */
|
|
84
|
+
export declare function discoverOAuth(issuer: string, fetcher: Fetcher, opts: {
|
|
85
|
+
hasSecret: boolean;
|
|
86
|
+
pkce?: boolean;
|
|
87
|
+
tokenAuth?: TokenAuth;
|
|
88
|
+
needsAuthorize?: boolean;
|
|
89
|
+
}): Promise<DiscoveredClientConfig>;
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Discovery from an issuer: give foldrun the provider's issuer URL and it
|
|
2
|
+
// reads the authorization server's own metadata (RFC 8414, OpenID Connect
|
|
3
|
+
// Discovery 1.0) instead of a person typing the authorize, token and
|
|
4
|
+
// revocation URLs by hand — where a typo costs a consent, and an endpoint
|
|
5
|
+
// on the wrong host is a mix-up attack waiting to happen.
|
|
6
|
+
//
|
|
7
|
+
// Where the metadata lives, in the order tried (RFC 8414 §3.1 and §5):
|
|
8
|
+
// https://host/.well-known/oauth-authorization-server{/path}
|
|
9
|
+
// https://host/.well-known/openid-configuration{/path}
|
|
10
|
+
// https://host{/path}/.well-known/openid-configuration (OIDC's own spelling)
|
|
11
|
+
// The document's `issuer` must equal the issuer asked for, exactly (RFC 8414
|
|
12
|
+
// §3.3) — a document that names another issuer is refused, not trusted.
|
|
13
|
+
/** Loopback is exempt: a server on this machine (tests, a local Keycloak)
|
|
14
|
+
* crosses no wire for http to leak on. Judged on the parsed URL, never on
|
|
15
|
+
* how it starts: `http://localhost:80@evil.example` is evil.example
|
|
16
|
+
* (Codex review, 1 Nov 2026). */
|
|
17
|
+
export function isSecureUrl(url) {
|
|
18
|
+
let u;
|
|
19
|
+
try {
|
|
20
|
+
u = new URL(url);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (u.username || u.password)
|
|
26
|
+
return false;
|
|
27
|
+
if (u.protocol === "https:")
|
|
28
|
+
return true;
|
|
29
|
+
return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
|
|
30
|
+
}
|
|
31
|
+
/** Every URL the metadata may be at, in the order to try. */
|
|
32
|
+
export function metadataUrls(issuer) {
|
|
33
|
+
const u = new URL(issuer);
|
|
34
|
+
const pathPart = u.pathname.replace(/\/+$/, "");
|
|
35
|
+
const origin = u.origin;
|
|
36
|
+
const urls = [
|
|
37
|
+
`${origin}/.well-known/oauth-authorization-server${pathPart}`,
|
|
38
|
+
`${origin}/.well-known/openid-configuration${pathPart}`,
|
|
39
|
+
];
|
|
40
|
+
if (pathPart)
|
|
41
|
+
urls.push(`${origin}${pathPart}/.well-known/openid-configuration`);
|
|
42
|
+
return [...new Set(urls)];
|
|
43
|
+
}
|
|
44
|
+
/** Why an issuer cannot be discovered from, or null. */
|
|
45
|
+
export function issuerProblem(issuer) {
|
|
46
|
+
let u;
|
|
47
|
+
try {
|
|
48
|
+
u = new URL(issuer);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "issuer must be a URL, like https://login.example.com";
|
|
52
|
+
}
|
|
53
|
+
if (!isSecureUrl(issuer))
|
|
54
|
+
return "issuer must be https";
|
|
55
|
+
if (u.search || u.hash)
|
|
56
|
+
return "issuer has no query or fragment (RFC 8414 §2)";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the authorization server's metadata. `fetcher` is how the caller
|
|
61
|
+
* reaches the network (the platform's SSRF-guarded fetchPublic, the CLI's
|
|
62
|
+
* fetch). Throws with every place tried when none answers.
|
|
63
|
+
*/
|
|
64
|
+
export async function fetchOAuthMetadata(issuer, fetcher) {
|
|
65
|
+
const problem = issuerProblem(issuer);
|
|
66
|
+
if (problem)
|
|
67
|
+
throw new Error(problem);
|
|
68
|
+
const tried = [];
|
|
69
|
+
for (const url of metadataUrls(issuer)) {
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await fetcher(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
tried.push(`${url} (${err instanceof Error ? err.message : String(err)})`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
await res.body?.cancel().catch(() => { });
|
|
80
|
+
tried.push(`${url} (${res.status})`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
let doc;
|
|
84
|
+
try {
|
|
85
|
+
doc = await res.json();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
tried.push(`${url} (not JSON)`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
92
|
+
tried.push(`${url} (not a metadata document)`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const metadata = doc;
|
|
96
|
+
// RFC 8414 §3.3: the issuer in the document is the one asked for, or
|
|
97
|
+
// the document is not used. Trailing-slash differences are not forgiven.
|
|
98
|
+
if (metadata.issuer !== issuer) {
|
|
99
|
+
throw new Error(`the metadata at ${url} names the issuer ${JSON.stringify(metadata.issuer ?? null)}, not ${issuer} — refused (RFC 8414 §3.3)`);
|
|
100
|
+
}
|
|
101
|
+
return { url, metadata };
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`no OAuth metadata for ${issuer} — tried ${tried.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
const ENDPOINTS = [
|
|
106
|
+
"authorization_endpoint", "token_endpoint", "revocation_endpoint",
|
|
107
|
+
"device_authorization_endpoint", "pushed_authorization_request_endpoint", "jwks_uri",
|
|
108
|
+
];
|
|
109
|
+
/**
|
|
110
|
+
* What a client config should say for this server. `hasSecret`: the app
|
|
111
|
+
* has a client secret; `pkce`: false when the caller turned PKCE off;
|
|
112
|
+
* `tokenAuth`: one the caller chose, which wins over the server's list.
|
|
113
|
+
*/
|
|
114
|
+
export function clientConfigFromMetadata(found, opts = { hasSecret: true }) {
|
|
115
|
+
const m = found.metadata;
|
|
116
|
+
for (const k of ENDPOINTS) {
|
|
117
|
+
const v = m[k];
|
|
118
|
+
if (v !== undefined && (typeof v !== "string" || !isSecureUrl(v)))
|
|
119
|
+
throw new Error(`the server's ${k} is not an https URL: ${String(v)}`);
|
|
120
|
+
}
|
|
121
|
+
if (!m.token_endpoint)
|
|
122
|
+
throw new Error(`${m.issuer} publishes no token_endpoint`);
|
|
123
|
+
if ((opts.needsAuthorize ?? true) && !m.authorization_endpoint)
|
|
124
|
+
throw new Error(`${m.issuer} publishes no authorization_endpoint — it does not do browser sign-in`);
|
|
125
|
+
// PKCE S256 is required of every connection (RFC 9700 §2.1.1). A server
|
|
126
|
+
// that lists its methods and leaves S256 out would ignore our challenge.
|
|
127
|
+
if (opts.pkce !== false && Array.isArray(m.code_challenge_methods_supported) && !m.code_challenge_methods_supported.includes("S256")) {
|
|
128
|
+
throw new Error(`${m.issuer} does not support PKCE S256 (it lists ${m.code_challenge_methods_supported.join(", ") || "none"}) — refused; turn PKCE off only if you are sure (--no-pkce)`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
issuer: m.issuer,
|
|
132
|
+
...(m.authorization_endpoint ? { authorize_url: m.authorization_endpoint } : {}),
|
|
133
|
+
token_url: m.token_endpoint,
|
|
134
|
+
...(m.revocation_endpoint ? { revocation_url: m.revocation_endpoint } : {}),
|
|
135
|
+
...(m.device_authorization_endpoint ? { device_url: m.device_authorization_endpoint } : {}),
|
|
136
|
+
...(m.pushed_authorization_request_endpoint ? { par_url: m.pushed_authorization_request_endpoint } : {}),
|
|
137
|
+
...(m.require_pushed_authorization_requests === true ? { par_required: true } : {}),
|
|
138
|
+
iss_parameter: m.authorization_response_iss_parameter_supported === true,
|
|
139
|
+
...pickTokenAuth(m.token_endpoint_auth_methods_supported, opts),
|
|
140
|
+
metadata_url: found.url,
|
|
141
|
+
metadata: m,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The token_auth to use. A choice the caller made is kept. Otherwise, with a
|
|
146
|
+
* secret: the form body when the server lists it (what every secret did
|
|
147
|
+
* before), else Basic — which is also the RFC 8414 default when the server
|
|
148
|
+
* lists nothing. Without a secret: none (a public client).
|
|
149
|
+
*/
|
|
150
|
+
export function pickTokenAuth(supported, opts) {
|
|
151
|
+
if (opts.tokenAuth)
|
|
152
|
+
return { token_auth: opts.tokenAuth };
|
|
153
|
+
const list = Array.isArray(supported) ? supported.filter((s) => typeof s === "string") : null;
|
|
154
|
+
// A public client sends no secret. Not refused when the list leaves out
|
|
155
|
+
// `none`: Keycloak takes public clients and lists only secret methods,
|
|
156
|
+
// so the list is not the last word on it (found by the conformance suite).
|
|
157
|
+
if (!opts.hasSecret)
|
|
158
|
+
return {};
|
|
159
|
+
if (list && list.includes("client_secret_post"))
|
|
160
|
+
return {};
|
|
161
|
+
if (!list || list.includes("client_secret_basic"))
|
|
162
|
+
return { token_auth: "client_secret_basic" };
|
|
163
|
+
throw new Error(`the server takes none of the ways foldrun signs in at its token URL (it lists ${list.join(", ") || "nothing"}; foldrun sends the secret in the form body or as HTTP Basic)`);
|
|
164
|
+
}
|
|
165
|
+
/** Discover and turn into a client config in one call. */
|
|
166
|
+
export async function discoverOAuth(issuer, fetcher, opts) {
|
|
167
|
+
return clientConfigFromMetadata(await fetchOAuthMetadata(issuer, fetcher), opts);
|
|
168
|
+
}
|
|
@@ -17,8 +17,13 @@ export declare function tokenAuthProblem(client: {
|
|
|
17
17
|
* fields (grant_type, code, refresh_token, …); the client's identity is
|
|
18
18
|
* added here, by its token_auth. Fields in `params` named client_id or
|
|
19
19
|
* client_secret are dropped — the method decides those, never an `extra`.
|
|
20
|
+
* `clientIdInBody`: client_id goes in the form whatever the method — a
|
|
21
|
+
* pushed authorization request carries it as a parameter of the request it
|
|
22
|
+
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
20
23
|
*/
|
|
21
|
-
export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string
|
|
24
|
+
export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string>, opts?: {
|
|
25
|
+
clientIdInBody?: boolean;
|
|
26
|
+
}): {
|
|
22
27
|
headers: Record<string, string>;
|
|
23
28
|
body: string;
|
|
24
29
|
};
|
package/dist/src/oauth-token.js
CHANGED
|
@@ -51,8 +51,11 @@ const formEncode = (s) => new URLSearchParams({ x: s }).toString().slice(2);
|
|
|
51
51
|
* fields (grant_type, code, refresh_token, …); the client's identity is
|
|
52
52
|
* added here, by its token_auth. Fields in `params` named client_id or
|
|
53
53
|
* client_secret are dropped — the method decides those, never an `extra`.
|
|
54
|
+
* `clientIdInBody`: client_id goes in the form whatever the method — a
|
|
55
|
+
* pushed authorization request carries it as a parameter of the request it
|
|
56
|
+
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
54
57
|
*/
|
|
55
|
-
export function oauthTokenRequest(client, params) {
|
|
58
|
+
export function oauthTokenRequest(client, params, opts = {}) {
|
|
56
59
|
const problem = tokenAuthProblem(client);
|
|
57
60
|
if (problem)
|
|
58
61
|
throw new Error(problem);
|
|
@@ -73,6 +76,8 @@ export function oauthTokenRequest(client, params) {
|
|
|
73
76
|
// not the body as well.
|
|
74
77
|
const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret)}`;
|
|
75
78
|
headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
|
|
79
|
+
if (opts.clientIdInBody)
|
|
80
|
+
form.set("client_id", client.client_id);
|
|
76
81
|
}
|
|
77
82
|
else {
|
|
78
83
|
form.set("client_id", client.client_id);
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -245,6 +245,9 @@ export interface OAuth2Config {
|
|
|
245
245
|
/** Plain fields the provider returned beside the first token (Salesforce's
|
|
246
246
|
* instance_url) — kept with the recipe, never a credential. */
|
|
247
247
|
fields?: Record<string, string>;
|
|
248
|
+
/** The provider's revocation endpoint (RFC 7009): deleting or replacing
|
|
249
|
+
* this secret revokes its tokens there (revokeOAuth2). */
|
|
250
|
+
revocation_url?: string;
|
|
248
251
|
}
|
|
249
252
|
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
250
253
|
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
@@ -271,6 +274,54 @@ export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string
|
|
|
271
274
|
* older token over a newer one. Returns whether a copy was replaced.
|
|
272
275
|
*/
|
|
273
276
|
export declare function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean;
|
|
277
|
+
/** The oauth2 recipe stored at exactly this scope — never the account copy a
|
|
278
|
+
* workspace would fall back to: revoking is about the copy being removed. */
|
|
279
|
+
export declare function oauth2ValueAt(tenant: string, name: string, workspace?: string): string | null;
|
|
280
|
+
/**
|
|
281
|
+
* Another stored secret of this account holds a grant revoking this one could
|
|
282
|
+
* end: the same refresh token (a copy made by hand, or one rotation reached),
|
|
283
|
+
* or any grant from the same app (token URL and client id). Providers such as
|
|
284
|
+
* Google revoke every token of a user's grant to an app together, so deleting
|
|
285
|
+
* the Ads connection would take down the Search Console one made with the
|
|
286
|
+
* same app (Codex review, 1 Nov 2026). Such a grant is left alone.
|
|
287
|
+
*/
|
|
288
|
+
export declare function oauthGrantSharedElsewhere(tenant: string, name: string, workspace: string | undefined, refreshToken: string, app?: {
|
|
289
|
+
token_url: string;
|
|
290
|
+
client_id: string;
|
|
291
|
+
}): boolean;
|
|
292
|
+
/** What revoking an oauth2 secret's tokens came to. */
|
|
293
|
+
export interface OAuthRevokeResult {
|
|
294
|
+
/** Nothing was asked: no revocation endpoint; another secret holds the
|
|
295
|
+
* same grant or one from the same app; the replacement is from the same
|
|
296
|
+
* app (a reconnect: revoking would end the new grant too at providers
|
|
297
|
+
* that revoke per app); or the stored recipe could not be read. */
|
|
298
|
+
skipped?: "no-revocation-url" | "shared-grant" | "same-app" | "unreadable";
|
|
299
|
+
/** The token types the provider accepted the revocation of. */
|
|
300
|
+
revoked: ("refresh_token" | "access_token")[];
|
|
301
|
+
/** Why one failed — reported, never a reason to keep a secret. */
|
|
302
|
+
error?: string;
|
|
303
|
+
}
|
|
304
|
+
type RevokeFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
305
|
+
/**
|
|
306
|
+
* Revoke what an oauth2 recipe holds at its provider (RFC 7009): the refresh
|
|
307
|
+
* token, then the access token this process has cached for it. The client
|
|
308
|
+
* authenticates as at the token URL (its token_auth). A 200 is success —
|
|
309
|
+
* the RFC answers 200 for a token already invalid too; anything else is
|
|
310
|
+
* reported in `error`, and the next token is still tried. `replacement`: the
|
|
311
|
+
* recipe taking this one's place; one from the same app (token URL and client
|
|
312
|
+
* id) means nothing is revoked, since a provider that revokes per app would
|
|
313
|
+
* end the new grant with the old.
|
|
314
|
+
*/
|
|
315
|
+
export declare function revokeOAuth2Value(value: string, opts?: {
|
|
316
|
+
tenant?: string;
|
|
317
|
+
name?: string;
|
|
318
|
+
workspace?: string;
|
|
319
|
+
fetcher?: RevokeFetcher;
|
|
320
|
+
replacement?: {
|
|
321
|
+
token_url: string;
|
|
322
|
+
client_id: string;
|
|
323
|
+
};
|
|
324
|
+
}): Promise<OAuthRevokeResult>;
|
|
274
325
|
/**
|
|
275
326
|
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
276
327
|
* async moment in secret resolution, kept out of the sync paths: callers
|
package/dist/src/secrets.js
CHANGED
|
@@ -24,6 +24,7 @@ import { dataRoot } from "./paths.js";
|
|
|
24
24
|
import { platform } from "./platform.js";
|
|
25
25
|
import { fetchUntrusted } from "./untrusted-fetch.js";
|
|
26
26
|
import { oauthTokenRequest, readTokenResponse, tokenAuthProblem } from "./oauth-token.js";
|
|
27
|
+
import { isSecureUrl } from "./oauth-discovery.js";
|
|
27
28
|
/**
|
|
28
29
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
29
30
|
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
@@ -822,12 +823,15 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
|
822
823
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
823
824
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
824
825
|
}
|
|
826
|
+
if (config.revocation_url !== undefined && (typeof config.revocation_url !== "string" || !isSecureUrl(config.revocation_url))) {
|
|
827
|
+
throw new Error("revocation_url must be https");
|
|
828
|
+
}
|
|
825
829
|
const authProblem = tokenAuthProblem(config);
|
|
826
830
|
if (authProblem)
|
|
827
831
|
throw new Error(`oauth2 secret: ${authProblem}`);
|
|
828
832
|
// Loopback is exempt: a token endpoint on this same machine (tests, a
|
|
829
833
|
// local mock, a sidecar) crosses no wire for http to leak on.
|
|
830
|
-
if (
|
|
834
|
+
if (!isSecureUrl(config.token_url)) {
|
|
831
835
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
832
836
|
}
|
|
833
837
|
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
@@ -961,6 +965,129 @@ async function exchange(config, cacheKey) {
|
|
|
961
965
|
oauthFlight.delete(cacheKey);
|
|
962
966
|
}
|
|
963
967
|
}
|
|
968
|
+
// ---------------------------------------------------------------- revocation
|
|
969
|
+
/** The oauth2 recipe stored at exactly this scope — never the account copy a
|
|
970
|
+
* workspace would fall back to: revoking is about the copy being removed. */
|
|
971
|
+
export function oauth2ValueAt(tenant, name, workspace) {
|
|
972
|
+
const v = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
973
|
+
return v !== null && isOAuth2Value(v) ? v : null;
|
|
974
|
+
}
|
|
975
|
+
/** The workspaces with a vault file of their own (current and legacy layout). */
|
|
976
|
+
function workspacesWithSecrets(tenant) {
|
|
977
|
+
const out = new Set();
|
|
978
|
+
for (const dir of ["workspaces", "projects"]) {
|
|
979
|
+
const base = path.join(dataRoot(), tenant, dir);
|
|
980
|
+
let names = [];
|
|
981
|
+
try {
|
|
982
|
+
names = fs.readdirSync(base);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
for (const n of names)
|
|
988
|
+
if (fs.existsSync(path.join(base, n, "secrets.json")))
|
|
989
|
+
out.add(n);
|
|
990
|
+
}
|
|
991
|
+
return [...out];
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Another stored secret of this account holds a grant revoking this one could
|
|
995
|
+
* end: the same refresh token (a copy made by hand, or one rotation reached),
|
|
996
|
+
* or any grant from the same app (token URL and client id). Providers such as
|
|
997
|
+
* Google revoke every token of a user's grant to an app together, so deleting
|
|
998
|
+
* the Ads connection would take down the Search Console one made with the
|
|
999
|
+
* same app (Codex review, 1 Nov 2026). Such a grant is left alone.
|
|
1000
|
+
*/
|
|
1001
|
+
export function oauthGrantSharedElsewhere(tenant, name, workspace, refreshToken, app) {
|
|
1002
|
+
for (const scope of [undefined, ...workspacesWithSecrets(tenant)]) {
|
|
1003
|
+
for (const [other, rec] of Object.entries(read(tenant, scope))) {
|
|
1004
|
+
if (other === name && scope === workspace)
|
|
1005
|
+
continue;
|
|
1006
|
+
// By the value, not the optional kind tag: a recipe copied with a
|
|
1007
|
+
// plain setSecret is still one materializeSecrets refreshes.
|
|
1008
|
+
const v = openRecord(tenant, scope, other, rec);
|
|
1009
|
+
if (!v || !isOAuth2Value(v))
|
|
1010
|
+
continue;
|
|
1011
|
+
try {
|
|
1012
|
+
const other = JSON.parse(v.slice(OAUTH2_PREFIX.length));
|
|
1013
|
+
if (other.refresh_token === refreshToken)
|
|
1014
|
+
return true;
|
|
1015
|
+
if (app && other.grant_type !== "client_credentials" && other.token_url === app.token_url && other.client_id === app.client_id)
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Revoke what an oauth2 recipe holds at its provider (RFC 7009): the refresh
|
|
1027
|
+
* token, then the access token this process has cached for it. The client
|
|
1028
|
+
* authenticates as at the token URL (its token_auth). A 200 is success —
|
|
1029
|
+
* the RFC answers 200 for a token already invalid too; anything else is
|
|
1030
|
+
* reported in `error`, and the next token is still tried. `replacement`: the
|
|
1031
|
+
* recipe taking this one's place; one from the same app (token URL and client
|
|
1032
|
+
* id) means nothing is revoked, since a provider that revokes per app would
|
|
1033
|
+
* end the new grant with the old.
|
|
1034
|
+
*/
|
|
1035
|
+
export async function revokeOAuth2Value(value, opts = {}) {
|
|
1036
|
+
let config;
|
|
1037
|
+
try {
|
|
1038
|
+
config = JSON.parse(value.slice(OAUTH2_PREFIX.length));
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
return { skipped: "unreadable", revoked: [] };
|
|
1042
|
+
}
|
|
1043
|
+
if (!config.revocation_url)
|
|
1044
|
+
return { skipped: "no-revocation-url", revoked: [] };
|
|
1045
|
+
if (opts.replacement && opts.replacement.token_url === config.token_url && opts.replacement.client_id === config.client_id) {
|
|
1046
|
+
return { skipped: "same-app", revoked: [] };
|
|
1047
|
+
}
|
|
1048
|
+
if (config.grant_type !== "client_credentials" && opts.tenant && opts.name
|
|
1049
|
+
&& oauthGrantSharedElsewhere(opts.tenant, opts.name, opts.workspace, config.refresh_token ?? "", { token_url: config.token_url, client_id: config.client_id })) {
|
|
1050
|
+
return { skipped: "shared-grant", revoked: [] };
|
|
1051
|
+
}
|
|
1052
|
+
// No redirect is followed: a 3xx is not ok, so a revocation endpoint that
|
|
1053
|
+
// sends us to a login page is reported, never counted as revoked.
|
|
1054
|
+
const fetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
|
|
1055
|
+
const key = tokenCacheKey("oauth2", value, opts.tenant);
|
|
1056
|
+
const access = oauthCache.get(key)?.token;
|
|
1057
|
+
const tokens = [];
|
|
1058
|
+
if (config.grant_type !== "client_credentials" && config.refresh_token)
|
|
1059
|
+
tokens.push(["refresh_token", config.refresh_token]);
|
|
1060
|
+
if (access)
|
|
1061
|
+
tokens.push(["access_token", access]);
|
|
1062
|
+
const out = { revoked: [] };
|
|
1063
|
+
for (const [hint, token] of tokens) {
|
|
1064
|
+
try {
|
|
1065
|
+
const request = oauthTokenRequest(config, { token, token_type_hint: hint });
|
|
1066
|
+
const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
1067
|
+
if (res.ok) {
|
|
1068
|
+
await res.body?.cancel().catch(() => { });
|
|
1069
|
+
out.revoked.push(hint);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
// Reported, and the next token is still tried: a provider that will
|
|
1073
|
+
// not revoke refresh tokens may still revoke the access token.
|
|
1074
|
+
const why = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
|
|
1075
|
+
out.error ??= `the provider refused to revoke the ${hint.replace("_", " ")} (${res.status}${why.error ? `: ${why.error_description ?? why.error}` : ""})`;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
out.error ??= `revoking the ${hint.replace("_", " ")} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// Every entry serving that access token goes: after a rotation the same
|
|
1083
|
+
// token is cached under the recipe before and after it (Codex review).
|
|
1084
|
+
oauthCache.delete(key);
|
|
1085
|
+
if (access)
|
|
1086
|
+
for (const [k, v] of oauthCache)
|
|
1087
|
+
if (v.token === access)
|
|
1088
|
+
oauthCache.delete(k);
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
964
1091
|
/**
|
|
965
1092
|
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
966
1093
|
* async moment in secret resolution, kept out of the sync paths: callers
|
|
@@ -24,4 +24,8 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
|
24
24
|
sameOrigin?: boolean;
|
|
25
25
|
/** The most the answer may be, read; MAX_BYTES unless said. */
|
|
26
26
|
maxBytes?: number;
|
|
27
|
+
/** Follow no redirect at all: a 3xx comes back as the answer, for the
|
|
28
|
+
* caller to treat as the failure it is (a revocation that redirects to
|
|
29
|
+
* a login page did not revoke anything). */
|
|
30
|
+
noRedirects?: boolean;
|
|
27
31
|
}): Promise<Response>;
|
|
@@ -93,7 +93,7 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
|
93
93
|
for (let hop = 0;; hop++) {
|
|
94
94
|
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
|
|
95
95
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
96
|
-
if (!location)
|
|
96
|
+
if (!location || opts.noRedirects)
|
|
97
97
|
return capBody(res, maxBytes, current);
|
|
98
98
|
// The body of a redirect is never read; free the socket.
|
|
99
99
|
await res.body?.cancel().catch(() => { });
|
package/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export * from "./src/storage.ts";
|
|
|
43
43
|
export * from "./src/secrets.ts";
|
|
44
44
|
export * from "./src/oauth-presets.ts";
|
|
45
45
|
export * from "./src/oauth-token.ts";
|
|
46
|
+
export * from "./src/oauth-discovery.ts";
|
|
46
47
|
export * from "./src/secret-files.ts";
|
|
47
48
|
export * from "./src/notify.ts";
|
|
48
49
|
export * from "./src/webhook.ts";
|
package/package.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Discovery from an issuer: give foldrun the provider's issuer URL and it
|
|
2
|
+
// reads the authorization server's own metadata (RFC 8414, OpenID Connect
|
|
3
|
+
// Discovery 1.0) instead of a person typing the authorize, token and
|
|
4
|
+
// revocation URLs by hand — where a typo costs a consent, and an endpoint
|
|
5
|
+
// on the wrong host is a mix-up attack waiting to happen.
|
|
6
|
+
//
|
|
7
|
+
// Where the metadata lives, in the order tried (RFC 8414 §3.1 and §5):
|
|
8
|
+
// https://host/.well-known/oauth-authorization-server{/path}
|
|
9
|
+
// https://host/.well-known/openid-configuration{/path}
|
|
10
|
+
// https://host{/path}/.well-known/openid-configuration (OIDC's own spelling)
|
|
11
|
+
// The document's `issuer` must equal the issuer asked for, exactly (RFC 8414
|
|
12
|
+
// §3.3) — a document that names another issuer is refused, not trusted.
|
|
13
|
+
|
|
14
|
+
import type { TokenAuth } from "./oauth-token.ts";
|
|
15
|
+
|
|
16
|
+
/** What foldrun reads from a metadata document. */
|
|
17
|
+
export interface OAuthServerMetadata {
|
|
18
|
+
issuer: string;
|
|
19
|
+
authorization_endpoint?: string;
|
|
20
|
+
token_endpoint?: string;
|
|
21
|
+
revocation_endpoint?: string;
|
|
22
|
+
device_authorization_endpoint?: string;
|
|
23
|
+
pushed_authorization_request_endpoint?: string;
|
|
24
|
+
require_pushed_authorization_requests?: boolean;
|
|
25
|
+
token_endpoint_auth_methods_supported?: string[];
|
|
26
|
+
code_challenge_methods_supported?: string[];
|
|
27
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
28
|
+
dpop_signing_alg_values_supported?: string[];
|
|
29
|
+
scopes_supported?: string[];
|
|
30
|
+
jwks_uri?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A discovered server, as the fields a saved app and an oauth2 secret take. */
|
|
34
|
+
export interface DiscoveredClientConfig {
|
|
35
|
+
issuer: string;
|
|
36
|
+
authorize_url?: string;
|
|
37
|
+
token_url: string;
|
|
38
|
+
revocation_url?: string;
|
|
39
|
+
device_url?: string;
|
|
40
|
+
par_url?: string;
|
|
41
|
+
/** The server requires PAR: an authorize URL without a request_uri fails. */
|
|
42
|
+
par_required?: boolean;
|
|
43
|
+
/** Whether the server sends `iss` with every authorization response
|
|
44
|
+
* (RFC 9207): when true, one that comes back without it is refused. */
|
|
45
|
+
iss_parameter: boolean;
|
|
46
|
+
/** How the client should authenticate at the token URL, given whether it
|
|
47
|
+
* has a secret and what the server supports; absent is the form body. */
|
|
48
|
+
token_auth?: TokenAuth;
|
|
49
|
+
/** Where the metadata was read from. */
|
|
50
|
+
metadata_url: string;
|
|
51
|
+
metadata: OAuthServerMetadata;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Loopback is exempt: a server on this machine (tests, a local Keycloak)
|
|
55
|
+
* crosses no wire for http to leak on. Judged on the parsed URL, never on
|
|
56
|
+
* how it starts: `http://localhost:80@evil.example` is evil.example
|
|
57
|
+
* (Codex review, 1 Nov 2026). */
|
|
58
|
+
export function isSecureUrl(url: string): boolean {
|
|
59
|
+
let u: URL;
|
|
60
|
+
try {
|
|
61
|
+
u = new URL(url);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (u.username || u.password) return false;
|
|
66
|
+
if (u.protocol === "https:") return true;
|
|
67
|
+
return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every URL the metadata may be at, in the order to try. */
|
|
71
|
+
export function metadataUrls(issuer: string): string[] {
|
|
72
|
+
const u = new URL(issuer);
|
|
73
|
+
const pathPart = u.pathname.replace(/\/+$/, "");
|
|
74
|
+
const origin = u.origin;
|
|
75
|
+
const urls = [
|
|
76
|
+
`${origin}/.well-known/oauth-authorization-server${pathPart}`,
|
|
77
|
+
`${origin}/.well-known/openid-configuration${pathPart}`,
|
|
78
|
+
];
|
|
79
|
+
if (pathPart) urls.push(`${origin}${pathPart}/.well-known/openid-configuration`);
|
|
80
|
+
return [...new Set(urls)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Why an issuer cannot be discovered from, or null. */
|
|
84
|
+
export function issuerProblem(issuer: string): string | null {
|
|
85
|
+
let u: URL;
|
|
86
|
+
try {
|
|
87
|
+
u = new URL(issuer);
|
|
88
|
+
} catch {
|
|
89
|
+
return "issuer must be a URL, like https://login.example.com";
|
|
90
|
+
}
|
|
91
|
+
if (!isSecureUrl(issuer)) return "issuer must be https";
|
|
92
|
+
if (u.search || u.hash) return "issuer has no query or fragment (RFC 8414 §2)";
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read the authorization server's metadata. `fetcher` is how the caller
|
|
100
|
+
* reaches the network (the platform's SSRF-guarded fetchPublic, the CLI's
|
|
101
|
+
* fetch). Throws with every place tried when none answers.
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchOAuthMetadata(issuer: string, fetcher: Fetcher): Promise<{ url: string; metadata: OAuthServerMetadata }> {
|
|
104
|
+
const problem = issuerProblem(issuer);
|
|
105
|
+
if (problem) throw new Error(problem);
|
|
106
|
+
const tried: string[] = [];
|
|
107
|
+
for (const url of metadataUrls(issuer)) {
|
|
108
|
+
let res: Response;
|
|
109
|
+
try {
|
|
110
|
+
res = await fetcher(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
tried.push(`${url} (${err instanceof Error ? err.message : String(err)})`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
await res.body?.cancel().catch(() => {});
|
|
117
|
+
tried.push(`${url} (${res.status})`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let doc: unknown;
|
|
121
|
+
try {
|
|
122
|
+
doc = await res.json();
|
|
123
|
+
} catch {
|
|
124
|
+
tried.push(`${url} (not JSON)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
128
|
+
tried.push(`${url} (not a metadata document)`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const metadata = doc as OAuthServerMetadata;
|
|
132
|
+
// RFC 8414 §3.3: the issuer in the document is the one asked for, or
|
|
133
|
+
// the document is not used. Trailing-slash differences are not forgiven.
|
|
134
|
+
if (metadata.issuer !== issuer) {
|
|
135
|
+
throw new Error(`the metadata at ${url} names the issuer ${JSON.stringify(metadata.issuer ?? null)}, not ${issuer} — refused (RFC 8414 §3.3)`);
|
|
136
|
+
}
|
|
137
|
+
return { url, metadata };
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`no OAuth metadata for ${issuer} — tried ${tried.join(", ")}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ENDPOINTS = [
|
|
143
|
+
"authorization_endpoint", "token_endpoint", "revocation_endpoint",
|
|
144
|
+
"device_authorization_endpoint", "pushed_authorization_request_endpoint", "jwks_uri",
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a client config should say for this server. `hasSecret`: the app
|
|
149
|
+
* has a client secret; `pkce`: false when the caller turned PKCE off;
|
|
150
|
+
* `tokenAuth`: one the caller chose, which wins over the server's list.
|
|
151
|
+
*/
|
|
152
|
+
export function clientConfigFromMetadata(
|
|
153
|
+
found: { url: string; metadata: OAuthServerMetadata },
|
|
154
|
+
opts: { hasSecret: boolean; pkce?: boolean; tokenAuth?: TokenAuth; needsAuthorize?: boolean } = { hasSecret: true },
|
|
155
|
+
): DiscoveredClientConfig {
|
|
156
|
+
const m = found.metadata;
|
|
157
|
+
for (const k of ENDPOINTS) {
|
|
158
|
+
const v = m[k];
|
|
159
|
+
if (v !== undefined && (typeof v !== "string" || !isSecureUrl(v))) throw new Error(`the server's ${k} is not an https URL: ${String(v)}`);
|
|
160
|
+
}
|
|
161
|
+
if (!m.token_endpoint) throw new Error(`${m.issuer} publishes no token_endpoint`);
|
|
162
|
+
if ((opts.needsAuthorize ?? true) && !m.authorization_endpoint) throw new Error(`${m.issuer} publishes no authorization_endpoint — it does not do browser sign-in`);
|
|
163
|
+
// PKCE S256 is required of every connection (RFC 9700 §2.1.1). A server
|
|
164
|
+
// that lists its methods and leaves S256 out would ignore our challenge.
|
|
165
|
+
if (opts.pkce !== false && Array.isArray(m.code_challenge_methods_supported) && !m.code_challenge_methods_supported.includes("S256")) {
|
|
166
|
+
throw new Error(`${m.issuer} does not support PKCE S256 (it lists ${m.code_challenge_methods_supported.join(", ") || "none"}) — refused; turn PKCE off only if you are sure (--no-pkce)`);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
issuer: m.issuer,
|
|
170
|
+
...(m.authorization_endpoint ? { authorize_url: m.authorization_endpoint } : {}),
|
|
171
|
+
token_url: m.token_endpoint,
|
|
172
|
+
...(m.revocation_endpoint ? { revocation_url: m.revocation_endpoint } : {}),
|
|
173
|
+
...(m.device_authorization_endpoint ? { device_url: m.device_authorization_endpoint } : {}),
|
|
174
|
+
...(m.pushed_authorization_request_endpoint ? { par_url: m.pushed_authorization_request_endpoint } : {}),
|
|
175
|
+
...(m.require_pushed_authorization_requests === true ? { par_required: true } : {}),
|
|
176
|
+
iss_parameter: m.authorization_response_iss_parameter_supported === true,
|
|
177
|
+
...pickTokenAuth(m.token_endpoint_auth_methods_supported, opts),
|
|
178
|
+
metadata_url: found.url,
|
|
179
|
+
metadata: m,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The token_auth to use. A choice the caller made is kept. Otherwise, with a
|
|
185
|
+
* secret: the form body when the server lists it (what every secret did
|
|
186
|
+
* before), else Basic — which is also the RFC 8414 default when the server
|
|
187
|
+
* lists nothing. Without a secret: none (a public client).
|
|
188
|
+
*/
|
|
189
|
+
export function pickTokenAuth(supported: unknown, opts: { hasSecret: boolean; tokenAuth?: TokenAuth }): { token_auth?: TokenAuth } {
|
|
190
|
+
if (opts.tokenAuth) return { token_auth: opts.tokenAuth };
|
|
191
|
+
const list = Array.isArray(supported) ? supported.filter((s): s is string => typeof s === "string") : null;
|
|
192
|
+
// A public client sends no secret. Not refused when the list leaves out
|
|
193
|
+
// `none`: Keycloak takes public clients and lists only secret methods,
|
|
194
|
+
// so the list is not the last word on it (found by the conformance suite).
|
|
195
|
+
if (!opts.hasSecret) return {};
|
|
196
|
+
if (list && list.includes("client_secret_post")) return {};
|
|
197
|
+
if (!list || list.includes("client_secret_basic")) return { token_auth: "client_secret_basic" };
|
|
198
|
+
throw new Error(`the server takes none of the ways foldrun signs in at its token URL (it lists ${list.join(", ") || "nothing"}; foldrun sends the secret in the form body or as HTTP Basic)`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Discover and turn into a client config in one call. */
|
|
202
|
+
export async function discoverOAuth(
|
|
203
|
+
issuer: string,
|
|
204
|
+
fetcher: Fetcher,
|
|
205
|
+
opts: { hasSecret: boolean; pkce?: boolean; tokenAuth?: TokenAuth; needsAuthorize?: boolean },
|
|
206
|
+
): Promise<DiscoveredClientConfig> {
|
|
207
|
+
return clientConfigFromMetadata(await fetchOAuthMetadata(issuer, fetcher), opts);
|
|
208
|
+
}
|
package/src/oauth-token.ts
CHANGED
|
@@ -56,8 +56,11 @@ const formEncode = (s: string) => new URLSearchParams({ x: s }).toString().slice
|
|
|
56
56
|
* fields (grant_type, code, refresh_token, …); the client's identity is
|
|
57
57
|
* added here, by its token_auth. Fields in `params` named client_id or
|
|
58
58
|
* client_secret are dropped — the method decides those, never an `extra`.
|
|
59
|
+
* `clientIdInBody`: client_id goes in the form whatever the method — a
|
|
60
|
+
* pushed authorization request carries it as a parameter of the request it
|
|
61
|
+
* describes even when the client authenticates with Basic (RFC 9126 §2.1).
|
|
59
62
|
*/
|
|
60
|
-
export function oauthTokenRequest(client: TokenClient, params: Record<string, string
|
|
63
|
+
export function oauthTokenRequest(client: TokenClient, params: Record<string, string>, opts: { clientIdInBody?: boolean } = {}): { headers: Record<string, string>; body: string } {
|
|
61
64
|
const problem = tokenAuthProblem(client);
|
|
62
65
|
if (problem) throw new Error(problem);
|
|
63
66
|
const form = new URLSearchParams();
|
|
@@ -76,6 +79,7 @@ export function oauthTokenRequest(client: TokenClient, params: Record<string, st
|
|
|
76
79
|
// not the body as well.
|
|
77
80
|
const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
|
|
78
81
|
headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
|
|
82
|
+
if (opts.clientIdInBody) form.set("client_id", client.client_id);
|
|
79
83
|
} else {
|
|
80
84
|
form.set("client_id", client.client_id);
|
|
81
85
|
if (auth === "client_secret_post") form.set("client_secret", client.client_secret!);
|
package/src/secrets.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { dataRoot } from "./paths.ts";
|
|
|
25
25
|
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
|
+
import { isSecureUrl } from "./oauth-discovery.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
@@ -941,6 +942,9 @@ export interface OAuth2Config {
|
|
|
941
942
|
/** Plain fields the provider returned beside the first token (Salesforce's
|
|
942
943
|
* instance_url) — kept with the recipe, never a credential. */
|
|
943
944
|
fields?: Record<string, string>;
|
|
945
|
+
/** The provider's revocation endpoint (RFC 7009): deleting or replacing
|
|
946
|
+
* this secret revokes its tokens there (revokeOAuth2). */
|
|
947
|
+
revocation_url?: string;
|
|
944
948
|
}
|
|
945
949
|
|
|
946
950
|
const OAUTH2_PREFIX = "@oauth2 ";
|
|
@@ -972,11 +976,14 @@ export function setOAuth2Secret(
|
|
|
972
976
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
973
977
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
974
978
|
}
|
|
979
|
+
if (config.revocation_url !== undefined && (typeof config.revocation_url !== "string" || !isSecureUrl(config.revocation_url))) {
|
|
980
|
+
throw new Error("revocation_url must be https");
|
|
981
|
+
}
|
|
975
982
|
const authProblem = tokenAuthProblem(config);
|
|
976
983
|
if (authProblem) throw new Error(`oauth2 secret: ${authProblem}`);
|
|
977
984
|
// Loopback is exempt: a token endpoint on this same machine (tests, a
|
|
978
985
|
// local mock, a sidecar) crosses no wire for http to leak on.
|
|
979
|
-
if (
|
|
986
|
+
if (!isSecureUrl(config.token_url)) {
|
|
980
987
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
981
988
|
}
|
|
982
989
|
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
@@ -1117,6 +1124,135 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
|
|
|
1117
1124
|
}
|
|
1118
1125
|
}
|
|
1119
1126
|
|
|
1127
|
+
// ---------------------------------------------------------------- revocation
|
|
1128
|
+
|
|
1129
|
+
/** The oauth2 recipe stored at exactly this scope — never the account copy a
|
|
1130
|
+
* workspace would fall back to: revoking is about the copy being removed. */
|
|
1131
|
+
export function oauth2ValueAt(tenant: string, name: string, workspace?: string): string | null {
|
|
1132
|
+
const v = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
1133
|
+
return v !== null && isOAuth2Value(v) ? v : null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** The workspaces with a vault file of their own (current and legacy layout). */
|
|
1137
|
+
function workspacesWithSecrets(tenant: string): string[] {
|
|
1138
|
+
const out = new Set<string>();
|
|
1139
|
+
for (const dir of ["workspaces", "projects"]) {
|
|
1140
|
+
const base = path.join(dataRoot(), tenant, dir);
|
|
1141
|
+
let names: string[] = [];
|
|
1142
|
+
try {
|
|
1143
|
+
names = fs.readdirSync(base);
|
|
1144
|
+
} catch {
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
for (const n of names) if (fs.existsSync(path.join(base, n, "secrets.json"))) out.add(n);
|
|
1148
|
+
}
|
|
1149
|
+
return [...out];
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Another stored secret of this account holds a grant revoking this one could
|
|
1154
|
+
* end: the same refresh token (a copy made by hand, or one rotation reached),
|
|
1155
|
+
* or any grant from the same app (token URL and client id). Providers such as
|
|
1156
|
+
* Google revoke every token of a user's grant to an app together, so deleting
|
|
1157
|
+
* the Ads connection would take down the Search Console one made with the
|
|
1158
|
+
* same app (Codex review, 1 Nov 2026). Such a grant is left alone.
|
|
1159
|
+
*/
|
|
1160
|
+
export function oauthGrantSharedElsewhere(tenant: string, name: string, workspace: string | undefined, refreshToken: string, app?: { token_url: string; client_id: string }): boolean {
|
|
1161
|
+
for (const scope of [undefined, ...workspacesWithSecrets(tenant)]) {
|
|
1162
|
+
for (const [other, rec] of Object.entries(read(tenant, scope))) {
|
|
1163
|
+
if (other === name && scope === workspace) continue;
|
|
1164
|
+
// By the value, not the optional kind tag: a recipe copied with a
|
|
1165
|
+
// plain setSecret is still one materializeSecrets refreshes.
|
|
1166
|
+
const v = openRecord(tenant, scope, other, rec);
|
|
1167
|
+
if (!v || !isOAuth2Value(v)) continue;
|
|
1168
|
+
try {
|
|
1169
|
+
const other = JSON.parse(v.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
|
|
1170
|
+
if (other.refresh_token === refreshToken) return true;
|
|
1171
|
+
if (app && other.grant_type !== "client_credentials" && other.token_url === app.token_url && other.client_id === app.client_id) return true;
|
|
1172
|
+
} catch {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** What revoking an oauth2 secret's tokens came to. */
|
|
1181
|
+
export interface OAuthRevokeResult {
|
|
1182
|
+
/** Nothing was asked: no revocation endpoint; another secret holds the
|
|
1183
|
+
* same grant or one from the same app; the replacement is from the same
|
|
1184
|
+
* app (a reconnect: revoking would end the new grant too at providers
|
|
1185
|
+
* that revoke per app); or the stored recipe could not be read. */
|
|
1186
|
+
skipped?: "no-revocation-url" | "shared-grant" | "same-app" | "unreadable";
|
|
1187
|
+
/** The token types the provider accepted the revocation of. */
|
|
1188
|
+
revoked: ("refresh_token" | "access_token")[];
|
|
1189
|
+
/** Why one failed — reported, never a reason to keep a secret. */
|
|
1190
|
+
error?: string;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
type RevokeFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Revoke what an oauth2 recipe holds at its provider (RFC 7009): the refresh
|
|
1197
|
+
* token, then the access token this process has cached for it. The client
|
|
1198
|
+
* authenticates as at the token URL (its token_auth). A 200 is success —
|
|
1199
|
+
* the RFC answers 200 for a token already invalid too; anything else is
|
|
1200
|
+
* reported in `error`, and the next token is still tried. `replacement`: the
|
|
1201
|
+
* recipe taking this one's place; one from the same app (token URL and client
|
|
1202
|
+
* id) means nothing is revoked, since a provider that revokes per app would
|
|
1203
|
+
* end the new grant with the old.
|
|
1204
|
+
*/
|
|
1205
|
+
export async function revokeOAuth2Value(
|
|
1206
|
+
value: string,
|
|
1207
|
+
opts: { tenant?: string; name?: string; workspace?: string; fetcher?: RevokeFetcher; replacement?: { token_url: string; client_id: string } } = {},
|
|
1208
|
+
): Promise<OAuthRevokeResult> {
|
|
1209
|
+
let config: OAuth2Config;
|
|
1210
|
+
try {
|
|
1211
|
+
config = JSON.parse(value.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
|
|
1212
|
+
} catch {
|
|
1213
|
+
return { skipped: "unreadable", revoked: [] };
|
|
1214
|
+
}
|
|
1215
|
+
if (!config.revocation_url) return { skipped: "no-revocation-url", revoked: [] };
|
|
1216
|
+
if (opts.replacement && opts.replacement.token_url === config.token_url && opts.replacement.client_id === config.client_id) {
|
|
1217
|
+
return { skipped: "same-app", revoked: [] };
|
|
1218
|
+
}
|
|
1219
|
+
if (config.grant_type !== "client_credentials" && opts.tenant && opts.name
|
|
1220
|
+
&& oauthGrantSharedElsewhere(opts.tenant, opts.name, opts.workspace, config.refresh_token ?? "", { token_url: config.token_url, client_id: config.client_id })) {
|
|
1221
|
+
return { skipped: "shared-grant", revoked: [] };
|
|
1222
|
+
}
|
|
1223
|
+
// No redirect is followed: a 3xx is not ok, so a revocation endpoint that
|
|
1224
|
+
// sends us to a login page is reported, never counted as revoked.
|
|
1225
|
+
const fetcher: RevokeFetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
|
|
1226
|
+
const key = tokenCacheKey("oauth2", value, opts.tenant);
|
|
1227
|
+
const access = oauthCache.get(key)?.token;
|
|
1228
|
+
const tokens: ["refresh_token" | "access_token", string][] = [];
|
|
1229
|
+
if (config.grant_type !== "client_credentials" && config.refresh_token) tokens.push(["refresh_token", config.refresh_token]);
|
|
1230
|
+
if (access) tokens.push(["access_token", access]);
|
|
1231
|
+
const out: OAuthRevokeResult = { revoked: [] };
|
|
1232
|
+
for (const [hint, token] of tokens) {
|
|
1233
|
+
try {
|
|
1234
|
+
const request = oauthTokenRequest(config, { token, token_type_hint: hint });
|
|
1235
|
+
const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
1236
|
+
if (res.ok) {
|
|
1237
|
+
await res.body?.cancel().catch(() => {});
|
|
1238
|
+
out.revoked.push(hint);
|
|
1239
|
+
} else {
|
|
1240
|
+
// Reported, and the next token is still tried: a provider that will
|
|
1241
|
+
// not revoke refresh tokens may still revoke the access token.
|
|
1242
|
+
const why = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
|
|
1243
|
+
out.error ??= `the provider refused to revoke the ${hint.replace("_", " ")} (${res.status}${why.error ? `: ${why.error_description ?? why.error}` : ""})`;
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
out.error ??= `revoking the ${hint.replace("_", " ")} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// Every entry serving that access token goes: after a rotation the same
|
|
1250
|
+
// token is cached under the recipe before and after it (Codex review).
|
|
1251
|
+
oauthCache.delete(key);
|
|
1252
|
+
if (access) for (const [k, v] of oauthCache) if (v.token === access) oauthCache.delete(k);
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1120
1256
|
/**
|
|
1121
1257
|
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
1122
1258
|
* async moment in secret resolution, kept out of the sync paths: callers
|
package/src/untrusted-fetch.ts
CHANGED
|
@@ -104,6 +104,10 @@ export async function fetchUntrusted(
|
|
|
104
104
|
sameOrigin?: boolean;
|
|
105
105
|
/** The most the answer may be, read; MAX_BYTES unless said. */
|
|
106
106
|
maxBytes?: number;
|
|
107
|
+
/** Follow no redirect at all: a 3xx comes back as the answer, for the
|
|
108
|
+
* caller to treat as the failure it is (a revocation that redirects to
|
|
109
|
+
* a login page did not revoke anything). */
|
|
110
|
+
noRedirects?: boolean;
|
|
107
111
|
} = {},
|
|
108
112
|
): Promise<Response> {
|
|
109
113
|
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
@@ -112,7 +116,7 @@ export async function fetchUntrusted(
|
|
|
112
116
|
for (let hop = 0; ; hop++) {
|
|
113
117
|
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
|
|
114
118
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
115
|
-
if (!location) return capBody(res, maxBytes, current);
|
|
119
|
+
if (!location || opts.noRedirects) return capBody(res, maxBytes, current);
|
|
116
120
|
// The body of a redirect is never read; free the socket.
|
|
117
121
|
await res.body?.cancel().catch(() => {});
|
|
118
122
|
if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|