@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,313 @@
1
+ ---
2
+ name: google-oauth
3
+ description: Set up Google OAuth (Gmail, Calendar, Drive, Docs, Sheets) and stash a refresh-capable token for Google Workspace MCP / direct API access.
4
+ triggers:
5
+ - "google workspace"
6
+ - "google oauth"
7
+ - "google api"
8
+ - "gmail"
9
+ - "google calendar"
10
+ - "google drive"
11
+ - "google docs"
12
+ - "google sheets"
13
+ - "google contacts"
14
+ - "google workspace mcp"
15
+ - "enable gmail"
16
+ - "connect gmail"
17
+ - "connect google"
18
+ - "sign in to google"
19
+ allowed-tools:
20
+ - oauth_authorize
21
+ - oauth_get_token
22
+ - oauth_clear_token
23
+ - install_plugin
24
+ - mcp_add_server
25
+ ---
26
+
27
+ # Google OAuth (for Workspace + direct API access)
28
+
29
+ Google requires extra ceremony vs other providers: a registered OAuth
30
+ client in Google Cloud Console, an exact-match redirect URI, and the
31
+ `access_type=offline` + `prompt=consent` params to actually receive a
32
+ refresh_token. This skill walks through both halves.
33
+
34
+ **Before doing anything else**, you MUST walk the user through the
35
+ Cloud Console setup. Send the Step 1 script BEFORE calling any tool —
36
+ the user can't authorize without a client_id, and a half-finished
37
+ attempt wastes everyone's time. Wait for them to paste back the
38
+ client_id + client_secret, then proceed to Step 2.
39
+
40
+ ## Step 1 — hands-on Google Cloud Console setup
41
+
42
+ Tell the user **exactly this script** (adapt phrasing, keep the
43
+ links + values verbatim). Pause after each block and confirm the
44
+ user reached the expected screen before moving to the next.
45
+
46
+ > **One-time Google Cloud setup — ~5 minutes**
47
+ >
48
+ > You'll register moxxy as an OAuth "Desktop app" in your Google
49
+ > Cloud project. Free, no review needed for personal use.
50
+ >
51
+ > ---
52
+ >
53
+ > **1) Pick or create a project**
54
+ >
55
+ > Open https://console.cloud.google.com/projectcreate
56
+ >
57
+ > Give it any name (e.g. `moxxy-oauth`) — leave Location as is and
58
+ > click **Create**. Wait a few seconds, then make sure the project
59
+ > dropdown at the top of the page shows your new project (if not,
60
+ > click the dropdown and pick it).
61
+ >
62
+ > Already have a project you want to reuse? Open
63
+ > https://console.cloud.google.com/ and switch to it from the top
64
+ > dropdown — that's fine too.
65
+ >
66
+ > ---
67
+ >
68
+ > **2) Enable the APIs you'll actually use**
69
+ >
70
+ > Open https://console.cloud.google.com/apis/library and search for
71
+ > each API below, click it, then click the blue **Enable** button.
72
+ > Do this once per API:
73
+ >
74
+ > - **Gmail API** (only if you want mail) — https://console.cloud.google.com/apis/library/gmail.googleapis.com
75
+ > - **Google Calendar API** — https://console.cloud.google.com/apis/library/calendar-json.googleapis.com
76
+ > - **Google Drive API** — https://console.cloud.google.com/apis/library/drive.googleapis.com
77
+ > - **Google Docs API** — https://console.cloud.google.com/apis/library/docs.googleapis.com
78
+ > - **Google Sheets API** — https://console.cloud.google.com/apis/library/sheets.googleapis.com
79
+ > - **People API** (contacts) — https://console.cloud.google.com/apis/library/people.googleapis.com
80
+ >
81
+ > If you skip an API here, calls to it later will return `403 -
82
+ > SERVICE_DISABLED` — you can always come back and enable more.
83
+ >
84
+ > ---
85
+ >
86
+ > **3) Configure the OAuth consent screen**
87
+ >
88
+ > Open https://console.cloud.google.com/apis/credentials/consent
89
+ >
90
+ > - **User type**: pick **External**, click Create.
91
+ > - **App name**: anything (e.g. `moxxy`).
92
+ > - **User support email** / **Developer contact email**: your own
93
+ > email is fine for both.
94
+ > - Leave the App logo / domain fields blank.
95
+ > - Click **Save and Continue**.
96
+ > - On the **Scopes** step, just click **Save and Continue** —
97
+ > don't add anything here; moxxy requests scopes at runtime.
98
+ > - On the **Test users** step, click **+ Add Users** and add your
99
+ > own Google account's email. Click **Save and Continue**.
100
+ > - Review and click **Back to Dashboard**.
101
+ >
102
+ > Your app's status will say "Testing" — that's correct, leave it
103
+ > there. You don't need to "Publish" or submit for verification for
104
+ > personal use.
105
+ >
106
+ > ---
107
+ >
108
+ > **4) Create the OAuth client ID**
109
+ >
110
+ > Open https://console.cloud.google.com/apis/credentials
111
+ >
112
+ > - Click **+ Create Credentials** → **OAuth client ID**.
113
+ > - **Application type**: pick **Desktop app**.
114
+ > - **Name**: `moxxy` (or anything).
115
+ > - Click **Create**.
116
+ >
117
+ > A popup appears with your **Client ID** and **Client Secret**.
118
+ > Click **Download JSON** to save a backup, then copy both values.
119
+ >
120
+ > ---
121
+ >
122
+ > **5) Paste them back here**
123
+ >
124
+ > Send me both values in the next message (they're not real secrets
125
+ > in the credential-theft sense for a Desktop app — but treat them
126
+ > like passwords; they go straight into the moxxy vault):
127
+ >
128
+ > ```
129
+ > client_id: <paste here>
130
+ > client_secret: <paste here>
131
+ > ```
132
+ >
133
+ > (Note: Desktop-app clients don't need a redirect URI registered —
134
+ > Google accepts any `http://localhost:*/...` callback for this app
135
+ > type. moxxy will use `http://localhost:8765/callback`.)
136
+
137
+ Wait for the user to come back with both values. Don't proceed to
138
+ Step 2 until both `client_id` and `client_secret` are in hand.
139
+
140
+ If the user pastes something that doesn't look like a Google client
141
+ id (the id looks like `123-abc.apps.googleusercontent.com`, the
142
+ secret like `GOCSPX-...`), re-ask — silently authorizing with wrong
143
+ values gives a confusing `invalid_client` error.
144
+
145
+ ## Step 2 — run the OAuth flow
146
+
147
+ Pick scopes for what the user actually wants. Common combos:
148
+
149
+ | Use case | Scopes |
150
+ |------------------------|-------------------------------------------------------------------------------------|
151
+ | Gmail read+send | `openid email profile https://www.googleapis.com/auth/gmail.modify` |
152
+ | Calendar full | `openid email profile https://www.googleapis.com/auth/calendar` |
153
+ | Drive (read) | `openid email profile https://www.googleapis.com/auth/drive.readonly` |
154
+ | Drive (full) | `openid email profile https://www.googleapis.com/auth/drive` |
155
+ | Docs + Sheets | `https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/spreadsheets` |
156
+ | Workspace MCP (broad) | `openid email profile https://mail.google.com/ https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/spreadsheets` |
157
+
158
+ Then:
159
+
160
+ ```
161
+ oauth_authorize({
162
+ provider: "google",
163
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
164
+ tokenUrl: "https://oauth2.googleapis.com/token",
165
+ clientId: "<from step 1>",
166
+ clientSecret: "<from step 1>",
167
+ scopes: [
168
+ "openid",
169
+ "email",
170
+ "profile",
171
+ "https://www.googleapis.com/auth/calendar",
172
+ // ...whatever the user asked for
173
+ ],
174
+ extraAuthParams: {
175
+ access_type: "offline", // REQUIRED to receive refresh_token
176
+ prompt: "consent", // forces consent screen so refresh_token is reissued
177
+ include_granted_scopes: "true" // optional: stacks new scopes onto existing grants
178
+ },
179
+ })
180
+ ```
181
+
182
+ The browser opens, the user picks their Google account, approves the
183
+ scopes, the local callback fires, and the tool returns with the
184
+ token summary. Tokens land in `oauth/google/*` in the vault.
185
+
186
+ ## Step 3 — use it
187
+
188
+ For ad-hoc API calls:
189
+
190
+ ```
191
+ oauth_get_token({ provider: "google" })
192
+ // → { accessToken, tokenType: "Bearer", expiresAt, scope }
193
+ ```
194
+
195
+ For Google Workspace MCP (the most common follow-on):
196
+
197
+ ```
198
+ # 1. Install the Workspace MCP server (if not already)
199
+ install_plugin({ packageName: "@moxxy/plugin-mcp" }) # if needed
200
+
201
+ # 2. Add the MCP server with the Google token
202
+ mcp_add_server({
203
+ name: "google-workspace",
204
+ command: "npx",
205
+ args: ["-y", "@taylorwilsdon/google_workspace_mcp", "--transport", "stdio"],
206
+ env: {
207
+ GOOGLE_OAUTH_CLIENT_ID: "<from step 1>",
208
+ GOOGLE_OAUTH_CLIENT_SECRET: "<from step 1>",
209
+ // Most Workspace MCP servers accept a refresh_token directly.
210
+ // Get it via: oauth_get_token({ provider: "google", includeRefresh: true })
211
+ GOOGLE_OAUTH_REFRESH_TOKEN: "<refresh_token from oauth_get_token>"
212
+ }
213
+ })
214
+ ```
215
+
216
+ **Full chain (what the agent actually runs)**:
217
+
218
+ ```
219
+ # After the user pastes client_id + client_secret from Step 1:
220
+ oauth_authorize({ provider: "google", clientId, clientSecret, ... })
221
+ → user approves in browser → tokens land in vault
222
+
223
+ # Grab the refresh_token (opt-in)
224
+ const tokens = oauth_get_token({
225
+ provider: "google",
226
+ includeRefresh: true
227
+ })
228
+
229
+ # Hand it to the MCP server
230
+ mcp_add_server({
231
+ name: "google-workspace",
232
+ command: "...", args: [...],
233
+ env: {
234
+ GOOGLE_OAUTH_CLIENT_ID: <user-pasted from Step 1>,
235
+ GOOGLE_OAUTH_CLIENT_SECRET: <user-pasted from Step 1>,
236
+ GOOGLE_OAUTH_REFRESH_TOKEN: tokens.refreshToken
237
+ }
238
+ })
239
+ ```
240
+
241
+ The MCP server now mints its own access tokens via the refresh_token,
242
+ so the chain is one-time setup. Future sessions just call
243
+ `oauth_get_token` if they need a direct token (the MCP server is
244
+ autonomous).
245
+
246
+ Then the model can call `mcp__google-workspace__*` tools directly.
247
+
248
+ ## Headless variant
249
+
250
+ Google supports the device flow at
251
+ `https://oauth2.googleapis.com/device/code`. If the user is on SSH
252
+ or a headless host:
253
+
254
+ ```
255
+ oauth_authorize({
256
+ provider: "google",
257
+ deviceUrl: "https://oauth2.googleapis.com/device/code",
258
+ tokenUrl: "https://oauth2.googleapis.com/token",
259
+ clientId: "...",
260
+ clientSecret: "...",
261
+ scopes: [...],
262
+ mode: "device",
263
+ extraAuthParams: { access_type: "offline" },
264
+ })
265
+ ```
266
+
267
+ Print the user_code + verification_uri the tool surfaces; tell the
268
+ user to open it on their phone / laptop.
269
+
270
+ ## Common failures
271
+
272
+ - **"invalid_client"** — the `client_id` or `client_secret` is wrong
273
+ or from a different project. Re-check the values the user pasted
274
+ (or re-download the JSON from
275
+ https://console.cloud.google.com/apis/credentials).
276
+ - **"redirect_uri_mismatch"** — only happens if the user picked
277
+ *Web application* instead of *Desktop app* in Step 1.4. Desktop
278
+ app clients accept any `http://localhost:*/...` redirect without
279
+ registration. Fix: delete the client and recreate as Desktop app.
280
+ - **"access_denied"** — the user clicked Cancel on the consent
281
+ screen, OR their Google account isn't in the test-users list of
282
+ the unverified app. Send them to
283
+ https://console.cloud.google.com/apis/credentials/consent → *Test
284
+ users* → **+ Add Users**.
285
+ - **"App is being verified"** / "Google hasn't verified this app"
286
+ warning — expected for unverified apps in test mode. The user
287
+ clicks **Advanced** → **Go to <appname> (unsafe)** to proceed.
288
+ Reassure them this is normal for personal-use apps.
289
+ - **No `refresh_token` in the response** — happens when the user has
290
+ already authorized the same scopes for this client; Google
291
+ silently skips reissuing it. Fix: pass `prompt: "consent"` (which
292
+ this skill always does) to force a fresh issuance.
293
+ - **403 `SERVICE_DISABLED` on first API call** — the specific Google
294
+ API (Gmail / Calendar / etc.) isn't enabled in the project. Send
295
+ the user back to https://console.cloud.google.com/apis/library to
296
+ enable it for their project.
297
+ - **`Token has been expired or revoked`** in long-running sessions —
298
+ Google's test-mode refresh_tokens expire after 7 days of
299
+ non-use. Re-run `oauth_authorize` to get a fresh one. (Publishing
300
+ the app to "Production" removes this limit but requires a
301
+ verification process you don't want for personal use.)
302
+
303
+ ## Don't
304
+
305
+ - Don't request scopes the user didn't ask for. Each extra scope is
306
+ one more thing on the consent screen and one more thing the user
307
+ has to trust the local moxxy install with.
308
+ - Don't suggest the user manually paste tokens into env vars or
309
+ config files. Vault is the durable store; `oauth_get_token`
310
+ always reads from there.
311
+ - Don't recommend "external + production" on the consent screen for
312
+ personal use — that puts the user on Google's review queue. Test
313
+ mode (External + test users) is fine for single-user setups.
@@ -0,0 +1,132 @@
1
+ ---
2
+ name: oauth-flow
3
+ description: Obtain and reuse OAuth 2.0 tokens for any provider via `oauth_authorize` / `oauth_get_token` / `oauth_clear_token`.
4
+ triggers:
5
+ - "oauth"
6
+ - "authorize with"
7
+ - "sign in to"
8
+ - "log in to"
9
+ - "connect to"
10
+ - "authenticate with"
11
+ - "get a token for"
12
+ - "google workspace"
13
+ - "google drive"
14
+ - "google calendar"
15
+ - "gmail"
16
+ - "github api"
17
+ - "notion api"
18
+ allowed-tools:
19
+ - oauth_authorize
20
+ - oauth_get_token
21
+ - oauth_clear_token
22
+ ---
23
+
24
+ # OAuth (generic)
25
+
26
+ This plugin runs the OAuth 2.0 authorization-code-with-PKCE dance
27
+ (loopback flow), or the RFC 8628 device-code flow for headless hosts.
28
+ Tokens persist in the user's vault under `oauth/<provider>/*`; the
29
+ `oauth_get_token` tool transparently refreshes when expired.
30
+
31
+ ## When to use
32
+
33
+ - The user asks to connect / authorize / sign in to some third-party
34
+ API (Google Workspace, GitHub, Notion, Linear, Spotify, …).
35
+ - A downstream tool / MCP server needs a bearer token to call that API
36
+ on the user's behalf.
37
+
38
+ If the user just wants to read a public page → `web_fetch` is simpler.
39
+ OAuth is for authenticated user-owned data.
40
+
41
+ ## Flow
42
+
43
+ ### Step 1 — confirm the user has an OAuth client registered
44
+
45
+ Every provider requires the user to register an OAuth app and give
46
+ you a `client_id` (and sometimes a `client_secret`). If the user
47
+ doesn't have one, send them to the provider's developer console and
48
+ tell them the exact redirect URI to register:
49
+
50
+ - Loopback mode (default): `http://localhost:8765/callback`
51
+ - Device mode: no redirect URI needed.
52
+
53
+ ### Step 2 — call `oauth_authorize`
54
+
55
+ ```
56
+ oauth_authorize({
57
+ provider: "github",
58
+ authUrl: "https://github.com/login/oauth/authorize",
59
+ tokenUrl: "https://github.com/login/oauth/access_token",
60
+ clientId: "<the user's client id>",
61
+ scopes: ["repo", "read:user"],
62
+ // clientSecret only for confidential clients (most server apps);
63
+ // omit for native/installed apps relying on PKCE alone.
64
+ })
65
+ ```
66
+
67
+ The plugin will:
68
+ 1. Generate PKCE codes + a CSRF state.
69
+ 2. Start a local server on `http://localhost:8765/callback`.
70
+ 3. Open the user's browser to the authorization URL.
71
+ 4. Wait for the redirect, exchange the code for tokens.
72
+ 5. Store tokens in `oauth/github/*` (vault).
73
+
74
+ The tool returns when the user finishes — usually under 30 seconds.
75
+
76
+ ### Step 3 — use the token
77
+
78
+ ```
79
+ oauth_get_token({ provider: "github" })
80
+ // → { accessToken: "ghp_…", tokenType: "Bearer", expiresAt: 1234567890, scope: "repo read:user" }
81
+ ```
82
+
83
+ Now pass `Authorization: Bearer <accessToken>` to whatever tool needs
84
+ to call the API. If the cached token expired, this call refreshes it
85
+ silently via the stored `refresh_token` before returning.
86
+
87
+ ### Step 4 — re-auth on scope changes / revoke
88
+
89
+ ```
90
+ oauth_clear_token({ provider: "github" }) // wipes vault
91
+ oauth_authorize({ provider: "github", scopes: ["repo", "read:user", "user:email"], ... })
92
+ ```
93
+
94
+ ## Headless mode (no browser available)
95
+
96
+ For SSH sessions, CI containers, kiosks etc. — set `mode: "device"`
97
+ and pass the provider's device-authorization endpoint:
98
+
99
+ ```
100
+ oauth_authorize({
101
+ provider: "google",
102
+ deviceUrl: "https://oauth2.googleapis.com/device/code",
103
+ tokenUrl: "https://oauth2.googleapis.com/token",
104
+ clientId: "...",
105
+ scopes: [...],
106
+ mode: "device",
107
+ })
108
+ ```
109
+
110
+ The user sees a short code + URL printed; they open it on any device
111
+ (phone, laptop), type the code, approve. The flow returns when done.
112
+
113
+ If the provider has no device flow → use loopback but set
114
+ `noOpen: true`. The auth URL is printed for the user to open
115
+ manually — works if they can SSH-tunnel port 8765 back to their
116
+ laptop, or if they're sitting at the same machine.
117
+
118
+ ## Don't
119
+
120
+ - **Don't paste tokens into chat.** They live in the vault; reference
121
+ by provider name via `oauth_get_token`. The model never needs to
122
+ see the raw token in scrollback.
123
+ - **Don't hardcode `clientId` / `clientSecret`.** Ask the user once,
124
+ use the values, and rely on the vault to remember them for next
125
+ time (the plugin stores them alongside the token).
126
+ - **Don't choose scopes the user didn't ask for.** Request the
127
+ minimum needed to do the immediate task; revisit with
128
+ `oauth_clear_token` + a fresh `oauth_authorize` if you need more
129
+ later.
130
+ - **Don't invent endpoint URLs.** If the user names a provider you
131
+ don't have URLs for, ask — guessing OAuth endpoints leads to
132
+ hard-to-diagnose 404s.
@@ -0,0 +1,191 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { MoxxyError } from '@moxxy/sdk';
3
+ import { openaiDeviceFlow } from './openai-device-flow.js';
4
+
5
+ const opts = {
6
+ issuer: 'https://auth.example.com',
7
+ tokenUrl: 'https://auth.example.com/oauth/token',
8
+ verificationUri: 'https://auth.example.com/codex/device',
9
+ };
10
+
11
+ /** Stub a single init-endpoint response body. */
12
+ function stubInit(body: Record<string, unknown>): void {
13
+ vi.stubGlobal(
14
+ 'fetch',
15
+ vi.fn(async () => ({
16
+ ok: true,
17
+ status: 200,
18
+ json: async () => ({ device_auth_id: 'd1', user_code: 'CODE-1', ...body }),
19
+ text: async () => '',
20
+ })),
21
+ );
22
+ }
23
+
24
+ describe('openaiDeviceFlow.start — interval/expires_in coercion (u89-2)', () => {
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ it('falls back to defaults (never NaN) on malformed string values', async () => {
30
+ stubInit({ interval: '', expires_in: 'oops' });
31
+ const init = await openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] });
32
+ expect(init.intervalMs).toBe(5000);
33
+ expect(init.expiresInMs).toBe(600000);
34
+ expect(Number.isNaN(init.intervalMs)).toBe(false);
35
+ expect(Number.isNaN(init.expiresInMs)).toBe(false);
36
+ });
37
+
38
+ it('parses valid numeric strings', async () => {
39
+ stubInit({ interval: '7', expires_in: '900' });
40
+ const init = await openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] });
41
+ expect(init.intervalMs).toBe(7000);
42
+ expect(init.expiresInMs).toBe(900000);
43
+ });
44
+
45
+ it('accepts numeric values and clamps interval to >= 1s', async () => {
46
+ stubInit({ interval: 0, expires_in: 1200 });
47
+ const init = await openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] });
48
+ expect(init.intervalMs).toBe(1000); // Math.max(0,1) * 1000
49
+ expect(init.expiresInMs).toBe(1200000);
50
+ });
51
+
52
+ it('uses defaults when the fields are absent', async () => {
53
+ stubInit({});
54
+ const init = await openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] });
55
+ expect(init.intervalMs).toBe(5000);
56
+ expect(init.expiresInMs).toBe(600000);
57
+ });
58
+ });
59
+
60
+ describe('openaiDeviceFlow.start — malformed response rejection', () => {
61
+ afterEach(() => {
62
+ vi.unstubAllGlobals();
63
+ });
64
+
65
+ /** Stub an init response whose JSON body is exactly `body` (no auto-fields). */
66
+ function stubRawInit(body: Record<string, unknown>): void {
67
+ vi.stubGlobal(
68
+ 'fetch',
69
+ vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => '' })),
70
+ );
71
+ }
72
+
73
+ it('rejects with PROVIDER_UNKNOWN_RESPONSE when device_auth_id is missing', async () => {
74
+ stubRawInit({ user_code: 'CODE-1' });
75
+ let err: unknown;
76
+ try {
77
+ await openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] });
78
+ } catch (e) {
79
+ err = e;
80
+ }
81
+ expect(err).toBeInstanceOf(MoxxyError);
82
+ expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
83
+ });
84
+
85
+ it('rejects with PROVIDER_UNKNOWN_RESPONSE when user_code is missing', async () => {
86
+ stubRawInit({ device_auth_id: 'd1' });
87
+ await expect(openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] })).rejects.toMatchObject({
88
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
89
+ });
90
+ });
91
+
92
+ it('rejects with PROVIDER_UNKNOWN_RESPONSE on a non-JSON 200 body', async () => {
93
+ vi.stubGlobal(
94
+ 'fetch',
95
+ vi.fn(async () => ({
96
+ ok: true,
97
+ status: 200,
98
+ json: async () => {
99
+ throw new SyntaxError('not json');
100
+ },
101
+ text: async () => '<html/>',
102
+ })),
103
+ );
104
+ await expect(openaiDeviceFlow(opts).start({ clientId: 'c1', scopes: [] })).rejects.toMatchObject({
105
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
106
+ });
107
+ });
108
+ });
109
+
110
+ describe('openaiDeviceFlow.poll — malformed success response rejection', () => {
111
+ afterEach(() => {
112
+ vi.unstubAllGlobals();
113
+ });
114
+
115
+ const init = {
116
+ userCode: 'CODE-1',
117
+ verificationUri: opts.verificationUri,
118
+ intervalMs: 5000,
119
+ expiresInMs: 600000,
120
+ providerData: { deviceAuthId: 'd1', userCode: 'CODE-1', clientId: 'c1' },
121
+ };
122
+
123
+ it('rejects when a 200 success body omits authorization_code/code_verifier (never exchanges undefined)', async () => {
124
+ const fetchSpy = vi.fn(async () => ({
125
+ ok: true,
126
+ status: 200,
127
+ json: async () => ({}), // missing both fields
128
+ text: async () => '',
129
+ }));
130
+ vi.stubGlobal('fetch', fetchSpy);
131
+ let err: unknown;
132
+ try {
133
+ await openaiDeviceFlow(opts).poll(init, { intervalMs: 5000 });
134
+ } catch (e) {
135
+ err = e;
136
+ }
137
+ expect(err).toBeInstanceOf(MoxxyError);
138
+ expect((err as MoxxyError).code).toBe('PROVIDER_UNKNOWN_RESPONSE');
139
+ // It must NOT have proceeded to a second fetch (the token exchange).
140
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it('rejects with PROVIDER_UNKNOWN_RESPONSE on a non-JSON 200 poll body', async () => {
144
+ vi.stubGlobal(
145
+ 'fetch',
146
+ vi.fn(async () => ({
147
+ ok: true,
148
+ status: 200,
149
+ json: async () => {
150
+ throw new SyntaxError('not json');
151
+ },
152
+ text: async () => 'oops',
153
+ })),
154
+ );
155
+ await expect(openaiDeviceFlow(opts).poll(init, { intervalMs: 5000 })).rejects.toMatchObject({
156
+ code: 'PROVIDER_UNKNOWN_RESPONSE',
157
+ });
158
+ });
159
+ });
160
+
161
+ describe('openaiDeviceFlow.poll — abort-responsive fetch (u89-3)', () => {
162
+ afterEach(() => {
163
+ vi.unstubAllGlobals();
164
+ });
165
+
166
+ it('threads PollState.signal into the in-flight poll fetch', async () => {
167
+ const seen: Array<AbortSignal | undefined> = [];
168
+ vi.stubGlobal(
169
+ 'fetch',
170
+ vi.fn(async (_url: string, reqInit?: RequestInit) => {
171
+ seen.push(reqInit?.signal ?? undefined);
172
+ // 403 = OpenAI's "still pending" signal — keeps poll() from exchanging.
173
+ return { ok: false, status: 403, json: async () => ({}), text: async () => '' };
174
+ }),
175
+ );
176
+ const controller = new AbortController();
177
+ const init = {
178
+ userCode: 'CODE-1',
179
+ verificationUri: opts.verificationUri,
180
+ intervalMs: 5000,
181
+ expiresInMs: 600000,
182
+ providerData: { deviceAuthId: 'd1', userCode: 'CODE-1', clientId: 'c1' },
183
+ };
184
+ const outcome = await openaiDeviceFlow(opts).poll(init, {
185
+ intervalMs: 5000,
186
+ signal: controller.signal,
187
+ });
188
+ expect(outcome).toEqual({ pending: true });
189
+ expect(seen[0]).toBe(controller.signal);
190
+ });
191
+ });