@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
package/src/tools.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { defineTool, MoxxyError, z } from '@moxxy/sdk';
2
+ import type { VaultStore } from '@moxxy/plugin-vault';
3
+ import {
4
+ buildAuthUrl,
5
+ runAuthorizationCodeFlow,
6
+ runDeviceCodeFlow,
7
+ type DevicePrompt,
8
+ type TokenSet,
9
+ } from './flow.js';
10
+ import { generateCodeVerifier, generateState } from './pkce.js';
11
+ import {
12
+ clearStoredCreds,
13
+ isExpired,
14
+ readStoredCreds,
15
+ storeTokenSet,
16
+ validateProvider,
17
+ } from './storage.js';
18
+ import { withCredentialLock } from './credential-lock.js';
19
+ import { refreshAndStore, type RefreshSpec } from './ensure-fresh.js';
20
+
21
+ export interface OAuthToolDeps {
22
+ readonly vault: VaultStore;
23
+ }
24
+
25
+ const providerNameField = z
26
+ .string()
27
+ .min(1)
28
+ .max(60)
29
+ .describe(
30
+ 'Stable provider key used as the vault namespace (e.g. "google", "github", ' +
31
+ '"notion"). Lowercase letters, digits, dot/dash/underscore only.',
32
+ );
33
+
34
+ export function buildOauthAuthorizeTool(deps: OAuthToolDeps) {
35
+ return defineTool({
36
+ name: 'oauth_authorize',
37
+ description:
38
+ 'Run an OAuth 2.0 authorization-code flow (PKCE) against any provider, ' +
39
+ 'or RFC 8628 device-code flow for headless hosts. Opens the user\'s ' +
40
+ 'browser (loopback mode), or prints a code to type on another device ' +
41
+ '(device mode). Stores the resulting tokens in the vault under ' +
42
+ '`oauth/<provider>/*`. Subsequent `oauth_get_token` calls auto-refresh ' +
43
+ 'when the access token expires. Returns when the user finishes the dance.',
44
+ inputSchema: z.object({
45
+ provider: providerNameField,
46
+ authUrl: z
47
+ .string()
48
+ .url()
49
+ .optional()
50
+ .describe(
51
+ 'Provider authorization endpoint. Required for `mode: "loopback"`. ' +
52
+ 'E.g. https://accounts.google.com/o/oauth2/v2/auth',
53
+ ),
54
+ tokenUrl: z
55
+ .string()
56
+ .url()
57
+ .describe('Provider token endpoint. E.g. https://oauth2.googleapis.com/token'),
58
+ deviceUrl: z
59
+ .string()
60
+ .url()
61
+ .optional()
62
+ .describe(
63
+ 'Provider device-authorization endpoint. Required for `mode: "device"`. ' +
64
+ 'E.g. https://oauth2.googleapis.com/device/code',
65
+ ),
66
+ clientId: z.string().min(1),
67
+ clientSecret: z
68
+ .string()
69
+ .optional()
70
+ .describe('Required for confidential clients (most server-side apps). Omit for native/installed apps using PKCE alone.'),
71
+ scopes: z.array(z.string().min(1)).min(1),
72
+ mode: z
73
+ .enum(['loopback', 'device'])
74
+ .optional()
75
+ .describe(
76
+ 'Flow mode. "loopback" (default) opens a browser + local callback ' +
77
+ 'server. "device" prints a code for the user to type into another ' +
78
+ 'device — use when running headless (SSH session, CI, no display).',
79
+ ),
80
+ redirectPort: z
81
+ .number()
82
+ .int()
83
+ .positive()
84
+ .max(65535)
85
+ .optional()
86
+ .describe('Loopback-mode redirect port. Default 8765. MUST match what you registered with the provider.'),
87
+ redirectPath: z
88
+ .string()
89
+ .optional()
90
+ .describe('Loopback-mode redirect path. Default "/callback".'),
91
+ extraAuthParams: z
92
+ .record(z.string(), z.string())
93
+ .optional()
94
+ .describe(
95
+ 'Provider-specific auth-URL params. Google wants `access_type=offline` + `prompt=consent` ' +
96
+ 'to issue a refresh_token; Auth0 may want `audience=...`.',
97
+ ),
98
+ noOpen: z
99
+ .boolean()
100
+ .optional()
101
+ .describe(
102
+ 'Loopback-mode only: skip auto-opening the browser. The auth URL is ' +
103
+ 'logged via the tool logger so the user can open it manually on the ' +
104
+ 'host where the loopback server is reachable.',
105
+ ),
106
+ }),
107
+ permission: { action: 'prompt' },
108
+ async handler(input, ctx) {
109
+ validateProvider(input.provider);
110
+ const mode = input.mode ?? 'loopback';
111
+
112
+ let tokens: TokenSet;
113
+ if (mode === 'device') {
114
+ if (!input.deviceUrl) {
115
+ throw new MoxxyError({
116
+ code: 'TOOL_ERROR',
117
+ message: 'mode="device" requires `deviceUrl` (the provider\'s device-authorization endpoint)',
118
+ });
119
+ }
120
+ tokens = await runDeviceCodeFlow({
121
+ deviceUrl: input.deviceUrl,
122
+ tokenUrl: input.tokenUrl,
123
+ clientId: input.clientId,
124
+ ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
125
+ scopes: input.scopes,
126
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
127
+ onPrompt: (info: DevicePrompt) => {
128
+ // Surface the prompt prominently via the tool logger so the
129
+ // channel renders it as a system notice. The model can also
130
+ // see this in the event log and echo it to the user.
131
+ ctx.logger.info('oauth_authorize: device flow — visit URL', {
132
+ verificationUri: info.verificationUri,
133
+ ...(info.verificationUriComplete ? { verificationUriComplete: info.verificationUriComplete } : {}),
134
+ userCode: info.userCode,
135
+ expiresInSec: info.expiresIn,
136
+ });
137
+ // Belt-and-suspenders: stderr in case the logger isn't
138
+ // wired to a visible surface in the current channel.
139
+ process.stderr.write(
140
+ `\n Open ${info.verificationUri} and enter code: ${info.userCode}\n` +
141
+ (info.verificationUriComplete
142
+ ? ` (or visit directly: ${info.verificationUriComplete})\n`
143
+ : '') +
144
+ ` Code expires in ${Math.floor(info.expiresIn / 60)}m.\n\n`,
145
+ );
146
+ },
147
+ });
148
+ } else {
149
+ if (!input.authUrl) {
150
+ throw new MoxxyError({
151
+ code: 'TOOL_ERROR',
152
+ message: 'mode="loopback" requires `authUrl` (the provider\'s authorization endpoint)',
153
+ });
154
+ }
155
+ if (input.noOpen) {
156
+ // Compute + log the URL without spawning a browser.
157
+ const verifier = generateCodeVerifier();
158
+ const state = generateState();
159
+ const { computeCodeChallenge } = await import('./pkce.js');
160
+ const challenge = computeCodeChallenge(verifier);
161
+ const port = input.redirectPort ?? 8765;
162
+ const path = input.redirectPath ?? '/callback';
163
+ const url = buildAuthUrl({
164
+ authUrl: input.authUrl,
165
+ clientId: input.clientId,
166
+ redirectUri: `http://localhost:${port}${path}`,
167
+ scopes: input.scopes,
168
+ codeChallenge: challenge,
169
+ state,
170
+ ...(input.extraAuthParams ? { extraAuthParams: input.extraAuthParams } : {}),
171
+ });
172
+ ctx.logger.info('oauth_authorize: open this URL manually', { url });
173
+ process.stderr.write(`\n Open this URL in a browser on this host:\n ${url}\n\n`);
174
+ }
175
+ tokens = await runAuthorizationCodeFlow({
176
+ authUrl: input.authUrl,
177
+ tokenUrl: input.tokenUrl,
178
+ clientId: input.clientId,
179
+ ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
180
+ scopes: input.scopes,
181
+ ...(input.redirectPort !== undefined ? { redirectPort: input.redirectPort } : {}),
182
+ ...(input.redirectPath !== undefined ? { redirectPath: input.redirectPath } : {}),
183
+ ...(input.extraAuthParams ? { extraAuthParams: input.extraAuthParams } : {}),
184
+ ...(input.noOpen ? { noOpen: true } : {}),
185
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
186
+ onAuthUrl: (url) => {
187
+ ctx.logger.info('oauth_authorize: opening browser', { url });
188
+ },
189
+ });
190
+ }
191
+
192
+ await storeTokenSet(deps.vault, input.provider, tokens, {
193
+ clientId: input.clientId,
194
+ ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
195
+ tokenUrl: input.tokenUrl,
196
+ });
197
+
198
+ return summarizeTokens(input.provider, tokens);
199
+ },
200
+ });
201
+ }
202
+
203
+ export function buildOauthGetTokenTool(deps: OAuthToolDeps) {
204
+ return defineTool({
205
+ name: 'oauth_get_token',
206
+ description:
207
+ 'Read the stored access token for an OAuth provider, transparently ' +
208
+ 'refreshing it via refresh_token if it has expired. Returns the bearer ' +
209
+ 'token ready to drop into an Authorization header. Throws if no token ' +
210
+ 'has been stored — call `oauth_authorize` first.',
211
+ inputSchema: z.object({
212
+ provider: providerNameField,
213
+ forceRefresh: z
214
+ .boolean()
215
+ .optional()
216
+ .describe('Refresh even if the cached token has not expired yet.'),
217
+ includeRefresh: z
218
+ .boolean()
219
+ .optional()
220
+ .describe(
221
+ 'Also return the refresh_token. Default false — the refresh_token ' +
222
+ 'is more sensitive than the access_token and most callers never ' +
223
+ 'need it. Set true when wiring an MCP server (or other long-lived ' +
224
+ 'subprocess) that needs to mint its own access tokens.',
225
+ ),
226
+ }),
227
+ permission: { action: 'prompt' },
228
+ async handler({ provider, forceRefresh, includeRefresh }, _ctx) {
229
+ const stored = await readStoredCreds(deps.vault, provider);
230
+ if (!stored) {
231
+ throw new MoxxyError({
232
+ code: 'AUTH_NO_CREDENTIALS',
233
+ message: `no stored OAuth credentials for "${provider}". Run oauth_authorize first.`,
234
+ context: { provider },
235
+ });
236
+ }
237
+ // Fast path: a still-fresh token needs no lock and no network. Refreshing
238
+ // a rotating single-use refresh_token, on the other hand, MUST serialize
239
+ // per credential (a second concurrent refresher — another oauth_get_token
240
+ // call OR a provider's ensureFreshTokens sharing this vault credential —
241
+ // would otherwise burn the now-rotated token and log the user out). Route
242
+ // the refresh+persist critical section through the same per-credential
243
+ // lock ensure-fresh.ts uses, re-reading inside the lock so a queued
244
+ // waiter reuses the winner's rotated token instead of re-racing.
245
+ if (!forceRefresh && !isExpired(stored.tokenSet)) {
246
+ return summarizeTokens(provider, stored.tokenSet, {
247
+ includeAccess: true,
248
+ ...(includeRefresh ? { includeRefresh: true } : {}),
249
+ });
250
+ }
251
+ const tokens = await withCredentialLock(`oauth-${provider}`, async () => {
252
+ // Re-read under the lock: another consumer/process may have refreshed
253
+ // while we queued. Reuse a now-fresh token even under forceRefresh,
254
+ // which exists for 401 recovery and is satisfied by ANY rotation.
255
+ const current = (await readStoredCreds(deps.vault, provider)) ?? stored;
256
+ const rotatedMeanwhile = current.tokenSet.accessToken !== stored.tokenSet.accessToken;
257
+ if (!isExpired(current.tokenSet) && (!forceRefresh || rotatedMeanwhile)) {
258
+ return current.tokenSet;
259
+ }
260
+ const { tokens } = await refreshAndStore(deps.vault, toolRefreshSpec(provider), current);
261
+ return tokens;
262
+ });
263
+ return summarizeTokens(provider, tokens, {
264
+ includeAccess: true,
265
+ ...(includeRefresh ? { includeRefresh: true } : {}),
266
+ });
267
+ },
268
+ });
269
+ }
270
+
271
+ export function buildOauthClearTool(deps: OAuthToolDeps) {
272
+ return defineTool({
273
+ name: 'oauth_clear_token',
274
+ description:
275
+ 'Delete every stored credential for an OAuth provider — access token, ' +
276
+ 'refresh token, client config, etc. Use when the user wants to revoke ' +
277
+ 'the grant or re-do the flow with different scopes.',
278
+ inputSchema: z.object({ provider: providerNameField }),
279
+ permission: { action: 'prompt' },
280
+ async handler({ provider }) {
281
+ const removed = await clearStoredCreds(deps.vault, provider);
282
+ return { ok: true, provider, removedKeys: removed };
283
+ },
284
+ });
285
+ }
286
+
287
+ /**
288
+ * RefreshSpec for `oauth_get_token`, which has no provider profile — it works
289
+ * off raw StoredCreds. Mirrors the shared `refreshAndStore` critical section in
290
+ * ensure-fresh.ts (lock-guarded; preserves a rotated-or-prior refresh_token per
291
+ * RFC 6749 §6; recovers once from an invalid_grant when another process rotated
292
+ * our refresh_token away). Re-persists the stored extras so a store-layer change
293
+ * can't silently wipe account_id on a tool-driven refresh, and re-throws a
294
+ * non-network refresh failure verbatim (the tool has no friendlier wording).
295
+ */
296
+ function toolRefreshSpec(provider: string): RefreshSpec {
297
+ return {
298
+ provider,
299
+ noRefreshTokenError: () =>
300
+ new MoxxyError({
301
+ code: 'AUTH_EXPIRED',
302
+ message: `OAuth token for "${provider}" expired and no refresh_token is stored. Re-run oauth_authorize.`,
303
+ context: { provider },
304
+ }),
305
+ wrapRefreshFailure: (err) => err,
306
+ resolveExtras: (_merged, storedExtras) =>
307
+ Object.keys(storedExtras).length > 0 ? { ...storedExtras } : undefined,
308
+ };
309
+ }
310
+
311
+ function summarizeTokens(
312
+ provider: string,
313
+ tokens: TokenSet,
314
+ opts: { includeAccess?: boolean; includeRefresh?: boolean } = {},
315
+ ): Record<string, unknown> {
316
+ return {
317
+ provider,
318
+ tokenType: tokens.tokenType,
319
+ expiresAt: tokens.expiresAt ?? null,
320
+ scope: tokens.scope ?? null,
321
+ hasRefreshToken: tokens.refreshToken !== undefined,
322
+ // The actual access_token only flows through `oauth_get_token`
323
+ // (the model needs it to make API calls). For `oauth_authorize`
324
+ // the response is a confirmation; the token stays in the vault.
325
+ ...(opts.includeAccess
326
+ ? { accessToken: tokens.accessToken, ...(tokens.idToken ? { idToken: tokens.idToken } : {}) }
327
+ : {}),
328
+ ...(opts.includeRefresh && tokens.refreshToken !== undefined
329
+ ? { refreshToken: tokens.refreshToken }
330
+ : {}),
331
+ };
332
+ }