@lanes-sh/link 0.7.2 → 0.9.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 (190) 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 +148 -73
  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 +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -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 +90 -88
  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 +16 -6
  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 +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
  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 +53 -5
  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/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
package/src/auth/index.ts CHANGED
@@ -28,13 +28,61 @@ import type { SecretRef, SecretStore } from '#secrets';
28
28
  export interface Principal {
29
29
  readonly id: string;
30
30
  readonly profile: string;
31
- readonly kind: 'owner';
31
+ readonly kind: 'owner' | 'member' | 'machine';
32
+ /**
33
+ * Every profile this caller may reach, or `undefined` for "all of them".
34
+ *
35
+ * `undefined` is the machine token and the stdio pipe: neither is a person,
36
+ * both reach the whole workspace, and saying so explicitly is better than
37
+ * enumerating a list that would then need keeping in step. A `member` always
38
+ * carries a list, because the list *is* the delegation (ADR-060).
39
+ */
40
+ readonly profiles?: readonly string[] | undefined;
32
41
  }
33
42
 
34
43
  export function ownerPrincipal(profile: string): Principal {
35
44
  return { id: `${profile}:owner`, profile, kind: 'owner' };
36
45
  }
37
46
 
47
+ /**
48
+ * A person, and the profiles whose `members:` name them.
49
+ *
50
+ * `profile` carries the one this call is acting within, which is what the audit
51
+ * log records and what policy is evaluated against. `profiles` is the whole set
52
+ * they may choose from, and `mayReach` is the check — kept here rather than in
53
+ * the dispatcher so discovery and enforcement cannot answer it differently,
54
+ * which is the same rule `allowedConnections` follows on the capability axis.
55
+ */
56
+ export function memberPrincipal(
57
+ subject: string,
58
+ profile: string,
59
+ profiles: readonly string[],
60
+ ): Principal {
61
+ return { id: subject, profile, kind: 'member', profiles };
62
+ }
63
+
64
+ /**
65
+ * The same caller, acting within a different profile.
66
+ *
67
+ * An endpoint serves several profiles and a principal is built once, from the
68
+ * primary — so the profile on it is where the *connection* was opened, not
69
+ * where this call is going. Every dispatch has to say which, because
70
+ * `principal.profile` is what the audit event records and what `mayReach` is
71
+ * checked against; without this the log attributes a member's call to a profile
72
+ * they may never have been able to reach.
73
+ *
74
+ * It does not widen anything. `profiles` carries over untouched, so a name this
75
+ * caller may not reach is still refused — one step later, by the check below.
76
+ */
77
+ export function forProfile(principal: Principal, profile: string): Principal {
78
+ return principal.profile === profile ? principal : { ...principal, profile };
79
+ }
80
+
81
+ /** Whether this caller may act within the named profile. */
82
+ export function mayReach(principal: Principal, profile: string): boolean {
83
+ return principal.profiles === undefined || principal.profiles.includes(profile);
84
+ }
85
+
38
86
  export type AuthOutcome =
39
87
  | { readonly ok: true; readonly principal: Principal }
40
88
  | { readonly ok: false; readonly reason: 'missing' | 'malformed' | 'invalid' | 'not_configured' };
@@ -215,7 +263,15 @@ export {
215
263
  type ChallengeError,
216
264
  type ResourceIdentity,
217
265
  } from './oauth/metadata.ts';
