@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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- 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 +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- 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 +90 -88
- 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 +16 -6
- 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 +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
- 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 +53 -5
- 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/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- 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-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -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 +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- 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 +34 -13
- package/src/deployments/upload.ts +60 -27
- 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 +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- 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 +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -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 +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- 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
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- 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 {
|
|
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
|
+
}
|