@moxxy/plugin-oauth 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/adapters/openai-device-flow.d.ts +34 -0
- package/dist/adapters/openai-device-flow.d.ts.map +1 -0
- package/dist/adapters/openai-device-flow.js +136 -0
- package/dist/adapters/openai-device-flow.js.map +1 -0
- package/dist/adapters/rfc8628-device-flow.d.ts +20 -0
- package/dist/adapters/rfc8628-device-flow.d.ts.map +1 -0
- package/dist/adapters/rfc8628-device-flow.js +54 -0
- package/dist/adapters/rfc8628-device-flow.js.map +1 -0
- package/dist/credential-lock.d.ts +60 -0
- package/dist/credential-lock.d.ts.map +1 -0
- package/dist/credential-lock.js +149 -0
- package/dist/credential-lock.js.map +1 -0
- package/dist/ensure-fresh.d.ts +63 -0
- package/dist/ensure-fresh.d.ts.map +1 -0
- package/dist/ensure-fresh.js +122 -0
- package/dist/ensure-fresh.js.map +1 -0
- package/dist/flow.d.ts +6 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +5 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/browser-flow.d.ts +23 -0
- package/dist/oauth/browser-flow.d.ts.map +1 -0
- package/dist/oauth/browser-flow.js +82 -0
- package/dist/oauth/browser-flow.js.map +1 -0
- package/dist/oauth/callback-server.d.ts +10 -0
- package/dist/oauth/callback-server.d.ts.map +1 -0
- package/dist/oauth/callback-server.js +158 -0
- package/dist/oauth/callback-server.js.map +1 -0
- package/dist/oauth/device-flow-shared.d.ts +39 -0
- package/dist/oauth/device-flow-shared.d.ts.map +1 -0
- package/dist/oauth/device-flow-shared.js +101 -0
- package/dist/oauth/device-flow-shared.js.map +1 -0
- package/dist/oauth/device-flow.d.ts +29 -0
- package/dist/oauth/device-flow.d.ts.map +1 -0
- package/dist/oauth/device-flow.js +74 -0
- package/dist/oauth/device-flow.js.map +1 -0
- package/dist/oauth/poll-until.d.ts +41 -0
- package/dist/oauth/poll-until.d.ts.map +1 -0
- package/dist/oauth/poll-until.js +70 -0
- package/dist/oauth/poll-until.js.map +1 -0
- package/dist/oauth/token-exchange.d.ts +28 -0
- package/dist/oauth/token-exchange.d.ts.map +1 -0
- package/dist/oauth/token-exchange.js +142 -0
- package/dist/oauth/token-exchange.js.map +1 -0
- package/dist/oauth/types.d.ts +90 -0
- package/dist/oauth/types.d.ts.map +1 -0
- package/dist/oauth/types.js +2 -0
- package/dist/oauth/types.js.map +1 -0
- package/dist/open-browser.d.ts +32 -0
- package/dist/open-browser.d.ts.map +1 -0
- package/dist/open-browser.js +83 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/pkce.d.ts +20 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +34 -0
- package/dist/pkce.js.map +1 -0
- package/dist/profile.d.ts +129 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +11 -0
- package/dist/profile.js.map +1 -0
- package/dist/run-login.d.ts +9 -0
- package/dist/run-login.d.ts.map +1 -0
- package/dist/run-login.js +93 -0
- package/dist/run-login.js.map +1 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +167 -0
- package/dist/storage.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +283 -0
- package/dist/tools.js.map +1 -0
- package/package.json +73 -0
- package/skills/google-oauth.md +313 -0
- package/skills/oauth-flow.md +132 -0
- package/src/adapters/openai-device-flow.test.ts +191 -0
- package/src/adapters/openai-device-flow.ts +179 -0
- package/src/adapters/rfc8628-device-flow.ts +75 -0
- package/src/credential-lock.ts +183 -0
- package/src/discovery.test.ts +35 -0
- package/src/ensure-fresh.test.ts +279 -0
- package/src/ensure-fresh.ts +178 -0
- package/src/flow.ts +9 -0
- package/src/index.ts +153 -0
- package/src/oauth/browser-flow.ts +96 -0
- package/src/oauth/callback-server.test.ts +90 -0
- package/src/oauth/callback-server.ts +208 -0
- package/src/oauth/device-flow-shared.test.ts +142 -0
- package/src/oauth/device-flow-shared.ts +129 -0
- package/src/oauth/device-flow.test.ts +102 -0
- package/src/oauth/device-flow.ts +82 -0
- package/src/oauth/poll-until.test.ts +63 -0
- package/src/oauth/poll-until.ts +100 -0
- package/src/oauth/token-exchange.test.ts +224 -0
- package/src/oauth/token-exchange.ts +169 -0
- package/src/oauth/types.ts +92 -0
- package/src/oauth.test.ts +185 -0
- package/src/open-browser-spawn.test.ts +69 -0
- package/src/open-browser.test.ts +30 -0
- package/src/open-browser.ts +84 -0
- package/src/pkce.ts +37 -0
- package/src/profile.test.ts +331 -0
- package/src/profile.ts +135 -0
- package/src/run-login.ts +123 -0
- package/src/storage.test.ts +191 -0
- package/src/storage.ts +208 -0
- package/src/tools.test.ts +239 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,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
|
+
});
|