218
- export { OAuthServer, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
266
+ export {
267
+ OAuthServer,
268
+ pkceChallengeFor,
269
+ type EndpointIdentity,
270
+ type Federation,
271
+ type OAuthResult,
272
+ } from './oauth/server.ts';
273
+ export { AssertionVerifier, type Assertion } from './lanes/assertion.ts';
274
+ export { lanesFederation, DEFAULT_WEB_URL, type FederationOptions } from './lanes/federation.ts';
219
275
  export { matchesRegistered } from './oauth/redirects.ts';
220
276
  export { OAuthStore, hashToken, randomToken } from './oauth/store.ts';
221
277
  export { OidcVerifier, type OidcVerifierOptions, type VerifiedSubject } from './oidc.ts';
@@ -0,0 +1,256 @@
1
+ import type { FetchLike } from './login.ts';
2
+
3
+ /**
4
+ * Verifying that lanes.sh vouched for the person at the browser.
5
+ *
6
+ * This is what replaces the pasted endpoint token on the consent screen
7
+ * (ADR-062). The endpoint no longer asks "do you hold the owner's credential" —
8
+ * it asks lanes.sh "who is this", and gets back a signed statement it can check
9
+ * without trusting the browser that carried it.
10
+ *
11
+ * Four checks, and each of them is load-bearing:
12
+ *
13
+ * - **Signature**, against the API's published JWKS. Without it the assertion
14
+ * is a string the browser handed us and anyone could write one.
15
+ * - **Audience**, which must be *this endpoint's own resource URL*. An
16
+ * assertion minted for somebody else's endpoint is a valid assertion; using
17
+ * it here is the confused-deputy case the MCP authorization spec calls out,
18
+ * and this is the only thing that stops it.
19
+ * - **Nonce**, single-use, minted by this endpoint when the flow began. It
20
+ * binds the assertion to *this* authorization request, so one captured on a
21
+ * different endpoint of ours cannot be replayed into this one.
22
+ * - **Expiry**, tight. The assertion crosses one redirect, so a minute is
23
+ * generous and anything longer is a bearer credential in a browser history.
24
+ *
25
+ * Verification is intentionally a public-key check and not a call back to the
26
+ * API. The endpoint may be behind somebody's firewall; it must be able to
27
+ * verify while only ever having *fetched* a key, and a cached JWKS makes the
28
+ * whole flow work with the API unreachable for the length of the cache.
29
+ *
30
+ * No JWT library. RS256 over a JWK is `crypto.subtle.importKey` plus
31
+ * `crypto.subtle.verify`, which is 30 lines and no supply chain — the same
32
+ * reasoning that has `connectivity/auth/oauth-jwt/key.ts` signing with subtle
33
+ * rather than pulling one in.
34
+ */
35
+
36
+ /** What an assertion says once it has been believed. */
37
+ export interface Assertion {
38
+ /** `lanes:<uid>`, the same string `lanes auth login` stores and `members:` names. */
39
+ readonly subject: string;
40
+ readonly email: string | null;
41
+ }
42
+
43
+ export interface AssertionVerifierOptions {
44
+ /** Where the signing keys are published. */
45
+ readonly jwksUrl: string;
46
+ /** Who is allowed to have signed. Checked against `iss`. */
47
+ readonly issuer: string;
48
+ readonly fetch?: FetchLike | undefined;
49
+ readonly now?: (() => number) | undefined;
50
+ /** How long a fetched key set is reused. */
51
+ readonly cacheTtlMs?: number | undefined;
52
+ }
53
+
54
+ /**
55
+ * One published key, as it arrives.
56
+ *
57
+ * Loose on purpose: `importKey` is the thing that decides whether a JWK is
58
+ * usable, and re-deciding that here with a stricter type would mean a key the
59
+ * platform accepts being dropped by our own schema.
60
+ */
61
+ type Jwk = Record<string, unknown> & { kid?: unknown; kty?: unknown; alg?: unknown };
62
+
63
+ /** An hour. A key rotation is noticed within it, and a miss refetches anyway. */
64
+ const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
65
+
66
+ /**
67
+ * The shortest interval between two refetches provoked by an unknown key id.
68
+ *
69
+ * Without it, a token naming a key that does not exist costs a round trip to
70
+ * the API — and since anyone can send one unauthenticated, every endpoint
71
+ * becomes an amplifier pointed at us. A minute bounds that at one request per
72
+ * endpoint per minute while still letting a genuine rotation land promptly.
73
+ */
74
+ const MISS_REFETCH_MS = 60_000;
75
+
76
+ /** Assertions cross one redirect; more than this is a credential left lying about. */
77
+ const MAX_LIFETIME_MS = 120_000;
78
+
79
+ /** Clocks differ. Small enough that it does not extend the window meaningfully. */
80
+ const CLOCK_SKEW_MS = 30_000;
81
+
82
+ /** What `importKey('jwk', …)` takes, without depending on a lib.dom global. */
83
+ type JsonWebKeyLike = Parameters<typeof crypto.subtle.importKey>[1] extends infer T
84
+ ? Extract<T, { kty?: string | undefined }>
85
+ : never;
86
+
87
+ export class AssertionVerifier {
88
+ readonly #options: AssertionVerifierOptions;
89
+ readonly #fetch: FetchLike;
90
+ readonly #now: () => number;
91
+ #keys: { at: number; byKid: Map<string, CryptoKey> } | null = null;
92
+ #missedAt = Number.NEGATIVE_INFINITY;
93
+
94
+ constructor(options: AssertionVerifierOptions) {
95
+ this.#options = options;
96
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
97
+ this.#now = options.now ?? Date.now;
98
+ }
99
+
100
+ /**
101
+ * The person this assertion names, or null.
102
+ *
103
+ * Null rather than a reason. The caller renders an error page to whoever is
104
+ * at the browser, and "the signature did not verify" versus "the audience was
105
+ * wrong" tells an attacker which of their attempts got closer while telling a
106
+ * legitimate user nothing they can act on. What *is* actionable — expiry — is
107
+ * the one case the caller can infer by retrying.
108
+ */
109
+ async verify(token: string, expected: { audience: string; nonce: string }): Promise<Assertion | null> {
110
+ const parts = token.split('.');
111
+ if (parts.length !== 3) return null;
112
+
113
+ const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string];
114
+
115
+ const header = decodeJson(encodedHeader);
116
+ const payload = decodeJson(encodedPayload);
117
+ if (header === null || payload === null) return null;
118
+
119
+ // Pinned, not read. `alg` arrives inside the token, so honouring it is how
120
+ // the `none` algorithm and the HMAC-with-the-public-key confusions work.
121
+ if (header['alg'] !== 'RS256') return null;
122
+
123
+ const kid = typeof header['kid'] === 'string' ? header['kid'] : null;
124
+ if (kid === null) return null;
125
+
126
+ const key = await this.#key(kid);
127
+ if (key === null) return null;
128
+
129
+ const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
130
+ const signature = base64url(encodedSignature);
131
+ if (signature === null) return null;
132
+
133
+ const valid = await crypto.subtle.verify(
134
+ { name: 'RSASSA-PKCS1-v1_5' },
135
+ key,
136
+ signature,
137
+ signed,
138
+ );
139
+ if (!valid) return null;
140
+
141
+ return this.#claims(payload, expected);
142
+ }
143
+
144
+ /** Everything that is true of a verified signature but not yet of this token. */
145
+ #claims(payload: Record<string, unknown>, expected: { audience: string; nonce: string }): Assertion | null {
146
+ if (payload['iss'] !== this.#options.issuer) return null;
147
+
148
+ // Exact match, per RFC 8707. A prefix or suffix comparison here would admit
149
+ // an assertion minted for a different endpoint on the same host.
150
+ const audience = payload['aud'];
151
+ const audiences = Array.isArray(audience) ? audience : [audience];
152
+ if (!audiences.includes(expected.audience)) return null;
153
+
154
+ if (payload['nonce'] !== expected.nonce) return null;
155
+
156
+ const now = this.#now();
157
+ const exp = typeof payload['exp'] === 'number' ? payload['exp'] * 1000 : 0;
158
+ const iat = typeof payload['iat'] === 'number' ? payload['iat'] * 1000 : 0;
159
+
160
+ if (exp <= now - CLOCK_SKEW_MS) return null;
161
+ if (iat > now + CLOCK_SKEW_MS) return null;
162
+
163
+ // Checked here as well as trusted from the issuer. A deployment that
164
+ // widened its own expiry would silently turn a redirect-scoped assertion
165
+ // into a long-lived bearer token, and this endpoint is the party that has
166
+ // to live with that.
167
+ if (exp - iat > MAX_LIFETIME_MS + CLOCK_SKEW_MS) return null;
168
+
169
+ const subject = payload['sub'];
170
+ if (typeof subject !== 'string' || subject.length === 0) return null;
171
+
172
+ return {
173
+ // The API signs the raw uid; the prefix is added at exactly one place in
174
+ // the client, here and in `login.ts`, so the two cannot disagree about
175
+ // what a subject looks like.
176
+ subject: subject.startsWith('lanes:') ? subject : `lanes:${subject}`,
177
+ email: typeof payload['email'] === 'string' ? payload['email'] : null,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The signing key with that id.
183
+ *
184
+ * A miss against a warm cache refetches, because that is what a key rotation
185
+ * looks like from here and the alternative is every endpoint refusing until
186
+ * its cache lapses. It refetches at most once a minute, because the *other*
187
+ * thing a miss looks like is an invented key id, and those arrive
188
+ * unauthenticated and as fast as anyone cares to send them.
189
+ */
190
+ async #key(kid: string): Promise<CryptoKey | null> {
191
+ const ttl = this.#options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
192
+ const now = this.#now();
193
+ const fresh = this.#keys !== null && now - this.#keys.at < ttl;
194
+
195
+ if (fresh) {
196
+ const hit = this.#keys?.byKid.get(kid);
197
+ if (hit) return hit;
198
+ if (now - this.#missedAt < MISS_REFETCH_MS) return null;
199
+ this.#missedAt = now;
200
+ }
201
+
202
+ const loaded = await this.#load();
203
+ return loaded.get(kid) ?? null;
204
+ }
205
+
206
+ async #load(): Promise<Map<string, CryptoKey>> {
207
+ const byKid = new Map<string, CryptoKey>();
208
+
209
+ const response = await this.#fetch(this.#options.jwksUrl).catch(() => null);
210
+ if (response === null || !response.ok) {
211
+ // Left uncached, so the next attempt tries again rather than treating an
212
+ // outage as "there are no keys" for an hour. Whatever was already known
213
+ // still answers, which is what keeps a verified endpoint working while
214
+ // the API is down.
215
+ return this.#keys?.byKid ?? byKid;
216
+ }
217
+
218
+ const body = (await response.json().catch(() => ({}))) as { keys?: Jwk[] };
219
+
220
+ for (const jwk of body.keys ?? []) {
221
+ if (jwk.kty !== 'RSA' || (jwk.alg !== undefined && jwk.alg !== 'RS256')) continue;
222
+ if (typeof jwk.kid !== 'string') continue;
223
+
224
+ const key = await crypto.subtle
225
+ .importKey('jwk', jwk as JsonWebKeyLike, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, [
226
+ 'verify',
227
+ ])
228
+ .catch(() => null);
229
+
230
+ if (key !== null) byKid.set(jwk.kid, key);
231
+ }
232
+
233
+ this.#keys = { at: this.#now(), byKid };
234
+ return byKid;
235
+ }
236
+ }
237
+
238
+ function base64url(value: string): ArrayBuffer | null {
239
+ try {
240
+ const bytes = Buffer.from(value, 'base64url');
241
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function decodeJson(value: string): Record<string, unknown> | null {
248
+ const bytes = base64url(value);
249
+ if (bytes === null) return null;
250
+ try {
251
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
252
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The loopback listener a browser comes back to, and nothing else.
3
+ *
4
+ * Split from `login.ts` because the two have one value between them — an
5
+ * authorization code — and everything else they know is disjoint. This file
6
+ * knows about sockets, `state`, and the page somebody is left looking at; it
7
+ * knows nothing about Google, Firebase, or where a session is stored.
8
+ */
9
+
10
+ /** What the browser should be told, for whoever is rendering it. */
11
+ export interface LandingPage {
12
+ readonly ok: boolean;
13
+ /** One line. The reason, when something went wrong. */
14
+ readonly detail: string;
15
+ }
16
+
17
+ /**
18
+ * Serve exactly one callback, then stop.
19
+ *
20
+ * Bound to `127.0.0.1` on a port the kernel picks, which is RFC 8252's loopback
21
+ * redirect. Google matches loopback by host and ignores the port, so nothing
22
+ * has to be registered per machine — the property `link_auth.py`'s
23
+ * `_is_loopback_redirect` relies on at the other end.
24
+ */
25
+ export async function awaitCallback(
26
+ state: string,
27
+ onListening: (redirectUri: string) => Promise<void>,
28
+ render: (outcome: LandingPage) => Response,
29
+ ): Promise<{ code: string; redirectUri: string }> {
30
+ const page = (outcome: LandingPage): Response => closing(render(outcome));
31
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
32
+
33
+ const server = Bun.serve({
34
+ hostname: '127.0.0.1',
35
+ port: 0,
36
+ fetch(request) {
37
+ const url = new URL(request.url);
38
+ // Anything else, including the favicon a browser asks for unprompted.
39
+ // It closes its connection too, so a stray request cannot keep the server
40
+ // alive past the redirect it is waiting for.
41
+ if (url.pathname !== '/callback') {
42
+ return closing(new Response('Not found', { status: 404 }));
43
+ }
44
+
45
+ const error = url.searchParams.get('error');
46
+ if (error) {
47
+ reject(new Error(`Google refused the sign-in: ${error}`));
48
+ return page({ ok: false, detail: `Google refused the sign-in: ${error}` });
49
+ }
50
+
51
+ // Checked before the code is read. `state` is the only thing standing
52
+ // between this listener and a page on the machine feeding it a code from
53
+ // somebody else's authorization.
54
+ if (url.searchParams.get('state') !== state) {
55
+ reject(new Error('The sign-in came back with the wrong state, so it was discarded.'));
56
+ return page({
57
+ ok: false,
58
+ detail: 'That sign-in came back with the wrong state, so it was discarded.',
59
+ });
60
+ }
61
+
62
+ const code = url.searchParams.get('code');
63
+ if (!code) {
64
+ reject(new Error('The sign-in came back without a code.'));
65
+ return page({ ok: false, detail: 'That sign-in came back without a code.' });
66
+ }
67
+
68
+ resolve(code);
69
+ return page({ ok: true, detail: 'You can close this tab and go back to your terminal.' });
70
+ },
71
+ });
72
+
73
+ const timeout = setTimeout(
74
+ () => reject(new Error('Timed out waiting for the browser. Nothing was changed.')),
75
+ 300_000,
76
+ );
77
+
78
+ // The redirect URI is returned rather than stashed. Google checks that the
79
+ // exchange sends back the *same* one, and a module-level variable holding it
80
+ // would be shared by two logins running at once — which is not hypothetical
81
+ // on a machine where somebody re-runs a command that seemed to hang.
82
+ const redirectUri = `http://127.0.0.1:${server.port}/callback`;
83
+
84
+ try {
85
+ await onListening(redirectUri);
86
+ return { code: await promise, redirectUri };
87
+ } finally {
88
+ clearTimeout(timeout);
89
+
90
+ // Graceful, and this is the whole of the fix for a login that worked and
91
+ // looked like it had not. `stop(true)` closes active connections
92
+ // immediately, and the active connection is the one carrying the page the
93
+ // person is waiting to see: `resolve` runs inside the handler, before Bun
94
+ // has written the response, so forcing here raced the browser and won every
95
+ // time. What they got was a connection error on a sign-in that had already
96
+ // succeeded, which is the worst possible way for this to fail.
97
+ //
98
+ // Nothing can hold a graceful stop open, because every response closes its
99
+ // own connection — see `closing`. That is what makes this safe to await
100
+ // rather than force.
101
+ await server.stop();
102
+ }
103
+ }
104
+
105
+ /**
106
+ * `Connection: close` on whatever was rendered.
107
+ *
108
+ * Applied here rather than asked of the renderer, because it is a property of
109
+ * *this* listener rather than of the page. The server is torn down the moment
110
+ * the code arrives, and a keep-alive connection would either hold the graceful
111
+ * stop open or be severed mid-response — the second is what used to happen.
112
+ * Telling the browser not to reuse the socket makes "respond, then stop" a
113
+ * sequence rather than a race.
114
+ */
115
+ function closing(response: Response): Response {
116
+ const headers = new Headers(response.headers);
117
+ headers.set('connection', 'close');
118
+ return new Response(response.body, { status: response.status, headers });
119
+ }
120
+
121
+ /**
122
+ * The page when nobody supplied one.
123
+ *
124
+ * Deliberately plain, and deliberately not what a person sees: `lanes auth
125
+ * login` passes the branded card. This is for tests, and for a caller that has
126
+ * no opinion about presentation.
127
+ */
128
+ export function plainPage(outcome: LandingPage): Response {
129
+ return new Response(
130
+ `<!doctype html><meta charset="utf-8"><title>Lanes</title>` +
131
+ `<body style="font:16px system-ui;display:grid;place-items:center;height:100vh;margin:0">` +
132
+ `<p>${outcome.ok ? 'Signed in.' : 'That did not work.'} ${outcome.detail}</p></body>`,
133
+ { headers: { 'content-type': 'text/html; charset=utf-8' } },
134
+ );
135
+ }
@@ -0,0 +1,50 @@
1
+ import { AssertionVerifier } from './assertion.ts';
2
+ import { DEFAULT_API_URL } from './login.ts';
3
+ import type { Federation } from '../oauth/server.ts';
4
+
5
+ /**
6
+ * The Lanes side of an endpoint's authorization, assembled.
7
+ *
8
+ * Three URLs and a membership lookup. It lives here rather than in
9
+ * `server/endpoint.ts` so that "who does this endpoint believe about identity"
10
+ * is one file with one answer, and so that a self-hoster changing it changes
11
+ * one thing — which is the same reasoning that keeps `oidc` a URL in config
12
+ * rather than a branch in the code.
13
+ *
14
+ * **Both URLs are overridable and neither is a vendor name in the request
15
+ * path.** `LANES_API_URL` and `LANES_WEB_URL` are what a self-hosted
16
+ * deployment sets, and they are also what makes `lanes dev` work against the
17
+ * API running on this machine.
18
+ */
19
+
20
+ /** Where the consent page lives, unless told otherwise. */
21
+ export const DEFAULT_WEB_URL = 'https://lanes.sh';
22
+
23
+ export interface FederationOptions {
24
+ /** Which profiles a subject may consume, read at the moment a token is minted. */
25
+ readonly profilesFor: (subject: string) => Promise<readonly string[]>;
26
+ readonly apiUrl?: string | undefined;
27
+ readonly webUrl?: string | undefined;
28
+ /** Injected for tests. Nothing in production passes one. */
29
+ readonly fetch?: ConstructorParameters<typeof AssertionVerifier>[0]['fetch'];
30
+ }
31
+
32
+ export function lanesFederation(options: FederationOptions): Federation {
33
+ const apiUrl = options.apiUrl ?? process.env['LANES_API_URL'] ?? DEFAULT_API_URL;
34
+ const webUrl = options.webUrl ?? process.env['LANES_WEB_URL'] ?? DEFAULT_WEB_URL;
35
+
36
+ const verifier = new AssertionVerifier({
37
+ jwksUrl: `${apiUrl}/.well-known/jwks.json`,
38
+ // The API is the issuer of the assertion, and the audience is *us*. Getting
39
+ // these the wrong way round is the mistake that makes both checks
40
+ // decorative, so they are named rather than positional.
41
+ issuer: apiUrl,
42
+ ...(options.fetch ? { fetch: options.fetch } : {}),
43
+ });
44
+
45
+ return {
46
+ consentUrl: `${webUrl}/link/authorize`,
47
+ verify: (assertion, expected) => verifier.verify(assertion, expected),
48
+ profilesFor: options.profilesFor,
49
+ };
50
+ }