@moxxy/plugin-oauth 0.0.33
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/LICENSE +21 -0
- package/dist/adapters/openai-device-flow.d.ts +34 -0
- package/dist/adapters/openai-device-flow.d.ts.map +1 -0
- package/dist/adapters/openai-device-flow.js +136 -0
- package/dist/adapters/openai-device-flow.js.map +1 -0
- package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
- package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
- package/dist/adapters/rfc8628-device-flow.js +54 -0
- package/dist/adapters/rfc8628-device-flow.js.map +1 -0
- package/dist/credential-lock.d.ts +60 -0
- package/dist/credential-lock.d.ts.map +1 -0
- package/dist/credential-lock.js +149 -0
- package/dist/credential-lock.js.map +1 -0
- package/dist/ensure-fresh.d.ts +63 -0
- package/dist/ensure-fresh.d.ts.map +1 -0
- package/dist/ensure-fresh.js +122 -0
- package/dist/ensure-fresh.js.map +1 -0
- package/dist/flow.d.ts +6 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +5 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/browser-flow.d.ts +23 -0
- package/dist/oauth/browser-flow.d.ts.map +1 -0
- package/dist/oauth/browser-flow.js +82 -0
- package/dist/oauth/browser-flow.js.map +1 -0
- package/dist/oauth/callback-server.d.ts +10 -0
- package/dist/oauth/callback-server.d.ts.map +1 -0
- package/dist/oauth/callback-server.js +158 -0
- package/dist/oauth/callback-server.js.map +1 -0
- package/dist/oauth/device-flow-shared.d.ts +39 -0
- package/dist/oauth/device-flow-shared.d.ts.map +1 -0
- package/dist/oauth/device-flow-shared.js +101 -0
- package/dist/oauth/device-flow-shared.js.map +1 -0
- package/dist/oauth/device-flow.d.ts +29 -0
- package/dist/oauth/device-flow.d.ts.map +1 -0
- package/dist/oauth/device-flow.js +74 -0
- package/dist/oauth/device-flow.js.map +1 -0
- package/dist/oauth/poll-until.d.ts +41 -0
- package/dist/oauth/poll-until.d.ts.map +1 -0
- package/dist/oauth/poll-until.js +70 -0
- package/dist/oauth/poll-until.js.map +1 -0
- package/dist/oauth/token-exchange.d.ts +28 -0
- package/dist/oauth/token-exchange.d.ts.map +1 -0
- package/dist/oauth/token-exchange.js +142 -0
- package/dist/oauth/token-exchange.js.map +1 -0
- package/dist/oauth/types.d.ts +90 -0
- package/dist/oauth/types.d.ts.map +1 -0
- package/dist/oauth/types.js +2 -0
- package/dist/oauth/types.js.map +1 -0
- package/dist/open-browser.d.ts +32 -0
- package/dist/open-browser.d.ts.map +1 -0
- package/dist/open-browser.js +83 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/pkce.d.ts +20 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +34 -0
- package/dist/pkce.js.map +1 -0
- package/dist/profile.d.ts +129 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +11 -0
- package/dist/profile.js.map +1 -0
- package/dist/run-login.d.ts +9 -0
- package/dist/run-login.d.ts.map +1 -0
- package/dist/run-login.js +93 -0
- package/dist/run-login.js.map +1 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +167 -0
- package/dist/storage.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +283 -0
- package/dist/tools.js.map +1 -0
- package/package.json +73 -0
- package/skills/google-oauth.md +313 -0
- package/skills/oauth-flow.md +132 -0
- package/src/adapters/openai-device-flow.test.ts +191 -0
- package/src/adapters/openai-device-flow.ts +179 -0
- package/src/adapters/rfc8628-device-flow.ts +75 -0
- package/src/credential-lock.ts +183 -0
- package/src/discovery.test.ts +35 -0
- package/src/ensure-fresh.test.ts +279 -0
- package/src/ensure-fresh.ts +178 -0
- package/src/flow.ts +9 -0
- package/src/index.ts +153 -0
- package/src/oauth/browser-flow.ts +96 -0
- package/src/oauth/callback-server.test.ts +90 -0
- package/src/oauth/callback-server.ts +208 -0
- package/src/oauth/device-flow-shared.test.ts +142 -0
- package/src/oauth/device-flow-shared.ts +129 -0
- package/src/oauth/device-flow.test.ts +102 -0
- package/src/oauth/device-flow.ts +82 -0
- package/src/oauth/poll-until.test.ts +63 -0
- package/src/oauth/poll-until.ts +100 -0
- package/src/oauth/token-exchange.test.ts +224 -0
- package/src/oauth/token-exchange.ts +169 -0
- package/src/oauth/types.ts +92 -0
- package/src/oauth.test.ts +185 -0
- package/src/open-browser-spawn.test.ts +69 -0
- package/src/open-browser.test.ts +30 -0
- package/src/open-browser.ts +84 -0
- package/src/pkce.ts +37 -0
- package/src/profile.test.ts +331 -0
- package/src/profile.ts +135 -0
- package/src/run-login.ts +123 -0
- package/src/storage.test.ts +191 -0
- package/src/storage.ts +208 -0
- package/src/tools.test.ts +239 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
3
|
+
import { runDeviceCodeFlow } from './device-flow.js';
|
|
4
|
+
import type { DeviceFlowOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
function jsonResponse(obj: unknown, status = 200): Response {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
json: async () => obj,
|
|
11
|
+
text: async () => JSON.stringify(obj),
|
|
12
|
+
} as unknown as Response;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function nonJsonResponse(status = 200): Response {
|
|
16
|
+
return {
|
|
17
|
+
ok: status >= 200 && status < 300,
|
|
18
|
+
status,
|
|
19
|
+
statusText: 'OK',
|
|
20
|
+
json: async () => {
|
|
21
|
+
throw new SyntaxError('Unexpected token < in JSON');
|
|
22
|
+
},
|
|
23
|
+
text: async () => '<html>captive portal</html>',
|
|
24
|
+
} as unknown as Response;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const baseOpts: Omit<DeviceFlowOptions, 'onPrompt'> = {
|
|
28
|
+
deviceUrl: 'https://idp.example/device',
|
|
29
|
+
tokenUrl: 'https://idp.example/token',
|
|
30
|
+
clientId: 'client-1',
|
|
31
|
+
scopes: ['openid'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('runDeviceCodeFlow — happy path applies the poll safety margin', () => {
|
|
40
|
+
it('polls only AFTER interval + DEVICE_POLL_SAFETY_MARGIN_MS (no edge-poll racing the rate limiter)', async () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
const deviceAuth = {
|
|
43
|
+
device_code: 'dc-1',
|
|
44
|
+
user_code: 'WXYZ',
|
|
45
|
+
verification_uri: 'https://idp.example/verify',
|
|
46
|
+
expires_in: 600,
|
|
47
|
+
interval: 5,
|
|
48
|
+
};
|
|
49
|
+
const calls: string[] = [];
|
|
50
|
+
vi.stubGlobal(
|
|
51
|
+
'fetch',
|
|
52
|
+
vi.fn(async (url: unknown) => {
|
|
53
|
+
const u = String(url);
|
|
54
|
+
if (u === baseOpts.deviceUrl) {
|
|
55
|
+
calls.push('device');
|
|
56
|
+
return jsonResponse(deviceAuth);
|
|
57
|
+
}
|
|
58
|
+
calls.push('poll');
|
|
59
|
+
return jsonResponse({ access_token: 'at-1', token_type: 'Bearer', expires_in: 3600 });
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
let prompted = false;
|
|
64
|
+
const p = runDeviceCodeFlow({ ...baseOpts, onPrompt: () => (prompted = true) });
|
|
65
|
+
// Let the device-auth request resolve.
|
|
66
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
67
|
+
expect(prompted).toBe(true);
|
|
68
|
+
expect(calls).toEqual(['device']);
|
|
69
|
+
|
|
70
|
+
// At interval (5s) exactly, the poll must NOT have fired — the +3s margin
|
|
71
|
+
// delays the first poll to 8s.
|
|
72
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
73
|
+
expect(calls).toEqual(['device']);
|
|
74
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
75
|
+
// Now the first poll fires.
|
|
76
|
+
const tokens = await p;
|
|
77
|
+
expect(calls).toEqual(['device', 'poll']);
|
|
78
|
+
expect(tokens.accessToken).toBe('at-1');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('runDeviceCodeFlow — hostile / malformed input degrades, never crashes', () => {
|
|
83
|
+
it('rejects a non-JSON 200 device-authorization body with a typed MoxxyError', async () => {
|
|
84
|
+
vi.stubGlobal('fetch', vi.fn(async () => nonJsonResponse(200)));
|
|
85
|
+
await expect(
|
|
86
|
+
runDeviceCodeFlow({ ...baseOpts, onPrompt: () => {} }),
|
|
87
|
+
).rejects.toMatchObject({ code: 'PROVIDER_UNKNOWN_RESPONSE' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a 200 device-authorization body missing required fields', async () => {
|
|
91
|
+
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ user_code: 'X' /* no device_code */ })));
|
|
92
|
+
const err = await runDeviceCodeFlow({ ...baseOpts, onPrompt: () => {} }).catch((e) => e);
|
|
93
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
94
|
+
expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('surfaces a non-ok device-authorization HTTP error as a MoxxyError (no raw throw)', async () => {
|
|
98
|
+
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'invalid_client' }, 400)));
|
|
99
|
+
const err = await runDeviceCodeFlow({ ...baseOpts, onPrompt: () => {} }).catch((e) => e);
|
|
100
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { pollUntil, type PollOutcome, type PollState } from './poll-until.js';
|
|
2
|
+
import { classifyDeviceTokenResponse, requestDeviceAuthorization } from './device-flow-shared.js';
|
|
3
|
+
import type { DeviceFlowOptions, TokenSet } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Small margin added to the provider's interval before the first/each poll, so
|
|
7
|
+
* a server that enforces the interval strictly doesn't answer edge polls with
|
|
8
|
+
* `slow_down`. Mirrors `run-login.ts`'s margin so both device-flow drivers
|
|
9
|
+
* behave identically.
|
|
10
|
+
*/
|
|
11
|
+
const DEVICE_POLL_SAFETY_MARGIN_MS = 3000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run the RFC 8628 device-authorization flow. Suitable for headless
|
|
15
|
+
* environments (SSH session, CI, kiosk, no display): the user opens
|
|
16
|
+
* the verification URL on any device, types the short user_code,
|
|
17
|
+
* approves the scopes, and the local process discovers the grant by
|
|
18
|
+
* polling the token endpoint.
|
|
19
|
+
*
|
|
20
|
+
* Phases:
|
|
21
|
+
* 1. POST `client_id` + `scope` to deviceUrl → returns user_code,
|
|
22
|
+
* verification_uri, device_code, expires_in, interval.
|
|
23
|
+
* 2. `onPrompt` fires once with the user-facing pieces — the caller
|
|
24
|
+
* surfaces them in whatever UI it has.
|
|
25
|
+
* 3. Poll tokenUrl every `interval` seconds with
|
|
26
|
+
* grant_type=urn:ietf:params:oauth:grant-type:device_code +
|
|
27
|
+
* device_code. The provider replies:
|
|
28
|
+
* - authorization_pending → keep polling.
|
|
29
|
+
* - slow_down → bump interval by 5s and keep polling.
|
|
30
|
+
* - access_denied → user clicked deny; throw.
|
|
31
|
+
* - expired_token → device_code expired; throw.
|
|
32
|
+
* - access_token, ... → success; return TokenSet.
|
|
33
|
+
*
|
|
34
|
+
* The device-authorization request + the poll-response classification are
|
|
35
|
+
* shared with the {@link rfc8628DeviceFlow} adapter via `device-flow-shared`.
|
|
36
|
+
* This flow additionally sends `client_id` (+ optional `client_secret`) on the
|
|
37
|
+
* poll, which is why it builds its own poll body.
|
|
38
|
+
*/
|
|
39
|
+
export async function runDeviceCodeFlow(opts: DeviceFlowOptions): Promise<TokenSet> {
|
|
40
|
+
const auth = await requestDeviceAuthorization({
|
|
41
|
+
deviceUrl: opts.deviceUrl,
|
|
42
|
+
clientId: opts.clientId,
|
|
43
|
+
scopes: opts.scopes,
|
|
44
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
opts.onPrompt({
|
|
48
|
+
userCode: auth.userCode,
|
|
49
|
+
verificationUri: auth.verificationUri,
|
|
50
|
+
...(auth.verificationUriComplete ? { verificationUriComplete: auth.verificationUriComplete } : {}),
|
|
51
|
+
expiresIn: auth.expiresInSec,
|
|
52
|
+
interval: auth.intervalSec,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return pollUntil((state) => pollOnce(opts, auth.deviceCode, state), {
|
|
56
|
+
intervalMs: auth.intervalSec * 1000 + DEVICE_POLL_SAFETY_MARGIN_MS,
|
|
57
|
+
timeoutMs: Math.min(opts.timeoutMs ?? auth.expiresInSec * 1000, auth.expiresInSec * 1000),
|
|
58
|
+
label: 'OAuth device flow',
|
|
59
|
+
leadingWait: true,
|
|
60
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function pollOnce(
|
|
65
|
+
opts: DeviceFlowOptions,
|
|
66
|
+
deviceCode: string,
|
|
67
|
+
state: PollState,
|
|
68
|
+
): Promise<PollOutcome<TokenSet>> {
|
|
69
|
+
const body = new URLSearchParams();
|
|
70
|
+
body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
71
|
+
body.set('device_code', deviceCode);
|
|
72
|
+
body.set('client_id', opts.clientId);
|
|
73
|
+
if (opts.clientSecret) body.set('client_secret', opts.clientSecret);
|
|
74
|
+
const pollRes = await fetch(opts.tokenUrl, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
77
|
+
body: body.toString(),
|
|
78
|
+
...(state.signal ? { signal: state.signal } : {}),
|
|
79
|
+
});
|
|
80
|
+
const pollJson = (await pollRes.json().catch(() => ({}))) as Record<string, unknown>;
|
|
81
|
+
return classifyDeviceTokenResponse(pollRes, pollJson, state);
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getEventListeners } from 'node:events';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
4
|
+
import { pollUntil } from './poll-until.js';
|
|
5
|
+
|
|
6
|
+
const abortListeners = (signal: AbortSignal): number => getEventListeners(signal, 'abort').length;
|
|
7
|
+
|
|
8
|
+
describe('pollUntil — abort-event listener hygiene', () => {
|
|
9
|
+
it('does not accumulate abort listeners across poll iterations', async () => {
|
|
10
|
+
// sleep() adds one `abort` listener per inter-poll wait against the SAME
|
|
11
|
+
// long-lived signal. If the timer-resolved path doesn't remove it, a device
|
|
12
|
+
// flow polling for minutes would pile up listeners and trip
|
|
13
|
+
// MaxListenersExceededWarning. Assert the count never grows past 1.
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const { signal } = controller;
|
|
16
|
+
let polls = 0;
|
|
17
|
+
let maxListeners = 0;
|
|
18
|
+
const result = await pollUntil(
|
|
19
|
+
async () => {
|
|
20
|
+
polls += 1;
|
|
21
|
+
maxListeners = Math.max(maxListeners, abortListeners(signal));
|
|
22
|
+
return polls >= 5 ? { done: 'ok' as const } : { pending: true as const };
|
|
23
|
+
},
|
|
24
|
+
{ intervalMs: 1, timeoutMs: 5_000, signal, leadingWait: false },
|
|
25
|
+
);
|
|
26
|
+
expect(result).toBe('ok');
|
|
27
|
+
expect(polls).toBe(5);
|
|
28
|
+
// At most one abort listener is ever attached at a time — no leak.
|
|
29
|
+
expect(maxListeners).toBeLessThanOrEqual(1);
|
|
30
|
+
// And none is left dangling after the flow resolves.
|
|
31
|
+
expect(abortListeners(signal)).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects with NETWORK_ABORTED when the signal aborts mid-wait', async () => {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const pending = pollUntil(async () => ({ pending: true as const }), {
|
|
37
|
+
intervalMs: 50,
|
|
38
|
+
timeoutMs: 5_000,
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
leadingWait: true,
|
|
41
|
+
});
|
|
42
|
+
const rejected = expect(pending).rejects.toMatchObject({ code: 'NETWORK_ABORTED' });
|
|
43
|
+
controller.abort();
|
|
44
|
+
await rejected;
|
|
45
|
+
// The abort listener is detached on the reject path too.
|
|
46
|
+
expect(abortListeners(controller.signal)).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('throws OAUTH_FLOW_TIMEOUT once the deadline passes', async () => {
|
|
50
|
+
let err: unknown;
|
|
51
|
+
try {
|
|
52
|
+
await pollUntil(async () => ({ pending: true as const }), {
|
|
53
|
+
intervalMs: 1,
|
|
54
|
+
timeoutMs: 10,
|
|
55
|
+
leadingWait: false,
|
|
56
|
+
});
|
|
57
|
+
} catch (e) {
|
|
58
|
+
err = e;
|
|
59
|
+
}
|
|
60
|
+
expect(err).toBeInstanceOf(MoxxyError);
|
|
61
|
+
expect((err as MoxxyError).code).toBe('OAUTH_FLOW_TIMEOUT');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared polling primitive for OAuth-style "ask again later" modes. Handles
|
|
3
|
+
* the gnarly bits — deadline math, abort-responsive sleep, interval bumps
|
|
4
|
+
* for `slow_down`-style backpressure — so each device-flow dialect only has
|
|
5
|
+
* to encode its HTTP shape, not the timing harness.
|
|
6
|
+
*
|
|
7
|
+
* Consumed by both `runDeviceCodeFlow` (RFC 8628) and the Codex device flow
|
|
8
|
+
* (non-standard OpenAI endpoints). The polling fn returns `{done}` to finish
|
|
9
|
+
* or `{pending}` to keep going, and may mutate `state.intervalMs` mid-flight
|
|
10
|
+
* to apply a `slow_down` bump.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
14
|
+
|
|
15
|
+
export interface PollState {
|
|
16
|
+
/** Mutable so the polling fn can bump on `slow_down`. */
|
|
17
|
+
intervalMs: number;
|
|
18
|
+
/**
|
|
19
|
+
* The flow's abort signal, threaded through so a polling fn can pass it
|
|
20
|
+
* into its in-flight fetch and cancel a hung request — not just the
|
|
21
|
+
* inter-poll sleep. Undefined when the caller supplied no signal.
|
|
22
|
+
*/
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PollOutcome<T> = { done: T } | { pending: true };
|
|
27
|
+
|
|
28
|
+
export interface PollUntilOpts {
|
|
29
|
+
readonly intervalMs: number;
|
|
30
|
+
readonly timeoutMs: number;
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
/**
|
|
33
|
+
* Wait BEFORE the first call. RFC 8628 says clients SHOULD wait `interval`
|
|
34
|
+
* before the first poll; some flows (e.g. Codex) poll immediately. Default
|
|
35
|
+
* true to match the more conservative RFC behavior.
|
|
36
|
+
*/
|
|
37
|
+
readonly leadingWait?: boolean;
|
|
38
|
+
/** Used in timeout / abort error messages. */
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function pollUntil<T>(
|
|
43
|
+
fn: (state: PollState) => Promise<PollOutcome<T>>,
|
|
44
|
+
opts: PollUntilOpts,
|
|
45
|
+
): Promise<T> {
|
|
46
|
+
const state: PollState = {
|
|
47
|
+
intervalMs: opts.intervalMs,
|
|
48
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
49
|
+
};
|
|
50
|
+
const leadingWait = opts.leadingWait ?? true;
|
|
51
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
52
|
+
const label = opts.label ?? 'poll';
|
|
53
|
+
|
|
54
|
+
let first = true;
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
if (opts.signal?.aborted) throw abortedError(label);
|
|
57
|
+
if (!first || leadingWait) {
|
|
58
|
+
const remaining = deadline - Date.now();
|
|
59
|
+
if (remaining <= 0) break;
|
|
60
|
+
await sleep(Math.min(state.intervalMs, remaining), opts.signal, label);
|
|
61
|
+
}
|
|
62
|
+
first = false;
|
|
63
|
+
const result = await fn(state);
|
|
64
|
+
if ('done' in result) return result.done;
|
|
65
|
+
}
|
|
66
|
+
throw new MoxxyError({
|
|
67
|
+
code: 'OAUTH_FLOW_TIMEOUT',
|
|
68
|
+
message: `${label} timed out waiting for completion`,
|
|
69
|
+
context: { label, timeout_ms: opts.timeoutMs },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function abortedError(label: string): MoxxyError {
|
|
74
|
+
return new MoxxyError({
|
|
75
|
+
code: 'NETWORK_ABORTED',
|
|
76
|
+
message: `${label} aborted`,
|
|
77
|
+
context: { label },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms: number, signal: AbortSignal | undefined, label: string): Promise<void> {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
if (signal?.aborted) return reject(abortedError(label));
|
|
84
|
+
// `{ once: true }` only auto-removes a listener that actually FIRES; on the
|
|
85
|
+
// timer-resolved path it would linger. pollUntil calls sleep() once per
|
|
86
|
+
// poll cycle against the SAME long-lived signal, so an unremoved listener
|
|
87
|
+
// accumulates one-per-iteration (~120 over a 10-min device flow), tripping
|
|
88
|
+
// MaxListenersExceededWarning and retaining each closure. Remove on BOTH
|
|
89
|
+
// exits so it's exactly one add / one remove per sleep.
|
|
90
|
+
const onAbort = (): void => {
|
|
91
|
+
clearTimeout(t);
|
|
92
|
+
reject(abortedError(label));
|
|
93
|
+
};
|
|
94
|
+
const t = setTimeout(() => {
|
|
95
|
+
signal?.removeEventListener('abort', onAbort);
|
|
96
|
+
resolve();
|
|
97
|
+
}, ms);
|
|
98
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
exchangeCodeForToken,
|
|
5
|
+
parseTokenResponse,
|
|
6
|
+
refreshAccessToken,
|
|
7
|
+
} from './token-exchange.js';
|
|
8
|
+
|
|
9
|
+
describe('parseTokenResponse', () => {
|
|
10
|
+
it('throws PROVIDER_UNKNOWN_RESPONSE when access_token is missing', () => {
|
|
11
|
+
let err: unknown;
|
|
12
|
+
try {
|
|
13
|
+
parseTokenResponse({ token_type: 'Bearer' });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
err = e;
|
|
16
|
+
}
|
|
17
|
+
expect(err).toBeInstanceOf(MoxxyError);
|
|
18
|
+
expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws when access_token is present but not a string', () => {
|
|
22
|
+
expect(() => parseTokenResponse({ access_token: 12345 })).toThrow(MoxxyError);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('maps every field of a full response', () => {
|
|
26
|
+
const before = Date.now();
|
|
27
|
+
const set = parseTokenResponse({
|
|
28
|
+
access_token: 'at',
|
|
29
|
+
refresh_token: 'rt',
|
|
30
|
+
expires_in: 3600,
|
|
31
|
+
scope: 'a b',
|
|
32
|
+
token_type: 'MAC',
|
|
33
|
+
id_token: 'idt',
|
|
34
|
+
});
|
|
35
|
+
expect(set.accessToken).toBe('at');
|
|
36
|
+
expect(set.refreshToken).toBe('rt');
|
|
37
|
+
expect(set.scope).toBe('a b');
|
|
38
|
+
expect(set.tokenType).toBe('MAC');
|
|
39
|
+
expect(set.idToken).toBe('idt');
|
|
40
|
+
// expiresAt = now + expires_in*1000 (allow for the small wall-clock delta).
|
|
41
|
+
expect(set.expiresAt!).toBeGreaterThanOrEqual(before + 3600 * 1000);
|
|
42
|
+
expect(set.expiresAt!).toBeLessThanOrEqual(Date.now() + 3600 * 1000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('omits expiresAt when expires_in is absent', () => {
|
|
46
|
+
const set = parseTokenResponse({ access_token: 'at' });
|
|
47
|
+
expect('expiresAt' in set).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('omits refreshToken/scope/idToken when absent', () => {
|
|
51
|
+
const set = parseTokenResponse({ access_token: 'at', expires_in: 10 });
|
|
52
|
+
expect('refreshToken' in set).toBe(false);
|
|
53
|
+
expect('scope' in set).toBe(false);
|
|
54
|
+
expect('idToken' in set).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("defaults token_type to 'Bearer' when absent or non-string", () => {
|
|
58
|
+
expect(parseTokenResponse({ access_token: 'at' }).tokenType).toBe('Bearer');
|
|
59
|
+
expect(parseTokenResponse({ access_token: 'at', token_type: 7 }).tokenType).toBe('Bearer');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('refreshAccessToken', () => {
|
|
64
|
+
const baseInput = {
|
|
65
|
+
tokenUrl: 'https://idp.example/token',
|
|
66
|
+
clientId: 'cid',
|
|
67
|
+
refreshToken: 'old-rt',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function fakeFetch(status: number, body: unknown): typeof fetch {
|
|
71
|
+
return (async () =>
|
|
72
|
+
({
|
|
73
|
+
ok: status >= 200 && status < 300,
|
|
74
|
+
status,
|
|
75
|
+
json: async () => body,
|
|
76
|
+
text: async () => JSON.stringify(body),
|
|
77
|
+
})) as unknown as typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it('preserves no refresh_token in the result when the provider omits one', async () => {
|
|
81
|
+
// RFC 6749 §6: a refresh response MAY omit refresh_token. The caller is
|
|
82
|
+
// documented to preserve the prior token, so the returned set must NOT
|
|
83
|
+
// carry a refreshToken key in that case.
|
|
84
|
+
const set = await refreshAccessToken(
|
|
85
|
+
baseInput,
|
|
86
|
+
fakeFetch(200, { access_token: 'new-at', expires_in: 3600 }),
|
|
87
|
+
);
|
|
88
|
+
expect(set.accessToken).toBe('new-at');
|
|
89
|
+
expect('refreshToken' in set).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns the rotated refresh_token when the provider supplies one', async () => {
|
|
93
|
+
const set = await refreshAccessToken(
|
|
94
|
+
baseInput,
|
|
95
|
+
fakeFetch(200, { access_token: 'new-at', refresh_token: 'rotated' }),
|
|
96
|
+
);
|
|
97
|
+
expect(set.refreshToken).toBe('rotated');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('throws a MoxxyError on a non-ok status', async () => {
|
|
101
|
+
await expect(
|
|
102
|
+
refreshAccessToken(baseInput, fakeFetch(400, { error: 'invalid_grant' })),
|
|
103
|
+
).rejects.toBeInstanceOf(MoxxyError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('sends grant_type=refresh_token with the refresh token in the body', async () => {
|
|
107
|
+
const spy = vi.fn(async () => ({
|
|
108
|
+
ok: true,
|
|
109
|
+
status: 200,
|
|
110
|
+
json: async () => ({ access_token: 'x' }),
|
|
111
|
+
text: async () => '',
|
|
112
|
+
}));
|
|
113
|
+
await refreshAccessToken(baseInput, spy as unknown as typeof fetch);
|
|
114
|
+
const body = (spy.mock.calls[0]![1] as RequestInit).body as string;
|
|
115
|
+
expect(body).toContain('grant_type=refresh_token');
|
|
116
|
+
expect(body).toContain('refresh_token=old-rt');
|
|
117
|
+
expect(body).toContain('client_id=cid');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('maps a non-JSON 200 success body to PROVIDER_UNKNOWN_RESPONSE (not a raw SyntaxError)', async () => {
|
|
121
|
+
// A captive portal / proxy can answer HTTP 200 with HTML; an unguarded
|
|
122
|
+
// res.json() would reject with a native SyntaxError that escapes the
|
|
123
|
+
// MoxxyError boundary (isAuthRejection can't classify it).
|
|
124
|
+
const nonJson: typeof fetch = (async () =>
|
|
125
|
+
({
|
|
126
|
+
ok: true,
|
|
127
|
+
status: 200,
|
|
128
|
+
json: async () => {
|
|
129
|
+
throw new SyntaxError('Unexpected token < in JSON at position 0');
|
|
130
|
+
},
|
|
131
|
+
text: async () => '<html>captive portal</html>',
|
|
132
|
+
})) as unknown as typeof fetch;
|
|
133
|
+
let err: unknown;
|
|
134
|
+
try {
|
|
135
|
+
await refreshAccessToken(baseInput, nonJson);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
err = e;
|
|
138
|
+
}
|
|
139
|
+
expect(err).toBeInstanceOf(MoxxyError);
|
|
140
|
+
expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('exchangeCodeForToken — error-body handling', () => {
|
|
145
|
+
const baseInput = {
|
|
146
|
+
tokenUrl: 'https://idp.example/token',
|
|
147
|
+
code: 'auth-code',
|
|
148
|
+
redirectUri: 'http://localhost:8765/callback',
|
|
149
|
+
clientId: 'cid',
|
|
150
|
+
codeVerifier: 'verifier',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function fakeRes(opts: {
|
|
154
|
+
ok: boolean;
|
|
155
|
+
status: number;
|
|
156
|
+
json?: () => Promise<unknown>;
|
|
157
|
+
text?: () => Promise<string>;
|
|
158
|
+
}): typeof fetch {
|
|
159
|
+
return (async () =>
|
|
160
|
+
({
|
|
161
|
+
ok: opts.ok,
|
|
162
|
+
status: opts.status,
|
|
163
|
+
json: opts.json ?? (async () => ({})),
|
|
164
|
+
text: opts.text ?? (async () => ''),
|
|
165
|
+
})) as unknown as typeof fetch;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
it('maps a non-JSON 200 success body to PROVIDER_UNKNOWN_RESPONSE', async () => {
|
|
169
|
+
let err: unknown;
|
|
170
|
+
try {
|
|
171
|
+
await exchangeCodeForToken(
|
|
172
|
+
baseInput,
|
|
173
|
+
fakeRes({
|
|
174
|
+
ok: true,
|
|
175
|
+
status: 200,
|
|
176
|
+
json: async () => {
|
|
177
|
+
throw new SyntaxError('boom');
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
err = e;
|
|
183
|
+
}
|
|
184
|
+
expect(err).toBeInstanceOf(MoxxyError);
|
|
185
|
+
expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('does NOT reflect an opaque/HTML error body into the human message (only structured fields)', async () => {
|
|
189
|
+
// status 402 is not mapped by classifyHttpStatus → hits the fallback.
|
|
190
|
+
let err: unknown;
|
|
191
|
+
try {
|
|
192
|
+
await exchangeCodeForToken(
|
|
193
|
+
baseInput,
|
|
194
|
+
fakeRes({ ok: false, status: 402, text: async () => '<html><script>evil()</script></html>' }),
|
|
195
|
+
);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
err = e;
|
|
198
|
+
}
|
|
199
|
+
expect(err).toBeInstanceOf(MoxxyError);
|
|
200
|
+
const e = err as MoxxyError;
|
|
201
|
+
// Opaque body never reaches the user-facing message…
|
|
202
|
+
expect(e.message).not.toContain('<html>');
|
|
203
|
+
expect(e.message).not.toContain('evil');
|
|
204
|
+
// …but the bounded raw body is retained in context for diagnostics.
|
|
205
|
+
expect(String(e.context?.body)).toContain('<html>');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('surfaces the structured error_description in the message on an unmapped status', async () => {
|
|
209
|
+
let err: unknown;
|
|
210
|
+
try {
|
|
211
|
+
await exchangeCodeForToken(
|
|
212
|
+
baseInput,
|
|
213
|
+
fakeRes({
|
|
214
|
+
ok: false,
|
|
215
|
+
status: 418,
|
|
216
|
+
text: async () => JSON.stringify({ error: 'teapot', error_description: 'short and steep' }),
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
err = e;
|
|
221
|
+
}
|
|
222
|
+
expect((err as MoxxyError).message).toContain('short and steep');
|
|
223
|
+
});
|
|
224
|
+
});
|