@oh-my-pi/pi-ai 16.3.14 → 16.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/CHANGELOG.md +45 -0
- package/README.md +5 -2
- package/dist/types/auth-broker/remote-store.d.ts +1 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +8 -0
- package/dist/types/auth-storage.d.ts +5 -0
- package/dist/types/providers/azure-openai-responses.d.ts +1 -1
- package/dist/types/providers/ollama.d.ts +1 -1
- package/dist/types/providers/openai-chat-server-schema.d.ts +1 -1
- package/dist/types/providers/openai-chat-wire.d.ts +1 -1
- package/dist/types/providers/openai-codex/request-transformer.d.ts +43 -5
- package/dist/types/providers/openai-codex-responses.d.ts +51 -8
- package/dist/types/providers/openai-completions.d.ts +1 -1
- package/dist/types/providers/openai-responses-wire.d.ts +16 -7
- package/dist/types/providers/openai-responses.d.ts +1 -1
- package/dist/types/providers/openai-shared.d.ts +38 -8
- package/dist/types/registry/novita.d.ts +6 -0
- package/dist/types/registry/oauth/device-code.d.ts +25 -0
- package/dist/types/registry/oauth/index.d.ts +1 -25
- package/dist/types/registry/oauth/xai-oauth.d.ts +9 -25
- package/dist/types/registry/registry.d.ts +4 -1
- package/dist/types/registry/xai-oauth.d.ts +0 -1
- package/dist/types/types.d.ts +26 -3
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +60 -5
- package/src/auth-broker/server.ts +1 -0
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/server.ts +2 -2
- package/src/auth-storage.ts +198 -60
- package/src/error/flags.ts +4 -1
- package/src/error/rate-limit.ts +7 -1
- package/src/providers/amazon-bedrock.ts +1 -0
- package/src/providers/anthropic-messages-server.ts +18 -0
- package/src/providers/azure-openai-responses.ts +3 -3
- package/src/providers/ollama.ts +1 -1
- package/src/providers/openai-chat-server-schema.ts +1 -1
- package/src/providers/openai-chat-server.ts +8 -1
- package/src/providers/openai-chat-wire.ts +1 -1
- package/src/providers/openai-codex/request-transformer.ts +94 -41
- package/src/providers/openai-codex-responses.ts +475 -39
- package/src/providers/openai-completions.ts +38 -12
- package/src/providers/openai-responses-server.ts +8 -1
- package/src/providers/openai-responses-wire.ts +16 -7
- package/src/providers/openai-responses.ts +18 -5
- package/src/providers/openai-shared.ts +125 -13
- package/src/registry/novita.ts +22 -0
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +191 -80
- package/src/registry/oauth/device-code.ts +92 -0
- package/src/registry/oauth/index.ts +1 -91
- package/src/registry/oauth/xai-oauth.ts +231 -191
- package/src/registry/registry.ts +2 -0
- package/src/registry/xai-oauth.ts +0 -1
- package/src/stream.ts +7 -1
- package/src/types.ts +29 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
-
import { isXAIAccessTokenExpiring, refreshXAIOAuthToken, validateXAIEndpoint
|
|
2
|
+
import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
|
|
3
3
|
|
|
4
4
|
afterEach(() => {
|
|
5
5
|
vi.restoreAllMocks();
|
|
@@ -11,6 +11,76 @@ function jwtWithExp(exp: number): string {
|
|
|
11
11
|
return `${header}.${payload}.sig`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
|
15
|
+
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
|
|
16
|
+
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
|
|
17
|
+
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
18
|
+
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
19
|
+
|
|
20
|
+
const DEVICE_AUTHORIZATION = {
|
|
21
|
+
device_code: "device-code-123",
|
|
22
|
+
user_code: "ABCD-EFGH",
|
|
23
|
+
verification_uri: "https://auth.x.ai/activate",
|
|
24
|
+
verification_uri_complete: "https://auth.x.ai/activate?user_code=ABCD-EFGH",
|
|
25
|
+
expires_in: 600,
|
|
26
|
+
interval: 1,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type RecordedRequest = {
|
|
30
|
+
url: string;
|
|
31
|
+
init: RequestInit | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TokenResponse = {
|
|
35
|
+
body: unknown;
|
|
36
|
+
status?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function jsonResponse(body: unknown, status: number = 200): Response {
|
|
40
|
+
return new Response(JSON.stringify(body), {
|
|
41
|
+
status,
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
|
|
47
|
+
const requests: RecordedRequest[] = [];
|
|
48
|
+
let tokenResponseIndex = 0;
|
|
49
|
+
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
50
|
+
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
|
51
|
+
requests.push({ url, init });
|
|
52
|
+
|
|
53
|
+
if (url === DISCOVERY_URL) {
|
|
54
|
+
return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
|
|
55
|
+
}
|
|
56
|
+
if (url === DEVICE_CODE_URL) {
|
|
57
|
+
return jsonResponse(DEVICE_AUTHORIZATION);
|
|
58
|
+
}
|
|
59
|
+
if (url === TOKEN_ENDPOINT) {
|
|
60
|
+
const tokenResponse = tokenResponses[tokenResponseIndex];
|
|
61
|
+
tokenResponseIndex += 1;
|
|
62
|
+
if (!tokenResponse) {
|
|
63
|
+
throw new Error(`Unexpected xAI token poll ${tokenResponseIndex}`);
|
|
64
|
+
}
|
|
65
|
+
return jsonResponse(tokenResponse.body, tokenResponse.status);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Unexpected xAI OAuth request: ${url}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
fetchMock: fetchMock as unknown as typeof fetch,
|
|
72
|
+
requests,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requestForm(request: RecordedRequest | undefined): URLSearchParams {
|
|
77
|
+
const body = request?.init?.body;
|
|
78
|
+
if (!(body instanceof URLSearchParams)) {
|
|
79
|
+
throw new Error("Expected an application/x-www-form-urlencoded request body");
|
|
80
|
+
}
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
|
|
14
84
|
describe("isXAIAccessTokenExpiring", () => {
|
|
15
85
|
it("returns false for an empty string", () => {
|
|
16
86
|
expect(isXAIAccessTokenExpiring("")).toBe(false);
|
|
@@ -63,97 +133,138 @@ describe("refreshXAIOAuthToken", () => {
|
|
|
63
133
|
});
|
|
64
134
|
});
|
|
65
135
|
|
|
66
|
-
describe("
|
|
67
|
-
it("
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
it("uses pasted-code login without starting a callback server", async () => {
|
|
74
|
-
const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
|
|
75
|
-
throw new Error("callback server should not start");
|
|
76
|
-
});
|
|
77
|
-
let authUrl = "";
|
|
78
|
-
let tokenRequestBody = "";
|
|
79
|
-
const progress: string[] = [];
|
|
80
|
-
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
81
|
-
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
|
82
|
-
if (url.includes("/.well-known/openid-configuration")) {
|
|
83
|
-
return new Response(
|
|
84
|
-
JSON.stringify({
|
|
85
|
-
authorization_endpoint: "https://auth.x.ai/oauth/authorize",
|
|
86
|
-
token_endpoint: "https://auth.x.ai/oauth/token",
|
|
87
|
-
}),
|
|
88
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
|
|
92
|
-
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
136
|
+
describe("loginXAIOAuth", () => {
|
|
137
|
+
it("performs the RFC 8628 device flow and returns the issued credentials", async () => {
|
|
138
|
+
const now = 1_800_000_000_000;
|
|
139
|
+
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
140
|
+
const { fetchMock, requests } = createDeviceFlowFetch([
|
|
141
|
+
{
|
|
142
|
+
body: {
|
|
94
143
|
access_token: "access-token",
|
|
95
144
|
refresh_token: "refresh-token",
|
|
96
145
|
expires_in: 3600,
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
const authEvents: Array<{ url: string; instructions?: string }> = [];
|
|
150
|
+
const progress: string[] = [];
|
|
151
|
+
const onAuth = vi.fn((info: { url: string; instructions?: string }) => {
|
|
152
|
+
authEvents.push(info);
|
|
153
|
+
});
|
|
154
|
+
const onProgress = vi.fn((message: string) => {
|
|
155
|
+
progress.push(message);
|
|
156
|
+
});
|
|
157
|
+
const onManualCodeInput = vi.fn(async () => {
|
|
158
|
+
throw new Error("device authorization must not request a pasted code");
|
|
100
159
|
});
|
|
101
160
|
|
|
102
|
-
const
|
|
103
|
-
fetch: fetchMock
|
|
104
|
-
onAuth
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
onManualCodeInput: async () => {
|
|
108
|
-
const parsed = new URL(authUrl);
|
|
109
|
-
const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
|
|
110
|
-
const state = parsed.searchParams.get("state") ?? "";
|
|
111
|
-
return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
|
|
112
|
-
},
|
|
113
|
-
onProgress: message => progress.push(message),
|
|
161
|
+
const credentials = await loginXAIOAuth({
|
|
162
|
+
fetch: fetchMock,
|
|
163
|
+
onAuth,
|
|
164
|
+
onProgress,
|
|
165
|
+
onManualCodeInput,
|
|
114
166
|
});
|
|
115
167
|
|
|
116
|
-
|
|
117
|
-
const authorizeUrl = new URL(authUrl);
|
|
118
|
-
const tokenParams = new URLSearchParams(tokenRequestBody);
|
|
168
|
+
expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
|
|
119
169
|
|
|
120
|
-
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
123
|
-
expect(tokenParams.get("code")).toBe("code-xyz");
|
|
124
|
-
expect(credentials.access).toBe("access-token");
|
|
125
|
-
expect(credentials.refresh).toBe("refresh-token");
|
|
126
|
-
});
|
|
127
|
-
});
|
|
170
|
+
const discoveryRequest = requests[0];
|
|
171
|
+
expect(discoveryRequest?.init?.method).toBe("GET");
|
|
172
|
+
expect(new Headers(discoveryRequest?.init?.headers).get("Accept")).toBe("application/json");
|
|
128
173
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
// Token-exchange response deliberately omits `access_token` to exercise
|
|
143
|
-
// the missing-token rejection path. The value of `refresh_token` here is
|
|
144
|
-
// a literal test marker, not a real secret — the test verifies
|
|
145
|
-
// exchangeToken throws before any token would be persisted.
|
|
146
|
-
return new Response(JSON.stringify({ refresh_token: "stub-refresh-token-for-test-only" }), {
|
|
147
|
-
status: 200,
|
|
148
|
-
headers: { "Content-Type": "application/json" },
|
|
149
|
-
});
|
|
174
|
+
const deviceRequest = requests[1];
|
|
175
|
+
expect(deviceRequest?.init?.method).toBe("POST");
|
|
176
|
+
const deviceHeaders = new Headers(deviceRequest?.init?.headers);
|
|
177
|
+
expect(deviceHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
|
178
|
+
expect(deviceHeaders.get("Accept")).toBe("application/json");
|
|
179
|
+
const deviceForm = requestForm(deviceRequest);
|
|
180
|
+
expect([...deviceForm.keys()].sort()).toEqual(["client_id", "scope"]);
|
|
181
|
+
expect(Object.fromEntries(deviceForm)).toEqual({
|
|
182
|
+
client_id: CLIENT_ID,
|
|
183
|
+
scope: SCOPE,
|
|
150
184
|
});
|
|
151
185
|
|
|
152
|
-
const
|
|
153
|
-
|
|
186
|
+
const tokenRequest = requests[2];
|
|
187
|
+
expect(tokenRequest?.init?.method).toBe("POST");
|
|
188
|
+
const tokenHeaders = new Headers(tokenRequest?.init?.headers);
|
|
189
|
+
expect(tokenHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
|
190
|
+
expect(tokenHeaders.get("Accept")).toBe("application/json");
|
|
191
|
+
const tokenForm = requestForm(tokenRequest);
|
|
192
|
+
expect([...tokenForm.keys()].sort()).toEqual(["client_id", "device_code", "grant_type"]);
|
|
193
|
+
expect(Object.fromEntries(tokenForm)).toEqual({
|
|
194
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
195
|
+
client_id: CLIENT_ID,
|
|
196
|
+
device_code: DEVICE_AUTHORIZATION.device_code,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(authEvents).toEqual([
|
|
200
|
+
{
|
|
201
|
+
url: DEVICE_AUTHORIZATION.verification_uri_complete,
|
|
202
|
+
instructions: `Enter code: ${DEVICE_AUTHORIZATION.user_code}`,
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
expect(authEvents[0]?.instructions).not.toMatch(/hermes/i);
|
|
206
|
+
expect(onManualCodeInput).not.toHaveBeenCalled();
|
|
207
|
+
expect(progress).toEqual(["Waiting for xAI device authorization..."]);
|
|
208
|
+
expect(credentials).toEqual({
|
|
209
|
+
access: "access-token",
|
|
210
|
+
refresh: "refresh-token",
|
|
211
|
+
expires: now + 3_300_000,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("continues through authorization_pending and slow_down responses", async () => {
|
|
216
|
+
const sleepSpy = vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
|
217
|
+
const { fetchMock, requests } = createDeviceFlowFetch([
|
|
218
|
+
{ status: 400, body: { error: "authorization_pending" } },
|
|
219
|
+
{ status: 400, body: { error: "slow_down" } },
|
|
220
|
+
{
|
|
221
|
+
body: {
|
|
222
|
+
access_token: "eventual-access-token",
|
|
223
|
+
refresh_token: "eventual-refresh-token",
|
|
224
|
+
expires_in: 3600,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const credentials = await loginXAIOAuth({ fetch: fetchMock });
|
|
230
|
+
|
|
231
|
+
const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
|
|
232
|
+
expect(tokenRequests).toHaveLength(3);
|
|
233
|
+
expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
|
|
234
|
+
{
|
|
235
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
236
|
+
client_id: CLIENT_ID,
|
|
237
|
+
device_code: DEVICE_AUTHORIZATION.device_code,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
241
|
+
client_id: CLIENT_ID,
|
|
242
|
+
device_code: DEVICE_AUTHORIZATION.device_code,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
246
|
+
client_id: CLIENT_ID,
|
|
247
|
+
device_code: DEVICE_AUTHORIZATION.device_code,
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
expect(sleepSpy.mock.calls).toEqual([[1000], [6000]]);
|
|
251
|
+
expect(credentials.access).toBe("eventual-access-token");
|
|
252
|
+
expect(credentials.refresh).toBe("eventual-refresh-token");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects a token response that omits access_token", async () => {
|
|
256
|
+
const { fetchMock, requests } = createDeviceFlowFetch([
|
|
257
|
+
{
|
|
258
|
+
body: {
|
|
259
|
+
refresh_token: "refresh-token",
|
|
260
|
+
expires_in: 3600,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
154
264
|
|
|
155
|
-
await expect(
|
|
156
|
-
/access_token/,
|
|
265
|
+
await expect(loginXAIOAuth({ fetch: fetchMock })).rejects.toThrow(
|
|
266
|
+
/xAI device-code token response missing access_token/,
|
|
157
267
|
);
|
|
268
|
+
expect(requests.filter(request => request.url === TOKEN_ENDPOINT)).toHaveLength(1);
|
|
158
269
|
});
|
|
159
270
|
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as AIError from "../../error";
|
|
2
|
+
|
|
3
|
+
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
|
4
|
+
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
|
5
|
+
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
|
6
|
+
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
|
7
|
+
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
|
8
|
+
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
|
9
|
+
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
/** Result returned by one OAuth device-code polling attempt. */
|
|
12
|
+
export type OAuthDeviceCodePollResult<T> =
|
|
13
|
+
| { status: "complete"; value: T }
|
|
14
|
+
| { status: "pending" }
|
|
15
|
+
| { status: "slow_down" }
|
|
16
|
+
| { status: "failed"; message: string };
|
|
17
|
+
|
|
18
|
+
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
|
19
|
+
export interface OAuthDeviceCodeFlowOptions<T> {
|
|
20
|
+
/** Poll the provider once and classify the response. */
|
|
21
|
+
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
|
22
|
+
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
|
23
|
+
intervalSeconds?: number;
|
|
24
|
+
/** Provider-issued expiry window for the device code. */
|
|
25
|
+
expiresInSeconds?: number;
|
|
26
|
+
/** Cancels the flow with the legacy "Login cancelled" error. */
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
31
|
+
if (!signal) {
|
|
32
|
+
await Bun.sleep(ms);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (signal.aborted) {
|
|
36
|
+
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
40
|
+
let timer: Timer | undefined;
|
|
41
|
+
const onAbort = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
|
|
44
|
+
};
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
signal.removeEventListener("abort", onAbort);
|
|
47
|
+
resolve();
|
|
48
|
+
}, ms);
|
|
49
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
50
|
+
await promise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
|
54
|
+
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
|
55
|
+
const deadline =
|
|
56
|
+
typeof options.expiresInSeconds === "number"
|
|
57
|
+
? Date.now() + options.expiresInSeconds * 1000
|
|
58
|
+
: Number.POSITIVE_INFINITY;
|
|
59
|
+
let intervalMs = Math.max(
|
|
60
|
+
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
|
61
|
+
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
|
62
|
+
);
|
|
63
|
+
let slowDownResponses = 0;
|
|
64
|
+
|
|
65
|
+
while (Date.now() < deadline) {
|
|
66
|
+
if (options.signal?.aborted) {
|
|
67
|
+
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
68
|
+
}
|
|
69
|
+
const result = await options.poll();
|
|
70
|
+
if (result.status === "complete") {
|
|
71
|
+
return result.value;
|
|
72
|
+
}
|
|
73
|
+
if (result.status === "failed") {
|
|
74
|
+
throw new AIError.OAuthError(result.message, { kind: "polling" });
|
|
75
|
+
}
|
|
76
|
+
if (result.status === "slow_down") {
|
|
77
|
+
slowDownResponses += 1;
|
|
78
|
+
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remainingMs = deadline - Date.now();
|
|
82
|
+
if (remainingMs <= 0) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new AIError.OAuthError(
|
|
89
|
+
slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
|
|
90
|
+
{ kind: "timeout" },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -12,99 +12,9 @@ import type {
|
|
|
12
12
|
OAuthProviderInterface,
|
|
13
13
|
} from "./types";
|
|
14
14
|
|
|
15
|
+
export * from "./device-code";
|
|
15
16
|
export type * from "./types";
|
|
16
17
|
|
|
17
|
-
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
|
18
|
-
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
|
19
|
-
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
|
20
|
-
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
|
21
|
-
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
|
22
|
-
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
|
23
|
-
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
|
24
|
-
|
|
25
|
-
/** Result returned by one OAuth device-code polling attempt. */
|
|
26
|
-
export type OAuthDeviceCodePollResult<T> =
|
|
27
|
-
| { status: "complete"; value: T }
|
|
28
|
-
| { status: "pending" }
|
|
29
|
-
| { status: "slow_down" }
|
|
30
|
-
| { status: "failed"; message: string };
|
|
31
|
-
|
|
32
|
-
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
|
33
|
-
export interface OAuthDeviceCodeFlowOptions<T> {
|
|
34
|
-
/** Poll the provider once and classify the response. */
|
|
35
|
-
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
|
36
|
-
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
|
37
|
-
intervalSeconds?: number;
|
|
38
|
-
/** Provider-issued expiry window for the device code. */
|
|
39
|
-
expiresInSeconds?: number;
|
|
40
|
-
/** Cancels the flow with the legacy "Login cancelled" error. */
|
|
41
|
-
signal?: AbortSignal;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
45
|
-
if (!signal) {
|
|
46
|
-
await Bun.sleep(ms);
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (signal.aborted) {
|
|
50
|
-
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
54
|
-
let timer: Timer | undefined;
|
|
55
|
-
const onAbort = () => {
|
|
56
|
-
if (timer) clearTimeout(timer);
|
|
57
|
-
reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
|
|
58
|
-
};
|
|
59
|
-
timer = setTimeout(() => {
|
|
60
|
-
signal.removeEventListener("abort", onAbort);
|
|
61
|
-
resolve();
|
|
62
|
-
}, ms);
|
|
63
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
-
await promise;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
|
68
|
-
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
|
69
|
-
const deadline =
|
|
70
|
-
typeof options.expiresInSeconds === "number"
|
|
71
|
-
? Date.now() + options.expiresInSeconds * 1000
|
|
72
|
-
: Number.POSITIVE_INFINITY;
|
|
73
|
-
let intervalMs = Math.max(
|
|
74
|
-
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
|
75
|
-
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
|
76
|
-
);
|
|
77
|
-
let slowDownResponses = 0;
|
|
78
|
-
|
|
79
|
-
while (Date.now() < deadline) {
|
|
80
|
-
if (options.signal?.aborted) {
|
|
81
|
-
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
|
82
|
-
}
|
|
83
|
-
const result = await options.poll();
|
|
84
|
-
if (result.status === "complete") {
|
|
85
|
-
return result.value;
|
|
86
|
-
}
|
|
87
|
-
if (result.status === "failed") {
|
|
88
|
-
throw new AIError.OAuthError(result.message, { kind: "polling" });
|
|
89
|
-
}
|
|
90
|
-
if (result.status === "slow_down") {
|
|
91
|
-
slowDownResponses += 1;
|
|
92
|
-
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const remainingMs = deadline - Date.now();
|
|
96
|
-
if (remainingMs <= 0) {
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
throw new AIError.OAuthError(
|
|
103
|
-
slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
|
|
104
|
-
{ kind: "timeout" },
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
18
|
const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
|
|
109
19
|
provider => provider.login && provider.showInLoginList !== false,
|
|
110
20
|
).map(provider => ({
|