@lanes-sh/link 0.7.1 → 0.8.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 +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -75
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an authorization step decided, before it is HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Its own file because both halves of the flow produce one — the browser leg in
|
|
5
|
+
* `server.ts` and the grant in `grant.ts` — and a shared type that lives in
|
|
6
|
+
* either would make the other import it, which is a cycle rather than a
|
|
7
|
+
* dependency.
|
|
8
|
+
*/
|
|
9
|
+
export type OAuthResult =
|
|
10
|
+
| { readonly kind: 'json'; readonly status: number; readonly body: unknown }
|
|
11
|
+
| { readonly kind: 'redirect'; readonly location: string }
|
|
12
|
+
/**
|
|
13
|
+
* Show this to whoever is at the browser.
|
|
14
|
+
*
|
|
15
|
+
* The only page this server renders now. There was a second — the approval
|
|
16
|
+
* form that asked for the endpoint token — and its removal is the point of
|
|
17
|
+
* ADR-062: nothing on loopback asks for a credential any more, so there is
|
|
18
|
+
* nothing there worth phishing.
|
|
19
|
+
*/
|
|
20
|
+
| { readonly kind: 'error'; readonly status: number; readonly message: string };
|
|
21
|
+
|
|
22
|
+
export function invalid(error: string, description: string): OAuthResult {
|
|
23
|
+
// RFC 6749 codes exactly. A client refreshing on a 401 branches on
|
|
24
|
+
// `invalid_grant` specifically; anything else and it retries forever or gives
|
|
25
|
+
// up without re-authorising.
|
|
26
|
+
return { kind: 'json', status: 400, body: { error, error_description: description } };
|
|
27
|
+
}
|
package/src/auth/oauth/server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { exchangeCode, pkceChallengeFor, refresh, type GrantContext } from './grant.ts';
|
|
1
2
|
import { grantableScope, MCP_SCOPE } from './metadata.ts';
|
|
2
3
|
import { isSafeRedirect, matchesRegistered } from './redirects.ts';
|
|
4
|
+
import { invalid, type OAuthResult } from './result.ts';
|
|
3
5
|
import {
|
|
4
6
|
hashToken,
|
|
5
7
|
randomToken,
|
|
@@ -19,63 +21,70 @@ import {
|
|
|
19
21
|
* Deliberately small. This implements one grant and one refresh, for public
|
|
20
22
|
* clients, with PKCE required. It is not a general authorization server and
|
|
21
23
|
* should not grow into one: no client credentials grant, no implicit flow, no
|
|
22
|
-
* consent scoping, no user directory.
|
|
23
|
-
*
|
|
24
|
+
* consent scoping, no user directory.
|
|
25
|
+
*
|
|
26
|
+
* **It does not authenticate anybody, and that is the change in 0.8.0.** It used
|
|
27
|
+
* to: `/authorize` rendered a form and the proof of being the owner was pasting
|
|
28
|
+
* the endpoint's own bearer token into it. Two things were wrong with that. A
|
|
29
|
+
* page on loopback asking for the one credential that opens everything is the
|
|
30
|
+
* most valuable thing a hostile local page could reach (ADR-039), and a
|
|
31
|
+
* credential is not a person — so a profile could not say *who* may consume it.
|
|
32
|
+
*
|
|
33
|
+
* Now the browser is sent to lanes.sh, which knows who is signed in, and comes
|
|
34
|
+
* back with a signed assertion this endpoint verifies against a published key
|
|
35
|
+
* (ADR-062). The endpoint learns a subject rather than a secret, and there is no
|
|
36
|
+
* form on loopback to phish.
|
|
24
37
|
*/
|
|
25
38
|
|
|
26
39
|
/** Codes live about as long as a redirect takes. */
|
|
27
40
|
const CODE_TTL_MS = 60_000;
|
|
28
|
-
|
|
41
|
+
|
|
42
|
+
/** A person finding a password manager, not a redirect completing. */
|
|
43
|
+
const PENDING_TTL_MS = 10 * 60_000;
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
|
-
*
|
|
46
|
+
* Where this endpoint sends people to be identified, and how it checks the answer.
|
|
32
47
|
*
|
|
33
|
-
*
|
|
34
|
-
* the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
48
|
+
* Every field is injected rather than built here, which is what keeps this file
|
|
49
|
+
* about the grant. It also means the whole federation can be replaced by a
|
|
50
|
+
* self-hoster's own — `mode: oidc` is the supported way to do that, and this is
|
|
51
|
+
* the same shape one layer down.
|
|
42
52
|
*/
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
readonly
|
|
70
|
-
|
|
71
|
-
readonly
|
|
72
|
-
readonly resource: string | undefined;
|
|
53
|
+
export interface Federation {
|
|
54
|
+
/** The page that knows who is signed in. `https://lanes.sh/link/authorize`. */
|
|
55
|
+
readonly consentUrl: string;
|
|
56
|
+
/**
|
|
57
|
+
* Believe an assertion, or do not.
|
|
58
|
+
*
|
|
59
|
+
* Returns the person, or null. Deliberately no reason: whoever is at the
|
|
60
|
+
* browser cannot act on "the audience was wrong", and an attacker can.
|
|
61
|
+
*/
|
|
62
|
+
readonly verify: (
|
|
63
|
+
assertion: string,
|
|
64
|
+
expected: { audience: string; nonce: string },
|
|
65
|
+
) => Promise<{ subject: string; email: string | null } | null>;
|
|
66
|
+
/**
|
|
67
|
+
* The profiles that subject may consume here.
|
|
68
|
+
*
|
|
69
|
+
* Empty is a real answer and the common one for a stranger: they signed in
|
|
70
|
+
* successfully, and no profile names them. It is refused with that reason,
|
|
71
|
+
* because "sign in again" would be advice that cannot work.
|
|
72
|
+
*/
|
|
73
|
+
readonly profilesFor: (subject: string) => Promise<readonly string[]>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Who this endpoint is, from the point of view of the request being served. */
|
|
77
|
+
export interface EndpointIdentity {
|
|
78
|
+
/** What an assertion must name as its audience — the MCP URL clients call. */
|
|
79
|
+
readonly resource: string;
|
|
80
|
+
/** Where lanes.sh sends the browser back to. */
|
|
81
|
+
readonly callbackUrl: string;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
export interface OAuthServerOptions {
|
|
76
85
|
readonly store: OAuthStore;
|
|
77
|
-
/**
|
|
78
|
-
readonly
|
|
86
|
+
/** Where the person is identified. See `Federation`. */
|
|
87
|
+
readonly federation: Federation;
|
|
79
88
|
readonly accessTokenTtlMs: number;
|
|
80
89
|
/** Where a replayed refresh token is recorded. Structural, because this layer
|
|
81
90
|
* may not import `#connectivity`; the endpoint's own logger satisfies it. */
|
|
@@ -145,7 +154,7 @@ export class OAuthServer {
|
|
|
145
154
|
* that failed validation is how an open redirector is built, so a bad
|
|
146
155
|
* `client_id` or `redirect_uri` ends at this endpoint and goes no further.
|
|
147
156
|
*/
|
|
148
|
-
async authorize(params: URLSearchParams): Promise<OAuthResult> {
|
|
157
|
+
async authorize(params: URLSearchParams, endpoint: EndpointIdentity): Promise<OAuthResult> {
|
|
149
158
|
if (params.get('response_type') !== 'code') {
|
|
150
159
|
return { kind: 'error', status: 400, message: 'Only response_type=code is supported.' };
|
|
151
160
|
}
|
|
@@ -173,193 +182,157 @@ export class OAuthServer {
|
|
|
173
182
|
return { kind: 'error', status: 400, message: 'code_challenge is required.' };
|
|
174
183
|
}
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
},
|
|
192
|
-
|
|
185
|
+
// Minted here and stored here. Everything the client asked for is kept
|
|
186
|
+
// server-side under this nonce, so nothing coming back through the browser
|
|
187
|
+
// is trusted — see `PendingAuthorization`.
|
|
188
|
+
const nonce = randomToken('lln');
|
|
189
|
+
|
|
190
|
+
await this.#options.store.putPending(nonce, {
|
|
191
|
+
clientId,
|
|
192
|
+
redirectUri,
|
|
193
|
+
codeChallenge: challenge,
|
|
194
|
+
...(params.get('state') !== null ? { state: params.get('state')! } : {}),
|
|
195
|
+
// The grantable part of what was asked for, not the request verbatim.
|
|
196
|
+
// Echoing it back through `#issue` was granting by echo, which was inert
|
|
197
|
+
// while `mcp` was the only scope and stops being inert now that there is
|
|
198
|
+
// a second one that means something.
|
|
199
|
+
scope: grantableScope(params.get('scope')) || MCP_SCOPE,
|
|
200
|
+
...(params.get('resource') !== null ? { resource: params.get('resource')! } : {}),
|
|
201
|
+
expiresAt: this.#now() + PENDING_TTL_MS,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const consent = new URL(this.#options.federation.consentUrl);
|
|
205
|
+
consent.searchParams.set('resource', endpoint.resource);
|
|
206
|
+
consent.searchParams.set('nonce', nonce);
|
|
207
|
+
consent.searchParams.set('return', endpoint.callbackUrl);
|
|
208
|
+
// Both shown to the person, and the second is the one that cannot be
|
|
209
|
+
// faked: a client may call itself anything, but the code still goes where
|
|
210
|
+
// its registration says, and that host is on the screen beside the name.
|
|
211
|
+
if (client.clientName) consent.searchParams.set('client', client.clientName);
|
|
212
|
+
consent.searchParams.set('redirect_host', hostOf(redirectUri));
|
|
213
|
+
|
|
214
|
+
return { kind: 'redirect', location: consent.toString() };
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
/**
|
|
196
|
-
* The
|
|
218
|
+
* The browser coming back from lanes.sh, carrying an assertion.
|
|
197
219
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
220
|
+
* This is where a person becomes a principal. Nothing in the query decides
|
|
221
|
+
* anything except *which* pending request this is: the client, the redirect
|
|
222
|
+
* URI and the PKCE challenge all come from the stored record, so a callback
|
|
223
|
+
* fabricated wholesale can at most spend a nonce it does not have.
|
|
224
|
+
*
|
|
225
|
+
* Errors are rendered rather than redirected, for the reason `authorize`
|
|
226
|
+
* gives: the redirect target is only trustworthy once the record it came
|
|
227
|
+
* from has been read, and by then the interesting failures have happened.
|
|
201
228
|
*/
|
|
202
|
-
async
|
|
203
|
-
const
|
|
229
|
+
async callback(params: URLSearchParams, endpoint: EndpointIdentity): Promise<OAuthResult> {
|
|
230
|
+
const nonce = params.get('nonce') ?? '';
|
|
231
|
+
const assertion = params.get('assertion') ?? '';
|
|
232
|
+
|
|
233
|
+
if (!nonce || !assertion) {
|
|
234
|
+
const refused = params.get('error');
|
|
235
|
+
return {
|
|
236
|
+
kind: 'error',
|
|
237
|
+
status: 400,
|
|
238
|
+
message: refused
|
|
239
|
+
? `Sign-in was not completed: ${refused}. Nothing was authorised.`
|
|
240
|
+
: 'That sign-in came back without an assertion. Start again from your client.',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pending = await this.#options.store.takePending(nonce);
|
|
245
|
+
if (!pending) {
|
|
246
|
+
return {
|
|
247
|
+
kind: 'error',
|
|
248
|
+
status: 400,
|
|
249
|
+
message: 'That sign-in has expired or was already used. Start again from your client.',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const person = await this.#options.federation.verify(assertion, {
|
|
254
|
+
audience: endpoint.resource,
|
|
255
|
+
nonce,
|
|
256
|
+
});
|
|
257
|
+
if (!person) {
|
|
258
|
+
return {
|
|
259
|
+
kind: 'error',
|
|
260
|
+
status: 403,
|
|
261
|
+
message: 'That sign-in could not be verified, so nothing was authorised.',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
204
264
|
|
|
205
|
-
|
|
265
|
+
// Read once, at the moment the credential is minted. A profile that stops
|
|
266
|
+
// naming this person later does not reach back and revoke a live session —
|
|
267
|
+
// `lanes link token rotate` is what does that, and `profile members remove`
|
|
268
|
+
// says so (ADR-060).
|
|
269
|
+
const profiles = await this.#options.federation.profilesFor(person.subject);
|
|
270
|
+
if (profiles.length === 0) {
|
|
206
271
|
return {
|
|
207
|
-
kind: '
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
272
|
+
kind: 'error',
|
|
273
|
+
status: 403,
|
|
274
|
+
message:
|
|
275
|
+
`You are signed in as ${person.email ?? person.subject}, and no profile on this ` +
|
|
276
|
+
'endpoint lists you as a member.\n\n' +
|
|
277
|
+
'Its owner can add you with:\n' +
|
|
278
|
+
` lanes link profile members add ${person.subject} --profile <name>`,
|
|
211
279
|
};
|
|
212
280
|
}
|
|
213
281
|
|
|
214
|
-
|
|
215
|
-
|
|
282
|
+
// Re-checked against the registration, not taken on trust from the record.
|
|
283
|
+
// The record was written by this endpoint, so this is belt and braces — but
|
|
284
|
+
// a client deregistered mid-flow is a real sequence, and minting a code for
|
|
285
|
+
// a redirect nobody claims any more is not something to do quietly.
|
|
286
|
+
const client = await this.#options.store.client(pending.clientId);
|
|
287
|
+
if (!client || !matchesRegistered(pending.redirectUri, client.redirectUris)) {
|
|
216
288
|
return { kind: 'error', status: 400, message: 'This approval no longer matches a client.' };
|
|
217
289
|
}
|
|
218
290
|
|
|
219
291
|
const code = randomToken('llx');
|
|
220
|
-
|
|
221
|
-
clientId:
|
|
222
|
-
redirectUri:
|
|
223
|
-
codeChallenge:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// URI are re-checked above for the same reason.
|
|
229
|
-
scope: grantableScope(request.scope) || MCP_SCOPE,
|
|
230
|
-
...(request.resource ? { resource: request.resource } : {}),
|
|
292
|
+
await this.#options.store.putCode(code, {
|
|
293
|
+
clientId: pending.clientId,
|
|
294
|
+
redirectUri: pending.redirectUri,
|
|
295
|
+
codeChallenge: pending.codeChallenge,
|
|
296
|
+
scope: grantableScope(pending.scope) || MCP_SCOPE,
|
|
297
|
+
...(pending.resource ? { resource: pending.resource } : {}),
|
|
298
|
+
subject: person.subject,
|
|
299
|
+
profiles,
|
|
231
300
|
expiresAt: this.#now() + CODE_TTL_MS,
|
|
232
|
-
};
|
|
233
|
-
await this.#options.store.putCode(code, record);
|
|
301
|
+
});
|
|
234
302
|
|
|
235
|
-
const location = new URL(
|
|
303
|
+
const location = new URL(pending.redirectUri);
|
|
236
304
|
location.searchParams.set('code', code);
|
|
237
|
-
if (
|
|
305
|
+
if (pending.state !== undefined) location.searchParams.set('state', pending.state);
|
|
238
306
|
return { kind: 'redirect', location: location.toString() };
|
|
239
307
|
}
|
|
240
308
|
|
|
241
309
|
/** Both grants. Form-encoded in, JSON out, RFC 6749 error codes throughout. */
|
|
242
310
|
async token(form: URLSearchParams): Promise<OAuthResult> {
|
|
311
|
+
const context: GrantContext = {
|
|
312
|
+
store: this.#options.store,
|
|
313
|
+
accessTokenTtlMs: this.#options.accessTokenTtlMs,
|
|
314
|
+
...(this.#options.log ? { log: this.#options.log } : {}),
|
|
315
|
+
now: this.#now,
|
|
316
|
+
};
|
|
317
|
+
|
|
243
318
|
switch (form.get('grant_type')) {
|
|
244
319
|
case 'authorization_code':
|
|
245
|
-
return
|
|
320
|
+
return exchangeCode(form, context);
|
|
246
321
|
case 'refresh_token':
|
|
247
|
-
return
|
|
322
|
+
return refresh(form, context);
|
|
248
323
|
default:
|
|
249
324
|
return invalid('unsupported_grant_type', 'Use authorization_code or refresh_token.');
|
|
250
325
|
}
|
|
251
326
|
}
|
|
252
|
-
|
|
253
|
-
async #exchangeCode(form: URLSearchParams): Promise<OAuthResult> {
|
|
254
|
-
const record = await this.#options.store.takeCode(form.get('code') ?? '');
|
|
255
|
-
if (!record) return invalid('invalid_grant', 'That code is unknown, used, or expired.');
|
|
256
|
-
|
|
257
|
-
if (record.clientId !== form.get('client_id')) {
|
|
258
|
-
return invalid('invalid_grant', 'That code was issued to a different client.');
|
|
259
|
-
}
|
|
260
|
-
// Checked even though the code is already bound to it: a client that sends a
|
|
261
|
-
// different redirect_uri here than it started with is not the client that
|
|
262
|
-
// started, and the spec requires the comparison.
|
|
263
|
-
if (record.redirectUri !== form.get('redirect_uri')) {
|
|
264
|
-
return invalid('invalid_grant', 'redirect_uri does not match the authorization request.');
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const verifier = form.get('code_verifier') ?? '';
|
|
268
|
-
if (!verifier || pkceChallengeFor(verifier) !== record.codeChallenge) {
|
|
269
|
-
return invalid('invalid_grant', 'code_verifier does not match the code_challenge.');
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return this.#issue(record.clientId, record.scope, randomToken('llr'));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async #refresh(form: URLSearchParams): Promise<OAuthResult> {
|
|
276
|
-
const presented = form.get('refresh_token') ?? '';
|
|
277
|
-
const record = await this.#options.store.token(presented);
|
|
278
|
-
|
|
279
|
-
if (!record || record.kind === 'access') {
|
|
280
|
-
return invalid('invalid_grant', 'That refresh token is unknown or expired.');
|
|
281
|
-
}
|
|
282
|
-
|
|
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.
|
|
287
|
-
if (record.kind === 'consumed') {
|
|
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
|
-
});
|
|
302
|
-
return invalid('invalid_grant', 'That refresh token has already been used.');
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (record.clientId !== form.get('client_id')) {
|
|
306
|
-
return invalid('invalid_grant', 'That refresh token was issued to a different client.');
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
await this.#options.store.consumeToken(presented);
|
|
310
|
-
return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async #issue(
|
|
314
|
-
clientId: string,
|
|
315
|
-
scope: string,
|
|
316
|
-
refreshToken: string,
|
|
317
|
-
family = randomToken('llf'),
|
|
318
|
-
): Promise<OAuthResult> {
|
|
319
|
-
const accessToken = randomToken('lla');
|
|
320
|
-
const expiresIn = Math.floor(this.#options.accessTokenTtlMs / 1000);
|
|
321
|
-
|
|
322
|
-
await this.#options.store.putToken(accessToken, {
|
|
323
|
-
clientId,
|
|
324
|
-
kind: 'access',
|
|
325
|
-
scope,
|
|
326
|
-
family,
|
|
327
|
-
expiresAt: this.#now() + this.#options.accessTokenTtlMs,
|
|
328
|
-
});
|
|
329
|
-
await this.#options.store.putToken(refreshToken, {
|
|
330
|
-
clientId,
|
|
331
|
-
kind: 'refresh',
|
|
332
|
-
scope,
|
|
333
|
-
family,
|
|
334
|
-
expiresAt: this.#now() + REFRESH_TTL_MS,
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
return {
|
|
338
|
-
kind: 'json',
|
|
339
|
-
status: 200,
|
|
340
|
-
body: {
|
|
341
|
-
access_token: accessToken,
|
|
342
|
-
token_type: 'Bearer',
|
|
343
|
-
expires_in: expiresIn,
|
|
344
|
-
refresh_token: refreshToken,
|
|
345
|
-
scope,
|
|
346
|
-
},
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** `base64url(sha256(verifier))`, which is what S256 means. */
|
|
352
|
-
export function pkceChallengeFor(verifier: string): string {
|
|
353
|
-
return Buffer.from(
|
|
354
|
-
new Bun.CryptoHasher('sha256').update(verifier, 'utf8').digest(),
|
|
355
|
-
).toString('base64url');
|
|
356
327
|
}
|
|
357
328
|
|
|
358
|
-
function
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
329
|
+
function hostOf(uri: string): string {
|
|
330
|
+
try {
|
|
331
|
+
return new URL(uri).host;
|
|
332
|
+
} catch {
|
|
333
|
+
return uri;
|
|
334
|
+
}
|
|
363
335
|
}
|
|
364
336
|
|
|
365
|
-
export { hashToken };
|
|
337
|
+
export { hashToken, pkceChallengeFor };
|
|
338
|
+
export type { OAuthResult };
|
package/src/auth/oauth/store.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { KeyValueStore } from '#stores/state';
|
|
|
23
23
|
const CLIENTS = 'oauth/clients';
|
|
24
24
|
const CODES = 'oauth/codes';
|
|
25
25
|
const TOKENS = 'oauth/tokens';
|
|
26
|
+
const PENDING = 'oauth/pending';
|
|
26
27
|
|
|
27
28
|
/** Far above any real number of connectors, and far below a problem. */
|
|
28
29
|
const MAX_CLIENTS = 200;
|
|
@@ -42,6 +43,41 @@ export interface AuthorizationCode {
|
|
|
42
43
|
readonly scope: string;
|
|
43
44
|
readonly resource?: string | undefined;
|
|
44
45
|
readonly expiresAt: number;
|
|
46
|
+
/**
|
|
47
|
+
* Who authorised this, and what they may reach.
|
|
48
|
+
*
|
|
49
|
+
* Absent on a code minted before delegation existed, which reads as the
|
|
50
|
+
* owner — the one caller every endpoint had. Present, it is a `lanes:`
|
|
51
|
+
* subject and the profiles whose `members:` named it *at the moment the flow
|
|
52
|
+
* completed*. Resolved once, here, rather than on every request: membership
|
|
53
|
+
* is a decision about issuing a credential, and re-reading it per call would
|
|
54
|
+
* make a profile edit silently revoke a live session (ADR-060).
|
|
55
|
+
*/
|
|
56
|
+
readonly subject?: string | undefined;
|
|
57
|
+
readonly profiles?: readonly string[] | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An authorization request waiting for the person to come back.
|
|
62
|
+
*
|
|
63
|
+
* The flow leaves this endpoint entirely — the browser goes to lanes.sh, signs
|
|
64
|
+
* in, and returns with an assertion — so what the client asked for has to
|
|
65
|
+
* survive the round trip somewhere. It is held here rather than in the redirect
|
|
66
|
+
* because everything in a redirect is attacker-supplied on the way back: the
|
|
67
|
+
* client id, the redirect URI and the PKCE challenge are read from this record
|
|
68
|
+
* and never from the callback's query string.
|
|
69
|
+
*
|
|
70
|
+
* Keyed by a nonce this endpoint minted, single-use, which is also what binds
|
|
71
|
+
* the returning assertion to *this* request.
|
|
72
|
+
*/
|
|
73
|
+
export interface PendingAuthorization {
|
|
74
|
+
readonly clientId: string;
|
|
75
|
+
readonly redirectUri: string;
|
|
76
|
+
readonly codeChallenge: string;
|
|
77
|
+
readonly scope: string;
|
|
78
|
+
readonly state?: string | undefined;
|
|
79
|
+
readonly resource?: string | undefined;
|
|
80
|
+
readonly expiresAt: number;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
83
|
/**
|
|
@@ -64,6 +100,9 @@ export interface IssuedToken {
|
|
|
64
100
|
readonly kind: TokenKind;
|
|
65
101
|
readonly scope: string;
|
|
66
102
|
readonly expiresAt: number;
|
|
103
|
+
/** Carried from the code, and from one refresh to the next. See `AuthorizationCode`. */
|
|
104
|
+
readonly subject?: string | undefined;
|
|
105
|
+
readonly profiles?: readonly string[] | undefined;
|
|
67
106
|
/**
|
|
68
107
|
* Which refresh chain this belongs to.
|
|
69
108
|
*
|
|
@@ -147,6 +186,32 @@ export class OAuthStore {
|
|
|
147
186
|
return this.#read<RegisteredClient>(CLIENTS, clientId);
|
|
148
187
|
}
|
|
149
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Remember an authorization request while its owner is away signing in.
|
|
191
|
+
*
|
|
192
|
+
* Ten minutes, which is a person finding a password manager rather than a
|
|
193
|
+
* redirect completing. Long enough that a real sign-in is not raced, short
|
|
194
|
+
* enough that an abandoned flow does not leave a usable slot.
|
|
195
|
+
*/
|
|
196
|
+
async putPending(nonce: string, record: PendingAuthorization): Promise<void> {
|
|
197
|
+
await this.#state.set(PENDING, hashToken(nonce), JSON.stringify(record));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read a pending request and consume it in the same step.
|
|
202
|
+
*
|
|
203
|
+
* Single-use for the same reason a code is: the nonce travels through a
|
|
204
|
+
* browser redirect, so it reaches history, referrers and anything watching
|
|
205
|
+
* the address bar. Consuming it here is what stops one assertion being
|
|
206
|
+
* presented twice.
|
|
207
|
+
*/
|
|
208
|
+
async takePending(nonce: string): Promise<PendingAuthorization | null> {
|
|
209
|
+
const key = hashToken(nonce);
|
|
210
|
+
const record = await this.#read<PendingAuthorization>(PENDING, key);
|
|
211
|
+
await this.#state.delete(PENDING, key);
|
|
212
|
+
return record && record.expiresAt > this.#now() ? record : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
150
215
|
async putCode(code: string, record: AuthorizationCode): Promise<void> {
|
|
151
216
|
await this.#state.set(CODES, hashToken(code), JSON.stringify(record));
|
|
152
217
|
}
|
package/src/auth/remote.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
memberPrincipal,
|
|
3
|
+
ownerPrincipal,
|
|
4
|
+
parseBearer,
|
|
5
|
+
type AuthOutcome,
|
|
6
|
+
type Authenticator,
|
|
7
|
+
} from './index.ts';
|
|
2
8
|
import type { OAuthStore } from './oauth/store.ts';
|
|
3
9
|
import type { OidcVerifier } from './oidc.ts';
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* The two ways a remote client's token becomes a principal.
|
|
7
13
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* A token this endpoint issued now carries *who completed the flow* and which
|
|
15
|
+
* profiles named them, so it resolves to a member principal (ADR-060). Both were
|
|
16
|
+
* the owner until 0.8.0, on the reading that there is one person behind an
|
|
17
|
+
* endpoint — which stopped being true the moment a profile could declare
|
|
18
|
+
* somebody else may consume it.
|
|
19
|
+
*
|
|
20
|
+
* **A token without a subject is still the owner**, and that is not a fallback
|
|
21
|
+
* to be tidied away: it is what a token minted before this release is, and
|
|
22
|
+
* every one of them keeps working until it expires rather than logging its
|
|
23
|
+
* holder out on upgrade.
|
|
24
|
+
*
|
|
25
|
+
* `OidcAuthenticator` still resolves to the owner, deliberately. A self-hoster
|
|
26
|
+
* pointing at their own issuer has an allowlist of subjects and no `members:`
|
|
27
|
+
* to map them onto — the delegation model is the Lanes one, and pretending
|
|
28
|
+
* otherwise would mean inventing a mapping nobody configured.
|
|
15
29
|
*
|
|
16
30
|
* Neither of these ever reports `missing` for a credential it simply does not
|
|
17
31
|
* recognise. That is what the chain's ranking is for: a token this link cannot
|
|
@@ -39,7 +53,16 @@ export class IssuedTokenAuthenticator implements Authenticator {
|
|
|
39
53
|
// check is the only thing separating them.
|
|
40
54
|
if (!record || record.kind !== 'access') return { ok: false, reason: 'invalid' };
|
|
41
55
|
|
|
42
|
-
return { ok: true, principal: ownerPrincipal(this.#profile) };
|
|
56
|
+
if (record.subject === undefined) return { ok: true, principal: ownerPrincipal(this.#profile) };
|
|
57
|
+
|
|
58
|
+
// The list resolved when the code was minted, not now. Re-reading it here
|
|
59
|
+
// would mean a profile edit silently ending a live session, which ADR-060
|
|
60
|
+
// deliberately does not do — `lanes link token rotate` is the way to close
|
|
61
|
+
// that window, and `profile members remove` says so out loud.
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
principal: memberPrincipal(record.subject, this.#profile, record.profiles ?? []),
|
|
65
|
+
};
|
|
43
66
|
}
|
|
44
67
|
}
|
|
45
68
|
|