akanjs 3.0.0-alpha.100 → 3.0.0-alpha.101
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/common/TrustedProxy.ts +6 -1
- package/constant/cascadePaths.ts +20 -20
- package/constant/fieldInfo.ts +22 -16
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanAppHeaders.ts +1 -1
- package/server/akanOption.ts +7 -6
- package/server/akanServer.ts +1 -1
- package/server/di/utils.ts +1 -1
- package/server/index.ts +1 -0
- package/server/mcp/McpAuth.ts +63 -19
- package/server/mcp/McpDispatcher.ts +4 -3
- package/server/mcp/McpRouter.ts +22 -1
- package/server/oauth/OAuthAuthorize.ts +84 -0
- package/server/oauth/OAuthClientIdMetadata.ts +98 -0
- package/server/oauth/OAuthErrors.ts +77 -0
- package/server/oauth/OAuthMetadata.ts +56 -0
- package/server/oauth/OAuthPkce.ts +29 -0
- package/server/oauth/OAuthRedirect.ts +57 -0
- package/server/oauth/OAuthRegistration.ts +86 -0
- package/server/oauth/OAuthToken.ts +112 -0
- package/server/oauth/index.ts +9 -0
- package/server/oauth/oauthTypes.ts +79 -0
- package/server/resolver/CascadeRunner.ts +1 -1
- package/signal/signalContext.ts +3 -1
- package/types/common/TrustedProxy.d.ts +5 -0
- package/types/constant/cascadePaths.d.ts +4 -2
- package/types/constant/fieldInfo.d.ts +15 -8
- package/types/server/akanOption.d.ts +4 -3
- package/types/server/di/utils.d.ts +1 -1
- package/types/server/index.d.ts +1 -0
- package/types/server/mcp/McpAuth.d.ts +28 -5
- package/types/server/oauth/OAuthAuthorize.d.ts +26 -0
- package/types/server/oauth/OAuthClientIdMetadata.d.ts +35 -0
- package/types/server/oauth/OAuthErrors.d.ts +40 -0
- package/types/server/oauth/OAuthMetadata.d.ts +35 -0
- package/types/server/oauth/OAuthPkce.d.ts +9 -0
- package/types/server/oauth/OAuthRedirect.d.ts +20 -0
- package/types/server/oauth/OAuthRegistration.d.ts +21 -0
- package/types/server/oauth/OAuthToken.d.ts +30 -0
- package/types/server/oauth/index.d.ts +9 -0
- package/types/server/oauth/oauthTypes.d.ts +49 -0
- package/types/server/oauth.d.ts +1 -0
package/common/TrustedProxy.ts
CHANGED
|
@@ -51,9 +51,14 @@ export class TrustedProxy {
|
|
|
51
51
|
/**
|
|
52
52
|
* The caller's address as recorded by a proxy, but only when the peer is one. Falls back to the peer itself,
|
|
53
53
|
* which is the right answer for a process nothing is proxying, and `null` when there is no peer either.
|
|
54
|
+
*
|
|
55
|
+
* `null` and `undefined` mean different things here. `null` is what `Server.requestIP` answers for a socket
|
|
56
|
+
* that has no address — a unix socket, which is how a gateway reaches its children — and only a process on
|
|
57
|
+
* this machine can open one, so the headers it wrote are a local proxy's and are believed. `undefined` is
|
|
58
|
+
* "nobody asked": no resolver was registered, and a header from an unknown peer is a header the client wrote.
|
|
54
59
|
*/
|
|
55
60
|
static clientAddress(headers: Headers, peerAddress: string | null | undefined): string | null {
|
|
56
|
-
if (TrustedProxy.isTrusted(peerAddress)) {
|
|
61
|
+
if (peerAddress === null || TrustedProxy.isTrusted(peerAddress)) {
|
|
57
62
|
const realIp = headers.get("x-real-ip")?.trim();
|
|
58
63
|
if (realIp) return normalizeIpAddress(realIp);
|
|
59
64
|
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
package/constant/cascadePaths.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type { ConstantModelRef } from "./via";
|
|
|
6
6
|
* Which end of a relation goes away with the other. `removeRef` removes what the field points at when this
|
|
7
7
|
* document is removed; `removeWith` removes this document when what the field points at is removed. The two
|
|
8
8
|
* read identically on a relation field, so the value has to name the direction — a mistake here is a data loss.
|
|
9
|
+
* `removeWithAny` is `removeWith` over an owner that is unknowable at build time, and it names the whole
|
|
10
|
+
* decision in one value so the widening cannot be declared apart from the direction it widens.
|
|
9
11
|
*/
|
|
10
|
-
export const cascadeActions = ["removeRef", "removeWith"] as const;
|
|
12
|
+
export const cascadeActions = ["removeRef", "removeWith", "removeWithAny"] as const;
|
|
11
13
|
export type CascadeAction = (typeof cascadeActions)[number];
|
|
12
14
|
|
|
13
15
|
/** How a `removeWith` field names the owner whose removal takes this document with it. */
|
|
@@ -21,7 +23,7 @@ export interface CascadeWithPath {
|
|
|
21
23
|
readonly typeKey: string | null;
|
|
22
24
|
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
|
|
23
25
|
readonly typeValues: readonly string[];
|
|
24
|
-
/** Set by `
|
|
26
|
+
/** Set by `cascade: "removeWithAny"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
|
|
25
27
|
readonly anyOwner: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -35,7 +37,6 @@ export class CascadePaths {
|
|
|
35
37
|
|
|
36
38
|
collect(fieldMap: FieldObject) {
|
|
37
39
|
for (const [key, field] of Object.entries(fieldMap)) {
|
|
38
|
-
this.#assertPolymorphicIsWiredUp(key, field);
|
|
39
40
|
if (!field.cascade) continue;
|
|
40
41
|
this.#assertKnownAction(key, field.cascade);
|
|
41
42
|
if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
|
|
@@ -44,16 +45,6 @@ export class CascadePaths {
|
|
|
44
45
|
return this;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** `polymorphic` widens one thing and nothing else, so anywhere else it is a declaration that does nothing. */
|
|
48
|
-
#assertPolymorphicIsWiredUp(key: string, field: ConstantField) {
|
|
49
|
-
if (!field.polymorphic) return;
|
|
50
|
-
if (field.cascade === "removeWith" && field.refPath) return;
|
|
51
|
-
throw new Error(
|
|
52
|
-
`Field "${key}" declares polymorphic: "${field.polymorphic}", which only widens a ` +
|
|
53
|
-
`cascade: "removeWith" field that names its owner type with refPath`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
48
|
#assertKnownAction(key: string, action: CascadeAction) {
|
|
58
49
|
|
|
59
50
|
if (!cascadeActions.includes(action)) {
|
|
@@ -74,7 +65,15 @@ export class CascadePaths {
|
|
|
74
65
|
|
|
75
66
|
if (field.arrDepth > 0) throw new Error(`Cascade field "${key}" is an array and names more than one owner`);
|
|
76
67
|
if (field.isMap) throw new Error(`Cascade field "${key}" is a Map and names no owner`);
|
|
77
|
-
|
|
68
|
+
const anyOwner = field.cascade === "removeWithAny";
|
|
69
|
+
if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap, anyOwner);
|
|
70
|
+
|
|
71
|
+
if (anyOwner) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Cascade field "${key}" declares cascade: "removeWithAny" and must name the field holding the owner's ` +
|
|
74
|
+
`refName with refPath: "<typeField>"`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
78
77
|
if (field.ref) {
|
|
79
78
|
this.#assertHoldsId(key, field);
|
|
80
79
|
return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [], anyOwner: false };
|
|
@@ -88,18 +87,18 @@ export class CascadePaths {
|
|
|
88
87
|
);
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
#readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
|
|
90
|
+
#readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject, anyOwner: boolean): CascadeWithPath {
|
|
92
91
|
if (field.ref) throw new Error(`Cascade field "${key}" declares both ref and refPath; keep one`);
|
|
93
92
|
this.#assertHoldsId(key, field);
|
|
94
93
|
const typeKey = field.refPath as string;
|
|
95
94
|
const typeField = fieldMap[typeKey];
|
|
96
95
|
if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
|
|
97
|
-
if (
|
|
96
|
+
if (anyOwner) return this.#readAnyOwner(key, typeKey, typeField);
|
|
98
97
|
|
|
99
98
|
if (!typeField.enum) {
|
|
100
99
|
throw new Error(
|
|
101
100
|
`Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
|
|
102
|
-
`refNames it may hold; declare
|
|
101
|
+
`refNames it may hold; declare cascade: "removeWithAny" to pay for a sweep on every removal instead`,
|
|
103
102
|
);
|
|
104
103
|
}
|
|
105
104
|
const typeValues = typeField.enum.values.map((value) => String(value));
|
|
@@ -110,14 +109,15 @@ export class CascadePaths {
|
|
|
110
109
|
|
|
111
110
|
if (typeField.enum) {
|
|
112
111
|
throw new Error(
|
|
113
|
-
`Cascade field "${key}" declares
|
|
112
|
+
`Cascade field "${key}" declares cascade: "removeWithAny" and a refPath naming an enumOf(...), which ` +
|
|
113
|
+
`already names its owners; use cascade: "removeWith"`,
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
if (this.#primitiveNameOf(typeField) !== "String") {
|
|
118
118
|
throw new Error(
|
|
119
|
-
`Cascade field "${key}" declares
|
|
120
|
-
`holding the owner's refName`,
|
|
119
|
+
`Cascade field "${key}" declares cascade: "removeWithAny", so refPath: "${typeKey}" must be a String ` +
|
|
120
|
+
`field holding the owner's refName`,
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
return { key, modelRef: null, refName: null, typeKey, typeValues: [], anyOwner: true };
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -106,11 +106,6 @@ export interface ConstantFieldProps<
|
|
|
106
106
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
107
107
|
text?: TextFieldRole;
|
|
108
108
|
cascade?: CascadeAction;
|
|
109
|
-
/**
|
|
110
|
-
* Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
|
|
111
|
-
* the owner is whatever refName the row happens to hold, found by sweeping at removal time.
|
|
112
|
-
*/
|
|
113
|
-
polymorphic?: "any";
|
|
114
109
|
/**
|
|
115
110
|
* Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
|
|
116
111
|
* in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
|
|
@@ -209,7 +204,6 @@ interface ConstantFieldBuildProps<
|
|
|
209
204
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
210
205
|
text?: TextFieldRole;
|
|
211
206
|
cascade?: CascadeAction;
|
|
212
|
-
polymorphic?: "any";
|
|
213
207
|
visual: boolean;
|
|
214
208
|
modelRef: ConstantModelRef;
|
|
215
209
|
arrDepth: number;
|
|
@@ -335,7 +329,6 @@ export class ConstantField<
|
|
|
335
329
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
336
330
|
readonly text?: TextFieldRole;
|
|
337
331
|
readonly cascade?: CascadeAction;
|
|
338
|
-
readonly polymorphic?: "any";
|
|
339
332
|
readonly visual: boolean;
|
|
340
333
|
readonly modelRef: ConstantModelRef;
|
|
341
334
|
readonly arrDepth: number;
|
|
@@ -369,7 +362,6 @@ export class ConstantField<
|
|
|
369
362
|
this.validate = props.validate;
|
|
370
363
|
this.text = props.text;
|
|
371
364
|
this.cascade = props.cascade;
|
|
372
|
-
this.polymorphic = props.polymorphic;
|
|
373
365
|
this.visual = props.visual;
|
|
374
366
|
this.modelRef = props.modelRef;
|
|
375
367
|
this.arrDepth = props.arrDepth;
|
|
@@ -450,7 +442,6 @@ export class ConstantField<
|
|
|
450
442
|
validate: option.validate,
|
|
451
443
|
text: option.text,
|
|
452
444
|
cascade: option.cascade,
|
|
453
|
-
polymorphic: option.polymorphic,
|
|
454
445
|
visual: option.visual ?? false,
|
|
455
446
|
modelRef,
|
|
456
447
|
arrDepth: arrDepth,
|
|
@@ -498,7 +489,6 @@ export class ConstantField<
|
|
|
498
489
|
validate: this.validate,
|
|
499
490
|
text: this.text,
|
|
500
491
|
cascade: this.cascade,
|
|
501
|
-
polymorphic: this.polymorphic,
|
|
502
492
|
visual: this.visual,
|
|
503
493
|
modelRef: this.modelRef,
|
|
504
494
|
arrDepth: this.arrDepth,
|
|
@@ -523,19 +513,35 @@ export interface FieldObject {
|
|
|
523
513
|
*/
|
|
524
514
|
type WithoutTextRole<Option> = Option extends unknown ? Omit<Option, "text"> : never;
|
|
525
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A wildcard owner is read off the row at removal time, so `removeWithAny` without a `refPath` names nothing to
|
|
518
|
+
* read it from — and the price of that action (no cascade in the app removes in one query) is one nobody should
|
|
519
|
+
* pay for a declaration that cascades nothing. Pairing the two in the option type refuses it at the call site;
|
|
520
|
+
* `CascadePaths` keeps the same refusal for the macro and bundled paths that reach it without a typecheck.
|
|
521
|
+
*/
|
|
522
|
+
type CascadeOption =
|
|
523
|
+
| { cascade?: "removeRef" | "removeWith"; refPath?: string }
|
|
524
|
+
| { cascade: "removeWithAny"; refPath: string };
|
|
525
|
+
|
|
526
|
+
type WithCascadePair<Option> = Omit<Option, "cascade" | "refPath"> & CascadeOption;
|
|
527
|
+
|
|
526
528
|
type FieldOption<
|
|
527
529
|
Value extends ConstantFieldTypeInput,
|
|
528
530
|
MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
|
|
529
531
|
Metadata extends { [key: string]: any } = { [key: string]: any },
|
|
530
532
|
_FieldToValue = FieldToValue<Value, MapValue> | null | undefined,
|
|
531
533
|
> =
|
|
532
|
-
|
|
|
533
|
-
|
|
534
|
-
|
|
534
|
+
| WithCascadePair<
|
|
535
|
+
Omit<
|
|
536
|
+
ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
|
|
537
|
+
"enum" | "meta" | "nullable" | "fieldType" | "select"
|
|
538
|
+
>
|
|
535
539
|
>
|
|
536
|
-
|
|
|
537
|
-
|
|
538
|
-
|
|
540
|
+
| WithCascadePair<
|
|
541
|
+
Omit<
|
|
542
|
+
ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
|
|
543
|
+
"enum" | "meta" | "nullable" | "fieldType" | "select"
|
|
544
|
+
>
|
|
539
545
|
>[];
|
|
540
546
|
|
|
541
547
|
export type PlainTypeToFieldType<PlainType> = PlainType extends [infer First, ...infer Rest]
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanAppHeaders.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
|
|
|
23
23
|
for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
|
|
24
24
|
const forwardedFor = headers.get("x-forwarded-for");
|
|
25
25
|
|
|
26
|
-
const clientAddress = TrustedProxy.clientAddress(headers, peer?.address);
|
|
26
|
+
const clientAddress = TrustedProxy.clientAddress(headers, peer === null ? null : peer?.address);
|
|
27
27
|
const host = headers.get("host");
|
|
28
28
|
|
|
29
29
|
if (clientAddress) {
|
package/server/akanOption.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
|
|
|
20
20
|
readonly #adaptorOverrides: AdaptorOverride[] = [];
|
|
21
21
|
readonly #webProxies: WebProxyRegistration[] = [];
|
|
22
22
|
readonly #getLlms: ((env: Env) => LlmOption)[] = [];
|
|
23
|
-
#
|
|
23
|
+
#getMcp: ((env: Env) => boolean | McpServerOption) | undefined;
|
|
24
24
|
#agentAccess: GuardCls | GuardCls[] | null | undefined;
|
|
25
25
|
#crossSite: CrossSiteOption | undefined;
|
|
26
26
|
constructor() {
|
|
@@ -48,10 +48,11 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
|
|
|
48
48
|
/**
|
|
49
49
|
* MCP server settings for the app mounting this option, merged over the `AKAN_MCP_*` environment and under an
|
|
50
50
|
* option the server is constructed with. The app's own `option.ts` is the last lib the server reads, so it wins
|
|
51
|
-
* over every library it depends on.
|
|
51
|
+
* over every library it depends on. The function form receives the server env, for a setting derived from it —
|
|
52
|
+
* a token verifier built on the app's signing secret, the issuer an authorization server publishes under.
|
|
52
53
|
*/
|
|
53
|
-
setMcp(
|
|
54
|
-
this.#
|
|
54
|
+
setMcp(mcpOrFn: boolean | McpServerOption | ((env: Env) => boolean | McpServerOption) = true) {
|
|
55
|
+
this.#getMcp = typeof mcpOrFn === "function" ? mcpOrFn : () => mcpOrFn;
|
|
55
56
|
return this;
|
|
56
57
|
}
|
|
57
58
|
/**
|
|
@@ -91,8 +92,8 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
|
|
|
91
92
|
getWebProxies(): WebProxyRegistration[] {
|
|
92
93
|
return this.#webProxies;
|
|
93
94
|
}
|
|
94
|
-
getMcp(): boolean | McpServerOption | undefined {
|
|
95
|
-
return this.#
|
|
95
|
+
getMcp(env: Env): boolean | McpServerOption | undefined {
|
|
96
|
+
return this.#getMcp?.(env);
|
|
96
97
|
}
|
|
97
98
|
getAgentAccess(): GuardCls | GuardCls[] | null | undefined {
|
|
98
99
|
return this.#agentAccess;
|
package/server/akanServer.ts
CHANGED
|
@@ -195,7 +195,7 @@ export class AkanServer {
|
|
|
195
195
|
this.openapi = options?.openapi ?? this.openapi;
|
|
196
196
|
|
|
197
197
|
libs.forEach((lib) => {
|
|
198
|
-
const mcp = lib.option.getMcp();
|
|
198
|
+
const mcp = lib.option.getMcp(this.env);
|
|
199
199
|
if (mcp !== undefined) this.setMcp(mcp);
|
|
200
200
|
const agentAccess = lib.option.getAgentAccess();
|
|
201
201
|
if (agentAccess !== undefined) AgentRelayAccess.use(agentAccess);
|
package/server/di/utils.ts
CHANGED
|
@@ -72,7 +72,7 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
|
|
|
72
72
|
* The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
|
|
73
73
|
* `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
|
|
74
74
|
* inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
|
|
75
|
-
* `
|
|
75
|
+
* `cascade: "removeWithAny"` names no module at all.
|
|
76
76
|
*/
|
|
77
77
|
export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
|
|
78
78
|
const dependencies = new Set<string>();
|
package/server/index.ts
CHANGED
package/server/mcp/McpAuth.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
+
import type { PromiseOrObject } from "akanjs/base";
|
|
1
2
|
import type { HttpRoutes } from "../types";
|
|
2
3
|
|
|
3
4
|
export interface McpAuthOption {
|
|
4
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Where a client may obtain a token, published in the metadata document for it to discover. Naming one also
|
|
7
|
+
* makes a credential mandatory: an MCP client begins its OAuth flow only on a 401, so a server that let an
|
|
8
|
+
* anonymous `initialize` succeed would leave it connected to the anonymous shelf and never asking.
|
|
9
|
+
*/
|
|
5
10
|
authorizationServers?: string[];
|
|
6
11
|
/** Scopes every call must carry. Declaring any turns `insufficient_scope` enforcement on. */
|
|
7
12
|
scopes?: string[];
|
|
8
13
|
/** Overrides the resource identifier when the public URL differs from what the request reports. */
|
|
9
14
|
resource?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Verifies a bearer token's signature and returns its claims, or `null` for one this server did not mint. The
|
|
17
|
+
* framework holds no signing key, so the module that issues tokens supplies this through `option.setMcp`; without
|
|
18
|
+
* it the claims are read unverified and a forged token degrades to an anonymous caller instead of being refused.
|
|
19
|
+
*/
|
|
20
|
+
verify?: (token: string) => PromiseOrObject<Record<string, unknown> | null>;
|
|
10
21
|
}
|
|
11
22
|
|
|
12
23
|
export interface McpAuthProps extends McpAuthOption {
|
|
@@ -48,34 +59,65 @@ export class McpAuth {
|
|
|
48
59
|
};
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A request that carried no credential at all gets a challenge with no `error` code — RFC 6750 §3.1 reserves
|
|
64
|
+
* the code for a credential that was presented and found wanting — so a client reads it as "authenticate",
|
|
65
|
+
* not as "your token is bad".
|
|
66
|
+
*/
|
|
51
67
|
unauthorized(req: Request, failure?: McpAuthFailure) {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
error: "invalid_token" as const,
|
|
55
|
-
description: "Authentication is required to use this MCP server.",
|
|
56
|
-
};
|
|
68
|
+
const status = failure?.status ?? 401;
|
|
69
|
+
const description = failure?.description ?? "Authentication is required to use this MCP server.";
|
|
57
70
|
return Response.json(
|
|
58
|
-
{ error, error_description: description },
|
|
59
|
-
{ status, headers: { "WWW-Authenticate": this.#challenge(req,
|
|
71
|
+
{ ...(failure ? { error: failure.error } : {}), error_description: description },
|
|
72
|
+
{ status, headers: { "WWW-Authenticate": this.#challenge(req, failure) } },
|
|
60
73
|
);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/**
|
|
64
77
|
* Refuses a bearer token that is already provably unusable, before the signal pipeline sees it.
|
|
65
78
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
79
|
+
* With a `verify` hook the token is checked end to end and one that fails is refused outright. Without one the
|
|
80
|
+
* claims are read **without verifying the signature**, which is sound only because every branch below can deny
|
|
81
|
+
* and none can grant: a forged token still has to pass the app's own middleware afterwards, so the worst a
|
|
82
|
+
* crafted payload achieves is refusing itself. What this buys is the failure mode it removes — an expired or
|
|
69
83
|
* foreign token otherwise degrades to an anonymous caller, and the agent is told a tool does not exist rather
|
|
70
84
|
* than that it needs to authenticate.
|
|
71
85
|
*/
|
|
72
|
-
reject(req: Request): Response | null {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
86
|
+
async reject(req: Request): Promise<Response | null> {
|
|
87
|
+
const token = McpAuth.#bearer(req);
|
|
88
|
+
if (!token) return null;
|
|
89
|
+
const claims = this.#props.verify ? await this.#verified(token) : McpAuth.#claims(token);
|
|
90
|
+
if (!claims) {
|
|
91
|
+
if (!this.#props.verify) return null;
|
|
92
|
+
return this.unauthorized(req, {
|
|
93
|
+
status: 401,
|
|
94
|
+
error: "invalid_token",
|
|
95
|
+
description: "The access token could not be verified.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
75
98
|
const failure = this.#failureOf(claims, req);
|
|
76
99
|
return failure ? this.unauthorized(req, failure) : null;
|
|
77
100
|
}
|
|
78
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The 401 a credential-less request gets once an authorization server is named. A server naming none keeps
|
|
104
|
+
* anonymous access, which is what a public catalogue wants; the public tools of a server naming one are still
|
|
105
|
+
* reachable, with the token the challenge sends the client off to obtain.
|
|
106
|
+
*/
|
|
107
|
+
challengeAnonymous(req: Request): Response | null {
|
|
108
|
+
if (!this.#props.authorizationServers?.length || McpAuth.#bearer(req)) return null;
|
|
109
|
+
return this.unauthorized(req);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A verifier that throws has said no: the token is refused, never let through as an anonymous caller. */
|
|
113
|
+
async #verified(token: string): Promise<Record<string, unknown> | null> {
|
|
114
|
+
try {
|
|
115
|
+
return (await this.#props.verify?.(token)) ?? null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
#failureOf(claims: Record<string, unknown>, req: Request): McpAuthFailure | null {
|
|
80
122
|
const { exp, aud } = claims;
|
|
81
123
|
if (typeof exp === "number" && exp * 1000 <= Date.now())
|
|
@@ -123,14 +165,13 @@ export class McpAuth {
|
|
|
123
165
|
);
|
|
124
166
|
}
|
|
125
167
|
|
|
126
|
-
#challenge(req: Request,
|
|
168
|
+
#challenge(req: Request, failure?: McpAuthFailure) {
|
|
127
169
|
const scopes = this.#props.scopes ?? [];
|
|
128
170
|
return [
|
|
129
171
|
"Bearer",
|
|
130
172
|
[
|
|
131
173
|
`resource_metadata="${new URL(McpAuth.wellKnownPath + this.#props.path, McpAuth.origin(req)).href}"`,
|
|
132
|
-
`error="${error}"`,
|
|
133
|
-
`error_description="${description}"`,
|
|
174
|
+
...(failure ? [`error="${failure.error}"`, `error_description="${failure.description}"`] : []),
|
|
134
175
|
...(scopes.length ? [`scope="${scopes.join(" ")}"`] : []),
|
|
135
176
|
].join(", "),
|
|
136
177
|
].join(" ");
|
|
@@ -161,9 +202,12 @@ export class McpAuth {
|
|
|
161
202
|
return `${forwarded("x-forwarded-proto") ?? url.protocol.replace(":", "")}://${host}`;
|
|
162
203
|
}
|
|
163
204
|
|
|
164
|
-
static #
|
|
205
|
+
static #bearer(req: Request): string | null {
|
|
165
206
|
const [scheme, token] = (req.headers.get("authorization") ?? "").split(" ");
|
|
166
|
-
|
|
207
|
+
return scheme === "Bearer" && token ? token : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
static #claims(token: string): Record<string, unknown> | null {
|
|
167
211
|
const segments = token.split(".");
|
|
168
212
|
|
|
169
213
|
if (segments.length !== 3) return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type BackendEnv, ENDPOINT_META } from "akanjs/base";
|
|
2
|
-
import { Logger } from "akanjs/common";
|
|
2
|
+
import { interpolateTranslation, Logger } from "akanjs/common";
|
|
3
3
|
import { ConstantRegistry, mask } from "akanjs/constant";
|
|
4
4
|
import { DictionaryLookup } from "akanjs/dictionary";
|
|
5
5
|
import { NoDocumentError } from "akanjs/document";
|
|
@@ -203,13 +203,14 @@ export class McpDispatcher {
|
|
|
203
203
|
|
|
204
204
|
if (error instanceof NoDocumentError) return `No ${refName} found for the arguments given.`;
|
|
205
205
|
if (status && status < 500) {
|
|
206
|
+
|
|
207
|
+
if (status === 401 || status === 403) return "You are not permitted to perform this action.";
|
|
206
208
|
const raw = error instanceof Error ? error.message : String(error);
|
|
207
209
|
|
|
208
210
|
this.#lookup ??= new DictionaryLookup(this.#props.language);
|
|
209
211
|
const text = this.#lookup.text(raw);
|
|
210
|
-
if (text) return text;
|
|
211
212
|
|
|
212
|
-
if (
|
|
213
|
+
if (text) return interpolateTranslation(text, (error as { data?: Record<string, unknown> }).data);
|
|
213
214
|
return raw;
|
|
214
215
|
}
|
|
215
216
|
|
package/server/mcp/McpRouter.ts
CHANGED
|
@@ -158,6 +158,7 @@ export class McpRouter {
|
|
|
158
158
|
McpRouter.logger.warn(
|
|
159
159
|
"MCP is enabled but published nothing. Every candidate was refused; see the reasons below.",
|
|
160
160
|
);
|
|
161
|
+
this.#reportUnverifiable(tools.map((tool) => tool.name));
|
|
161
162
|
for (const { key, reason } of refusals) McpRouter.logger.verbose(`MCP did not expose "${key}": ${reason}`);
|
|
162
163
|
for (const { key, reason } of undescribed)
|
|
163
164
|
McpRouter.logger.warn(`MCP exposed "${key}" with no description: ${reason}`);
|
|
@@ -169,6 +170,24 @@ export class McpRouter {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A guarded tool is only reachable with a credential, and this server can neither issue one nor check one unless
|
|
175
|
+
* something configured `auth`. Left silent, the shelf lists tools no client can ever call and a forged token
|
|
176
|
+
* reads as anonymous — so it is said once at boot, and said louder anywhere but a developer's machine.
|
|
177
|
+
*/
|
|
178
|
+
#reportUnverifiable(toolNames: string[]) {
|
|
179
|
+
const { verify, authorizationServers } = this.#props.auth ?? {};
|
|
180
|
+
if (verify || authorizationServers?.length) return;
|
|
181
|
+
const document = this.#getDocument();
|
|
182
|
+
const guarded = toolNames.filter((name) =>
|
|
183
|
+
(document.findTool(name)?.endpoint.guards ?? []).some((guard) => guard !== "Public"),
|
|
184
|
+
).length;
|
|
185
|
+
if (!guarded) return;
|
|
186
|
+
const line = `MCP publishes ${guarded} guarded tool(s) but no authorization server is configured: a client cannot obtain a token for them, and a bearer token's signature is not checked here. Set \`auth.verify\` and \`auth.authorizationServers\` through option.setMcp (a module that issues tokens does this), or AKAN_MCP_AUTH_SERVERS.`;
|
|
187
|
+
if (getEnv().operationMode === "local") McpRouter.logger.warn(line);
|
|
188
|
+
else McpRouter.logger.error(line);
|
|
189
|
+
}
|
|
190
|
+
|
|
172
191
|
static #kb(bytes: number) {
|
|
173
192
|
return bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
|
|
174
193
|
}
|
|
@@ -196,7 +215,9 @@ export class McpRouter {
|
|
|
196
215
|
|
|
197
216
|
async #post(req: Request) {
|
|
198
217
|
if (!this.#originAllowed(req)) return new Response("Forbidden", { status: 403 });
|
|
199
|
-
|
|
218
|
+
|
|
219
|
+
req.headers.delete("cookie");
|
|
220
|
+
const rejected = this.#auth.challengeAnonymous(req) ?? (await this.#auth.reject(req));
|
|
200
221
|
if (rejected) return rejected;
|
|
201
222
|
let body: McpJsonRpcRequest;
|
|
202
223
|
try {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { OAuthErrors } from "./OAuthErrors";
|
|
2
|
+
import { OAuthPkce } from "./OAuthPkce";
|
|
3
|
+
import { OAuthRedirect } from "./OAuthRedirect";
|
|
4
|
+
import type { OAuthAuthorizeErrorCode, OAuthAuthorizeParams, OAuthClientRecord } from "./oauthTypes";
|
|
5
|
+
|
|
6
|
+
export interface OAuthAuthorizeContext {
|
|
7
|
+
/** The client the request's `client_id` resolved to, however it was registered; `null` when it resolved to nothing. */
|
|
8
|
+
client: OAuthClientRecord | null;
|
|
9
|
+
issuer: string;
|
|
10
|
+
/** The one resource this server issues tokens for — the MCP endpoint's canonical URL. */
|
|
11
|
+
resource: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type OAuthAuthorizeResult = { ok: true; params: OAuthAuthorizeParams } | { ok: false; response: Response };
|
|
15
|
+
|
|
16
|
+
export class OAuthAuthorize {
|
|
17
|
+
/**
|
|
18
|
+
* Validates in the order RFC 6749 §4.1.2.1 prescribes: the client and its redirect URI first, answered with a page
|
|
19
|
+
* because nothing may be redirected to an unverified destination; everything after that is answered through the
|
|
20
|
+
* redirect URI the client registered, with `state` and `iss` so the client can match and trust the answer.
|
|
21
|
+
*/
|
|
22
|
+
static parse(url: URL, { client, issuer, resource }: OAuthAuthorizeContext): OAuthAuthorizeResult {
|
|
23
|
+
const param = (name: string) => url.searchParams.get(name) ?? undefined;
|
|
24
|
+
const clientId = param("client_id");
|
|
25
|
+
if (!clientId) return OAuthAuthorize.#page("Missing client", "The authorization request names no client_id.");
|
|
26
|
+
if (!client) return OAuthAuthorize.#page("Unknown client", `No client is registered as "${clientId}".`);
|
|
27
|
+
|
|
28
|
+
const redirectUri =
|
|
29
|
+
param("redirect_uri") ?? (client.redirectUris.length === 1 ? client.redirectUris[0] : undefined);
|
|
30
|
+
if (!redirectUri)
|
|
31
|
+
return OAuthAuthorize.#page(
|
|
32
|
+
"Missing redirect URI",
|
|
33
|
+
"The client registered several redirect URIs and named none.",
|
|
34
|
+
);
|
|
35
|
+
if (!OAuthRedirect.matches(client.redirectUris, redirectUri))
|
|
36
|
+
return OAuthAuthorize.#page(
|
|
37
|
+
"Redirect URI not registered",
|
|
38
|
+
`"${redirectUri}" is not a redirect URI registered for this client.`,
|
|
39
|
+
);
|
|
40
|
+
const state = param("state");
|
|
41
|
+
const fail = (error: OAuthAuthorizeErrorCode, description: string): OAuthAuthorizeResult => ({
|
|
42
|
+
ok: false,
|
|
43
|
+
response: OAuthErrors.redirect(redirectUri, { error, description, state, iss: issuer }),
|
|
44
|
+
});
|
|
45
|
+
if (param("response_type") !== "code")
|
|
46
|
+
return fail("unsupported_response_type", "Only response_type=code is supported.");
|
|
47
|
+
if (!client.grantTypes.includes("authorization_code"))
|
|
48
|
+
return fail("unauthorized_client", "This client is not registered for the authorization_code grant.");
|
|
49
|
+
const codeChallenge = param("code_challenge");
|
|
50
|
+
const method = param("code_challenge_method");
|
|
51
|
+
|
|
52
|
+
if (method !== undefined && method !== OAuthPkce.method)
|
|
53
|
+
return fail("invalid_request", `code_challenge_method "${method}" is not supported; use ${OAuthPkce.method}.`);
|
|
54
|
+
if (!OAuthPkce.isChallenge(codeChallenge)) return fail("invalid_request", "A PKCE code_challenge is required.");
|
|
55
|
+
if (method === undefined)
|
|
56
|
+
return fail("invalid_request", `code_challenge_method is required and must be ${OAuthPkce.method}.`);
|
|
57
|
+
const requested = param("resource");
|
|
58
|
+
if (requested !== undefined && !OAuthAuthorize.sameResource(requested, resource))
|
|
59
|
+
return fail("invalid_target", `This server issues tokens for ${resource} only.`);
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
params: { clientId, redirectUri, codeChallenge, state, resource, scope: param("scope") },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 8707 §2 compares resource identifiers as URIs, so a trailing slash a client added is not another server. */
|
|
67
|
+
static sameResource(a: string, b: string): boolean {
|
|
68
|
+
return OAuthAuthorize.#canonical(a) === OAuthAuthorize.#canonical(b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #canonical(resource: string): string {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(resource);
|
|
74
|
+
url.hash = "";
|
|
75
|
+
return url.href.replace(/\/$/, "");
|
|
76
|
+
} catch {
|
|
77
|
+
return resource;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static #page(title: string, detail: string): OAuthAuthorizeResult {
|
|
82
|
+
return { ok: false, response: OAuthErrors.page(400, title, detail) };
|
|
83
|
+
}
|
|
84
|
+
}
|