@foldrun/core 9.2.1 → 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 CHANGED
@@ -30,6 +30,8 @@ export * from "./src/package.ts";
30
30
  export * from "./src/storage.ts";
31
31
  export * from "./src/secrets.ts";
32
32
  export * from "./src/oauth-presets.ts";
33
+ export * from "./src/oauth-token.ts";
34
+ export * from "./src/oauth-discovery.ts";
33
35
  export * from "./src/secret-files.ts";
34
36
  export * from "./src/notify.ts";
35
37
  export * from "./src/webhook.ts";
package/dist/index.js CHANGED
@@ -41,6 +41,8 @@ export * from "./src/package.js";
41
41
  export * from "./src/storage.js";
42
42
  export * from "./src/secrets.js";
43
43
  export * from "./src/oauth-presets.js";
44
+ export * from "./src/oauth-token.js";
45
+ export * from "./src/oauth-discovery.js";
44
46
  export * from "./src/secret-files.js";
45
47
  export * from "./src/notify.js";
46
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
+ }
@@ -0,0 +1,51 @@
1
+ export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none"];
2
+ export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
3
+ /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
4
+ export declare function parseTokenAuth(value: string): TokenAuth | null;
5
+ export interface TokenClient {
6
+ client_id: string;
7
+ client_secret?: string;
8
+ token_auth?: TokenAuth;
9
+ }
10
+ /** Why a client's token_auth cannot work, or null. */
11
+ export declare function tokenAuthProblem(client: {
12
+ client_secret?: string;
13
+ token_auth?: unknown;
14
+ }): string | null;
15
+ /**
16
+ * The headers and body of one token request. `params` is the grant's own
17
+ * fields (grant_type, code, refresh_token, …); the client's identity is
18
+ * added here, by its token_auth. Fields in `params` named client_id or
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).
23
+ */
24
+ export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string>, opts?: {
25
+ clientIdInBody?: boolean;
26
+ }): {
27
+ headers: Record<string, string>;
28
+ body: string;
29
+ };
30
+ /** A token endpoint's answer, read the ways providers actually write it. */
31
+ export interface TokenResponse {
32
+ access_token?: string;
33
+ refresh_token?: string;
34
+ /** Seconds; a string "3600" (some providers) is read as the number. */
35
+ expires_in?: number;
36
+ refresh_token_expires_in?: number;
37
+ token_type?: string;
38
+ scope?: string;
39
+ error?: string;
40
+ error_description?: string;
41
+ /** The other plain fields beside the token — Salesforce's instance_url,
42
+ * Slack's team — kept for the caller; never a token. */
43
+ fields: Record<string, string>;
44
+ }
45
+ /**
46
+ * Read a token endpoint's body: JSON, or form-encoded (GitHub without an
47
+ * Accept header, some older servers), whatever the content type claims.
48
+ * Slack's user-token install nests the token under `authed_user` and says
49
+ * `ok: false` with a 200 when it refuses; both are read here.
50
+ */
51
+ export declare function readTokenResponse(text: string, contentType?: string): TokenResponse;
@@ -0,0 +1,169 @@
1
+ // Every request to an OAuth token endpoint, and every answer read from one,
2
+ // goes through here: core's refresh (refresh_token, client_credentials), the
3
+ // platform's code exchange after a consent, and the CLI's own loopback
4
+ // connect. Three hand-written bodies had already drifted — none could send
5
+ // the client secret any way but in the form body, which is the one way RFC
6
+ // 6749 §2.3.1 lets a server refuse (Notion, Spotify, Reddit, Zoom do).
7
+ //
8
+ // How the client proves itself at the token endpoint (`token_auth`):
9
+ // client_secret_post id and secret in the form body (the default — what
10
+ // every stored secret did before 1 Nov 2026)
11
+ // client_secret_basic HTTP Basic, the id and secret each form-urlencoded
12
+ // first (RFC 6749 §2.3.1, Appendix B) — not plain
13
+ // base64 of the raw pair, which breaks on a secret
14
+ // holding ':' or '%'
15
+ // none a public client: client_id in the body, no secret
16
+ //
17
+ // Absent means the old behaviour: the body, with the secret if there is one.
18
+ export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none"];
19
+ /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
20
+ export function parseTokenAuth(value) {
21
+ const v = value.trim().toLowerCase();
22
+ if (TOKEN_AUTH_METHODS.includes(v))
23
+ return v;
24
+ if (v === "post" || v === "body")
25
+ return "client_secret_post";
26
+ if (v === "basic")
27
+ return "client_secret_basic";
28
+ return null;
29
+ }
30
+ /** Why a client's token_auth cannot work, or null. */
31
+ export function tokenAuthProblem(client) {
32
+ const auth = client.token_auth;
33
+ if (auth === undefined)
34
+ return null;
35
+ if (typeof auth !== "string" || !TOKEN_AUTH_METHODS.includes(auth)) {
36
+ return `token_auth must be one of ${TOKEN_AUTH_METHODS.join(", ")}`;
37
+ }
38
+ const hasSecret = !!client.client_secret?.trim();
39
+ if (auth === "client_secret_basic" && !hasSecret)
40
+ return "token_auth client_secret_basic needs a client_secret";
41
+ if (auth === "client_secret_post" && !hasSecret)
42
+ return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
43
+ if (auth === "none" && hasSecret)
44
+ 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";
45
+ return null;
46
+ }
47
+ /** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
48
+ const formEncode = (s) => new URLSearchParams({ x: s }).toString().slice(2);
49
+ /**
50
+ * The headers and body of one token request. `params` is the grant's own
51
+ * fields (grant_type, code, refresh_token, …); the client's identity is
52
+ * added here, by its token_auth. Fields in `params` named client_id or
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).
57
+ */
58
+ export function oauthTokenRequest(client, params, opts = {}) {
59
+ const problem = tokenAuthProblem(client);
60
+ if (problem)
61
+ throw new Error(problem);
62
+ const form = new URLSearchParams();
63
+ for (const [k, v] of Object.entries(params)) {
64
+ if (k === "client_id" || k === "client_secret")
65
+ continue;
66
+ form.set(k, v);
67
+ }
68
+ const headers = {
69
+ "content-type": "application/x-www-form-urlencoded",
70
+ // GitHub answers form-encoded unless asked; every server may answer JSON.
71
+ accept: "application/json",
72
+ };
73
+ const auth = client.token_auth ?? (client.client_secret ? "client_secret_post" : "none");
74
+ if (auth === "client_secret_basic") {
75
+ // One method per request (RFC 6749 §2.3): the id travels in the header,
76
+ // not the body as well.
77
+ const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret)}`;
78
+ headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
79
+ if (opts.clientIdInBody)
80
+ form.set("client_id", client.client_id);
81
+ }
82
+ else {
83
+ form.set("client_id", client.client_id);
84
+ if (auth === "client_secret_post")
85
+ form.set("client_secret", client.client_secret);
86
+ }
87
+ return { headers, body: form.toString() };
88
+ }
89
+ /** Fields that are credentials or are read into their own slot above. */
90
+ const NOT_FIELDS = new Set([
91
+ "access_token", "refresh_token", "id_token", "expires_in", "refresh_token_expires_in",
92
+ "error", "error_description", "error_uri", "authed_user", "ok",
93
+ ]);
94
+ function seconds(v) {
95
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
96
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
97
+ }
98
+ function str(v) {
99
+ return typeof v === "string" && v !== "" ? v : undefined;
100
+ }
101
+ /**
102
+ * Read a token endpoint's body: JSON, or form-encoded (GitHub without an
103
+ * Accept header, some older servers), whatever the content type claims.
104
+ * Slack's user-token install nests the token under `authed_user` and says
105
+ * `ok: false` with a 200 when it refuses; both are read here.
106
+ */
107
+ export function readTokenResponse(text, contentType = "") {
108
+ let raw = {};
109
+ const trimmed = text.trim();
110
+ if (trimmed.startsWith("{")) {
111
+ try {
112
+ const parsed = JSON.parse(trimmed);
113
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
114
+ raw = parsed;
115
+ }
116
+ catch {
117
+ raw = {};
118
+ }
119
+ }
120
+ else if (trimmed && (/x-www-form-urlencoded|text\/plain/i.test(contentType) || /^[\w.-]+=/.test(trimmed))) {
121
+ raw = Object.fromEntries(new URLSearchParams(trimmed));
122
+ }
123
+ // Slack: a user token alone comes back under authed_user, with its own
124
+ // refresh token and expiry when rotation is on. The top-level token (a bot
125
+ // token) wins when there is one.
126
+ let source = raw;
127
+ const nested = raw.authed_user;
128
+ if (!str(raw.access_token) && nested && typeof nested === "object" && str(nested.access_token)) {
129
+ source = nested;
130
+ }
131
+ const fields = {};
132
+ for (const [k, v] of Object.entries(raw)) {
133
+ if (NOT_FIELDS.has(k))
134
+ continue;
135
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
136
+ fields[k] = String(v);
137
+ }
138
+ // From the nested object: its scope and type describe the token we took.
139
+ const scope = str(source.scope) ?? str(raw.scope);
140
+ const tokenType = str(source.token_type) ?? str(raw.token_type);
141
+ const out = { fields };
142
+ const access = str(source.access_token);
143
+ const refresh = str(source.refresh_token);
144
+ const expires = seconds(source.expires_in);
145
+ const refreshExpires = seconds(source.refresh_token_expires_in);
146
+ if (access)
147
+ out.access_token = access;
148
+ if (refresh)
149
+ out.refresh_token = refresh;
150
+ if (expires !== undefined)
151
+ out.expires_in = expires;
152
+ if (refreshExpires !== undefined)
153
+ out.refresh_token_expires_in = refreshExpires;
154
+ if (tokenType)
155
+ out.token_type = tokenType;
156
+ if (scope)
157
+ out.scope = scope;
158
+ const error = str(raw.error) ?? (raw.ok === false ? "refused" : undefined);
159
+ if (error) {
160
+ out.error = error;
161
+ // A refusal carries no token, whatever else the body held.
162
+ delete out.access_token;
163
+ delete out.refresh_token;
164
+ }
165
+ const description = str(raw.error_description);
166
+ if (description)
167
+ out.error_description = description;
168
+ return out;
169
+ }
@@ -1,3 +1,4 @@
1
+ import { type TokenAuth } from "./oauth-token.ts";
1
2
  /** Which store a secret came from. */
2
3
  export type SecretScope = "account" | "workspace";
3
4
  /**
@@ -238,6 +239,15 @@ export interface OAuth2Config {
238
239
  grant_type?: "refresh_token" | "client_credentials";
239
240
  /** Extra form fields some providers want (audience, scope, …). */
240
241
  extra?: Record<string, string>;
242
+ /** How the client authenticates at the token endpoint (oauth-token.ts):
243
+ * absent is the form body, with the secret when there is one. */
244
+ token_auth?: TokenAuth;
245
+ /** Plain fields the provider returned beside the first token (Salesforce's
246
+ * instance_url) — kept with the recipe, never a credential. */
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;
241
251
  }
242
252
  /** Tries at a token endpoint before a refresh is called failed: the first,
243
253
  * and two more after a short wait, for a network error, a 5xx or a 429. */
@@ -264,6 +274,54 @@ export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string
264
274
  * older token over a newer one. Returns whether a copy was replaced.
265
275
  */
266
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>;
267
325
  /**
268
326
  * Swap every oauth2 recipe in an env map for a live access token. The one
269
327
  * async moment in secret resolution, kept out of the sync paths: callers