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