@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/openai-device-flow.d.ts +34 -0
  3. package/dist/adapters/openai-device-flow.d.ts.map +1 -0
  4. package/dist/adapters/openai-device-flow.js +136 -0
  5. package/dist/adapters/openai-device-flow.js.map +1 -0
  6. package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
  7. package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
  8. package/dist/adapters/rfc8628-device-flow.js +54 -0
  9. package/dist/adapters/rfc8628-device-flow.js.map +1 -0
  10. package/dist/credential-lock.d.ts +60 -0
  11. package/dist/credential-lock.d.ts.map +1 -0
  12. package/dist/credential-lock.js +149 -0
  13. package/dist/credential-lock.js.map +1 -0
  14. package/dist/ensure-fresh.d.ts +63 -0
  15. package/dist/ensure-fresh.d.ts.map +1 -0
  16. package/dist/ensure-fresh.js +122 -0
  17. package/dist/ensure-fresh.js.map +1 -0
  18. package/dist/flow.d.ts +6 -0
  19. package/dist/flow.d.ts.map +1 -0
  20. package/dist/flow.js +5 -0
  21. package/dist/flow.js.map +1 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +82 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/oauth/browser-flow.d.ts +23 -0
  27. package/dist/oauth/browser-flow.d.ts.map +1 -0
  28. package/dist/oauth/browser-flow.js +82 -0
  29. package/dist/oauth/browser-flow.js.map +1 -0
  30. package/dist/oauth/callback-server.d.ts +10 -0
  31. package/dist/oauth/callback-server.d.ts.map +1 -0
  32. package/dist/oauth/callback-server.js +158 -0
  33. package/dist/oauth/callback-server.js.map +1 -0
  34. package/dist/oauth/device-flow-shared.d.ts +39 -0
  35. package/dist/oauth/device-flow-shared.d.ts.map +1 -0
  36. package/dist/oauth/device-flow-shared.js +101 -0
  37. package/dist/oauth/device-flow-shared.js.map +1 -0
  38. package/dist/oauth/device-flow.d.ts +29 -0
  39. package/dist/oauth/device-flow.d.ts.map +1 -0
  40. package/dist/oauth/device-flow.js +74 -0
  41. package/dist/oauth/device-flow.js.map +1 -0
  42. package/dist/oauth/poll-until.d.ts +41 -0
  43. package/dist/oauth/poll-until.d.ts.map +1 -0
  44. package/dist/oauth/poll-until.js +70 -0
  45. package/dist/oauth/poll-until.js.map +1 -0
  46. package/dist/oauth/token-exchange.d.ts +28 -0
  47. package/dist/oauth/token-exchange.d.ts.map +1 -0
  48. package/dist/oauth/token-exchange.js +142 -0
  49. package/dist/oauth/token-exchange.js.map +1 -0
  50. package/dist/oauth/types.d.ts +90 -0
  51. package/dist/oauth/types.d.ts.map +1 -0
  52. package/dist/oauth/types.js +2 -0
  53. package/dist/oauth/types.js.map +1 -0
  54. package/dist/open-browser.d.ts +32 -0
  55. package/dist/open-browser.d.ts.map +1 -0
  56. package/dist/open-browser.js +83 -0
  57. package/dist/open-browser.js.map +1 -0
  58. package/dist/pkce.d.ts +20 -0
  59. package/dist/pkce.d.ts.map +1 -0
  60. package/dist/pkce.js +34 -0
  61. package/dist/pkce.js.map +1 -0
  62. package/dist/profile.d.ts +129 -0
  63. package/dist/profile.d.ts.map +1 -0
  64. package/dist/profile.js +11 -0
  65. package/dist/profile.js.map +1 -0
  66. package/dist/run-login.d.ts +9 -0
  67. package/dist/run-login.d.ts.map +1 -0
  68. package/dist/run-login.js +93 -0
  69. package/dist/run-login.js.map +1 -0
  70. package/dist/storage.d.ts +46 -0
  71. package/dist/storage.d.ts.map +1 -0
  72. package/dist/storage.js +167 -0
  73. package/dist/storage.js.map +1 -0
  74. package/dist/tools.d.ts +8 -0
  75. package/dist/tools.d.ts.map +1 -0
  76. package/dist/tools.js +283 -0
  77. package/dist/tools.js.map +1 -0
  78. package/package.json +73 -0
  79. package/skills/google-oauth.md +313 -0
  80. package/skills/oauth-flow.md +132 -0
  81. package/src/adapters/openai-device-flow.test.ts +191 -0
  82. package/src/adapters/openai-device-flow.ts +179 -0
  83. package/src/adapters/rfc8628-device-flow.ts +75 -0
  84. package/src/credential-lock.ts +183 -0
  85. package/src/discovery.test.ts +35 -0
  86. package/src/ensure-fresh.test.ts +279 -0
  87. package/src/ensure-fresh.ts +178 -0
  88. package/src/flow.ts +9 -0
  89. package/src/index.ts +153 -0
  90. package/src/oauth/browser-flow.ts +96 -0
  91. package/src/oauth/callback-server.test.ts +90 -0
  92. package/src/oauth/callback-server.ts +208 -0
  93. package/src/oauth/device-flow-shared.test.ts +142 -0
  94. package/src/oauth/device-flow-shared.ts +129 -0
  95. package/src/oauth/device-flow.test.ts +102 -0
  96. package/src/oauth/device-flow.ts +82 -0
  97. package/src/oauth/poll-until.test.ts +63 -0
  98. package/src/oauth/poll-until.ts +100 -0
  99. package/src/oauth/token-exchange.test.ts +224 -0
  100. package/src/oauth/token-exchange.ts +169 -0
  101. package/src/oauth/types.ts +92 -0
  102. package/src/oauth.test.ts +185 -0
  103. package/src/open-browser-spawn.test.ts +69 -0
  104. package/src/open-browser.test.ts +30 -0
  105. package/src/open-browser.ts +84 -0
  106. package/src/pkce.ts +37 -0
  107. package/src/profile.test.ts +331 -0
  108. package/src/profile.ts +135 -0
  109. package/src/run-login.ts +123 -0
  110. package/src/storage.test.ts +191 -0
  111. package/src/storage.ts +208 -0
  112. package/src/tools.test.ts +239 -0
  113. package/src/tools.ts +332 -0
