@lanes-sh/link 0.6.8 → 0.6.10
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/.gcloudignore +50 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/auth/oidc.ts +65 -12
- package/src/cli/brand.ts +16 -9
- package/src/cli/commands/operate/auth.ts +333 -0
- package/src/cli/commands/operate/desktop.ts +220 -0
- package/src/cli/commands/operate/findings.ts +9 -38
- package/src/cli/commands/operate/inspect.ts +59 -40
- package/src/cli/commands/operate/serve.ts +0 -3
- package/src/cli/commands/operate.ts +5 -3
- package/src/cli/main.ts +19 -4
- package/src/cli/selection.ts +15 -4
- package/src/cli/usage.ts +5 -1
- package/src/connectivity/auth/index.ts +1 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
- package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
- package/src/connectivity/auth/reauth.ts +48 -0
- package/src/deployments/gcp/Dockerfile +27 -2
- package/src/deployments/gcp/bucket.ts +82 -9
- package/src/deployments/gcp/driver.ts +43 -5
- package/src/deployments/gcp/lifecycle.json +12 -0
- package/src/deployments/gcp/survey.ts +12 -1
- package/src/policy/limits.ts +76 -3
- package/src/profile/index.ts +1 -0
- package/src/profile/legacy.ts +8 -3
- package/src/profile/schema.ts +65 -0
- package/src/server/cors.ts +3 -3
- package/src/server/edge.ts +188 -1
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +10 -7
- package/src/server/index.ts +62 -90
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- package/src/server/oauth.ts +21 -2
- package/src/cli/commands/operate/dashboard.ts +0 -107
- package/src/cli/dashboard-page.ts +0 -293
- package/src/cli/dashboard-shell.ts +0 -125
- package/src/cli/provider-marks.ts +0 -45
- package/src/server/dashboard.ts +0 -212
package/src/server/cors.ts
CHANGED
|
@@ -217,9 +217,9 @@ function withCors(response: Response, headers: Record<string, string>): Response
|
|
|
217
217
|
*
|
|
218
218
|
* Wrapped at `serve()` rather than inside the router, which is also where the
|
|
219
219
|
* policy is decided: cross-origin access is a property of the address this is
|
|
220
|
-
* bound to, exactly as `allowedHostnames` and
|
|
221
|
-
*
|
|
222
|
-
*
|
|
220
|
+
* bound to, exactly as `allowedHostnames` is, and putting both in one function
|
|
221
|
+
* is what makes the loopback exclusion legible instead of an invariant spread
|
|
222
|
+
* across two files.
|
|
223
223
|
*
|
|
224
224
|
* It is what makes the ordering safe, too. A preflight answered here never
|
|
225
225
|
* reaches the rebinding guard inside `inner` — and does not need to, because a
|
package/src/server/edge.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { challenge, type AuthOutcome, type ChallengeError } from '#auth';
|
|
1
2
|
import { RateLimiter } from '#policy';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -15,6 +16,56 @@ import { RateLimiter } from '#policy';
|
|
|
15
16
|
*/
|
|
16
17
|
export const FAILED_AUTH_PER_MINUTE = 30;
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A ceiling on the surface that answers *before* authentication.
|
|
21
|
+
*
|
|
22
|
+
* The limit above sits behind the bearer gate, and for a long time that was the
|
|
23
|
+
* whole of it — which left the four things that answer in front of the gate with
|
|
24
|
+
* no ceiling at all. On a `public` deployment every one of them costs the owner
|
|
25
|
+
* something, and none of them needs a credential to reach:
|
|
26
|
+
*
|
|
27
|
+
* - `/health` presented with a credential re-reads the credential store, which
|
|
28
|
+
* on a deployed target is a Secret Manager call — two, when the value the
|
|
29
|
+
* process cached does not match, because a mismatch against a cached value
|
|
30
|
+
* forces the re-read that makes a rotation take effect.
|
|
31
|
+
* - `/register` writes an object to the workspace bucket, then lists two
|
|
32
|
+
* namespaces to decide whether anything needs evicting.
|
|
33
|
+
* - `/authorize` compares against the endpoint token, which is another read of
|
|
34
|
+
* the credential store.
|
|
35
|
+
* - `/token` reads and writes bucket objects.
|
|
36
|
+
*
|
|
37
|
+
* `/health` presented with *no* credential is deliberately free: it reads
|
|
38
|
+
* nothing, and it is what a platform probe and `lanes link outputs` send.
|
|
39
|
+
* Metering it would put a ceiling on the one request that costs nothing to
|
|
40
|
+
* answer.
|
|
41
|
+
*/
|
|
42
|
+
export const UNAUTHENTICATED_PER_MINUTE = 30;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The same ceiling for the endpoint as a whole, keyed on nothing.
|
|
46
|
+
*
|
|
47
|
+
* **This is the one that actually holds.** The per-caller key below is the first
|
|
48
|
+
* `X-Forwarded-For` hop, which anyone talking to the endpoint directly can write
|
|
49
|
+
* as they please — so a per-caller limit alone bounds only a caller who is not
|
|
50
|
+
* trying, and rotating the header walks straight through it.
|
|
51
|
+
*
|
|
52
|
+
* A shared bucket has the opposite problem: whoever is spending it locks
|
|
53
|
+
* everyone else out, which is why `index.ts` refuses to key the *failed-auth*
|
|
54
|
+
* limit that way. Here the trade is different, because what is behind these
|
|
55
|
+
* paths is not the owner's ability to use their endpoint — a client that has
|
|
56
|
+
* already authorised holds a token and never comes back through them — it is a
|
|
57
|
+
* discovery document, a registration, and a consent screen. Losing those for a
|
|
58
|
+
* minute is an authorization that has to be retried. Not losing them costs a
|
|
59
|
+
* stranger's arbitrary spend against the credential store.
|
|
60
|
+
*
|
|
61
|
+
* Two hundred a minute is far above what an authorization flow uses: a connector
|
|
62
|
+
* being added is a handful of requests, once.
|
|
63
|
+
*/
|
|
64
|
+
export const UNAUTHENTICATED_TOTAL_PER_MINUTE = 200;
|
|
65
|
+
|
|
66
|
+
/** The key the endpoint-wide bucket is held under. Constant on purpose. */
|
|
67
|
+
const EVERYONE = 'endpoint';
|
|
68
|
+
|
|
18
69
|
/**
|
|
19
70
|
* Who an attempt is counted against.
|
|
20
71
|
*
|
|
@@ -47,7 +98,143 @@ export function tooManyAttempts(retryAfterMs: number): Response {
|
|
|
47
98
|
);
|
|
48
99
|
}
|
|
49
100
|
|
|
50
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* One bucket set per endpoint.
|
|
103
|
+
*
|
|
104
|
+
* The map is bounded by `RateLimiter` itself rather than by a caller
|
|
105
|
+
* remembering to prune it. This comment used to say idle callers were dropped
|
|
106
|
+
* "so keys do not accumulate", and nothing anywhere called `prune` — so on a
|
|
107
|
+
* public URL the map grew one entry per distinct `X-Forwarded-For` for as long
|
|
108
|
+
* as the process lived, which is a header a stranger writes.
|
|
109
|
+
*/
|
|
51
110
|
export function failedAuthLimiter(): RateLimiter {
|
|
52
111
|
return new RateLimiter();
|
|
53
112
|
}
|
|
113
|
+
|
|
114
|
+
/** The same, for the pre-authentication surface. Separate so one cannot spend the other. */
|
|
115
|
+
export function unauthenticatedLimiter(): RateLimiter {
|
|
116
|
+
return new RateLimiter();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The refusal for a pre-authentication request over budget, or `undefined` to
|
|
121
|
+
* let it through.
|
|
122
|
+
*
|
|
123
|
+
* One function rather than a branch in the router, for the reason `./cors.ts`
|
|
124
|
+
* and `./oauth.ts` are their own files: `index.ts` gains a delegation instead of
|
|
125
|
+
* the whole of this behaviour, and it stays inside the size budget that would
|
|
126
|
+
* otherwise be the rule relaxed to fit this in.
|
|
127
|
+
*
|
|
128
|
+
* `isAuthorizationPath` is passed rather than imported so this file does not
|
|
129
|
+
* reach back into the router's path constants — `corsAware` takes the same shape
|
|
130
|
+
* for the same reason.
|
|
131
|
+
*
|
|
132
|
+
* Two buckets are taken rather than one short-circuiting the other, so a caller
|
|
133
|
+
* that has exhausted its own budget still counts against the endpoint's: the
|
|
134
|
+
* alternative lets a flood of distinct forwarded-for values leave the shared
|
|
135
|
+
* bucket untouched, which is precisely the case the shared bucket exists for.
|
|
136
|
+
*/
|
|
137
|
+
export function unauthenticatedRefusal(input: {
|
|
138
|
+
readonly request: Request;
|
|
139
|
+
readonly pathname: string;
|
|
140
|
+
readonly limiter: RateLimiter;
|
|
141
|
+
readonly healthPath: string;
|
|
142
|
+
readonly isAuthorizationPath: (pathname: string) => boolean;
|
|
143
|
+
readonly authorizationEnabled: boolean;
|
|
144
|
+
}): Response | undefined {
|
|
145
|
+
// A `/health` carrying no credential reads nothing and is deliberately free —
|
|
146
|
+
// it is what a platform probe and `lanes link outputs` send, and a ceiling on
|
|
147
|
+
// the one request that costs nothing to answer is an outage waiting for an
|
|
148
|
+
// attack that did not have to cause one.
|
|
149
|
+
const costly =
|
|
150
|
+
input.pathname === input.healthPath
|
|
151
|
+
? input.request.headers.get('authorization') !== null
|
|
152
|
+
: input.authorizationEnabled && input.isAuthorizationPath(input.pathname);
|
|
153
|
+
|
|
154
|
+
if (!costly) return undefined;
|
|
155
|
+
|
|
156
|
+
const caller = callerKey(input.request);
|
|
157
|
+
const mine = input.limiter.take(`caller:${caller}`, UNAUTHENTICATED_PER_MINUTE);
|
|
158
|
+
const everyone = input.limiter.take(EVERYONE, UNAUTHENTICATED_TOTAL_PER_MINUTE);
|
|
159
|
+
if (mine.allowed && everyone.allowed) return undefined;
|
|
160
|
+
|
|
161
|
+
return tooManyAttempts(Math.max(mine.retryAfterMs, everyone.retryAfterMs));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What a caller should do about each refusal.
|
|
168
|
+
*
|
|
169
|
+
* `invalid` is the only one a client can act on by itself: it presented a
|
|
170
|
+
* credential and this endpoint did not accept it, which is what a refresh is
|
|
171
|
+
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
172
|
+
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
173
|
+
* a client after a token it does not hold. `malformed` says nothing either —
|
|
174
|
+
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
175
|
+
* status is a larger question than this answers.
|
|
176
|
+
*/
|
|
177
|
+
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
178
|
+
invalid: {
|
|
179
|
+
code: 'invalid_token',
|
|
180
|
+
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The same four, for whoever is reading the body rather than the header. */
|
|
185
|
+
const HINTS: Record<RefusalReason, string> = {
|
|
186
|
+
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
187
|
+
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
188
|
+
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
189
|
+
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The `401`, with whatever the caller can act on.
|
|
194
|
+
*
|
|
195
|
+
* Here rather than in the router because it is the same subject as the two
|
|
196
|
+
* ceilings above — what this endpoint does about a caller who has not
|
|
197
|
+
* authenticated — and because the router was over its size budget carrying both.
|
|
198
|
+
* The vocabulary is RFC 6750's on the header and plain English in the body, so
|
|
199
|
+
* whoever is reading a terminal and whoever is writing a client each get the
|
|
200
|
+
* version they can use.
|
|
201
|
+
*/
|
|
202
|
+
function unauthorized(reason: RefusalReason, metadataUrl: string | null): Response {
|
|
203
|
+
return new Response(
|
|
204
|
+
JSON.stringify({ error: 'unauthorized', reason, hint: HINTS[reason] }),
|
|
205
|
+
{
|
|
206
|
+
status: 401,
|
|
207
|
+
headers: {
|
|
208
|
+
'content-type': 'application/json',
|
|
209
|
+
'www-authenticate': challenge(metadataUrl, CHALLENGE[reason]),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What a caller who failed authentication gets: the `401`, or the `429` once
|
|
217
|
+
* they have failed too often.
|
|
218
|
+
*
|
|
219
|
+
* The ceiling is spent **after** the attempt rather than before it. Keyed on the
|
|
220
|
+
* caller alone, anyone able to reach the endpoint could spend the owner's budget
|
|
221
|
+
* and lock them out, which trades a cost problem for a worse availability one.
|
|
222
|
+
* Only a failure consumes a token, so a valid credential is never refused by
|
|
223
|
+
* this.
|
|
224
|
+
*
|
|
225
|
+
* `metadataUrl` is the whole handshake for a remote client: it reads the named
|
|
226
|
+
* document, finds the authorization server, and starts a flow. Without it the
|
|
227
|
+
* client has to guess the document's location, and a client that guesses wrong
|
|
228
|
+
* reports the endpoint as unreachable.
|
|
229
|
+
*/
|
|
230
|
+
export function authRefusal(input: {
|
|
231
|
+
readonly request: Request;
|
|
232
|
+
readonly reason: RefusalReason;
|
|
233
|
+
readonly limiter: RateLimiter;
|
|
234
|
+
readonly metadataUrl: string | null;
|
|
235
|
+
}): Response {
|
|
236
|
+
const budget = input.limiter.take(callerKey(input.request), FAILED_AUTH_PER_MINUTE);
|
|
237
|
+
return budget.allowed
|
|
238
|
+
? unauthorized(input.reason, input.metadataUrl)
|
|
239
|
+
: tooManyAttempts(budget.retryAfterMs);
|
|
240
|
+
}
|
package/src/server/endpoint.ts
CHANGED
|
@@ -77,16 +77,6 @@ export interface EndpointOptions {
|
|
|
77
77
|
readonly reporter?: EndpointReporter | undefined;
|
|
78
78
|
/** Operational events. Silent when absent, which is what the tests want. */
|
|
79
79
|
readonly log?: Logger | undefined;
|
|
80
|
-
/**
|
|
81
|
-
* Serve the dashboard at `/dashboard`.
|
|
82
|
-
*
|
|
83
|
-
* True for `lanes link start`, absent in a container — the same split as
|
|
84
|
-
* `mintToken`, and for a related reason. A deployed instance has no door a
|
|
85
|
-
* browser can come through (ADR-018), so a page there would be either
|
|
86
|
-
* unreachable or unguarded depending on `deploy.access`, and both are worse
|
|
87
|
-
* than not having one.
|
|
88
|
-
*/
|
|
89
|
-
readonly dashboard?: boolean | undefined;
|
|
90
80
|
}
|
|
91
81
|
|
|
92
82
|
export interface RunningEndpoint {
|
|
@@ -184,12 +174,6 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
|
|
|
184
174
|
// through `skills.manage.write`, or by `lanes link skills add` in another
|
|
185
175
|
// terminal — is a prompt without a restart (ADR-014).
|
|
186
176
|
refreshSkills: runtime.refreshSkills,
|
|
187
|
-
// For a surface that reports rather than dispatches. A thunk rather
|
|
188
|
-
// than a snapshot because a reconcile lands between requests, and the
|
|
189
|
-
// dashboard reading a list captured at boot would keep showing an
|
|
190
|
-
// account as unauthorized after the connect that fixed it.
|
|
191
|
-
target: runtime.target,
|
|
192
|
-
connections: () => runtime.state.connections.list(),
|
|
193
177
|
},
|
|
194
178
|
]),
|
|
195
179
|
);
|
|
@@ -336,7 +320,6 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
336
320
|
: primary.authenticator,
|
|
337
321
|
log,
|
|
338
322
|
...(gate ? { authorization: gate.surface } : {}),
|
|
339
|
-
...(options.dashboard ? { dashboard: true } : {}),
|
|
340
323
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
341
324
|
...(options.host !== undefined ? { host: options.host } : {}),
|
|
342
325
|
});
|
package/src/server/harness.ts
CHANGED
|
@@ -116,8 +116,15 @@ export interface HarnessOptions {
|
|
|
116
116
|
* serving the old generation" case is reached; absent means nothing new.
|
|
117
117
|
*/
|
|
118
118
|
reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
|
|
119
|
-
/**
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Meter the pre-authentication surface, as a routable deployment does.
|
|
121
|
+
*
|
|
122
|
+
* A harness binds loopback, where `serve()` leaves this off — the ceiling
|
|
123
|
+
* protects a credential-store call over the network and an object written to a
|
|
124
|
+
* bucket, and on loopback both are a local file. Set it to drive the deployed
|
|
125
|
+
* behaviour without a deployment.
|
|
126
|
+
*/
|
|
127
|
+
meterUnauthenticated?: boolean;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
/**
|
|
@@ -178,10 +185,6 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
178
185
|
dispatcher,
|
|
179
186
|
policy,
|
|
180
187
|
...(options.refreshSkills ? { refreshSkills: () => options.refreshSkills!(registry) } : {}),
|
|
181
|
-
// As `profileRuntimes` supplies them for real. A harness that claims to
|
|
182
|
-
// be the real wiring and omits a field leaves that field untested.
|
|
183
|
-
target: 'local',
|
|
184
|
-
connections: () => state.connections.list(),
|
|
185
188
|
}),
|
|
186
189
|
);
|
|
187
190
|
|
|
@@ -260,7 +263,7 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
260
263
|
primary: options.profile,
|
|
261
264
|
authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
|
|
262
265
|
...(gate ? { authorization: gate.surface } : {}),
|
|
263
|
-
...(options.
|
|
266
|
+
...(options.meterUnauthenticated ? { meterUnauthenticated: true } : {}),
|
|
264
267
|
log,
|
|
265
268
|
});
|
|
266
269
|
|
package/src/server/index.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Authenticator } from '#auth';
|
|
2
2
|
import type { Logger } from '#connectivity';
|
|
3
3
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
4
4
|
import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
|
|
5
5
|
import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
|
|
6
6
|
import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
|
|
7
|
-
import {
|
|
8
|
-
DASHBOARD_PATH,
|
|
9
|
-
dashboardSessions,
|
|
10
|
-
handleDashboard,
|
|
11
|
-
servesDashboard,
|
|
12
|
-
} from './dashboard.ts';
|
|
13
7
|
import type { Generation } from './generation.ts';
|
|
14
8
|
import type { Generations } from './generations.ts';
|
|
15
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
authRefusal,
|
|
11
|
+
failedAuthLimiter,
|
|
12
|
+
unauthenticatedLimiter,
|
|
13
|
+
unauthenticatedRefusal,
|
|
14
|
+
} from './edge.ts';
|
|
16
15
|
import {
|
|
17
16
|
handleAuthorization,
|
|
18
17
|
isAuthorizationPath,
|
|
@@ -57,46 +56,28 @@ export interface ServerOptions {
|
|
|
57
56
|
/** Hostnames this endpoint answers to. See `./rebinding.ts`. */
|
|
58
57
|
readonly allowedHostnames?: readonly string[] | undefined;
|
|
59
58
|
/**
|
|
60
|
-
*
|
|
59
|
+
* Meter the surface that answers before authentication.
|
|
61
60
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* to
|
|
61
|
+
* A property of what this is bound to, exactly as `cors` and
|
|
62
|
+
* `allowedHostnames` are, and decided in the same lines of `serve()`. Off on
|
|
63
|
+
* loopback, and that is not a gap: what the ceiling protects is a
|
|
64
|
+
* credential-store call over the network and an object written to a bucket,
|
|
65
|
+
* and on loopback both are a local file belonging to whoever is already
|
|
66
|
+
* standing at the machine. `./rebinding.ts` refuses the one caller that is not
|
|
67
|
+
* — a page the owner happens to be visiting — before this would be reached.
|
|
66
68
|
*/
|
|
67
|
-
readonly
|
|
69
|
+
readonly meterUnauthenticated?: boolean | undefined;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* What a caller should do about each refusal.
|
|
74
|
-
*
|
|
75
|
-
* `invalid` is the only one a client can act on by itself: it presented a
|
|
76
|
-
* credential and this endpoint did not accept it, which is what a refresh is
|
|
77
|
-
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
78
|
-
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
79
|
-
* a client after a token it does not hold. `malformed` says nothing either —
|
|
80
|
-
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
81
|
-
* status is a larger question than this answers.
|
|
82
|
-
*/
|
|
83
|
-
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
84
|
-
invalid: {
|
|
85
|
-
code: 'invalid_token',
|
|
86
|
-
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/** The same four, for whoever is reading the body rather than the header. */
|
|
91
|
-
const HINTS: Record<RefusalReason, string> = {
|
|
92
|
-
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
93
|
-
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
94
|
-
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
95
|
-
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
96
|
-
};
|
|
97
|
-
|
|
98
72
|
export const MCP_PATH = '/mcp';
|
|
99
73
|
export const RELOAD_PATH = '/reload';
|
|
74
|
+
/**
|
|
75
|
+
* Named rather than inline, because two places now decide about it: the branch
|
|
76
|
+
* that answers it, and the ceiling in front of that branch which has to know
|
|
77
|
+
* that a `/health` carrying a credential is not the free request the other one
|
|
78
|
+
* is.
|
|
79
|
+
*/
|
|
80
|
+
export const HEALTH_PATH = '/health';
|
|
100
81
|
|
|
101
82
|
/**
|
|
102
83
|
* Loopback addresses. What binding to one changes is the browser threat model,
|
|
@@ -126,7 +107,7 @@ export interface RequestHandler {
|
|
|
126
107
|
export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
127
108
|
let probedAt = 0;
|
|
128
109
|
const failedAuth = failedAuthLimiter();
|
|
129
|
-
const
|
|
110
|
+
const unauthenticated = options.meterUnauthenticated ? unauthenticatedLimiter() : null;
|
|
130
111
|
|
|
131
112
|
/**
|
|
132
113
|
* Re-read the config because a call named a tool we do not serve.
|
|
@@ -160,6 +141,27 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
160
141
|
|
|
161
142
|
const url = new URL(request.url);
|
|
162
143
|
|
|
144
|
+
// Ahead of every path that answers without a credential, because the
|
|
145
|
+
// ceiling further down is inside the `401` branch and so has never covered
|
|
146
|
+
// any of them. Each costs a read of the credential store or a write to the
|
|
147
|
+
// workspace bucket, and none of them asks who is calling first. See
|
|
148
|
+
// `./edge.ts` for which ones, and for why `/health` is only sometimes
|
|
149
|
+
// among them.
|
|
150
|
+
if (unauthenticated) {
|
|
151
|
+
const refusal = unauthenticatedRefusal({
|
|
152
|
+
request,
|
|
153
|
+
pathname: url.pathname,
|
|
154
|
+
limiter: unauthenticated,
|
|
155
|
+
healthPath: HEALTH_PATH,
|
|
156
|
+
isAuthorizationPath,
|
|
157
|
+
authorizationEnabled: options.authorization !== undefined,
|
|
158
|
+
});
|
|
159
|
+
if (refusal) {
|
|
160
|
+
options.log.warn('rejected request', { reason: 'unauthenticated_rate' });
|
|
161
|
+
return refusal;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
163
165
|
// Before authentication, deliberately: a client's first request is the
|
|
164
166
|
// one that discovers how to authenticate, so requiring a token to read
|
|
165
167
|
// the document that says where tokens come from would close the loop it
|
|
@@ -168,7 +170,7 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
168
170
|
return await handleAuthorization(request, options.authorization);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
if (url.pathname ===
|
|
173
|
+
if (url.pathname === HEALTH_PATH) {
|
|
172
174
|
// `status` is unauthenticated because the platform's own probe reads it
|
|
173
175
|
// and a deploy waits on it. The profile *names* are not: on a public URL
|
|
174
176
|
// that is a list of what this endpoint holds, handed to anyone who asks,
|
|
@@ -186,19 +188,6 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
186
188
|
});
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
// Above the 404 gate and outside the bearer path below, because a
|
|
190
|
-
// top-level browser navigation carries no `Authorization` header — so it
|
|
191
|
-
// authenticates itself, against the same authenticator. Unset, this is
|
|
192
|
-
// never reached and `/dashboard` is a 404 like any other unknown path.
|
|
193
|
-
if (options.dashboard && url.pathname === DASHBOARD_PATH) {
|
|
194
|
-
return await handleDashboard(request, {
|
|
195
|
-
generations: options.generations,
|
|
196
|
-
authenticator: options.authenticator,
|
|
197
|
-
primary: options.primary,
|
|
198
|
-
sessions,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
191
|
if (
|
|
203
192
|
url.pathname !== MCP_PATH &&
|
|
204
193
|
url.pathname !== ATTACHMENTS_PATH &&
|
|
@@ -211,37 +200,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
211
200
|
request.headers.get('authorization'),
|
|
212
201
|
);
|
|
213
202
|
|
|
203
|
+
// The refusal, and the ceiling on how often one may be provoked. Both in
|
|
204
|
+
// `./edge.ts`, which is the subject: what this endpoint does about a
|
|
205
|
+
// caller who has not authenticated.
|
|
214
206
|
if (!outcome.ok) {
|
|
215
207
|
options.log.warn('rejected request', { reason: outcome.reason });
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const budget = failedAuth.take(callerKey(request), FAILED_AUTH_PER_MINUTE);
|
|
223
|
-
if (!budget.allowed) return tooManyAttempts(budget.retryAfterMs);
|
|
224
|
-
|
|
225
|
-
// The pointer is the whole handshake for a remote client: it reads the
|
|
226
|
-
// named document, finds the authorization server, and starts a flow.
|
|
227
|
-
// Without it the client has to guess the document's location, and a
|
|
228
|
-
// client that guesses wrong reports the endpoint as unreachable.
|
|
229
|
-
const metadata = options.authorization ? resourceMetadataUrl(request) : null;
|
|
230
|
-
|
|
231
|
-
return new Response(
|
|
232
|
-
JSON.stringify({
|
|
233
|
-
error: 'unauthorized',
|
|
234
|
-
reason: outcome.reason,
|
|
235
|
-
hint: HINTS[outcome.reason],
|
|
236
|
-
}),
|
|
237
|
-
{
|
|
238
|
-
status: 401,
|
|
239
|
-
headers: {
|
|
240
|
-
'content-type': 'application/json',
|
|
241
|
-
'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
);
|
|
208
|
+
return authRefusal({
|
|
209
|
+
request,
|
|
210
|
+
reason: outcome.reason,
|
|
211
|
+
limiter: failedAuth,
|
|
212
|
+
metadataUrl: options.authorization ? resourceMetadataUrl(request) : null,
|
|
213
|
+
});
|
|
245
214
|
}
|
|
246
215
|
|
|
247
216
|
// Behind the same bearer check as everything else, and deliberately not a
|
|
@@ -369,15 +338,18 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
369
338
|
const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, loopback);
|
|
370
339
|
|
|
371
340
|
// Cross-origin access, and its absence, are decided here for the same reason
|
|
372
|
-
// `allowedHostnames`
|
|
373
|
-
//
|
|
374
|
-
//
|
|
341
|
+
// `allowedHostnames` is: both are properties of what this is bound to. The
|
|
342
|
+
// two are mutually exclusive and the exclusion is the decision — see
|
|
343
|
+
// `./cors.ts`, and ADR-039.
|
|
375
344
|
const cors: CorsPolicy | undefined = loopback
|
|
376
345
|
? undefined
|
|
377
346
|
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|
|
378
347
|
const handler = createRequestHandler({
|
|
379
348
|
...options,
|
|
380
|
-
|
|
349
|
+
// Off on loopback for the same reason `cors` is undefined there, and decided
|
|
350
|
+
// here so every property of the bind address is decided together. An
|
|
351
|
+
// explicit `true` wins, which is how a test drives the deployed behaviour.
|
|
352
|
+
meterUnauthenticated: options.meterUnauthenticated ?? !loopback,
|
|
381
353
|
...(allowedHostnames ? { allowedHostnames } : {}),
|
|
382
354
|
});
|
|
383
355
|
|
package/src/server/mcp/index.ts
CHANGED
|
@@ -20,20 +20,6 @@ import { allowedConnections } from '#policy';
|
|
|
20
20
|
* still a leak.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
* A connection's reconciled state, as much of it as a reader needs.
|
|
25
|
-
*
|
|
26
|
-
* Structural rather than the store's own `ConnectionRecord`: `server` does not
|
|
27
|
-
* import `stores` (`src/architecture.test.ts`), and what a surface that reports
|
|
28
|
-
* wants from a connection is its key and whether it is working — not the
|
|
29
|
-
* timestamps and credential expiry the repository keeps behind it.
|
|
30
|
-
*/
|
|
31
|
-
export interface ConnectionState {
|
|
32
|
-
readonly provider: string;
|
|
33
|
-
readonly id: string;
|
|
34
|
-
readonly status: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
23
|
/** Everything one profile contributes to the endpoint. */
|
|
38
24
|
export interface ProfileRuntime {
|
|
39
25
|
readonly config: Config;
|
|
@@ -49,25 +35,6 @@ export interface ProfileRuntime {
|
|
|
49
35
|
* how often to ask (ADR-014).
|
|
50
36
|
*/
|
|
51
37
|
refreshSkills?(): Promise<void>;
|
|
52
|
-
/**
|
|
53
|
-
* Which target's adapters this profile was opened against.
|
|
54
|
-
*
|
|
55
|
-
* Not derivable from `config`: a target is *selected* per run, and the config
|
|
56
|
-
* only says which one is the default. Optional for the same reason the one
|
|
57
|
-
* below is — a runtime built to answer "what is visible" was never opened
|
|
58
|
-
* against anything.
|
|
59
|
-
*/
|
|
60
|
-
readonly target?: string | undefined;
|
|
61
|
-
/**
|
|
62
|
-
* Reconciled connection state, for a surface that reports rather than
|
|
63
|
-
* dispatches.
|
|
64
|
-
*
|
|
65
|
-
* Optional exactly as `refreshSkills` is: only a served endpoint holds the
|
|
66
|
-
* state handle this reads through, and nothing on the dispatch path asks —
|
|
67
|
-
* a capability's visibility is decided by policy, not by whether the
|
|
68
|
-
* credential behind it currently works.
|
|
69
|
-
*/
|
|
70
|
-
connections?(): Promise<readonly ConnectionState[]>;
|
|
71
38
|
}
|
|
72
39
|
|
|
73
40
|
export interface BuildServerOptions {
|
package/src/server/oauth.ts
CHANGED
|
@@ -62,11 +62,30 @@ export function isAuthorizationPath(pathname: string): boolean {
|
|
|
62
62
|
* `http` and every metadata document would name a resource no client asked for
|
|
63
63
|
* — which fails the exact-match the specification requires. Config cannot help
|
|
64
64
|
* either: the hostname carries a project hash assigned at deploy time.
|
|
65
|
+
*
|
|
66
|
+
* **The host is `Host`, and `X-Forwarded-Host` is not consulted.** It used to
|
|
67
|
+
* be, ahead of `Host`, and the justification above is entirely about the
|
|
68
|
+
* *scheme* — nothing ever needed the other header. What it cost is that four
|
|
69
|
+
* documents were steerable per request by whoever sent it: both discovery
|
|
70
|
+
* documents, the `resource_metadata` pointer on every `401`, and the `action` of
|
|
71
|
+
* the consent form that asks the owner to paste their endpoint token. Cloud Run
|
|
72
|
+
* sets `Host` and routes on it, so it is the one value a caller cannot invent
|
|
73
|
+
* without the request going somewhere else; a proxy that genuinely rewrites it
|
|
74
|
+
* rewrites `Host` too, which is what a domain mapping does.
|
|
75
|
+
*
|
|
76
|
+
* The scheme is checked rather than trusted for the same reason, and it is a
|
|
77
|
+
* smaller hole — naming `http` in a document downgrades nothing, it just makes
|
|
78
|
+
* the exact-match fail — but a header that decides part of a URL should not
|
|
79
|
+
* accept an arbitrary string.
|
|
65
80
|
*/
|
|
66
81
|
export function publicOrigin(request: Request): string {
|
|
67
82
|
const url = new URL(request.url);
|
|
68
|
-
const host = request.headers.get('
|
|
69
|
-
|
|
83
|
+
const host = request.headers.get('host') ?? url.host;
|
|
84
|
+
|
|
85
|
+
const forwarded = request.headers.get('x-forwarded-proto');
|
|
86
|
+
const proto =
|
|
87
|
+
forwarded === 'https' || forwarded === 'http' ? forwarded : url.protocol.replace(':', '');
|
|
88
|
+
|
|
70
89
|
return `${proto}://${host}`;
|
|
71
90
|
}
|
|
72
91
|
|