@lanes-sh/link 0.7.2 → 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.
Files changed (156) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. 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
+ }
@@ -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. There is exactly one user here, and the
23
- * proof of being them is the endpoint token they already have.
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
- const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
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
- * How long a spent refresh token still answers.
46
+ * Where this endpoint sends people to be identified, and how it checks the answer.
32
47
  *
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.
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
- const REFRESH_REUSE_MS = 30_000;
44
-
45
- export type OAuthResult =
46
- | { readonly kind: 'json'; readonly status: number; readonly body: unknown }
47
- | { readonly kind: 'redirect'; readonly location: string }
48
- /** Render the approval page. The parameters are carried through it. */
49
- | {
50
- readonly kind: 'consent';
51
- readonly request: AuthorizeRequest;
52
- readonly retry: boolean;
53
- /**
54
- * What the client calls itself, if it said.
55
- *
56
- * Self-reported and therefore not evidence — registration is open, so
57
- * anything may claim any name. It is shown because a name is what makes
58
- * the screen legible, and shown *beside the redirect host*, which is the
59
- * part that cannot be faked: an impostor calling itself Claude still has
60
- * to send the code somewhere, and that somewhere is on the screen.
61
- */
62
- readonly clientName?: string | undefined;
63
- }
64
- | { readonly kind: 'error'; readonly status: number; readonly message: string };
65
-
66
- export interface AuthorizeRequest {
67
- readonly clientId: string;
68
- readonly redirectUri: string;
69
- readonly codeChallenge: string;
70
- readonly state: string | undefined;
71
- readonly scope: string;
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
- /** Proof of being the owner. The same token the endpoint already accepts. */
78
- readonly verifyOwner: (presented: string) => Promise<boolean>;
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
- return {
177
- kind: 'consent',
178
- retry: false,
179
- ...(client.clientName ? { clientName: client.clientName } : {}),
180
- request: {
181
- clientId,
182
- redirectUri,
183
- codeChallenge: challenge,
184
- state: params.get('state') ?? undefined,
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,
190
- resource: params.get('resource') ?? undefined,
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 owner approving, by presenting the endpoint token.
218
+ * The browser coming back from lanes.sh, carrying an assertion.
197
219
  *
198
- * A wrong token re-renders the form rather than redirecting an error back to
199
- * the client: the client has no business being told whether the owner typed
200
- * their token correctly, and a redirect would end the flow on the first typo.
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 approve(request: AuthorizeRequest, presented: string): Promise<OAuthResult> {
203
- const registered = await this.#options.store.client(request.clientId);
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
- if (!presented || !(await this.#options.verifyOwner(presented))) {
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: 'consent',
208
- request,
209
- retry: true,
210
- ...(registered?.clientName ? { clientName: registered.clientName } : {}),
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
- const client = registered;
215
- if (!client || !matchesRegistered(request.redirectUri, client.redirectUris)) {
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
- const record: AuthorizationCode = {
221
- clientId: request.clientId,
222
- redirectUri: request.redirectUri,
223
- codeChallenge: request.codeChallenge,
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,
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(request.redirectUri);
303
+ const location = new URL(pending.redirectUri);
236
304
  location.searchParams.set('code', code);
237
- if (request.state !== undefined) location.searchParams.set('state', request.state);
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 this.#exchangeCode(form);
320
+ return exchangeCode(form, context);
246
321
  case 'refresh_token':
247
- return this.#refresh(form);
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 invalid(error: string, description: string): OAuthResult {
359
- // RFC 6749 codes exactly. A client refreshing on a 401 branches on
360
- // `invalid_grant` specifically; anything else and it retries forever or gives
361
- // up without re-authorising.
362
- return { kind: 'json', status: 400, body: { error, error_description: description } };
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 };
@@ -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
  }
@@ -1,17 +1,31 @@
1
- import { ownerPrincipal, parseBearer, type AuthOutcome, type Authenticator } from './index.ts';
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
- * Both resolve to the **owner** principal, and that is a deliberate limit rather
9
- * than an omission. There is one person behind this endpoint; what a caller may
10
- * do is decided by the profile's policy per capability per call, not by which
11
- * credential opened the door. Delegated principals are additive later the
12
- * dispatch path already takes a principal rather than assuming the owner — and
13
- * inventing a second kind now would mean inventing the policy axis to go with
14
- * it before anything needed one.
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