@@ -0,0 +1,96 @@
1
+ import { computeCodeChallenge, generateCodeVerifier, generateState } from '../pkce.js';
2
+ import { openInBrowser } from '../open-browser.js';
3
+ import { waitForCallback } from './callback-server.js';
4
+ import { exchangeCodeForToken } from './token-exchange.js';
5
+ import type { OAuthFlowOptions, TokenSet } from './types.js';
6
+
7
+ export interface BuildAuthUrlInput {
8
+ readonly authUrl: string;
9
+ readonly clientId: string;
10
+ readonly redirectUri: string;
11
+ readonly scopes: ReadonlyArray<string>;
12
+ readonly codeChallenge: string;
13
+ readonly state: string;
14
+ readonly extraAuthParams?: Readonly<Record<string, string>>;
15
+ }
16
+
17
+ /** Pure URL builder, exported separately so tests can assert on it. */
18
+ export function buildAuthUrl(input: BuildAuthUrlInput): string {
19
+ const url = new URL(input.authUrl);
20
+ url.searchParams.set('client_id', input.clientId);
21
+ url.searchParams.set('redirect_uri', input.redirectUri);
22
+ url.searchParams.set('response_type', 'code');
23
+ url.searchParams.set('scope', input.scopes.join(' '));
24
+ url.searchParams.set('state', input.state);
25
+ url.searchParams.set('code_challenge', input.codeChallenge);
26
+ url.searchParams.set('code_challenge_method', 'S256');
27
+ for (const [k, v] of Object.entries(input.extraAuthParams ?? {})) {
28
+ url.searchParams.set(k, v);
29
+ }
30
+ return url.toString();
31
+ }
32
+
33
+ /**
34
+ * Run the full authorization-code-with-PKCE dance:
35
+ * 1. Bind a loopback HTTP server on `redirectPort`.
36
+ * 2. Build the auth URL (PKCE challenge, CSRF state, scopes, etc.).
37
+ * 3. Open the URL in the user's default browser.
38
+ * 4. Wait for the provider to redirect back with `code` + `state`.
39
+ * 5. Verify state, POST the code to the token endpoint with the
40
+ * verifier, return the parsed token set.
41
+ */
42
+ export async function runAuthorizationCodeFlow(opts: OAuthFlowOptions): Promise<TokenSet> {
43
+ const port = opts.redirectPort ?? 8765;
44
+ const path = opts.redirectPath ?? '/callback';
45
+ const codeVerifier = generateCodeVerifier();
46
+ const codeChallenge = computeCodeChallenge(codeVerifier);
47
+ const state = generateState();
48
+ const redirectUri = `http://localhost:${port}${path}`;
49
+
50
+ const authUrl = buildAuthUrl({
51
+ authUrl: opts.authUrl,
52
+ clientId: opts.clientId,
53
+ redirectUri,
54
+ scopes: opts.scopes,
55
+ codeChallenge,
56
+ state,
57
+ ...(opts.extraAuthParams ? { extraAuthParams: opts.extraAuthParams } : {}),
58
+ });
59
+
60
+ // Start the server BEFORE opening the browser — otherwise the user
61
+ // could complete the consent screen before we're listening and the
62
+ // redirect would 404.
63
+ const codePromise = waitForCallback({
64
+ port,
65
+ path,
66
+ expectedState: state,
67
+ timeoutMs: opts.timeoutMs ?? 300_000,
68
+ ...(opts.signal ? { signal: opts.signal } : {}),
69
+ });
70
+
71
+ if (opts.onAuthUrl) opts.onAuthUrl(authUrl);
72
+ if (!opts.noOpen) {
73
+ try {
74
+ await openInBrowser(authUrl);
75
+ } catch {
76
+ // Failed to open the browser — not fatal; the user can visit
77
+ // the URL surfaced via onAuthUrl. The loopback server is still
78
+ // listening.
79
+ }
80
+ }
81
+
82
+ const code = await codePromise;
83
+ // Thread the abort signal into the exchange too: a turn cancelled AFTER the
84
+ // browser redirect arrives but DURING the token POST must cancel the in-flight
85
+ // fetch, not leak a hung request past the cancel. (waitForCallback already
86
+ // honours the same signal for the listening phase.)
87
+ return exchangeCodeForToken({
88
+ tokenUrl: opts.tokenUrl,
89
+ code,
90
+ redirectUri,
91
+ clientId: opts.clientId,
92
+ ...(opts.clientSecret ? { clientSecret: opts.clientSecret } : {}),
93
+ codeVerifier,
94
+ ...(opts.signal ? { signal: opts.signal } : {}),
95
+ });
96
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import net from 'node:net';
3
+ import { request } from 'node:http';
4
+ import { waitForCallback } from './callback-server';
5
+
6
+ /** Grab a port that's free right now (best-effort; good enough for a test). */
7
+ function freePort(): Promise<number> {
8
+ return new Promise((resolve, reject) => {
9
+ const s = net.createServer();
10
+ s.once('error', reject);
11
+ s.listen(0, '127.0.0.1', () => {
12
+ const port = (s.address() as net.AddressInfo).port;
13
+ s.close(() => resolve(port));
14
+ });
15
+ });
16
+ }
17
+
18
+ /** GET host:port/path, resolving once the response completes (or rejecting on
19
+ * a connection error — e.g. the stack isn't listening). */
20
+ function hit(host: string, port: number, path: string): Promise<void> {
21
+ return new Promise((resolve, reject) => {
22
+ const req = request({ host, port, path, method: 'GET' }, (res) => {
23
+ res.resume();
24
+ res.on('end', () => resolve());
25
+ });
26
+ req.on('error', reject);
27
+ req.end();
28
+ });
29
+ }
30
+
31
+ const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 50));
32
+
33
+ describe('waitForCallback — dual-stack loopback', () => {
34
+ it('receives the callback over IPv4 (127.0.0.1)', async () => {
35
+ const port = await freePort();
36
+ const pending = waitForCallback({ port, path: '/auth/callback', expectedState: 'S', timeoutMs: 4_000 });
37
+ await tick(); // let both listeners bind
38
+ await hit('127.0.0.1', port, '/auth/callback?code=CODE4&state=S');
39
+ expect(await pending).toBe('CODE4');
40
+ });
41
+
42
+ it('receives the callback over IPv6 (::1) — the Windows `localhost` path', async () => {
43
+ const port = await freePort();
44
+ const pending = waitForCallback({ port, path: '/auth/callback', expectedState: 'S', timeoutMs: 4_000 });
45
+ await tick();
46
+ // Prefer IPv6 (what Windows hits); fall back to IPv4 only if this host has
47
+ // no IPv6 loopback, so the test stays green on IPv6-less CI runners.
48
+ try {
49
+ await hit('::1', port, '/auth/callback?code=CODE6&state=S');
50
+ } catch {
51
+ await hit('127.0.0.1', port, '/auth/callback?code=CODE6&state=S');
52
+ }
53
+ expect(await pending).toBe('CODE6');
54
+ });
55
+
56
+ it('rejects a state mismatch (CSRF guard) regardless of stack', async () => {
57
+ const port = await freePort();
58
+ const pending = waitForCallback({ port, path: '/auth/callback', expectedState: 'GOOD', timeoutMs: 4_000 });
59
+ // Attach the rejection assertion BEFORE triggering the callback so the
60
+ // rejection is never momentarily unhandled.
61
+ const rejected = expect(pending).rejects.toThrow(/state mismatch/i);
62
+ await tick();
63
+ await hit('127.0.0.1', port, '/auth/callback?code=X&state=EVIL');
64
+ await rejected;
65
+ });
66
+
67
+ it('rejects immediately on an ALREADY-aborted signal — never binds, never blocks', async () => {
68
+ const port = await freePort();
69
+ const ac = new AbortController();
70
+ ac.abort(); // aborted BEFORE the flow starts → the abort event never fires
71
+ const started = Date.now();
72
+ // A huge timeout: if the guard were missing it would block for the full
73
+ // timeout (the abort listener never runs for a pre-aborted signal).
74
+ await expect(
75
+ waitForCallback({
76
+ port,
77
+ path: '/auth/callback',
78
+ expectedState: 'S',
79
+ timeoutMs: 60_000,
80
+ signal: ac.signal,
81
+ }),
82
+ ).rejects.toMatchObject({ code: 'NETWORK_ABORTED' });
83
+ expect(Date.now() - started).toBeLessThan(1_000);
84
+ // The port was never bound — a fresh listener can claim it without EADDRINUSE.
85
+ const second = waitForCallback({ port, path: '/auth/callback', expectedState: 'S', timeoutMs: 2_000 });
86
+ await tick();
87
+ await hit('127.0.0.1', port, '/auth/callback?code=AFTER&state=S');
88
+ expect(await second).toBe('AFTER');
89
+ });
90
+ });
@@ -0,0 +1,208 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+
4
+ interface WaitForCallbackOpts {
5
+ readonly port: number;
6
+ readonly path: string;
7
+ readonly expectedState: string;
8
+ readonly timeoutMs: number;
9
+ readonly signal?: AbortSignal;
10
+ }
11
+
12
+ export function waitForCallback(opts: WaitForCallbackOpts): Promise<string> {
13
+ return new Promise<string>((resolve, reject) => {
14
+ // Bind BOTH loopback stacks. The redirect_uri uses `localhost`, which
15
+ // Windows resolves to `::1` (IPv6) first while macOS/Linux use `127.0.0.1`
16
+ // (IPv4). A v4-only listener therefore never receives the redirect on
17
+ // Windows → the login hangs on "waiting for the browser". IPv4 is required;
18
+ // IPv6 is best-effort (a host without it still works, since there
19
+ // `localhost` is IPv4 anyway).
20
+ const servers: Server[] = [];
21
+ let settled = false;
22
+ const settle = (fn: () => void): void => {
23
+ if (settled) return;
24
+ settled = true;
25
+ // Single cleanup chokepoint: clear the timeout AND detach the abort
26
+ // listener on every exit (success/timeout/abort/error), so neither a
27
+ // dangling timer nor a lingering signal listener is left behind. Guard
28
+ // each close()/fn() so a Server.close() that throws ERR_SERVER_NOT_RUNNING
29
+ // (server pushed before its listen completed) can never strand the
30
+ // remaining servers or skip the resolve/reject.
31
+ clearTimeout(timer);
32
+ opts.signal?.removeEventListener('abort', onAbort);
33
+ try {
34
+ for (const s of servers) {
35
+ try {
36
+ s.close();
37
+ } catch {
38
+ /* not yet listening — nothing to close */
39
+ }
40
+ }
41
+ } finally {
42
+ fn();
43
+ }
44
+ };
45
+
46
+ const timer = setTimeout(() => {
47
+ settle(() =>
48
+ reject(
49
+ new MoxxyError({
50
+ code: 'OAUTH_FLOW_TIMEOUT',
51
+ message: `OAuth callback timed out after ${Math.round(opts.timeoutMs / 1000)}s.`,
52
+ hint: 'Re-run the login command and complete the consent screen before the timeout.',
53
+ context: { port: opts.port, path: opts.path, timeout_ms: opts.timeoutMs },
54
+ }),
55
+ ),
56
+ );
57
+ }, opts.timeoutMs);
58
+ timer.unref?.();
59
+
60
+ const onAbort = (): void => {
61
+ settle(() =>
62
+ reject(
63
+ new MoxxyError({
64
+ code: 'NETWORK_ABORTED',
65
+ message: 'OAuth flow was aborted.',
66
+ }),
67
+ ),
68
+ );
69
+ };
70
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
71
+
72
+ // A signal that was ALREADY aborted before entry never fires `abort`, so
73
+ // the listener above won't run — without this guard we'd bind two HTTP
74
+ // servers and block for the full timeout, ignoring the cancel. (pollUntil
75
+ // guards the same case.) settle() detaches the listener and skips binding.
76
+ if (opts.signal?.aborted) {
77
+ settle(() =>
78
+ reject(
79
+ new MoxxyError({
80
+ code: 'NETWORK_ABORTED',
81
+ message: 'OAuth flow was aborted.',
82
+ }),
83
+ ),
84
+ );
85
+ return;
86
+ }
87
+
88
+ const handleRequest = (req: IncomingMessage, res: ServerResponse): void => {
89
+ const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
90
+ if (url.pathname !== opts.path) {
91
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
92
+ res.end('not found');
93
+ return;
94
+ }
95
+ const err = url.searchParams.get('error');
96
+ const errDesc = url.searchParams.get('error_description');
97
+ if (err) {
98
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
99
+ res.end(htmlPage('OAuth error', `${err}${errDesc ? `: ${errDesc}` : ''}`));
100
+ const denied = err === 'access_denied';
101
+ settle(() =>
102
+ reject(
103
+ new MoxxyError({
104
+ code: denied ? 'OAUTH_FLOW_DENIED' : 'AUTH_INVALID',
105
+ message: denied
106
+ ? 'You declined the authorization request.'
107
+ : `Authorization server returned an error: ${err}${errDesc ? ` — ${errDesc}` : ''}.`,
108
+ ...(denied
109
+ ? { hint: 'Re-run the login command and approve the consent screen to continue.' }
110
+ : {}),
111
+ context: { provider_error: err, ...(errDesc ? { description: errDesc } : {}) },
112
+ }),
113
+ ),
114
+ );
115
+ return;
116
+ }
117
+ const code = url.searchParams.get('code');
118
+ const returnedState = url.searchParams.get('state');
119
+ if (!code || !returnedState) {
120
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
121
+ res.end(htmlPage('OAuth error', 'callback was missing code or state'));
122
+ settle(() =>
123
+ reject(
124
+ new MoxxyError({
125
+ code: 'AUTH_INVALID',
126
+ message: 'OAuth callback was missing code or state — the upstream redirect is malformed.',
127
+ hint: 'Re-run the login command. If this persists, the provider may have rejected the request.',
128
+ }),
129
+ ),
130
+ );
131
+ return;
132
+ }
133
+ if (returnedState !== opts.expectedState) {
134
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
135
+ res.end(htmlPage('OAuth error', 'state mismatch — possible CSRF, refusing'));
136
+ settle(() =>
137
+ reject(
138
+ new MoxxyError({
139
+ code: 'OAUTH_FLOW_STATE_MISMATCH',
140
+ message: 'OAuth state mismatch — possible CSRF attempt, refusing to continue.',
141
+ hint:
142
+ 'Make sure no other moxxy login is running at the same time, and re-run the command. ' +
143
+ 'If this keeps happening, your browser or a proxy may be tampering with redirects.',
144
+ }),
145
+ ),
146
+ );
147
+ return;
148
+ }
149
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
150
+ res.end(htmlPage('Authorized', 'You can close this window — moxxy received the token.'));
151
+ settle(() => resolve(code));
152
+ };
153
+
154
+ const onFatalError = (e: NodeJS.ErrnoException): void => {
155
+ if (e.code === 'EADDRINUSE') {
156
+ settle(() =>
157
+ reject(
158
+ new MoxxyError({
159
+ code: 'OAUTH_FLOW_PORT_BUSY',
160
+ message: `OAuth callback port ${opts.port} is already in use.`,
161
+ hint:
162
+ 'Another moxxy login may already be running, or the port is occupied by something else. ' +
163
+ `Stop the other process, or set a different port for this provider's redirect.`,
164
+ context: { port: opts.port },
165
+ cause: e,
166
+ }),
167
+ ),
168
+ );
169
+ return;
170
+ }
171
+ settle(() =>
172
+ reject(
173
+ MoxxyError.wrap(e, {
174
+ code: 'INTERNAL',
175
+ message: `OAuth callback server failed: ${e.message}`,
176
+ }),
177
+ ),
178
+ );
179
+ };
180
+
181
+ // IPv4 loopback — the required listener; its bind/port errors are fatal.
182
+ const v4 = createServer(handleRequest);
183
+ v4.on('error', onFatalError);
184
+ v4.listen(opts.port, '127.0.0.1');
185
+ servers.push(v4);
186
+
187
+ // IPv6 loopback — Windows resolves `localhost` to ::1 first, so the
188
+ // redirect lands here. Best-effort: swallow bind errors (host without IPv6,
189
+ // or ::1 already bound), since the IPv4 listener above is the guaranteed
190
+ // path and on such hosts `localhost` is IPv4 anyway.
191
+ const v6 = createServer(handleRequest);
192
+ v6.on('error', () => undefined);
193
+ v6.listen(opts.port, '::1');
194
+ servers.push(v6);
195
+ });
196
+ }
197
+
198
+ function htmlPage(title: string, body: string): string {
199
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
200
+ <style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#111;color:#eee;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}h1{font-weight:300}</style>
201
+ </head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p></body></html>`;
202
+ }
203
+
204
+ function escapeHtml(s: string): string {
205
+ return s.replace(/[&<>"']/g, (c) =>
206
+ c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
207
+ );
208
+ }
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+ import {
4
+ classifyDeviceTokenResponse,
5
+ requestDeviceAuthorization,
6
+ } from './device-flow-shared.js';
7
+
8
+ /** RFC 8628 §3.5 state machine — the throwing branches were previously
9
+ * untested, so a regression in the error-code mapping would ship silently. */
10
+ describe('classifyDeviceTokenResponse', () => {
11
+ const ok = { ok: true, status: 200 };
12
+ const bad = { ok: false, status: 400 };
13
+
14
+ it('returns {done} with a parsed TokenSet on success', () => {
15
+ const state = { intervalMs: 5000 };
16
+ const outcome = classifyDeviceTokenResponse(
17
+ ok,
18
+ { access_token: 'at-1', refresh_token: 'rt-1', token_type: 'Bearer', expires_in: 3600 },
19
+ state,
20
+ );
21
+ expect('done' in outcome).toBe(true);
22
+ if ('done' in outcome) {
23
+ expect(outcome.done.accessToken).toBe('at-1');
24
+ }
25
+ });
26
+
27
+ it('authorization_pending → keep polling, interval unchanged', () => {
28
+ const state = { intervalMs: 5000 };
29
+ const outcome = classifyDeviceTokenResponse(bad, { error: 'authorization_pending' }, state);
30
+ expect(outcome).toEqual({ pending: true });
31
+ expect(state.intervalMs).toBe(5000);
32
+ });
33
+
34
+ it('slow_down → pending and bumps the interval by exactly 5000ms', () => {
35
+ const state = { intervalMs: 5000 };
36
+ const outcome = classifyDeviceTokenResponse(bad, { error: 'slow_down' }, state);
37
+ expect(outcome).toEqual({ pending: true });
38
+ expect(state.intervalMs).toBe(10000);
39
+ });
40
+
41
+ it('access_denied → throws OAUTH_FLOW_DENIED', () => {
42
+ const state = { intervalMs: 5000 };
43
+ try {
44
+ classifyDeviceTokenResponse(bad, { error: 'access_denied' }, state);
45
+ throw new Error('expected throw');
46
+ } catch (err) {
47
+ expect(err).toBeInstanceOf(MoxxyError);
48
+ expect((err as MoxxyError).code).toBe('OAUTH_FLOW_DENIED');
49
+ }
50
+ });
51
+
52
+ it('expired_token → throws OAUTH_FLOW_TIMEOUT', () => {
53
+ const state = { intervalMs: 5000 };
54
+ try {
55
+ classifyDeviceTokenResponse(bad, { error: 'expired_token' }, state);
56
+ throw new Error('expected throw');
57
+ } catch (err) {
58
+ expect(err).toBeInstanceOf(MoxxyError);
59
+ expect((err as MoxxyError).code).toBe('OAUTH_FLOW_TIMEOUT');
60
+ }
61
+ });
62
+
63
+ it('generic error → throws AUTH_INVALID with the provider_error in context', () => {
64
+ const state = { intervalMs: 5000 };
65
+ try {
66
+ classifyDeviceTokenResponse(
67
+ bad,
68
+ { error: 'invalid_grant', error_description: 'bad code' },
69
+ state,
70
+ );
71
+ throw new Error('expected throw');
72
+ } catch (err) {
73
+ expect(err).toBeInstanceOf(MoxxyError);
74
+ const e = err as MoxxyError;
75
+ expect(e.code).toBe('AUTH_INVALID');
76
+ expect(e.context?.provider_error).toBe('invalid_grant');
77
+ expect(e.context?.description).toBe('bad code');
78
+ }
79
+ });
80
+
81
+ it('non-ok with no error field → AUTH_INVALID keyed by the HTTP status', () => {
82
+ const state = { intervalMs: 5000 };
83
+ try {
84
+ classifyDeviceTokenResponse({ ok: false, status: 503 }, {}, state);
85
+ throw new Error('expected throw');
86
+ } catch (err) {
87
+ expect(err).toBeInstanceOf(MoxxyError);
88
+ const e = err as MoxxyError;
89
+ expect(e.code).toBe('AUTH_INVALID');
90
+ expect(e.context?.provider_error).toBe('HTTP 503');
91
+ }
92
+ });
93
+ });
94
+
95
+ describe('requestDeviceAuthorization — malformed 2xx body', () => {
96
+ afterEach(() => {
97
+ vi.unstubAllGlobals();
98
+ });
99
+
100
+ const args = {
101
+ deviceUrl: 'https://idp.example/device',
102
+ clientId: 'cid',
103
+ scopes: ['openid'],
104
+ };
105
+
106
+ it('maps a non-JSON 200 body to PROVIDER_UNKNOWN_RESPONSE (not a raw SyntaxError)', async () => {
107
+ vi.stubGlobal(
108
+ 'fetch',
109
+ vi.fn(async () => ({
110
+ ok: true,
111
+ status: 200,
112
+ json: async () => {
113
+ throw new SyntaxError('Unexpected token < in JSON at position 0');
114
+ },
115
+ text: async () => '<html>proxy error</html>',
116
+ })),
117
+ );
118
+ let err: unknown;
119
+ try {
120
+ await requestDeviceAuthorization(args);
121
+ } catch (e) {
122
+ err = e;
123
+ }
124
+ expect(err).toBeInstanceOf(MoxxyError);
125
+ expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
126
+ });
127
+
128
+ it('still rejects with PROVIDER_UNKNOWN_RESPONSE when required fields are absent', async () => {
129
+ vi.stubGlobal(
130
+ 'fetch',
131
+ vi.fn(async () => ({
132
+ ok: true,
133
+ status: 200,
134
+ json: async () => ({ user_code: 'ABC' }), // missing device_code + verification_uri
135
+ text: async () => '',
136
+ })),
137
+ );
138
+ await expect(requestDeviceAuthorization(args)).rejects.toMatchObject({
139
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,129 @@
1
+ import { classifyHttpStatus, MoxxyError } from '@moxxy/sdk';
2
+ import { parseTokenResponse } from './token-exchange.js';
3
+ import type { PollOutcome } from './poll-until.js';
4
+ import type { TokenSet } from './types.js';
5
+
6
+ /**
7
+ * Parsed RFC 8628 device-authorization response. Times are kept in seconds (as
8
+ * the wire reports them); callers convert to ms where their API needs it.
9
+ */
10
+ export interface DeviceAuthorization {
11
+ readonly deviceCode: string;
12
+ readonly userCode: string;
13
+ readonly verificationUri: string;
14
+ readonly verificationUriComplete?: string;
15
+ readonly expiresInSec: number;
16
+ readonly intervalSec: number;
17
+ }
18
+
19
+ /**
20
+ * POST the RFC 8628 device-authorization request and parse the response. The
21
+ * single home for the request + field parsing + error handling shared by the
22
+ * legacy `runDeviceCodeFlow` and the `rfc8628DeviceFlow` adapter.
23
+ */
24
+ export async function requestDeviceAuthorization(args: {
25
+ readonly deviceUrl: string;
26
+ readonly clientId: string;
27
+ readonly scopes: ReadonlyArray<string>;
28
+ readonly signal?: AbortSignal;
29
+ }): Promise<DeviceAuthorization> {
30
+ const body = new URLSearchParams();
31
+ body.set('client_id', args.clientId);
32
+ body.set('scope', args.scopes.join(' '));
33
+ const res = await fetch(args.deviceUrl, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
36
+ body: body.toString(),
37
+ ...(args.signal ? { signal: args.signal } : {}),
38
+ });
39
+ if (!res.ok) {
40
+ const text = await res.text().catch(() => '');
41
+ throw (
42
+ classifyHttpStatus(res.status, { url: args.deviceUrl, body: text }) ??
43
+ new MoxxyError({
44
+ code: 'AUTH_INVALID',
45
+ message: `device-code request failed (HTTP ${res.status}): ${text.slice(0, 300)}`,
46
+ context: { status: res.status, url: args.deviceUrl },
47
+ })
48
+ );
49
+ }
50
+ // A 200 with a non-JSON/truncated body (proxy/captive-portal error page)
51
+ // would make res.json() reject with a raw SyntaxError that escapes the
52
+ // MoxxyError boundary; convert it to a typed PROVIDER_UNKNOWN_RESPONSE.
53
+ const json = (await res.json().catch(() => {
54
+ throw new MoxxyError({
55
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
56
+ message: 'device-authorization endpoint returned a non-JSON success body',
57
+ context: { url: args.deviceUrl },
58
+ });
59
+ })) as Record<string, unknown>;
60
+ const deviceCode = typeof json.device_code === 'string' ? json.device_code : null;
61
+ const userCode = typeof json.user_code === 'string' ? json.user_code : null;
62
+ const verificationUri =
63
+ typeof json.verification_uri === 'string'
64
+ ? json.verification_uri
65
+ : typeof json.verification_url === 'string'
66
+ ? json.verification_url
67
+ : null;
68
+ const verificationUriComplete =
69
+ typeof json.verification_uri_complete === 'string' ? json.verification_uri_complete : undefined;
70
+ const expiresInSec = typeof json.expires_in === 'number' ? json.expires_in : 600;
71
+ const intervalSec = typeof json.interval === 'number' ? json.interval : 5;
72
+ if (!deviceCode || !userCode || !verificationUri) {
73
+ throw new MoxxyError({
74
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
75
+ message: `device-code response missing required fields: ${JSON.stringify(json).slice(0, 200)}`,
76
+ });
77
+ }
78
+ return {
79
+ deviceCode,
80
+ userCode,
81
+ verificationUri,
82
+ ...(verificationUriComplete ? { verificationUriComplete } : {}),
83
+ expiresInSec,
84
+ intervalSec,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Classify a device-flow token-poll response per RFC 8628 §3.5: success →
90
+ * `done`; `authorization_pending` → keep waiting; `slow_down` → +5s and wait;
91
+ * `access_denied` / `expired_token` / other → throw a MoxxyError. Shared by both
92
+ * poll loops; each caller builds its own request body (the legacy flow appends
93
+ * client_id/client_secret) and hands the response + parsed JSON here.
94
+ */
95
+ export function classifyDeviceTokenResponse(
96
+ res: { readonly ok: boolean; readonly status: number },
97
+ json: Record<string, unknown>,
98
+ state: { intervalMs: number },
99
+ ): PollOutcome<TokenSet> {
100
+ if (res.ok && typeof json.access_token === 'string') {
101
+ return { done: parseTokenResponse(json) };
102
+ }
103
+ const err = typeof json.error === 'string' ? json.error : `HTTP ${res.status}`;
104
+ if (err === 'authorization_pending') return { pending: true };
105
+ if (err === 'slow_down') {
106
+ state.intervalMs += 5000;
107
+ return { pending: true };
108
+ }
109
+ if (err === 'access_denied') {
110
+ throw new MoxxyError({
111
+ code: 'OAUTH_FLOW_DENIED',
112
+ message: 'You declined the device authorization.',
113
+ hint: 'Re-run the login command and approve the consent screen on your browser device.',
114
+ });
115
+ }
116
+ if (err === 'expired_token') {
117
+ throw new MoxxyError({
118
+ code: 'OAUTH_FLOW_TIMEOUT',
119
+ message: 'The device code expired before you finished signing in.',
120
+ hint: 'Re-run the login command — a new code will be generated.',
121
+ });
122
+ }
123
+ const desc = typeof json.error_description === 'string' ? json.error_description : '';
124
+ throw new MoxxyError({
125
+ code: 'AUTH_INVALID',
126
+ message: `OAuth device flow failed: ${err}${desc ? ` — ${desc}` : ''}.`,
127
+ context: { provider_error: String(err), ...(desc ? { description: desc } : {}) },
128
+ });
129
+ }