@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,331 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+ import {
4
+ openaiDeviceFlow,
5
+ pollUntil,
6
+ readStoredCreds,
7
+ rfc8628DeviceFlow,
8
+ storeTokenSet,
9
+ type OAuthVault,
10
+ type PollOutcome,
11
+ type TokenSet,
12
+ } from './index.js';
13
+
14
+ function newFakeVault(): OAuthVault & { dump(): Record<string, string> } {
15
+ const store = new Map<string, string>();
16
+ return {
17
+ async get(key) {
18
+ return store.has(key) ? store.get(key)! : null;
19
+ },
20
+ async set(key, value) {
21
+ store.set(key, value);
22
+ },
23
+ async delete(key) {
24
+ return store.delete(key);
25
+ },
26
+ dump() {
27
+ return Object.fromEntries(store);
28
+ },
29
+ };
30
+ }
31
+
32
+ describe('pollUntil', () => {
33
+ it('returns the first `done` value', async () => {
34
+ let calls = 0;
35
+ const result = await pollUntil<string>(
36
+ async () => {
37
+ calls += 1;
38
+ if (calls < 3) return { pending: true };
39
+ return { done: 'ok' };
40
+ },
41
+ { intervalMs: 1, timeoutMs: 1000, leadingWait: false },
42
+ );
43
+ expect(result).toBe('ok');
44
+ expect(calls).toBe(3);
45
+ });
46
+
47
+ it('throws a typed OAUTH_FLOW_TIMEOUT MoxxyError when the deadline elapses', async () => {
48
+ const err = await pollUntil<string>(
49
+ async () => ({ pending: true }),
50
+ { intervalMs: 1, timeoutMs: 5, leadingWait: false },
51
+ ).catch((e) => e);
52
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
53
+ expect((err as MoxxyError).code).toBe('OAUTH_FLOW_TIMEOUT');
54
+ expect((err as MoxxyError).message).toMatch(/timed out/);
55
+ });
56
+
57
+ it('respects an in-flight abort signal (typed NETWORK_ABORTED MoxxyError)', async () => {
58
+ const ctrl = new AbortController();
59
+ const p = pollUntil<string>(
60
+ async () => ({ pending: true }),
61
+ { intervalMs: 100, timeoutMs: 1000, signal: ctrl.signal, leadingWait: false },
62
+ );
63
+ setTimeout(() => ctrl.abort(), 10);
64
+ const err = await p.catch((e) => e);
65
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
66
+ expect((err as MoxxyError).code).toBe('NETWORK_ABORTED');
67
+ expect((err as MoxxyError).message).toMatch(/aborted/);
68
+ });
69
+
70
+ it('throws a typed NETWORK_ABORTED MoxxyError when the signal is already aborted', async () => {
71
+ const ctrl = new AbortController();
72
+ ctrl.abort();
73
+ const err = await pollUntil<string>(
74
+ async () => ({ pending: true }),
75
+ { intervalMs: 1, timeoutMs: 1000, signal: ctrl.signal },
76
+ ).catch((e) => e);
77
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
78
+ expect((err as MoxxyError).code).toBe('NETWORK_ABORTED');
79
+ });
80
+
81
+ it('lets the polling fn bump state.intervalMs', async () => {
82
+ let bumpedOnce = false;
83
+ const calls: number[] = [];
84
+ await pollUntil<string>(
85
+ async (state) => {
86
+ calls.push(state.intervalMs);
87
+ if (!bumpedOnce) {
88
+ state.intervalMs += 50;
89
+ bumpedOnce = true;
90
+ return { pending: true };
91
+ }
92
+ return { done: 'x' };
93
+ },
94
+ { intervalMs: 1, timeoutMs: 1000, leadingWait: false },
95
+ );
96
+ expect(calls).toEqual([1, 51]);
97
+ });
98
+ });
99
+
100
+ describe('storage extras round-trip', () => {
101
+ it('persists + restores arbitrary string extras', async () => {
102
+ const vault = newFakeVault();
103
+ const tokens: TokenSet = {
104
+ accessToken: 'AT',
105
+ refreshToken: 'RT',
106
+ expiresAt: Date.now() + 60_000,
107
+ tokenType: 'Bearer',
108
+ };
109
+ await storeTokenSet(vault, 'demo', tokens, {
110
+ clientId: 'CID',
111
+ tokenUrl: 'https://example.test/token',
112
+ extras: { account_id: 'acct_123', team_slug: 'eng' },
113
+ });
114
+ const stored = await readStoredCreds(vault, 'demo');
115
+ expect(stored).not.toBeNull();
116
+ expect(stored!.extras).toEqual({ account_id: 'acct_123', team_slug: 'eng' });
117
+ expect(stored!.tokenSet.accessToken).toBe('AT');
118
+ });
119
+
120
+ it('returns an empty extras map when none were stored', async () => {
121
+ const vault = newFakeVault();
122
+ await storeTokenSet(vault, 'demo', { accessToken: 'AT', tokenType: 'Bearer' }, {
123
+ clientId: 'CID',
124
+ tokenUrl: 'https://example.test/token',
125
+ });
126
+ const stored = await readStoredCreds(vault, 'demo');
127
+ expect(stored!.extras).toEqual({});
128
+ });
129
+ });
130
+
131
+ describe('rfc8628DeviceFlow adapter', () => {
132
+ it('start() POSTs form-encoded client_id+scope and parses the init', async () => {
133
+ const fetchSpy = vi.fn(async (url: RequestInfo | URL, init: RequestInit) => {
134
+ expect(String(url)).toBe('https://example.test/device/code');
135
+ expect(init.method).toBe('POST');
136
+ const body = new URLSearchParams(String(init.body ?? ''));
137
+ expect(body.get('client_id')).toBe('CID');
138
+ expect(body.get('scope')).toBe('openid email');
139
+ return new Response(
140
+ JSON.stringify({
141
+ device_code: 'DEV',
142
+ user_code: 'AB-CD',
143
+ verification_uri: 'https://example.test/device',
144
+ interval: 3,
145
+ expires_in: 600,
146
+ }),
147
+ { status: 200 },
148
+ );
149
+ });
150
+ const realFetch = globalThis.fetch;
151
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
152
+ try {
153
+ const adapter = rfc8628DeviceFlow({
154
+ deviceUrl: 'https://example.test/device/code',
155
+ tokenUrl: 'https://example.test/token',
156
+ });
157
+ const init = await adapter.start({ clientId: 'CID', scopes: ['openid', 'email'] });
158
+ expect(init.userCode).toBe('AB-CD');
159
+ expect(init.verificationUri).toBe('https://example.test/device');
160
+ expect(init.intervalMs).toBe(3000);
161
+ expect(init.expiresInMs).toBe(600_000);
162
+ } finally {
163
+ globalThis.fetch = realFetch;
164
+ }
165
+ });
166
+
167
+ it('poll() classifies authorization_pending / slow_down / done', async () => {
168
+ const adapter = rfc8628DeviceFlow({
169
+ deviceUrl: 'https://example.test/device/code',
170
+ tokenUrl: 'https://example.test/token',
171
+ });
172
+ const init = {
173
+ userCode: 'X',
174
+ verificationUri: 'https://example.test/device',
175
+ intervalMs: 5000,
176
+ expiresInMs: 60_000,
177
+ providerData: { deviceCode: 'DEV' },
178
+ };
179
+
180
+ const realFetch = globalThis.fetch;
181
+ const responses: Array<() => Response> = [
182
+ () =>
183
+ new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 400 }),
184
+ () => new Response(JSON.stringify({ error: 'slow_down' }), { status: 400 }),
185
+ () =>
186
+ new Response(
187
+ JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }),
188
+ { status: 200 },
189
+ ),
190
+ ];
191
+ globalThis.fetch = (async () => responses.shift()!()) as unknown as typeof fetch;
192
+ try {
193
+ const state = { intervalMs: 5000 };
194
+ const r1 = await adapter.poll(init, state);
195
+ expect('pending' in r1 && r1.pending).toBe(true);
196
+ const r2 = await adapter.poll(init, state);
197
+ expect('pending' in r2 && r2.pending).toBe(true);
198
+ expect(state.intervalMs).toBe(10_000);
199
+ const r3 = (await adapter.poll(init, state)) as Extract<
200
+ PollOutcome<TokenSet>,
201
+ { done: TokenSet }
202
+ >;
203
+ expect(r3.done.accessToken).toBe('AT');
204
+ } finally {
205
+ globalThis.fetch = realFetch;
206
+ }
207
+ });
208
+ });
209
+
210
+ describe('openaiDeviceFlow adapter', () => {
211
+ it('start() POSTs JSON client_id and stashes device_auth_id', async () => {
212
+ const realFetch = globalThis.fetch;
213
+ globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => {
214
+ expect(String(url)).toBe('https://issuer.test/api/accounts/deviceauth/usercode');
215
+ expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
216
+ const parsed = JSON.parse(String(init.body ?? '{}'));
217
+ expect(parsed.client_id).toBe('CID');
218
+ return new Response(
219
+ JSON.stringify({ device_auth_id: 'DA', user_code: 'XYZ', interval: 4 }),
220
+ { status: 200 },
221
+ );
222
+ }) as unknown as typeof fetch;
223
+ try {
224
+ const adapter = openaiDeviceFlow({
225
+ issuer: 'https://issuer.test',
226
+ tokenUrl: 'https://issuer.test/oauth/token',
227
+ verificationUri: 'https://issuer.test/codex/device',
228
+ });
229
+ const init = await adapter.start({ clientId: 'CID', scopes: ['openid'] });
230
+ expect(init.userCode).toBe('XYZ');
231
+ expect(init.verificationUri).toBe('https://issuer.test/codex/device');
232
+ expect(init.intervalMs).toBe(4000);
233
+ expect((init.providerData as { deviceAuthId: string }).deviceAuthId).toBe('DA');
234
+ expect((init.providerData as { clientId: string }).clientId).toBe('CID');
235
+ } finally {
236
+ globalThis.fetch = realFetch;
237
+ }
238
+ });
239
+
240
+ it('poll() returns pending on 403/404 and exchanges authorization_code on success', async () => {
241
+ const adapter = openaiDeviceFlow({
242
+ issuer: 'https://issuer.test',
243
+ tokenUrl: 'https://issuer.test/oauth/token',
244
+ verificationUri: 'https://issuer.test/codex/device',
245
+ });
246
+ const init = {
247
+ userCode: 'XYZ',
248
+ verificationUri: 'https://issuer.test/codex/device',
249
+ intervalMs: 4000,
250
+ expiresInMs: 600_000,
251
+ providerData: { deviceAuthId: 'DA', userCode: 'XYZ', clientId: 'CID' },
252
+ };
253
+
254
+ const realFetch = globalThis.fetch;
255
+ let call = 0;
256
+ globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
257
+ call += 1;
258
+ if (call === 1) {
259
+ expect(String(url)).toBe('https://issuer.test/api/accounts/deviceauth/token');
260
+ return new Response('', { status: 403 });
261
+ }
262
+ if (call === 2) {
263
+ return new Response(
264
+ JSON.stringify({ authorization_code: 'CODE', code_verifier: 'V' }),
265
+ { status: 200 },
266
+ );
267
+ }
268
+ // Third call: exchange via /oauth/token
269
+ expect(String(url)).toBe('https://issuer.test/oauth/token');
270
+ const body = new URLSearchParams(String(init?.body ?? ''));
271
+ expect(body.get('grant_type')).toBe('authorization_code');
272
+ expect(body.get('code')).toBe('CODE');
273
+ expect(body.get('code_verifier')).toBe('V');
274
+ expect(body.get('client_id')).toBe('CID');
275
+ expect(body.get('redirect_uri')).toBe('https://issuer.test/deviceauth/callback');
276
+ return new Response(
277
+ JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }),
278
+ { status: 200 },
279
+ );
280
+ }) as unknown as typeof fetch;
281
+ try {
282
+ const state = { intervalMs: 4000 };
283
+ const pending = await adapter.poll(init, state);
284
+ expect('pending' in pending && pending.pending).toBe(true);
285
+ const done = (await adapter.poll(init, state)) as Extract<
286
+ PollOutcome<TokenSet>,
287
+ { done: TokenSet }
288
+ >;
289
+ expect(done.done.accessToken).toBe('AT');
290
+ expect(done.done.refreshToken).toBe('RT');
291
+ } finally {
292
+ globalThis.fetch = realFetch;
293
+ }
294
+ });
295
+
296
+ it('poll() surfaces a classified MoxxyError when the shared exchange returns non-ok', async () => {
297
+ const adapter = openaiDeviceFlow({
298
+ issuer: 'https://issuer.test',
299
+ tokenUrl: 'https://issuer.test/oauth/token',
300
+ verificationUri: 'https://issuer.test/codex/device',
301
+ });
302
+ const init = {
303
+ userCode: 'XYZ',
304
+ verificationUri: 'https://issuer.test/codex/device',
305
+ intervalMs: 4000,
306
+ expiresInMs: 600_000,
307
+ providerData: { deviceAuthId: 'DA', userCode: 'XYZ', clientId: 'CID' },
308
+ };
309
+
310
+ const realFetch = globalThis.fetch;
311
+ let call = 0;
312
+ globalThis.fetch = (async () => {
313
+ call += 1;
314
+ // 1st: poll returns the authorization_code; 2nd: the exchange rejects.
315
+ if (call === 1) {
316
+ return new Response(
317
+ JSON.stringify({ authorization_code: 'CODE', code_verifier: 'V' }),
318
+ { status: 200 },
319
+ );
320
+ }
321
+ return new Response('bad request', { status: 400 });
322
+ }) as unknown as typeof fetch;
323
+ try {
324
+ const state = { intervalMs: 4000 };
325
+ const err = await adapter.poll(init, state).catch((e) => e);
326
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
327
+ } finally {
328
+ globalThis.fetch = realFetch;
329
+ }
330
+ });
331
+ });
package/src/profile.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Provider-framework types. A new OAuth provider plugs in by exporting an
3
+ * `OAuthProviderProfile` and (if it supports device-flow) one of the bundled
4
+ * adapters — or a custom adapter for a non-standard dialect.
5
+ *
6
+ * The host then calls `runOauthLogin(profile, ctx)` to drive the full dance
7
+ * and `ensureFreshTokens(profile, vault)` before each upstream request to
8
+ * keep the access token fresh.
9
+ */
10
+
11
+ import type { PollOutcome, PollState } from './oauth/poll-until.js';
12
+ import type { TokenSet } from './oauth/types.js';
13
+
14
+ /**
15
+ * Single source of truth for everything OAuth-related about a provider.
16
+ * Constants + extraction logic live here; the framework handles the
17
+ * actual HTTP / vault / refresh plumbing.
18
+ */
19
+ export interface OAuthProviderProfile {
20
+ /**
21
+ * Stable namespace under `oauth/<id>/*` in the vault. Lowercase,
22
+ * digits, dot/dash/underscore only — same rules as `validateProvider`.
23
+ * Example: `'openai-codex'`, `'google'`, `'github'`.
24
+ */
25
+ readonly id: string;
26
+ /** Authorization endpoint, e.g. https://accounts.google.com/o/oauth2/v2/auth */
27
+ readonly authUrl: string;
28
+ /** Token endpoint, e.g. https://oauth2.googleapis.com/token */
29
+ readonly tokenUrl: string;
30
+ readonly clientId: string;
31
+ /** Confidential clients only — loopback flows usually omit. */
32
+ readonly clientSecret?: string;
33
+ readonly scopes: ReadonlyArray<string>;
34
+ /**
35
+ * Provider-specific auth-URL params. Google wants
36
+ * `access_type=offline` + `prompt=consent` for a refresh_token; Codex
37
+ * wants `id_token_add_organizations=true` etc.
38
+ */
39
+ readonly extraAuthParams?: Readonly<Record<string, string>>;
40
+ /**
41
+ * Loopback callback config. Provider's registered redirect URI must
42
+ * match `http://localhost:<port><path>` EXACTLY.
43
+ */
44
+ readonly redirect?: {
45
+ readonly port?: number;
46
+ readonly path?: string;
47
+ };
48
+ /**
49
+ * Device-flow adapter. Omit when the provider doesn't support a
50
+ * headless flow — `runOauthLogin` will reject `headless=true` with a
51
+ * helpful message.
52
+ */
53
+ readonly deviceFlow?: DeviceFlowAdapter;
54
+ /**
55
+ * Pull a stable per-account identifier out of the token set. Codex
56
+ * extracts `chatgpt_account_id` from the id_token; Google could
57
+ * extract `email`. Returned value is shown to the user post-login and
58
+ * persisted under `extras.account_id`. Optional — providers that
59
+ * don't expose a useful identifier skip it.
60
+ */
61
+ readonly extractAccountId?: (tokens: TokenSet) => string | undefined;
62
+ /**
63
+ * Extract extra provider-specific fields to persist alongside the
64
+ * token set (e.g. `team_slug`, `org_id`). Merged with the account_id
65
+ * (if any) into the `extras` map under `oauth/<id>/extras`.
66
+ */
67
+ readonly extractExtras?: (tokens: TokenSet) => Readonly<Record<string, string>>;
68
+ /**
69
+ * Human-readable name used in prompts and success messages, e.g.
70
+ * "ChatGPT Pro/Plus", "Google Workspace". Falls back to `id` when omitted.
71
+ */
72
+ readonly displayName?: string;
73
+ }
74
+
75
+ /**
76
+ * Pluggable device-flow contract. Implementations encode a single dialect
77
+ * (RFC 8628, OpenAI's flavor, ...). The framework calls `start()` once,
78
+ * surfaces the returned prompt to the user, then modes on `poll()` via
79
+ * the shared `pollUntil` primitive.
80
+ */
81
+ export interface DeviceFlowAdapter {
82
+ start(args: DeviceFlowStartArgs): Promise<DeviceFlowInit>;
83
+ /**
84
+ * Single poll attempt against the provider's poll endpoint. May
85
+ * mutate `state.intervalMs` to honour a `slow_down` from the server.
86
+ * Resolves to `{done}` with the issued TokenSet, or `{pending: true}`
87
+ * to keep polling. Throws on fatal errors (denied / expired).
88
+ */
89
+ poll(init: DeviceFlowInit, state: PollState): Promise<PollOutcome<TokenSet>>;
90
+ }
91
+
92
+ export interface DeviceFlowStartArgs {
93
+ readonly clientId: string;
94
+ readonly clientSecret?: string;
95
+ readonly scopes: ReadonlyArray<string>;
96
+ readonly signal?: AbortSignal;
97
+ }
98
+
99
+ export interface DeviceFlowInit {
100
+ /** Short code the user enters at `verificationUri`. */
101
+ readonly userCode: string;
102
+ readonly verificationUri: string;
103
+ /** Some providers return a URL that already embeds the user_code. */
104
+ readonly verificationUriComplete?: string;
105
+ /** Initial poll interval. The framework honours `slow_down` bumps. */
106
+ readonly intervalMs: number;
107
+ /** Hard deadline (ms from now) before the device_code dies. */
108
+ readonly expiresInMs: number;
109
+ /**
110
+ * Opaque data the adapter threads from `start()` to `poll()` —
111
+ * device_code, server-assigned ids, code_verifier, etc. The framework
112
+ * never inspects this.
113
+ */
114
+ readonly providerData: unknown;
115
+ }
116
+
117
+ export interface RunOauthLoginCtx {
118
+ readonly vault: {
119
+ get(key: string): Promise<string | null>;
120
+ set(key: string, value: string, tags?: ReadonlyArray<string>): Promise<void>;
121
+ delete?(key: string): Promise<boolean>;
122
+ };
123
+ /** True ⇒ choose the device flow. False ⇒ open a browser. */
124
+ readonly headless: boolean;
125
+ /** Progress-message sink. The host wires this to its preferred renderer. */
126
+ readonly write: (chunk: string) => void;
127
+ readonly signal?: AbortSignal;
128
+ }
129
+
130
+ export interface RunOauthLoginResult {
131
+ readonly tokens: TokenSet;
132
+ /** Convenience surface for `ProviderOAuthResult.accountId`. */
133
+ readonly accountId?: string;
134
+ readonly extras: Readonly<Record<string, string>>;
135
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * High-level orchestrator. Drives the full login dance for any provider
3
+ * declared via `OAuthProviderProfile` — picks browser or device flow based
4
+ * on `ctx.headless`, surfaces the user-facing prompt, persists the result
5
+ * (token set + extras) under `oauth/<profile.id>/*`.
6
+ */
7
+
8
+ import { MoxxyError } from '@moxxy/sdk';
9
+ import { runAuthorizationCodeFlow } from './oauth/browser-flow.js';
10
+ import { pollUntil } from './oauth/poll-until.js';
11
+ import { storeTokenSet } from './storage.js';
12
+ import type { TokenSet } from './oauth/types.js';
13
+ import type {
14
+ DeviceFlowAdapter,
15
+ DeviceFlowInit,
16
+ OAuthProviderProfile,
17
+ RunOauthLoginCtx,
18
+ RunOauthLoginResult,
19
+ } from './profile.js';
20
+
21
+ const DEFAULT_BROWSER_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const DEFAULT_DEVICE_TIMEOUT_MS = 10 * 60 * 1000;
23
+ const DEVICE_POLL_SAFETY_MARGIN_MS = 3000;
24
+
25
+ export async function runOauthLogin(
26
+ profile: OAuthProviderProfile,
27
+ ctx: RunOauthLoginCtx,
28
+ ): Promise<RunOauthLoginResult> {
29
+ const tokens = ctx.headless
30
+ ? await runDeviceFlow(profile, ctx)
31
+ : await runBrowserFlow(profile, ctx);
32
+
33
+ const accountId = profile.extractAccountId?.(tokens);
34
+ const customExtras = profile.extractExtras?.(tokens) ?? {};
35
+ const extras: Record<string, string> = { ...customExtras };
36
+ if (accountId) extras.account_id = accountId;
37
+
38
+ await storeTokenSet(ctx.vault, profile.id, tokens, {
39
+ clientId: profile.clientId,
40
+ ...(profile.clientSecret ? { clientSecret: profile.clientSecret } : {}),
41
+ tokenUrl: profile.tokenUrl,
42
+ extras,
43
+ });
44
+
45
+ return {
46
+ tokens,
47
+ ...(accountId ? { accountId } : {}),
48
+ extras,
49
+ };
50
+ }
51
+
52
+ async function runBrowserFlow(
53
+ profile: OAuthProviderProfile,
54
+ ctx: RunOauthLoginCtx,
55
+ ): Promise<TokenSet> {
56
+ const port = profile.redirect?.port ?? 8765;
57
+ const path = profile.redirect?.path ?? '/callback';
58
+ const serviceName = profile.displayName ?? profile.id;
59
+ return runAuthorizationCodeFlow({
60
+ authUrl: profile.authUrl,
61
+ tokenUrl: profile.tokenUrl,
62
+ clientId: profile.clientId,
63
+ ...(profile.clientSecret ? { clientSecret: profile.clientSecret } : {}),
64
+ scopes: profile.scopes,
65
+ redirectPort: port,
66
+ redirectPath: path,
67
+ ...(profile.extraAuthParams ? { extraAuthParams: profile.extraAuthParams } : {}),
68
+ timeoutMs: DEFAULT_BROWSER_TIMEOUT_MS,
69
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
70
+ onAuthUrl: (url) => {
71
+ ctx.write(
72
+ `\nSign in to ${serviceName} to authorize moxxy\n\n` +
73
+ `If your browser doesn't open automatically, paste this URL:\n\n ${url}\n\n` +
74
+ `Waiting for callback on http://localhost:${port}${path} (5 min timeout)…\n\n`,
75
+ );
76
+ },
77
+ });
78
+ }
79
+
80
+ async function runDeviceFlow(
81
+ profile: OAuthProviderProfile,
82
+ ctx: RunOauthLoginCtx,
83
+ ): Promise<TokenSet> {
84
+ if (!profile.deviceFlow) {
85
+ throw new MoxxyError({
86
+ code: 'OAUTH_FLOW_NOT_SUPPORTED',
87
+ message: `Provider "${profile.id}" doesn't expose a headless (device-code) flow.`,
88
+ hint:
89
+ 'Run this command on a host with a browser, or ask the provider plugin author to ' +
90
+ 'add a deviceFlow adapter to the profile.',
91
+ context: { provider: profile.id },
92
+ });
93
+ }
94
+ const adapter: DeviceFlowAdapter = profile.deviceFlow;
95
+ const init: DeviceFlowInit = await adapter.start({
96
+ clientId: profile.clientId,
97
+ ...(profile.clientSecret ? { clientSecret: profile.clientSecret } : {}),
98
+ scopes: profile.scopes,
99
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
100
+ });
101
+ const serviceName = profile.displayName ?? profile.id;
102
+ ctx.write(
103
+ `\nSign in to ${serviceName} (headless / device code flow)\n\n` +
104
+ ` 1. On any browser-capable device, open:\n` +
105
+ ` ${init.verificationUriComplete ?? init.verificationUri}\n\n` +
106
+ ` 2. Enter this code:\n` +
107
+ ` ${init.userCode}\n\n` +
108
+ `Polling every ${Math.round(init.intervalMs / 1000)}s ` +
109
+ `(${Math.round(init.expiresInMs / 60000)} min timeout)…\n\n`,
110
+ );
111
+
112
+ // Cap the polling deadline by the device_code's own expiry — once it
113
+ // dies, any further poll is wasted.
114
+ const timeoutMs = Math.min(init.expiresInMs, DEFAULT_DEVICE_TIMEOUT_MS);
115
+
116
+ return pollUntil((state) => adapter.poll(init, state), {
117
+ intervalMs: init.intervalMs + DEVICE_POLL_SAFETY_MARGIN_MS,
118
+ timeoutMs,
119
+ label: `${profile.id} device flow`,
120
+ leadingWait: true,
121
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
122
+ });
123
+ }