@lanes-sh/link 0.2.1 → 0.3.0
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/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
package/README.md
CHANGED
|
@@ -48,8 +48,8 @@ Needs [Bun](https://bun.com) 1.3.11+. Nothing else — no account anywhere.
|
|
|
48
48
|
|
|
49
49
|
```console
|
|
50
50
|
$ bun install -g @lanes-sh/link # puts `lanes` on your PATH
|
|
51
|
-
$ lanes link profile add personal --
|
|
52
|
-
$ lanes link start
|
|
51
|
+
$ lanes link profile add personal --target local
|
|
52
|
+
$ lanes link start --profile personal --target local
|
|
53
53
|
ok serving http://127.0.0.1:7337/mcp
|
|
54
54
|
profiles: personal
|
|
55
55
|
```
|
|
@@ -57,7 +57,7 @@ ok serving http://127.0.0.1:7337/mcp
|
|
|
57
57
|
Then, in another shell:
|
|
58
58
|
|
|
59
59
|
```console
|
|
60
|
-
$ lanes link mcp add # every agent installed; or name one: claude, codex
|
|
60
|
+
$ lanes link mcp add --profile personal --target local # every agent installed; or name one: claude, codex
|
|
61
61
|
ok registered lanes-link with Claude Code (user scope)
|
|
62
62
|
ok registered lanes-link with Codex
|
|
63
63
|
```
|
|
@@ -78,6 +78,17 @@ calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
|
|
|
78
78
|
Memory and skills are plain Markdown files, so a text editor and an agent reach the same bytes. All
|
|
79
79
|
four belong to one profile: what you add under `work` is invisible under `personal`.
|
|
80
80
|
|
|
81
|
+
Keep those two in a private GitHub repository instead of on this machine, and get history, diffs,
|
|
82
|
+
and the same notes from anywhere you run this:
|
|
83
|
+
|
|
84
|
+
```console
|
|
85
|
+
$ lanes link knowledge use github --repo <owner/name> --migrate
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It moves what you have already stored, in one commit, and `lanes link knowledge use local
|
|
89
|
+
--migrate` brings it back. Nothing else moves — your credentials and your vault stay where they
|
|
90
|
+
are, and there is no setting that would put them in a repository.
|
|
91
|
+
|
|
81
92
|
## Connect an account
|
|
82
93
|
|
|
83
94
|
One command per account. Run it again to add a second mailbox, a second calendar, a second anything.
|
|
@@ -99,15 +110,17 @@ One command per account. Run it again to add a second mailbox, a second calendar
|
|
|
99
110
|
| Linear | `lanes link connect linear` |
|
|
100
111
|
| GitHub | `lanes link connect github` |
|
|
101
112
|
| Slack | `lanes link connect slack` |
|
|
113
|
+
| Gmail (IMAP, app password) | `lanes link connect gmail_imap` |
|
|
102
114
|
| Gmail (Google MCP) | `lanes link connect gmail_mcp` |
|
|
103
115
|
| Drive (Google MCP) | `lanes link connect drive_mcp` |
|
|
104
116
|
|
|
105
117
|
Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
|
|
106
|
-
Contacts together, because one app-specific password covers all three. Google
|
|
107
|
-
of your own:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
Contacts together, because one app-specific password covers all three. Google and Slack need no
|
|
119
|
+
OAuth client of your own: both authorise against the one Lanes operates, so there is no console to
|
|
120
|
+
visit — for Google, add `--own-client` if you would rather register your own, or take a service
|
|
121
|
+
account key or an app password over IMAP where you would rather nothing expired. And GitHub takes a
|
|
122
|
+
token you paste rather than a browser sign-in, because it will not register a client for us; that
|
|
123
|
+
is the one console visit left here.
|
|
111
124
|
|
|
112
125
|
Full guide — what each one gives your agent, what it needs, and adding your own:
|
|
113
126
|
**[docs/connect.md](docs/connect.md)**.
|
|
@@ -24,6 +24,13 @@ is listed first.** Quietly picking one crosses the line the profile exists to
|
|
|
24
24
|
draw. There is no "current profile" to switch — the choice is made per call, and
|
|
25
25
|
`lanes link profile list` shows what exists.
|
|
26
26
|
|
|
27
|
+
**Every `lanes link` command names its profile and its target.** Both are required
|
|
28
|
+
flags with no default, no environment variable, and nothing in a config file
|
|
29
|
+
behind them, so a command missing either refuses rather than acting somewhere
|
|
30
|
+
else. When you write one out for the owner, either fill both in or leave them as
|
|
31
|
+
`<name>` for them to complete — never drop them. `lanes link target list
|
|
32
|
+
--profile <name>` shows what a profile declares.
|
|
33
|
+
|
|
27
34
|
A `connection` names an account within that profile. One profile may hold
|
|
28
35
|
several of the same kind, and naming a connection belonging to a *different*
|
|
29
36
|
profile is refused rather than guessed at.
|
|
@@ -37,7 +44,7 @@ try more than one wording before deciding it is not there.
|
|
|
37
44
|
Writing is a separate grant, and it should be. What you write is served back to
|
|
38
45
|
every later session, including to a different agent, so **write when you are
|
|
39
46
|
asked to remember something, not as a habit.** The owner reaches the same
|
|
40
|
-
entries with `lanes link memory list
|
|
47
|
+
entries with `lanes link memory list --profile <name> --target <name>` and a text editor.
|
|
41
48
|
|
|
42
49
|
## Skills are theirs, not yours
|
|
43
50
|
|
|
@@ -47,7 +54,7 @@ cannot read a skill's body; that is deliberate, not a gap to work around.
|
|
|
47
54
|
|
|
48
55
|
So when a task has a skill for it, **say the skill exists and let them invoke
|
|
49
56
|
it** rather than improvising your own version of their procedure. They manage
|
|
50
|
-
these with `lanes link skills list
|
|
57
|
+
these with `lanes link skills list --profile <name> --target <name>` and `lanes link skills show <skill> --profile <name> --target <name>`.
|
|
51
58
|
|
|
52
59
|
## Vault values are credentials
|
|
53
60
|
|
|
@@ -55,6 +62,27 @@ Use one to do the thing that needs it. Never quote it back, summarise it, echo
|
|
|
55
62
|
it into a file, or paste it into a command whose output you will show. If a
|
|
56
63
|
value has been printed by accident, say so.
|
|
57
64
|
|
|
65
|
+
## Who you are writing as is declared, not inferred
|
|
66
|
+
|
|
67
|
+
A profile may declare the names, addresses and handles its owner wants used when
|
|
68
|
+
something is written as them. Call `identity_list` for the profile in play
|
|
69
|
+
before signing a message, choosing an address to send from, or attributing work
|
|
70
|
+
to a handle — **do not** read a name off the conversation, off a previous
|
|
71
|
+
message's signature, or off the account label on a connection. That label is the
|
|
72
|
+
identity a provider reports for a mailbox; it is not necessarily what they sign
|
|
73
|
+
with.
|
|
74
|
+
|
|
75
|
+
A profile may declare several of a kind on purpose. The first is the default and
|
|
76
|
+
each carries a note saying when to prefer it, so read the notes rather than
|
|
77
|
+
picking the first unconditionally. If none of them fits what you are doing, ask
|
|
78
|
+
— do not combine two, and do not carry one profile's name into another. That
|
|
79
|
+
crossing is the specific mistake this exists to prevent.
|
|
80
|
+
|
|
81
|
+
If the tool is not there, the profile has declared nothing. Ask rather than
|
|
82
|
+
inventing something; they add one with `lanes link identity add <kind> <value> --profile <profile>
|
|
83
|
+
--target <target>` — both flags, because neither has a fallback.
|
|
84
|
+
Nothing you can call writes here, deliberately.
|
|
85
|
+
|
|
58
86
|
## Attachments are named, not carried
|
|
59
87
|
|
|
60
88
|
Where a tool takes `attachments`, each entry names **one** source and the endpoint
|
|
@@ -84,7 +112,7 @@ its content.
|
|
|
84
112
|
|
|
85
113
|
**If the endpoint is not on the same machine as the file**, `path` names the
|
|
86
114
|
*server's* filesystem rather than theirs, and will not find it. Ask them to run
|
|
87
|
-
`lanes link attach <file> --connection <provider>.<account>`, which prints a
|
|
115
|
+
`lanes link attach <file> --profile <name> --target <name> --connection <provider>.<account>`, which prints a
|
|
88
116
|
handle to use instead.
|
|
89
117
|
|
|
90
118
|
**`draft_only: true`** saves instead of sending, where they should see it before
|
|
@@ -104,9 +132,9 @@ granted, and retrying will not reveal it. A call that *is* refused was refused b
|
|
|
104
132
|
policy on purpose.
|
|
105
133
|
|
|
106
134
|
Report it plainly and let the owner decide whether to widen the grant —
|
|
107
|
-
`lanes link policy list
|
|
135
|
+
`lanes link policy list --profile <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
|
|
108
136
|
changes them, and that is their call, not yours. **Do not look for another route
|
|
109
|
-
to the same data.** Every call is audited either way; `lanes link audit tail
|
|
137
|
+
to the same data.** Every call is audited either way; `lanes link audit tail --profile <name> --target <name>`
|
|
110
138
|
shows what was attempted, refusals included.
|
|
111
139
|
|
|
112
140
|
## Setting something up
|
|
@@ -125,9 +153,9 @@ credential — those are `lanes link` in a terminal, deliberately. What you can
|
|
|
125
153
|
know exactly what to hand over, and say what it will ask for before they start.
|
|
126
154
|
|
|
127
155
|
If you have a shell, you can run it yourself for anything that does *not* need a
|
|
128
|
-
browser: `lanes link setup plan <provider> --json` lists what to store,
|
|
129
|
-
`lanes link secrets set <ref>` takes the value on stdin, and
|
|
130
|
-
`lanes link connect <provider> --
|
|
156
|
+
browser: `lanes link setup plan <provider> --profile <name> --target <name> --json` lists what to store,
|
|
157
|
+
`lanes link secrets set <ref> --profile <name> --target <name>` takes the value on stdin, and
|
|
158
|
+
`lanes link connect <provider> --profile <name> --target <name> --id <id> --non-interactive --json` finishes.
|
|
131
159
|
Anything with a browser sign-in belongs to whoever owns the account — give them
|
|
132
160
|
the line.
|
|
133
161
|
|
|
@@ -139,10 +167,10 @@ straight after connecting will still not show it. Say so rather than retrying.
|
|
|
139
167
|
|
|
140
168
|
## Registering it, and re-registering it
|
|
141
169
|
|
|
142
|
-
`lanes link mcp add
|
|
170
|
+
`lanes link mcp add --profile <name> --target <name>` runs each harness's own registration command and installs
|
|
143
171
|
this skill where that harness keeps them. With no argument it does every harness
|
|
144
172
|
installed; name one (`claude`, `codex`) to be specific. Run it again after
|
|
145
|
-
`lanes link token rotate
|
|
173
|
+
`lanes link token rotate --profile <name> --target <name>` — add `--force`, since Claude Code stores the token as
|
|
146
174
|
a value rather than a command.
|
|
147
175
|
|
|
148
176
|
**Never paste the token.** There is a right way and a wrong way, and the
|
|
@@ -151,7 +179,7 @@ difference matters:
|
|
|
151
179
|
```bash
|
|
152
180
|
# RIGHT — the token goes from the CLI to the harness. You never see it.
|
|
153
181
|
claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \
|
|
154
|
-
--header "Authorization: Bearer $(lanes link token show --raw)"
|
|
182
|
+
--header "Authorization: Bearer $(lanes link token show --raw --profile <name> --target <name>)"
|
|
155
183
|
|
|
156
184
|
# WRONG — the token is now in your context, and in the transcript, forever.
|
|
157
185
|
lanes link token show --show # then copying the value into the command
|
|
@@ -159,9 +187,9 @@ lanes link token show --show # then copying the value into the command
|
|
|
159
187
|
|
|
160
188
|
The token reaches every account of every profile the endpoint serves. Use the
|
|
161
189
|
substitution form. If you have already printed one by accident, say so and offer
|
|
162
|
-
`lanes link token rotate
|
|
190
|
+
`lanes link token rotate --profile <name> --target <name>`.
|
|
163
191
|
|
|
164
|
-
Prefer `lanes link mcp add
|
|
192
|
+
Prefer `lanes link mcp add --profile <name> --target <name>` to writing the command yourself: it checks the
|
|
165
193
|
endpoint is reachable, refuses to silently shadow an existing registration, and
|
|
166
194
|
cannot mistype the token. For a harness it does not know, take the command from
|
|
167
195
|
`lanes link outputs` rather than writing it blind — that command checks whether
|
|
@@ -173,7 +201,7 @@ If you register Codex, tell the user to export the token — Codex stores only t
|
|
|
173
201
|
variable name, so nothing works until it is set:
|
|
174
202
|
|
|
175
203
|
```bash
|
|
176
|
-
export LANES_LINK_TOKEN="$(lanes link token show --raw)"
|
|
204
|
+
export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --target <name>)"
|
|
177
205
|
```
|
|
178
206
|
|
|
179
207
|
One registration covers every profile. Do not add one per profile; they share a
|
|
@@ -185,3 +213,20 @@ URL and a token.
|
|
|
185
213
|
user the command rather than backgrounding it silently on their behalf.
|
|
186
214
|
Registration works while it is down — the harness simply cannot reach it yet,
|
|
187
215
|
and the first symptom is a failed call much later.
|
|
216
|
+
|
|
217
|
+
## When a call does not land
|
|
218
|
+
|
|
219
|
+
Different from the above, and more common: calls were working, and then one does
|
|
220
|
+
not go through. A deployed endpoint is one machine its owner runs, and a client
|
|
221
|
+
can report it unreachable while it is up — sometimes without sending anything at
|
|
222
|
+
all, which is why the endpoint's own log can show no trace of the attempt.
|
|
223
|
+
|
|
224
|
+
Treat it as ordinary. **Say the call did not land, and stop there.** It is not a
|
|
225
|
+
fault to diagnose, and it is not authorization that has lapsed — do not tell them
|
|
226
|
+
to sign in again unless the endpoint itself said so.
|
|
227
|
+
|
|
228
|
+
**Do not redo what already succeeded.** A call that returned is done, and the
|
|
229
|
+
next one failing does not undo it. Re-deriving a finished answer, or rewriting a
|
|
230
|
+
memory entry that was already written, is the expensive mistake here and the one
|
|
231
|
+
that actually gets made. Say which parts landed, which did not, and offer to
|
|
232
|
+
retry the rest.
|
package/package.json
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -212,9 +212,11 @@ export {
|
|
|
212
212
|
challenge,
|
|
213
213
|
protectedResourceMetadata,
|
|
214
214
|
MCP_SCOPE,
|
|
215
|
+
type ChallengeError,
|
|
215
216
|
type ResourceIdentity,
|
|
216
217
|
} from './oauth/metadata.ts';
|
|
217
|
-
export { OAuthServer,
|
|
218
|
+
export { OAuthServer, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
|
|
219
|
+
export { matchesRegistered } from './oauth/redirects.ts';
|
|
218
220
|
export { OAuthStore, hashToken, randomToken } from './oauth/store.ts';
|
|
219
221
|
export { OidcVerifier, type OidcVerifierOptions, type VerifiedSubject } from './oidc.ts';
|
|
220
222
|
export { IssuedTokenAuthenticator, OidcAuthenticator } from './remote.ts';
|
|
@@ -22,21 +22,62 @@ export interface ResourceIdentity {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* Two scopes, and neither is a permission axis.
|
|
26
26
|
*
|
|
27
27
|
* What a caller may do is decided by the profile's policy, per capability, per
|
|
28
28
|
* call, and recorded in the audit log. A second permission system expressed as
|
|
29
29
|
* scopes could only either duplicate that or disagree with it, and a client
|
|
30
|
-
* cannot be trusted to ask for less than it wants anyway. The
|
|
31
|
-
* because the protocol has a slot for
|
|
30
|
+
* cannot be trusted to ask for less than it wants anyway. The scopes exist
|
|
31
|
+
* because the protocol has a slot for them, and because a client reads that
|
|
32
|
+
* slot to decide what this endpoint will do for it.
|
|
32
33
|
*/
|
|
33
34
|
export const MCP_SCOPE = 'mcp';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* OIDC Core §11's name for "issue me a refresh token", and the reason this
|
|
38
|
+
* endpoint stopped sending its owner back to a browser.
|
|
39
|
+
*
|
|
40
|
+
* A refresh token has always been issued here, unconditionally. What was missing
|
|
41
|
+
* was saying so. A client's requested scope defaults to whatever the *resource*
|
|
42
|
+
* document lists, and the reference MCP client appends `offline_access` only
|
|
43
|
+
* when the *authorization server* document advertises it:
|
|
44
|
+
*
|
|
45
|
+
* ```js
|
|
46
|
+
* let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(" ") || …
|
|
47
|
+
* if (effectiveScope && authServerMetadata?.scopes_supported?.includes("offline_access") && …)
|
|
48
|
+
* effectiveScope = `${effectiveScope} offline_access`;
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* So both documents matter and they matter differently. Advertising it in
|
|
52
|
+
* neither left a client no grounds to request, persist, or use the refresh
|
|
53
|
+
* token it was being handed — and a client with no grounds reconnects, which
|
|
54
|
+
* means its owner approving in a browser.
|
|
55
|
+
*/
|
|
56
|
+
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
|
|
57
|
+
|
|
58
|
+
/** Everything grantable here. A request for anything else is narrowed, not refused. */
|
|
59
|
+
export const SUPPORTED_SCOPES = [MCP_SCOPE, OFFLINE_ACCESS_SCOPE] as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The grantable part of what was asked for.
|
|
63
|
+
*
|
|
64
|
+
* Empty means the request named nothing we recognise, and the caller falls back
|
|
65
|
+
* to `MCP_SCOPE` — refusing with `invalid_scope` would turn an unknown token in
|
|
66
|
+
* a client's default string into a connector that cannot be added at all, and
|
|
67
|
+
* scope is not the thing protecting anything here.
|
|
68
|
+
*/
|
|
69
|
+
export function grantableScope(requested: string | null | undefined): string {
|
|
70
|
+
const asked = new Set((requested ?? '').split(/\s+/).filter(Boolean));
|
|
71
|
+
return SUPPORTED_SCOPES.filter((scope) => asked.has(scope)).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
export function protectedResourceMetadata(identity: ResourceIdentity): Record<string, unknown> {
|
|
36
75
|
return {
|
|
37
76
|
resource: identity.resource,
|
|
38
77
|
authorization_servers: [identity.issuer],
|
|
39
|
-
|
|
78
|
+
// Where a client's *default* requested scope comes from, so this is the
|
|
79
|
+
// list that decides what an untouched connector asks for.
|
|
80
|
+
scopes_supported: [...SUPPORTED_SCOPES],
|
|
40
81
|
bearer_methods_supported: ['header'],
|
|
41
82
|
};
|
|
42
83
|
}
|
|
@@ -47,7 +88,9 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
47
88
|
authorization_endpoint: `${origin}/authorize`,
|
|
48
89
|
token_endpoint: `${origin}/token`,
|
|
49
90
|
registration_endpoint: `${origin}/register`,
|
|
50
|
-
|
|
91
|
+
// And this is the list that gates whether `offline_access` is appended at
|
|
92
|
+
// all. Both documents have to carry it; neither one alone is enough.
|
|
93
|
+
scopes_supported: [...SUPPORTED_SCOPES],
|
|
51
94
|
response_types_supported: ['code'],
|
|
52
95
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
53
96
|
// Advertised because a spec-compliant client checks for it before starting
|
|
@@ -60,6 +103,27 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
60
103
|
};
|
|
61
104
|
}
|
|
62
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Why a credential was refused, in RFC 6750 §3.1's vocabulary.
|
|
108
|
+
*
|
|
109
|
+
* There is one code worth sending and it carries the whole distinction a client
|
|
110
|
+
* needs: `invalid_token` says the credential was *rejected*, where an otherwise
|
|
111
|
+
* identical challenge says only that authorization is required. A client that
|
|
112
|
+
* cannot tell those apart cannot tell "refresh — you hold a refresh token for
|
|
113
|
+
* this" from "start a new authorization", and the safe-looking guess is the
|
|
114
|
+
* second, which means the owner approving in a browser for a credential a
|
|
115
|
+
* silent refresh would have replaced.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately absent when nothing was presented. RFC 6750 §3: a resource
|
|
118
|
+
* server SHOULD NOT include an error code where the request carried no
|
|
119
|
+
* authentication information — and sending one would set a client refreshing a
|
|
120
|
+
* credential it does not have.
|
|
121
|
+
*/
|
|
122
|
+
export interface ChallengeError {
|
|
123
|
+
readonly code: 'invalid_token';
|
|
124
|
+
readonly description: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
/**
|
|
64
128
|
* The `WWW-Authenticate` value on a 401.
|
|
65
129
|
*
|
|
@@ -67,9 +131,19 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
67
131
|
* has to guess the document's location by probing well-known paths, which costs
|
|
68
132
|
* round trips and fails entirely on a host that does not serve them. Clients do
|
|
69
133
|
* not honour this header on a `200`, so the status has to be right too.
|
|
134
|
+
*
|
|
135
|
+
* It stays on the header even when a token was rejected. A client that decides
|
|
136
|
+
* to authorize after all — because the refresh was refused too — must not have
|
|
137
|
+
* to go and find the document a second time.
|
|
70
138
|
*/
|
|
71
|
-
export function challenge(metadataUrl: string | null): string {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
139
|
+
export function challenge(metadataUrl: string | null, error?: ChallengeError): string {
|
|
140
|
+
// Every value is a quoted-string, so none may contain a quote. Both of these
|
|
141
|
+
// are constants in this repository and the types keep them that way.
|
|
142
|
+
const parts = [
|
|
143
|
+
'realm="lanes-link"',
|
|
144
|
+
...(error ? [`error="${error.code}"`, `error_description="${error.description}"`] : []),
|
|
145
|
+
...(metadataUrl ? [`resource_metadata="${metadataUrl}"`] : []),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
return `Bearer ${parts.join(', ')}`;
|
|
75
149
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which redirect URIs this server will send an authorization code to.
|
|
3
|
+
*
|
|
4
|
+
* Its own file because it is its own subject. `server.ts` is the flow as
|
|
5
|
+
* decisions — a code exchanged, a token rotated, an owner approving — and none
|
|
6
|
+
* of it is about URL shapes. Both halves stayed inside the file-size budget
|
|
7
|
+
* until they did not, and the budget exists to point at exactly this: it was
|
|
8
|
+
* not too long, it was two things.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here consults configuration. What a client registered is checked
|
|
11
|
+
* against what it now presents, and the rules are RFC 8252's rather than ours.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* https, or loopback for a native client.
|
|
16
|
+
*
|
|
17
|
+
* A native client cannot receive an https redirect, so RFC 8252 has it listen
|
|
18
|
+
* on a loopback port instead. Everything else is refused: a redirect to `http://`
|
|
19
|
+
* on a routable host puts an authorization code on the wire in clear text.
|
|
20
|
+
*/
|
|
21
|
+
export function isSafeRedirect(uri: string): boolean {
|
|
22
|
+
let parsed: URL;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(uri);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parsed.protocol === 'https:') return true;
|
|
30
|
+
return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
34
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Exact match, except for the port of a loopback URI.
|
|
39
|
+
*
|
|
40
|
+
* RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
|
|
41
|
+
* native client binds an ephemeral one it cannot know at registration time.
|
|
42
|
+
* Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
|
|
43
|
+
* and then listens on whatever port it got, so the same allowance has to cover
|
|
44
|
+
* `localhost` or it never connects.
|
|
45
|
+
*/
|
|
46
|
+
export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
|
|
47
|
+
if (registered.includes(candidate)) return true;
|
|
48
|
+
|
|
49
|
+
let parsed: URL;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(candidate);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!isLoopbackHost(parsed.hostname)) return false;
|
|
56
|
+
|
|
57
|
+
return registered.some((uri) => {
|
|
58
|
+
try {
|
|
59
|
+
const other = new URL(uri);
|
|
60
|
+
return (
|
|
61
|
+
isLoopbackHost(other.hostname) &&
|
|
62
|
+
other.protocol === parsed.protocol &&
|
|
63
|
+
other.hostname === parsed.hostname &&
|
|
64
|
+
other.pathname === parsed.pathname
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/auth/oauth/server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { MCP_SCOPE } from './metadata.ts';
|
|
1
|
+
import { grantableScope, MCP_SCOPE } from './metadata.ts';
|
|
2
|
+
import { isSafeRedirect, matchesRegistered } from './redirects.ts';
|
|
2
3
|
import {
|
|
3
4
|
hashToken,
|
|
4
5
|
randomToken,
|
|
@@ -26,6 +27,21 @@ import {
|
|
|
26
27
|
const CODE_TTL_MS = 60_000;
|
|
27
28
|
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long a spent refresh token still answers.
|
|
32
|
+
*
|
|
33
|
+
* A client whose refresh succeeded but whose *response* was lost holds a token
|
|
34
|
+
* the server has already spent, and retrying with it is the only move it has.
|
|
35
|
+
* Without a window that retry is `invalid_grant`, and the reference MCP client
|
|
36
|
+
* rethrows every `OAuthError` but `server_error` rather than recovering — so
|
|
37
|
+
* the connector dies and its owner is sent to a browser, over a network blip.
|
|
38
|
+
*
|
|
39
|
+
* Thirty seconds is the band Auth0's reuse interval (0–60 s) and Okta's grace
|
|
40
|
+
* period occupy. What it costs: a captured refresh token keeps working for up
|
|
41
|
+
* to this long after the real client next rotates it.
|
|
42
|
+
*/
|
|
43
|
+
const REFRESH_REUSE_MS = 30_000;
|
|
44
|
+
|
|
29
45
|
export type OAuthResult =
|
|
30
46
|
| { readonly kind: 'json'; readonly status: number; readonly body: unknown }
|
|
31
47
|
| { readonly kind: 'redirect'; readonly location: string }
|
|
@@ -61,6 +77,9 @@ export interface OAuthServerOptions {
|
|
|
61
77
|
/** Proof of being the owner. The same token the endpoint already accepts. */
|
|
62
78
|
readonly verifyOwner: (presented: string) => Promise<boolean>;
|
|
63
79
|
readonly accessTokenTtlMs: number;
|
|
80
|
+
/** Where a replayed refresh token is recorded. Structural, because this layer
|
|
81
|
+
* may not import `#connectivity`; the endpoint's own logger satisfies it. */
|
|
82
|
+
readonly log?: { warn(message: string, detail?: Record<string, unknown>): void };
|
|
64
83
|
readonly now?: () => number;
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -163,7 +182,11 @@ export class OAuthServer {
|
|
|
163
182
|
redirectUri,
|
|
164
183
|
codeChallenge: challenge,
|
|
165
184
|
state: params.get('state') ?? undefined,
|
|
166
|
-
|
|
185
|
+
// The grantable part of what was asked for, not the request verbatim.
|
|
186
|
+
// Echoing it back through `#issue` was granting by echo, which was inert
|
|
187
|
+
// while `mcp` was the only scope and stops being inert now that there is
|
|
188
|
+
// a second one that means something.
|
|
189
|
+
scope: grantableScope(params.get('scope')) || MCP_SCOPE,
|
|
167
190
|
resource: params.get('resource') ?? undefined,
|
|
168
191
|
},
|
|
169
192
|
};
|
|
@@ -198,7 +221,12 @@ export class OAuthServer {
|
|
|
198
221
|
clientId: request.clientId,
|
|
199
222
|
redirectUri: request.redirectUri,
|
|
200
223
|
codeChallenge: request.codeChallenge,
|
|
201
|
-
|
|
224
|
+
// Narrowed here as well as in `authorize`, and this is the one that
|
|
225
|
+
// matters: the request arrives back through hidden form fields, so a
|
|
226
|
+
// caller can post any scope it likes straight to this endpoint. Nothing
|
|
227
|
+
// round-tripped through the form is trusted — the client and the redirect
|
|
228
|
+
// URI are re-checked above for the same reason.
|
|
229
|
+
scope: grantableScope(request.scope) || MCP_SCOPE,
|
|
202
230
|
...(request.resource ? { resource: request.resource } : {}),
|
|
203
231
|
expiresAt: this.#now() + CODE_TTL_MS,
|
|
204
232
|
};
|
|
@@ -252,15 +280,25 @@ export class OAuthServer {
|
|
|
252
280
|
return invalid('invalid_grant', 'That refresh token is unknown or expired.');
|
|
253
281
|
}
|
|
254
282
|
|
|
255
|
-
// A spent token presented again
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// So the whole chain goes. A client retrying a response it never saw and a
|
|
260
|
-
// thief replaying are indistinguishable from here, and re-authorising is
|
|
261
|
-
// the cheaper of the two mistakes.
|
|
283
|
+
// A spent token presented again used to take its whole family with it, on
|
|
284
|
+
// the reading that a replay is a theft. Against a real connector that was
|
|
285
|
+
// wrong twice over, and ADR-035 has the evidence. Two answers replace it,
|
|
286
|
+
// and the tombstone's age is what tells them apart.
|
|
262
287
|
if (record.kind === 'consumed') {
|
|
263
|
-
|
|
288
|
+
// Inside the window it is a retry of a request already answered, and the
|
|
289
|
+
// client is owed the answer rather than a dead connector. Not re-consumed:
|
|
290
|
+
// a client retrying twice is still retrying.
|
|
291
|
+
const spentAt = record.consumedAt;
|
|
292
|
+
if (spentAt !== undefined && this.#now() - spentAt <= REFRESH_REUSE_MS) {
|
|
293
|
+
return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Outside it, refused on its own — and the family survives, which is the
|
|
297
|
+
// half that was taking live sessions down with it.
|
|
298
|
+
this.#options.log?.warn('refresh token replayed', {
|
|
299
|
+
clientId: record.clientId,
|
|
300
|
+
family: record.family,
|
|
301
|
+
});
|
|
264
302
|
return invalid('invalid_grant', 'That refresh token has already been used.');
|
|
265
303
|
}
|
|
266
304
|
|
|
@@ -324,62 +362,4 @@ function invalid(error: string, description: string): OAuthResult {
|
|
|
324
362
|
return { kind: 'json', status: 400, body: { error, error_description: description } };
|
|
325
363
|
}
|
|
326
364
|
|
|
327
|
-
/**
|
|
328
|
-
* https, or loopback for a native client.
|
|
329
|
-
*
|
|
330
|
-
* A native client cannot receive an https redirect, so RFC 8252 has it listen
|
|
331
|
-
* on a loopback port instead. Everything else is refused: a redirect to `http://`
|
|
332
|
-
* on a routable host puts an authorization code on the wire in clear text.
|
|
333
|
-
*/
|
|
334
|
-
function isSafeRedirect(uri: string): boolean {
|
|
335
|
-
let parsed: URL;
|
|
336
|
-
try {
|
|
337
|
-
parsed = new URL(uri);
|
|
338
|
-
} catch {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (parsed.protocol === 'https:') return true;
|
|
343
|
-
return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function isLoopbackHost(hostname: string): boolean {
|
|
347
|
-
return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Exact match, except for the port of a loopback URI.
|
|
352
|
-
*
|
|
353
|
-
* RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
|
|
354
|
-
* native client binds an ephemeral one it cannot know at registration time.
|
|
355
|
-
* Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
|
|
356
|
-
* and then listens on whatever port it got, so the same allowance has to cover
|
|
357
|
-
* `localhost` or it never connects.
|
|
358
|
-
*/
|
|
359
|
-
export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
|
|
360
|
-
if (registered.includes(candidate)) return true;
|
|
361
|
-
|
|
362
|
-
let parsed: URL;
|
|
363
|
-
try {
|
|
364
|
-
parsed = new URL(candidate);
|
|
365
|
-
} catch {
|
|
366
|
-
return false;
|
|
367
|
-
}
|
|
368
|
-
if (!isLoopbackHost(parsed.hostname)) return false;
|
|
369
|
-
|
|
370
|
-
return registered.some((uri) => {
|
|
371
|
-
try {
|
|
372
|
-
const other = new URL(uri);
|
|
373
|
-
return (
|
|
374
|
-
isLoopbackHost(other.hostname) &&
|
|
375
|
-
other.protocol === parsed.protocol &&
|
|
376
|
-
other.hostname === parsed.hostname &&
|
|
377
|
-
other.pathname === parsed.pathname
|
|
378
|
-
);
|
|
379
|
-
} catch {
|
|
380
|
-
return false;
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
365
|
export { hashToken };
|
package/src/auth/oauth/store.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface AuthorizationCode {
|
|
|
53
53
|
* it arrived. A tombstone keeps the family id and nothing else useful, and it
|
|
54
54
|
* opens no more than a deleted row does — every check that admits a credential
|
|
55
55
|
* tests for `access` by name.
|
|
56
|
+
*
|
|
57
|
+
* What is *done* about a detected replay changed in ADR-035: the presented
|
|
58
|
+
* token is refused and the replay logged, rather than the family revoked.
|
|
56
59
|
*/
|
|
57
60
|
export type TokenKind = 'access' | 'refresh' | 'consumed';
|
|
58
61
|
|
|
@@ -71,6 +74,15 @@ export interface IssuedToken {
|
|
|
71
74
|
* the theft and the retry look identical from here.
|
|
72
75
|
*/
|
|
73
76
|
readonly family: string;
|
|
77
|
+
/**
|
|
78
|
+
* When this token was spent, on a `consumed` tombstone and nowhere else.
|
|
79
|
+
*
|
|
80
|
+
* What makes the reuse interval possible: without it a spent token carries no
|
|
81
|
+
* hint whether it was spent a second ago or a month ago, and those are a retry
|
|
82
|
+
* and a replay. A tombstone written before this existed has no `consumedAt`
|
|
83
|
+
* and is read as the older one, which is the safe direction.
|
|
84
|
+
*/
|
|
85
|
+
readonly consumedAt?: number;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
export function hashToken(value: string): string {
|
|
@@ -182,16 +194,18 @@ export class OAuthStore {
|
|
|
182
194
|
const key = hashToken(token);
|
|
183
195
|
const record = await this.#read<IssuedToken>(TOKENS, key);
|
|
184
196
|
if (!record) return;
|
|
185
|
-
|
|
197
|
+
const spent: IssuedToken = { ...record, kind: 'consumed', consumedAt: this.#now() };
|
|
198
|
+
await this.#state.set(TOKENS, key, JSON.stringify(spent));
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
/**
|
|
189
202
|
* Drop every token in a refresh family.
|
|
190
203
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
204
|
+
* A replay no longer calls this, and a replay was the only thing that did —
|
|
205
|
+
* see `OAuthServer.#refresh` and ADR-035. Kept because it is the shape a
|
|
206
|
+
* deliberate revocation takes: one authorization's whole chain, dropped on
|
|
207
|
+
* purpose. Nothing in `src/` reaches it today, so read a call site as new
|
|
208
|
+
* policy rather than as the old one returning.
|
|
195
209
|
*/
|
|
196
210
|
async revokeFamily(family: string): Promise<void> {
|
|
197
211
|
for (const key of await this.#state.keys(TOKENS)) {
|