skybridge 1.3.4 → 1.4.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 +1 -0
- package/dist/context-warnings.d.ts +5 -0
- package/dist/context-warnings.js +31 -0
- package/dist/context-warnings.js.map +1 -0
- package/dist/context-warnings.test.d.ts +1 -0
- package/dist/context-warnings.test.js +28 -0
- package/dist/context-warnings.test.js.map +1 -0
- package/dist/server/auth/index.d.ts +30 -4
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/auth/providers/auth0.d.ts +24 -2
- package/dist/server/auth/providers/auth0.js.map +1 -1
- package/dist/server/auth/providers/auth0.test.js +4 -2
- package/dist/server/auth/providers/auth0.test.js.map +1 -1
- package/dist/server/auth/providers/authplane.d.ts +49 -0
- package/dist/server/auth/providers/authplane.js +57 -0
- package/dist/server/auth/providers/authplane.js.map +1 -0
- package/dist/server/auth/providers/authplane.test.d.ts +1 -0
- package/dist/server/auth/providers/authplane.test.js +140 -0
- package/dist/server/auth/providers/authplane.test.js.map +1 -0
- package/dist/server/auth/providers/clerk.d.ts +39 -2
- package/dist/server/auth/providers/clerk.js +4 -1
- package/dist/server/auth/providers/clerk.js.map +1 -1
- package/dist/server/auth/providers/clerk.test.js +5 -3
- package/dist/server/auth/providers/clerk.test.js.map +1 -1
- package/dist/server/auth/providers/custom.d.ts +15 -5
- package/dist/server/auth/providers/custom.js +25 -9
- package/dist/server/auth/providers/custom.js.map +1 -1
- package/dist/server/auth/providers/custom.test.js +46 -16
- package/dist/server/auth/providers/custom.test.js.map +1 -1
- package/dist/server/auth/providers/descope.d.ts +31 -2
- package/dist/server/auth/providers/descope.js +12 -3
- package/dist/server/auth/providers/descope.js.map +1 -1
- package/dist/server/auth/providers/descope.test.js +42 -19
- package/dist/server/auth/providers/descope.test.js.map +1 -1
- package/dist/server/auth/providers/stytch.d.ts +15 -2
- package/dist/server/auth/providers/stytch.js +4 -1
- package/dist/server/auth/providers/stytch.js.map +1 -1
- package/dist/server/auth/providers/verify-spy.d.ts +12 -0
- package/dist/server/auth/providers/verify-spy.js +21 -0
- package/dist/server/auth/providers/verify-spy.js.map +1 -0
- package/dist/server/auth/providers/workos.d.ts +24 -2
- package/dist/server/auth/providers/workos.js +4 -1
- package/dist/server/auth/providers/workos.js.map +1 -1
- package/dist/server/auth/setup.d.ts +1 -1
- package/dist/server/auth/setup.js +3 -3
- package/dist/server/auth/setup.js.map +1 -1
- package/dist/server/auth/setup.test.js +39 -5
- package/dist/server/auth/setup.test.js.map +1 -1
- package/dist/server/auth/verify.d.ts +37 -3
- package/dist/server/auth/verify.js +26 -2
- package/dist/server/auth/verify.js.map +1 -1
- package/dist/server/auth/verify.test.js +49 -0
- package/dist/server/auth/verify.test.js.map +1 -1
- package/dist/server/auth-extra.test-d.d.ts +1 -0
- package/dist/server/auth-extra.test-d.js +82 -0
- package/dist/server/auth-extra.test-d.js.map +1 -0
- package/dist/server/auth.d.ts +39 -1
- package/dist/server/auth.js.map +1 -1
- package/dist/server/index.d.ts +9 -6
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/middleware.d.ts +31 -6
- package/dist/server/middleware.js +33 -0
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/middleware.test.js +37 -1
- package/dist/server/middleware.test.js.map +1 -1
- package/dist/server/server.d.ts +26 -16
- package/dist/server/server.js +16 -5
- package/dist/server/server.js.map +1 -1
- package/dist/test/utils.d.ts +3 -3
- package/dist/test/view.test.js +16 -0
- package/dist/test/view.test.js.map +1 -1
- package/dist/web/bridges/adaptor.js +1 -15
- package/dist/web/bridges/adaptor.js.map +1 -1
- package/dist/web/bridges/adaptor.test.js +3 -2
- package/dist/web/bridges/adaptor.test.js.map +1 -1
- package/dist/web/bridges/mcp-app/bridge.js +5 -3
- package/dist/web/bridges/mcp-app/bridge.js.map +1 -1
- package/dist/web/bridges/mcp-app/types.d.ts +2 -0
- package/dist/web/bridges/mcp-app/types.js.map +1 -1
- package/dist/web/data-llm.test.js +3 -2
- package/dist/web/data-llm.test.js.map +1 -1
- package/dist/web/hooks/index.d.ts +1 -0
- package/dist/web/hooks/index.js +1 -0
- package/dist/web/hooks/index.js.map +1 -1
- package/dist/web/hooks/test/utils.d.ts +2 -1
- package/dist/web/hooks/test/utils.js +1 -1
- package/dist/web/hooks/test/utils.js.map +1 -1
- package/dist/web/hooks/use-host-info.d.ts +22 -0
- package/dist/web/hooks/use-host-info.js +30 -0
- package/dist/web/hooks/use-host-info.js.map +1 -0
- package/dist/web/hooks/use-host-info.test.d.ts +1 -0
- package/dist/web/hooks/use-host-info.test.js +59 -0
- package/dist/web/hooks/use-host-info.test.js.map +1 -0
- package/dist/web/hooks/use-view-state.js +19 -9
- package/dist/web/hooks/use-view-state.js.map +1 -1
- package/dist/web/hooks/use-view-state.test.js +31 -0
- package/dist/web/hooks/use-view-state.test.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,6 +144,7 @@ Explore all our example templates in the [Examples](https://docs.skybridge.tech/
|
|
|
144
144
|
| <img src="docs/images/showcase-workos.png" alt="Auth WorkOS AuthKit" width="160" /> | WorkOS AuthKit | Full OAuth authentication with WorkOS AuthKit and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-workos) |
|
|
145
145
|
| <img src="docs/images/showcase-stytch.png" alt="Auth Stytch" width="160" /> | Stytch | Full OAuth authentication with Stytch and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-stytch) |
|
|
146
146
|
| <img src="docs/images/showcase-auth0.png" alt="Auth Auth0" width="160" /> | Auth0 | Full OAuth authentication with Auth0 and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-auth0) |
|
|
147
|
+
| <img src="docs/images/showcase-authplane.png" alt="Auth Authplane" width="160" /> | Authplane | Full OAuth authentication with Authplane and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-authplane) |
|
|
147
148
|
|
|
148
149
|
### UI and component libraries
|
|
149
150
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const TOOL_OUTPUT_WARNING_TOKENS = 5000;
|
|
2
|
+
export declare const VIEW_STATE_WARNING_TOKENS = 20000;
|
|
3
|
+
export declare function estimateContextTokens(value: unknown): number;
|
|
4
|
+
export declare function warnOnLargeToolOutput(result: unknown, toolName: string): void;
|
|
5
|
+
export declare function warnOnLargeViewState(value: unknown, source: string): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const TOOL_OUTPUT_WARNING_TOKENS = 5_000;
|
|
2
|
+
export const VIEW_STATE_WARNING_TOKENS = 20_000;
|
|
3
|
+
export function estimateContextTokens(value) {
|
|
4
|
+
try {
|
|
5
|
+
const serialized = JSON.stringify(value);
|
|
6
|
+
if (!serialized) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
return Math.ceil(new TextEncoder().encode(serialized).length / 4);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function warnOnLargeToolOutput(result, toolName) {
|
|
16
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const { _meta, ...modelVisibleResult } = result;
|
|
20
|
+
const tokenCount = estimateContextTokens(modelVisibleResult);
|
|
21
|
+
if (tokenCount >= TOOL_OUTPUT_WARNING_TOKENS) {
|
|
22
|
+
console.warn(`[skybridge] Tool "${toolName}" returned ${tokenCount} estimated model-visible tokens; this reaches the ${TOOL_OUTPUT_WARNING_TOKENS}-token warning threshold and may overload model context. Content and structured content are included in this estimate.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function warnOnLargeViewState(value, source) {
|
|
26
|
+
const tokenCount = estimateContextTokens(value);
|
|
27
|
+
if (tokenCount >= VIEW_STATE_WARNING_TOKENS) {
|
|
28
|
+
console.warn(`[skybridge] ${source} is persisting ${tokenCount} estimated tokens in model-visible view state; this reaches the ${VIEW_STATE_WARNING_TOKENS}-token warning threshold and may overload model context. Persisted view state and data-llm context are included in this estimate.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=context-warnings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context-warnings.js","sourceRoot":"","sources":["../src/context-warnings.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,0BAA0B,GAAG,KAAK,CAAC;AAChD,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAM,CAAC;AAEhD,MAAM,UAAU,qBAAqB,CAAC,KAAc;IAClD,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,OAAO,CAAC,CAAC;QACX,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,CAAC;IACX,CAAC;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,MAAe,EAAE,QAAgB;IACrE,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACnE,OAAO;IACT,CAAC;IACD,MAAM,EAAE,KAAK,EAAE,GAAG,kBAAkB,EAAE,GAAG,MAAiC,CAAC;IAC3E,MAAM,UAAU,GAAG,qBAAqB,CAAC,kBAAkB,CAAC,CAAC;IAC7D,IAAI,UAAU,IAAI,0BAA0B,EAAE,CAAC;QAC7C,OAAO,CAAC,IAAI,CACV,qBAAqB,QAAQ,cAAc,UAAU,qDAAqD,0BAA0B,wHAAwH,CAC7P,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,KAAc,EAAE,MAAc;IACjE,MAAM,UAAU,GAAG,qBAAqB,CAAC,KAAK,CAAC,CAAC;IAChD,IAAI,UAAU,IAAI,yBAAyB,EAAE,CAAC;QAC5C,OAAO,CAAC,IAAI,CACV,eAAe,MAAM,kBAAkB,UAAU,mEAAmE,yBAAyB,mIAAmI,CACjR,CAAC;IACJ,CAAC;AACH,CAAC","sourcesContent":["export const TOOL_OUTPUT_WARNING_TOKENS = 5_000;\nexport const VIEW_STATE_WARNING_TOKENS = 20_000;\n\nexport function estimateContextTokens(value: unknown): number {\n try {\n const serialized = JSON.stringify(value);\n if (!serialized) {\n return 0;\n }\n return Math.ceil(new TextEncoder().encode(serialized).length / 4);\n } catch {\n return 0;\n }\n}\n\nexport function warnOnLargeToolOutput(result: unknown, toolName: string): void {\n if (!result || typeof result !== \"object\" || Array.isArray(result)) {\n return;\n }\n const { _meta, ...modelVisibleResult } = result as Record<string, unknown>;\n const tokenCount = estimateContextTokens(modelVisibleResult);\n if (tokenCount >= TOOL_OUTPUT_WARNING_TOKENS) {\n console.warn(\n `[skybridge] Tool \"${toolName}\" returned ${tokenCount} estimated model-visible tokens; this reaches the ${TOOL_OUTPUT_WARNING_TOKENS}-token warning threshold and may overload model context. Content and structured content are included in this estimate.`,\n );\n }\n}\n\nexport function warnOnLargeViewState(value: unknown, source: string): void {\n const tokenCount = estimateContextTokens(value);\n if (tokenCount >= VIEW_STATE_WARNING_TOKENS) {\n console.warn(\n `[skybridge] ${source} is persisting ${tokenCount} estimated tokens in model-visible view state; this reaches the ${VIEW_STATE_WARNING_TOKENS}-token warning threshold and may overload model context. Persisted view state and data-llm context are included in this estimate.`,\n );\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { estimateContextTokens, TOOL_OUTPUT_WARNING_TOKENS, warnOnLargeToolOutput, } from "./context-warnings.js";
|
|
3
|
+
describe("context warnings", () => {
|
|
4
|
+
it("estimates UTF-8 bytes and returns zero for no content", () => {
|
|
5
|
+
expect(estimateContextTokens("é")).toBe(1);
|
|
6
|
+
expect(estimateContextTokens(undefined)).toBe(0);
|
|
7
|
+
});
|
|
8
|
+
it("warns when model-visible tool output reaches the threshold", () => {
|
|
9
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
10
|
+
warnOnLargeToolOutput({
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
type: "text",
|
|
14
|
+
text: "x".repeat(TOOL_OUTPUT_WARNING_TOKENS * 4),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
}, "large-output");
|
|
18
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Tool "large-output" returned'));
|
|
19
|
+
warnSpy.mockRestore();
|
|
20
|
+
});
|
|
21
|
+
it("does not warn for large metadata", () => {
|
|
22
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
23
|
+
warnOnLargeToolOutput({ content: [], _meta: { private: "x".repeat(100_000) } }, "large-meta");
|
|
24
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
25
|
+
warnSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
//# sourceMappingURL=context-warnings.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context-warnings.test.js","sourceRoot":"","sources":["../src/context-warnings.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EACL,qBAAqB,EACrB,0BAA0B,EAC1B,qBAAqB,GACtB,MAAM,uBAAuB,CAAC;AAE/B,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,EAAE,CAAC,uDAAuD,EAAE,GAAG,EAAE;QAC/D,MAAM,CAAC,qBAAqB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3C,MAAM,CAAC,qBAAqB,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAEvE,qBAAqB,CACnB;YACE,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,0BAA0B,GAAG,CAAC,CAAC;iBACjD;aACF;SACF,EACD,cAAc,CACf,CAAC;QAEF,MAAM,CAAC,OAAO,CAAC,CAAC,oBAAoB,CAClC,MAAM,CAAC,gBAAgB,CAAC,8BAA8B,CAAC,CACxD,CAAC;QACF,OAAO,CAAC,WAAW,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C,MAAM,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,kBAAkB,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAEvE,qBAAqB,CACnB,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,EACxD,YAAY,CACb,CAAC;QAEF,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;QACvC,OAAO,CAAC,WAAW,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { describe, expect, it, vi } from \"vitest\";\nimport {\n estimateContextTokens,\n TOOL_OUTPUT_WARNING_TOKENS,\n warnOnLargeToolOutput,\n} from \"./context-warnings.js\";\n\ndescribe(\"context warnings\", () => {\n it(\"estimates UTF-8 bytes and returns zero for no content\", () => {\n expect(estimateContextTokens(\"é\")).toBe(1);\n expect(estimateContextTokens(undefined)).toBe(0);\n });\n\n it(\"warns when model-visible tool output reaches the threshold\", () => {\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n warnOnLargeToolOutput(\n {\n content: [\n {\n type: \"text\",\n text: \"x\".repeat(TOOL_OUTPUT_WARNING_TOKENS * 4),\n },\n ],\n },\n \"large-output\",\n );\n\n expect(warnSpy).toHaveBeenCalledWith(\n expect.stringContaining('Tool \"large-output\" returned'),\n );\n warnSpy.mockRestore();\n });\n\n it(\"does not warn for large metadata\", () => {\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n warnOnLargeToolOutput(\n { content: [], _meta: { private: \"x\".repeat(100_000) } },\n \"large-meta\",\n );\n\n expect(warnSpy).not.toHaveBeenCalled();\n warnSpy.mockRestore();\n });\n});\n"]}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
2
|
+
import type { ExtraClaims, TokenVerifier } from "../auth.js";
|
|
2
3
|
import type { JwksVerifyConfig } from "./verify.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Resource-server OAuth config for `SkybridgeServerOptions.oauth`.
|
|
6
|
+
*
|
|
7
|
+
* Supply either a `verifier` or the legacy `verify` parameters, never both.
|
|
8
|
+
* `TExtra` comes from the `verifier` and flows on to tool handlers and
|
|
9
|
+
* `mcpMiddleware`, so the claim shape is declared once by whoever checks the
|
|
10
|
+
* token. The branded providers set it from the claims their IdP documents.
|
|
11
|
+
*
|
|
12
|
+
* @typeParam TExtra - Claims the verifier populates in `AuthInfo["extra"]`.
|
|
13
|
+
*/
|
|
14
|
+
export type OAuthConfig<TExtra extends ExtraClaims = ExtraClaims> = {
|
|
5
15
|
/**
|
|
6
16
|
* Public URL of this server; sets `resourceServerUrl` and the
|
|
7
17
|
* `resource_metadata` URL. When omitted, it is inferred per request from
|
|
@@ -10,9 +20,25 @@ export type OAuthConfig = {
|
|
|
10
20
|
baseUrl?: string;
|
|
11
21
|
/** AS metadata served at `/.well-known/oauth-authorization-server`. */
|
|
12
22
|
oauthMetadata: OAuthMetadata;
|
|
13
|
-
verify: JwksVerifyConfig;
|
|
14
23
|
/** Scopes advertised in protected-resource metadata. */
|
|
15
24
|
scopesSupported?: string[];
|
|
16
25
|
/** Server-wide required-scope floor. */
|
|
17
26
|
requiredScopes?: string[];
|
|
18
|
-
}
|
|
27
|
+
} & ({
|
|
28
|
+
/**
|
|
29
|
+
* Checks each bearer token. Build one with `createJwksVerifier` for a
|
|
30
|
+
* JWT-issuing IdP, or supply your own for opaque tokens.
|
|
31
|
+
*/
|
|
32
|
+
verifier: TokenVerifier<TExtra>;
|
|
33
|
+
verify?: never;
|
|
34
|
+
} | {
|
|
35
|
+
/**
|
|
36
|
+
* JWKS verification parameters, used to build the verifier.
|
|
37
|
+
*
|
|
38
|
+
* @deprecated Pass `verifier: createJwksVerifier({ … })` instead, which
|
|
39
|
+
* also types the claims handlers receive. Claims stay
|
|
40
|
+
* `Record<string, unknown>` on this path.
|
|
41
|
+
*/
|
|
42
|
+
verify: JwksVerifyConfig;
|
|
43
|
+
verifier?: never;
|
|
44
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/server/auth/index.ts"],"names":[],"mappings":"","sourcesContent":["import type { OAuthMetadata } from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport type { JwksVerifyConfig } from \"./verify.js\";\n\n
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/server/auth/index.ts"],"names":[],"mappings":"","sourcesContent":["import type { OAuthMetadata } from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport type { ExtraClaims, TokenVerifier } from \"../auth.js\";\nimport type { JwksVerifyConfig } from \"./verify.js\";\n\n/**\n * Resource-server OAuth config for `SkybridgeServerOptions.oauth`.\n *\n * Supply either a `verifier` or the legacy `verify` parameters, never both.\n * `TExtra` comes from the `verifier` and flows on to tool handlers and\n * `mcpMiddleware`, so the claim shape is declared once by whoever checks the\n * token. The branded providers set it from the claims their IdP documents.\n *\n * @typeParam TExtra - Claims the verifier populates in `AuthInfo[\"extra\"]`.\n */\nexport type OAuthConfig<TExtra extends ExtraClaims = ExtraClaims> = {\n /**\n * Public URL of this server; sets `resourceServerUrl` and the\n * `resource_metadata` URL. When omitted, it is inferred per request from\n * `x-forwarded-host`/`origin`/`host` headers.\n */\n baseUrl?: string;\n /** AS metadata served at `/.well-known/oauth-authorization-server`. */\n oauthMetadata: OAuthMetadata;\n /** Scopes advertised in protected-resource metadata. */\n scopesSupported?: string[];\n /** Server-wide required-scope floor. */\n requiredScopes?: string[];\n} & (\n | {\n /**\n * Checks each bearer token. Build one with `createJwksVerifier` for a\n * JWT-issuing IdP, or supply your own for opaque tokens.\n */\n verifier: TokenVerifier<TExtra>;\n verify?: never;\n }\n | {\n /**\n * JWKS verification parameters, used to build the verifier.\n *\n * @deprecated Pass `verifier: createJwksVerifier({ … })` instead, which\n * also types the claims handlers receive. Claims stay\n * `Record<string, unknown>` on this path.\n */\n verify: JwksVerifyConfig;\n verifier?: never;\n }\n);\n"]}
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
+
import type { ExtraClaims } from "../../auth.js";
|
|
1
2
|
import type { OAuthConfig } from "../index.js";
|
|
3
|
+
import type { RegisteredClaims } from "../verify.js";
|
|
2
4
|
import { type CustomProviderOptions } from "./custom.js";
|
|
5
|
+
/**
|
|
6
|
+
* Claims an Auth0 access token carries for a custom API audience. Auth0 puts no
|
|
7
|
+
* user attributes beyond the id in the token, so there is no `email`: call
|
|
8
|
+
* `/userinfo` for the profile. Custom claims must be namespaced (a bare `email`
|
|
9
|
+
* key is silently dropped), so add them through the type parameter under their
|
|
10
|
+
* full URI.
|
|
11
|
+
*
|
|
12
|
+
* @see https://auth0.com/docs/secure/tokens/access-tokens
|
|
13
|
+
* @see https://auth0.com/docs/secure/tokens/json-web-tokens/create-custom-claims
|
|
14
|
+
*/
|
|
15
|
+
export type Auth0Claims = {
|
|
16
|
+
/** Authorized party: the client id the token was issued to. */
|
|
17
|
+
azp?: string;
|
|
18
|
+
/** Only when the API has RBAC and "Add Permissions in the Access Token" on. */
|
|
19
|
+
permissions?: string[];
|
|
20
|
+
/** Organization id, when the tenant uses Organizations. */
|
|
21
|
+
org_id?: string;
|
|
22
|
+
/** Organization name, when the tenant uses Organizations. */
|
|
23
|
+
org_name?: string;
|
|
24
|
+
};
|
|
3
25
|
/**
|
|
4
26
|
* OAuth provider for Auth0. `domain` is the tenant domain (e.g.
|
|
5
27
|
* `acme.us.auth0.com`); `audience` is the API Identifier; `serverUrl` is this
|
|
@@ -11,8 +33,8 @@ import { type CustomProviderOptions } from "./custom.js";
|
|
|
11
33
|
* `authorization_endpoint`. The token's `aud` is the API; `verify.issuer` stays
|
|
12
34
|
* Auth0 (the token's real `iss`).
|
|
13
35
|
*/
|
|
14
|
-
export declare function auth0Provider(opts: {
|
|
36
|
+
export declare function auth0Provider<TCustom extends ExtraClaims = Record<never, never>>(opts: {
|
|
15
37
|
domain: string;
|
|
16
38
|
audience: string;
|
|
17
39
|
serverUrl: string;
|
|
18
|
-
} & Omit<CustomProviderOptions, "issuer" | "audience" | "baseUrl" | "serverUrl">): Promise<OAuthConfig
|
|
40
|
+
} & Omit<CustomProviderOptions, "issuer" | "audience" | "baseUrl" | "serverUrl">): Promise<OAuthConfig<Auth0Claims & TCustom & RegisteredClaims>>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth0.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/auth0.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth0.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/auth0.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,cAAc,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB1C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAGjC,IAGC;IAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3C,MAAM,MAAM,GAAG,MAAM,cAAc,CAAwB;QACzD,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC;QAC3B,QAAQ;QACR,GAAG,IAAI;KACR,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IACrE,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC/C,OAAO;QACL,GAAG,MAAM;QACT,aAAa,EAAE;YACb,GAAG,MAAM,CAAC,aAAa;YACvB,sBAAsB,EAAE,OAAO,CAAC,IAAI;SACrC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import type { ExtraClaims } from \"../../auth.js\";\nimport type { OAuthConfig } from \"../index.js\";\nimport type { RegisteredClaims } from \"../verify.js\";\nimport { type CustomProviderOptions, customProvider } from \"./custom.js\";\nimport { toIssuerUrl } from \"./shared.js\";\n\n/**\n * Claims an Auth0 access token carries for a custom API audience. Auth0 puts no\n * user attributes beyond the id in the token, so there is no `email`: call\n * `/userinfo` for the profile. Custom claims must be namespaced (a bare `email`\n * key is silently dropped), so add them through the type parameter under their\n * full URI.\n *\n * @see https://auth0.com/docs/secure/tokens/access-tokens\n * @see https://auth0.com/docs/secure/tokens/json-web-tokens/create-custom-claims\n */\nexport type Auth0Claims = {\n /** Authorized party: the client id the token was issued to. */\n azp?: string;\n /** Only when the API has RBAC and \"Add Permissions in the Access Token\" on. */\n permissions?: string[];\n /** Organization id, when the tenant uses Organizations. */\n org_id?: string;\n /** Organization name, when the tenant uses Organizations. */\n org_name?: string;\n};\n\n/**\n * OAuth provider for Auth0. `domain` is the tenant domain (e.g.\n * `acme.us.auth0.com`); `audience` is the API Identifier; `serverUrl` is this\n * server's public URL. Requires DCR enabled on the tenant.\n *\n * Auth0 can't use the client's resource indicator — `audience` must be *in* the\n * authorize request. So it runs as skybridge-as-AS (`serverUrl`, see\n * {@link customProvider}) and bakes `?audience=<id>` into the advertised\n * `authorization_endpoint`. The token's `aud` is the API; `verify.issuer` stays\n * Auth0 (the token's real `iss`).\n */\nexport async function auth0Provider<\n TCustom extends ExtraClaims = Record<never, never>,\n>(\n opts: { domain: string; audience: string; serverUrl: string } & Omit<\n CustomProviderOptions,\n \"issuer\" | \"audience\" | \"baseUrl\" | \"serverUrl\"\n >,\n): Promise<OAuthConfig<Auth0Claims & TCustom & RegisteredClaims>> {\n const { domain, audience, ...rest } = opts;\n const config = await customProvider<Auth0Claims & TCustom>({\n issuer: toIssuerUrl(domain),\n audience,\n ...rest,\n });\n const authUrl = new URL(config.oauthMetadata.authorization_endpoint);\n authUrl.searchParams.set(\"audience\", audience);\n return {\n ...config,\n oauthMetadata: {\n ...config.oauthMetadata,\n authorization_endpoint: authUrl.href,\n },\n };\n}\n"]}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { auth0Provider } from "./auth0.js";
|
|
4
|
+
import { lastJwksConfig as jwks } from "./verify-spy.js";
|
|
5
|
+
vi.mock("../verify.js", () => import("./verify-spy.js"));
|
|
4
6
|
afterEach(() => vi.restoreAllMocks());
|
|
5
7
|
function doc(issuer) {
|
|
6
8
|
return {
|
|
@@ -41,8 +43,8 @@ describe("auth0Provider", () => {
|
|
|
41
43
|
// baseUrl left unset so the PRM resource resolves per request (Alpic-safe).
|
|
42
44
|
expect(config.baseUrl).toBeUndefined();
|
|
43
45
|
// token verification still uses Auth0 as issuer + the API audience.
|
|
44
|
-
expect(
|
|
45
|
-
expect(
|
|
46
|
+
expect(jwks().issuer).toBe(issuer);
|
|
47
|
+
expect(jwks().audience).toBe("https://api.example.com/");
|
|
46
48
|
});
|
|
47
49
|
});
|
|
48
50
|
//# sourceMappingURL=auth0.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth0.test.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/auth0.test.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"auth0.test.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/auth0.test.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAE,cAAc,IAAI,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEzD,EAAE,CAAC,IAAI,CAAC,cAAc,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAEzD,SAAS,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,eAAe,EAAE,CAAC,CAAC;AAEtC,SAAS,GAAG,CAAC,MAAc;IACzB,OAAO;QACL,MAAM;QACN,sBAAsB,EAAE,GAAG,MAAM,YAAY;QAC7C,cAAc,EAAE,GAAG,MAAM,cAAc;QACvC,qBAAqB,EAAE,GAAG,MAAM,gBAAgB;QAChD,wBAAwB,EAAE,CAAC,MAAM,CAAC;QAClC,gBAAgB,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC;QAChD,QAAQ,EAAE,GAAG,MAAM,wBAAwB;KAC5C,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;IAC7B,EAAE,CAAC,2FAA2F,EAAE,KAAK,IAAI,EAAE;QACzG,MAAM,MAAM,GAAG,2BAA2B,CAAC;QAC3C,EAAE,CAAC,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,iBAAiB,CAC7C,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE;YACxC,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;SAChD,CAAC,CACH,CAAC;QAEF,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC;YACjC,MAAM,EAAE,mBAAmB;YAC3B,QAAQ,EAAE,0BAA0B;YACpC,SAAS,EAAE,0BAA0B;YACrC,MAAM,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC;SACvC,CAAC,CAAC;QAEH,uFAAuF;QACvF,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;QACpE,+EAA+E;QAC/E,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC,OAAO,CAAC;YACpD,QAAQ;YACR,SAAS;YACT,OAAO;SACR,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;QACvE,mFAAmF;QACnF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;QACjE,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,MAAM,YAAY,CAAC,CAAC;QAC9D,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;QAC1E,4EAA4E;QAC5E,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,aAAa,EAAE,CAAC;QACvC,oEAAoE;QACpE,MAAM,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACnC,MAAM,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["// @vitest-environment node\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { auth0Provider } from \"./auth0.js\";\nimport { lastJwksConfig as jwks } from \"./verify-spy.js\";\n\nvi.mock(\"../verify.js\", () => import(\"./verify-spy.js\"));\n\nafterEach(() => vi.restoreAllMocks());\n\nfunction doc(issuer: string) {\n return {\n issuer,\n authorization_endpoint: `${issuer}/authorize`,\n token_endpoint: `${issuer}/oauth/token`,\n registration_endpoint: `${issuer}/oidc/register`,\n response_types_supported: [\"code\"],\n scopes_supported: [\"openid\", \"profile\", \"email\"],\n jwks_uri: `${issuer}/.well-known/jwks.json`,\n };\n}\n\ndescribe(\"auth0Provider\", () => {\n it(\"advertises skybridge as the AS (static issuer) and bakes audience; verifies against Auth0\", async () => {\n const issuer = \"https://acme.us.auth0.com\";\n vi.spyOn(globalThis, \"fetch\").mockResolvedValue(\n new Response(JSON.stringify(doc(issuer)), {\n headers: { \"content-type\": \"application/json\" },\n }),\n );\n\n const config = await auth0Provider({\n domain: \"acme.us.auth0.com\",\n audience: \"https://api.example.com/\",\n serverUrl: \"https://app.example.com/\",\n scopes: [\"openid\", \"profile\", \"email\"],\n });\n\n // skybridge is the advertised AS: static issuer = serverUrl (trailing slash stripped).\n expect(config.oauthMetadata.issuer).toBe(\"https://app.example.com\");\n // AS metadata scopes match the advertised set (clients read scopes from here).\n expect(config.oauthMetadata.scopes_supported).toEqual([\n \"openid\",\n \"profile\",\n \"email\",\n ]);\n expect(config.scopesSupported).toEqual([\"openid\", \"profile\", \"email\"]);\n // audience baked into the advertised authorize endpoint (still pointing at Auth0).\n const url = new URL(config.oauthMetadata.authorization_endpoint);\n expect(url.origin + url.pathname).toBe(`${issuer}/authorize`);\n expect(url.searchParams.get(\"audience\")).toBe(\"https://api.example.com/\");\n // baseUrl left unset so the PRM resource resolves per request (Alpic-safe).\n expect(config.baseUrl).toBeUndefined();\n // token verification still uses Auth0 as issuer + the API audience.\n expect(jwks().issuer).toBe(issuer);\n expect(jwks().audience).toBe(\"https://api.example.com/\");\n });\n});\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtraClaims } from "../../auth.js";
|
|
2
|
+
import type { OAuthConfig } from "../index.js";
|
|
3
|
+
import type { RegisteredClaims } from "../verify.js";
|
|
4
|
+
import { type CustomProviderOptions } from "./custom.js";
|
|
5
|
+
/** Options accepted by {@link authplaneProvider}. */
|
|
6
|
+
export type AuthplaneProviderOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The authorization server's issuer identifier (RFC 8414 §2) — your
|
|
9
|
+
* Authplane deployment, e.g. `https://auth.acme.com` (or
|
|
10
|
+
* `http://localhost:9000` in local development).
|
|
11
|
+
*/
|
|
12
|
+
issuer: string;
|
|
13
|
+
/**
|
|
14
|
+
* This server's resource identifier (RFC 9728 §1.2): the public URL clients
|
|
15
|
+
* reach, advertised as the `resource` field of its protected-resource
|
|
16
|
+
* metadata. Required, unlike the other providers.
|
|
17
|
+
*
|
|
18
|
+
* Authplane binds the access token's `aud` to the RFC 8707 `resource`
|
|
19
|
+
* parameter the client sends, and the client takes that value from the
|
|
20
|
+
* advertised metadata. Setting it explicitly gives the deployment one fixed
|
|
21
|
+
* identifier, which is what the audience is checked against.
|
|
22
|
+
*
|
|
23
|
+
* Resource identifiers are compared by exact string match, so give it in the
|
|
24
|
+
* form it will be advertised and register that same string in Authplane. RFC
|
|
25
|
+
* 8707 §2 asks for the most specific URI available, e.g.
|
|
26
|
+
* `https://acme.example.com/mcp`.
|
|
27
|
+
*/
|
|
28
|
+
resource: string;
|
|
29
|
+
/**
|
|
30
|
+
* Expected token `aud`. Defaults to `resource`.
|
|
31
|
+
*
|
|
32
|
+
* RFC 8707 §2 lets an authorization server use the resource identifier
|
|
33
|
+
* verbatim as the audience or map it to another value; set this only for a
|
|
34
|
+
* resource configured in Authplane with such an override, and pass that
|
|
35
|
+
* value verbatim.
|
|
36
|
+
*/
|
|
37
|
+
audience?: string;
|
|
38
|
+
} & Omit<CustomProviderOptions, "issuer" | "audience" | "baseUrl">;
|
|
39
|
+
/**
|
|
40
|
+
* OAuth provider for Authplane. `issuer` is the authorization server's issuer
|
|
41
|
+
* identifier and `resource` is this server's resource identifier, which also
|
|
42
|
+
* supplies the expected token audience.
|
|
43
|
+
*
|
|
44
|
+
* Dynamic Client Registration is supported natively, so no registration proxy
|
|
45
|
+
* is needed: clients register with Authplane directly and this server stays out
|
|
46
|
+
* of the authorization path. Pass `serverUrl` to advertise this server as the
|
|
47
|
+
* authorization server instead (see {@link customProvider}).
|
|
48
|
+
*/
|
|
49
|
+
export declare function authplaneProvider<TCustom extends ExtraClaims = Record<never, never>>(opts: AuthplaneProviderOptions): Promise<OAuthConfig<TCustom & RegisteredClaims>>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { customProvider } from "./custom.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rejects anything that cannot serve as an OAuth identifier: RFC 8707 §2
|
|
4
|
+
* requires an absolute URI and forbids a fragment, and the discovery and
|
|
5
|
+
* protected-resource metadata URLs are both built from the scheme and host.
|
|
6
|
+
*/
|
|
7
|
+
function parseIdentifier(value, option) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`authplaneProvider: \`${option}\` must be an absolute URL, got ${JSON.stringify(value)}`);
|
|
14
|
+
}
|
|
15
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
16
|
+
throw new Error(`authplaneProvider: \`${option}\` must use the http or https scheme, got ${JSON.stringify(value)}`);
|
|
17
|
+
}
|
|
18
|
+
if (!parsed.host) {
|
|
19
|
+
throw new Error(`authplaneProvider: \`${option}\` must include a host, got ${JSON.stringify(value)}`);
|
|
20
|
+
}
|
|
21
|
+
if (parsed.hash) {
|
|
22
|
+
throw new Error(`authplaneProvider: \`${option}\` must not include a fragment, got ${JSON.stringify(value)}`);
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* OAuth provider for Authplane. `issuer` is the authorization server's issuer
|
|
28
|
+
* identifier and `resource` is this server's resource identifier, which also
|
|
29
|
+
* supplies the expected token audience.
|
|
30
|
+
*
|
|
31
|
+
* Dynamic Client Registration is supported natively, so no registration proxy
|
|
32
|
+
* is needed: clients register with Authplane directly and this server stays out
|
|
33
|
+
* of the authorization path. Pass `serverUrl` to advertise this server as the
|
|
34
|
+
* authorization server instead (see {@link customProvider}).
|
|
35
|
+
*/
|
|
36
|
+
export function authplaneProvider(opts) {
|
|
37
|
+
const { issuer, resource, audience, ...rest } = opts;
|
|
38
|
+
parseIdentifier(issuer, "issuer");
|
|
39
|
+
const parsedResource = parseIdentifier(resource, "resource");
|
|
40
|
+
// The advertised resource is the URL-normalised form of this value. Where
|
|
41
|
+
// normalisation would change the string — a bare origin gaining a root path,
|
|
42
|
+
// an uppercase host, an explicit default port — the configured and advertised
|
|
43
|
+
// identifiers would differ, and the audience check compares them exactly.
|
|
44
|
+
// Require the advertised form up front so the two always match.
|
|
45
|
+
if (parsedResource.href !== resource) {
|
|
46
|
+
throw new Error(`authplaneProvider: \`resource\` must be given in the form it will be advertised. ` +
|
|
47
|
+
`${JSON.stringify(resource)} is advertised as ${JSON.stringify(parsedResource.href)}. ` +
|
|
48
|
+
`Use ${JSON.stringify(parsedResource.href)}, or a path-qualified URL such as "https://acme.example.com/mcp", and register the same value in Authplane.`);
|
|
49
|
+
}
|
|
50
|
+
return customProvider({
|
|
51
|
+
issuer,
|
|
52
|
+
audience: audience ?? resource,
|
|
53
|
+
baseUrl: resource,
|
|
54
|
+
...rest,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=authplane.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authplane.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/authplane.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,cAAc,EAAE,MAAM,aAAa,CAAC;AAqCzE;;;;GAIG;AACH,SAAS,eAAe,CAAC,KAAa,EAAE,MAAc;IACpD,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CACb,wBAAwB,MAAM,mCAAmC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CACzF,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,wBAAwB,MAAM,6CAA6C,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CACnG,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CACb,wBAAwB,MAAM,+BAA+B,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CACrF,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CACb,wBAAwB,MAAM,uCAAuC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAC7F,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAG/B,IAA8B;IAE9B,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;IAErD,eAAe,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAClC,MAAM,cAAc,GAAG,eAAe,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAE7D,0EAA0E;IAC1E,6EAA6E;IAC7E,8EAA8E;IAC9E,0EAA0E;IAC1E,gEAAgE;IAChE,IAAI,cAAc,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,mFAAmF;YACjF,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,qBAAqB,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI;YACvF,OAAO,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,6GAA6G,CAC1J,CAAC;IACJ,CAAC;IAED,OAAO,cAAc,CAAU;QAC7B,MAAM;QACN,QAAQ,EAAE,QAAQ,IAAI,QAAQ;QAC9B,OAAO,EAAE,QAAQ;QACjB,GAAG,IAAI;KACR,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { ExtraClaims } from \"../../auth.js\";\nimport type { OAuthConfig } from \"../index.js\";\nimport type { RegisteredClaims } from \"../verify.js\";\nimport { type CustomProviderOptions, customProvider } from \"./custom.js\";\n\n/** Options accepted by {@link authplaneProvider}. */\nexport type AuthplaneProviderOptions = {\n /**\n * The authorization server's issuer identifier (RFC 8414 §2) — your\n * Authplane deployment, e.g. `https://auth.acme.com` (or\n * `http://localhost:9000` in local development).\n */\n issuer: string;\n /**\n * This server's resource identifier (RFC 9728 §1.2): the public URL clients\n * reach, advertised as the `resource` field of its protected-resource\n * metadata. Required, unlike the other providers.\n *\n * Authplane binds the access token's `aud` to the RFC 8707 `resource`\n * parameter the client sends, and the client takes that value from the\n * advertised metadata. Setting it explicitly gives the deployment one fixed\n * identifier, which is what the audience is checked against.\n *\n * Resource identifiers are compared by exact string match, so give it in the\n * form it will be advertised and register that same string in Authplane. RFC\n * 8707 §2 asks for the most specific URI available, e.g.\n * `https://acme.example.com/mcp`.\n */\n resource: string;\n /**\n * Expected token `aud`. Defaults to `resource`.\n *\n * RFC 8707 §2 lets an authorization server use the resource identifier\n * verbatim as the audience or map it to another value; set this only for a\n * resource configured in Authplane with such an override, and pass that\n * value verbatim.\n */\n audience?: string;\n} & Omit<CustomProviderOptions, \"issuer\" | \"audience\" | \"baseUrl\">;\n\n/**\n * Rejects anything that cannot serve as an OAuth identifier: RFC 8707 §2\n * requires an absolute URI and forbids a fragment, and the discovery and\n * protected-resource metadata URLs are both built from the scheme and host.\n */\nfunction parseIdentifier(value: string, option: string): URL {\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n throw new Error(\n `authplaneProvider: \\`${option}\\` must be an absolute URL, got ${JSON.stringify(value)}`,\n );\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new Error(\n `authplaneProvider: \\`${option}\\` must use the http or https scheme, got ${JSON.stringify(value)}`,\n );\n }\n if (!parsed.host) {\n throw new Error(\n `authplaneProvider: \\`${option}\\` must include a host, got ${JSON.stringify(value)}`,\n );\n }\n if (parsed.hash) {\n throw new Error(\n `authplaneProvider: \\`${option}\\` must not include a fragment, got ${JSON.stringify(value)}`,\n );\n }\n return parsed;\n}\n\n/**\n * OAuth provider for Authplane. `issuer` is the authorization server's issuer\n * identifier and `resource` is this server's resource identifier, which also\n * supplies the expected token audience.\n *\n * Dynamic Client Registration is supported natively, so no registration proxy\n * is needed: clients register with Authplane directly and this server stays out\n * of the authorization path. Pass `serverUrl` to advertise this server as the\n * authorization server instead (see {@link customProvider}).\n */\nexport function authplaneProvider<\n TCustom extends ExtraClaims = Record<never, never>,\n>(\n opts: AuthplaneProviderOptions,\n): Promise<OAuthConfig<TCustom & RegisteredClaims>> {\n const { issuer, resource, audience, ...rest } = opts;\n\n parseIdentifier(issuer, \"issuer\");\n const parsedResource = parseIdentifier(resource, \"resource\");\n\n // The advertised resource is the URL-normalised form of this value. Where\n // normalisation would change the string — a bare origin gaining a root path,\n // an uppercase host, an explicit default port — the configured and advertised\n // identifiers would differ, and the audience check compares them exactly.\n // Require the advertised form up front so the two always match.\n if (parsedResource.href !== resource) {\n throw new Error(\n `authplaneProvider: \\`resource\\` must be given in the form it will be advertised. ` +\n `${JSON.stringify(resource)} is advertised as ${JSON.stringify(parsedResource.href)}. ` +\n `Use ${JSON.stringify(parsedResource.href)}, or a path-qualified URL such as \"https://acme.example.com/mcp\", and register the same value in Authplane.`,\n );\n }\n\n return customProvider<TCustom>({\n issuer,\n audience: audience ?? resource,\n baseUrl: resource,\n ...rest,\n });\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { authplaneProvider } from "./authplane.js";
|
|
4
|
+
import { lastJwksConfig as jwks } from "./verify-spy.js";
|
|
5
|
+
vi.mock("../verify.js", () => import("./verify-spy.js"));
|
|
6
|
+
afterEach(() => vi.restoreAllMocks());
|
|
7
|
+
const ISSUER = "https://auth.acme.com";
|
|
8
|
+
function discoveryDoc(issuer, scopes) {
|
|
9
|
+
return {
|
|
10
|
+
issuer,
|
|
11
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
12
|
+
token_endpoint: `${issuer}/token`,
|
|
13
|
+
registration_endpoint: `${issuer}/register`,
|
|
14
|
+
response_types_supported: ["code"],
|
|
15
|
+
scopes_supported: scopes,
|
|
16
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function mockDiscovery(docs) {
|
|
20
|
+
return vi
|
|
21
|
+
.spyOn(globalThis, "fetch")
|
|
22
|
+
.mockImplementation(async (input) => {
|
|
23
|
+
const url = String(input);
|
|
24
|
+
const entry = Object.entries(docs).find(([issuer]) => url === `${issuer}/.well-known/openid-configuration`);
|
|
25
|
+
if (entry === undefined) {
|
|
26
|
+
return new Response(null, { status: 404 });
|
|
27
|
+
}
|
|
28
|
+
return new Response(JSON.stringify(discoveryDoc(...entry)), {
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
describe("authplaneProvider", () => {
|
|
34
|
+
it("derives the audience from the resource the server advertises", async () => {
|
|
35
|
+
mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
36
|
+
const config = await authplaneProvider({
|
|
37
|
+
issuer: ISSUER,
|
|
38
|
+
resource: "https://coffee.example.com/mcp",
|
|
39
|
+
});
|
|
40
|
+
expect(jwks().audience).toBe("https://coffee.example.com/mcp");
|
|
41
|
+
expect(jwks().issuer).toBe(ISSUER);
|
|
42
|
+
expect(jwks().jwksUri).toBe(`${ISSUER}/.well-known/jwks.json`);
|
|
43
|
+
expect(config.baseUrl).toBe("https://coffee.example.com/mcp");
|
|
44
|
+
});
|
|
45
|
+
it.each([
|
|
46
|
+
"https://coffee.example.com/mcp",
|
|
47
|
+
"https://coffee.example.com/v2/mcp",
|
|
48
|
+
"https://coffee.example.com/",
|
|
49
|
+
"http://localhost:3000/mcp",
|
|
50
|
+
"https://coffee.example.com:8443/mcp",
|
|
51
|
+
])("passes %s through as both resource and audience", async (resource) => {
|
|
52
|
+
mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
53
|
+
const config = await authplaneProvider({ issuer: ISSUER, resource });
|
|
54
|
+
expect(config.baseUrl).toBe(resource);
|
|
55
|
+
expect(jwks().audience).toBe(resource);
|
|
56
|
+
});
|
|
57
|
+
// The metadata router serialises the resource identifier through `URL` before
|
|
58
|
+
// advertising it. Anything that serialisation would rewrite is refused here,
|
|
59
|
+
// so the advertised identifier is always the configured one.
|
|
60
|
+
it.each([
|
|
61
|
+
// Pathless origin — gains a root path.
|
|
62
|
+
["https://coffee.example.com", "https://coffee.example.com/"],
|
|
63
|
+
["http://localhost:3000", "http://localhost:3000/"],
|
|
64
|
+
// Uppercase host — lowercased.
|
|
65
|
+
["https://COFFEE.EXAMPLE.COM/mcp", "https://coffee.example.com/mcp"],
|
|
66
|
+
// Explicit default port — dropped.
|
|
67
|
+
["https://coffee.example.com:443/mcp", "https://coffee.example.com/mcp"],
|
|
68
|
+
])("rejects %s and names %s instead", async (resource, published) => {
|
|
69
|
+
const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
70
|
+
expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow(`is advertised as ${JSON.stringify(published)}`);
|
|
71
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
it("lets an explicit audience override the default", async () => {
|
|
74
|
+
mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
75
|
+
await authplaneProvider({
|
|
76
|
+
issuer: ISSUER,
|
|
77
|
+
resource: "https://coffee.example.com/mcp",
|
|
78
|
+
audience: "urn:acme:coffee",
|
|
79
|
+
});
|
|
80
|
+
expect(jwks().audience).toBe("urn:acme:coffee");
|
|
81
|
+
});
|
|
82
|
+
it.each([
|
|
83
|
+
["coffee.example.com/mcp", /must be an absolute URL/],
|
|
84
|
+
["ftp://coffee.example.com/mcp", /must use the http or https scheme/],
|
|
85
|
+
["https://coffee.example.com/mcp#frag", /must not include a fragment/],
|
|
86
|
+
])("rejects the malformed resource %s", async (resource, message) => {
|
|
87
|
+
const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
88
|
+
expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow(message);
|
|
89
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
it("rejects a malformed issuer before any discovery", async () => {
|
|
92
|
+
const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
93
|
+
expect(() => authplaneProvider({
|
|
94
|
+
issuer: "auth.acme.com",
|
|
95
|
+
resource: "https://coffee.example.com/mcp",
|
|
96
|
+
})).toThrow(/`issuer` must be an absolute URL/);
|
|
97
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
it("accepts an issuer carrying a trailing slash", async () => {
|
|
100
|
+
// Discovery resolves the issuer itself; the provider passes it through
|
|
101
|
+
// rather than rewriting the operator's value.
|
|
102
|
+
mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
103
|
+
await authplaneProvider({
|
|
104
|
+
issuer: `${ISSUER}/`,
|
|
105
|
+
resource: "https://coffee.example.com/mcp",
|
|
106
|
+
});
|
|
107
|
+
expect(jwks().issuer).toBe(ISSUER);
|
|
108
|
+
});
|
|
109
|
+
it("accepts a local http issuer for development", async () => {
|
|
110
|
+
const local = "http://localhost:9000";
|
|
111
|
+
mockDiscovery({ [local]: ["checkout"] });
|
|
112
|
+
await authplaneProvider({
|
|
113
|
+
issuer: local,
|
|
114
|
+
resource: "http://localhost:3000/mcp",
|
|
115
|
+
});
|
|
116
|
+
expect(jwks().issuer).toBe(local);
|
|
117
|
+
expect(jwks().audience).toBe("http://localhost:3000/mcp");
|
|
118
|
+
});
|
|
119
|
+
it("forwards scopes and the required-scope floor", async () => {
|
|
120
|
+
mockDiscovery({ [ISSUER]: ["checkout", "profile"] });
|
|
121
|
+
const config = await authplaneProvider({
|
|
122
|
+
issuer: ISSUER,
|
|
123
|
+
resource: "https://coffee.example.com/mcp",
|
|
124
|
+
scopes: ["checkout"],
|
|
125
|
+
requiredScopes: ["checkout"],
|
|
126
|
+
});
|
|
127
|
+
expect(config.scopesSupported).toEqual(["checkout"]);
|
|
128
|
+
expect(config.requiredScopes).toEqual(["checkout"]);
|
|
129
|
+
});
|
|
130
|
+
it("advertises the registration endpoint for dynamic client registration", async () => {
|
|
131
|
+
mockDiscovery({ [ISSUER]: ["checkout"] });
|
|
132
|
+
const config = await authplaneProvider({
|
|
133
|
+
issuer: ISSUER,
|
|
134
|
+
resource: "https://coffee.example.com/mcp",
|
|
135
|
+
});
|
|
136
|
+
expect(config.oauthMetadata.issuer).toBe(ISSUER);
|
|
137
|
+
expect(config.oauthMetadata.registration_endpoint).toBe(`${ISSUER}/register`);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
//# sourceMappingURL=authplane.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authplane.test.js","sourceRoot":"","sources":["../../../../src/server/auth/providers/authplane.test.ts"],"names":[],"mappings":"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","sourcesContent":["// @vitest-environment node\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { authplaneProvider } from \"./authplane.js\";\nimport { lastJwksConfig as jwks } from \"./verify-spy.js\";\n\nvi.mock(\"../verify.js\", () => import(\"./verify-spy.js\"));\n\nafterEach(() => vi.restoreAllMocks());\n\nconst ISSUER = \"https://auth.acme.com\";\n\nfunction discoveryDoc(issuer: string, scopes: string[]) {\n return {\n issuer,\n authorization_endpoint: `${issuer}/authorize`,\n token_endpoint: `${issuer}/token`,\n registration_endpoint: `${issuer}/register`,\n response_types_supported: [\"code\"],\n scopes_supported: scopes,\n jwks_uri: `${issuer}/.well-known/jwks.json`,\n };\n}\n\nfunction mockDiscovery(docs: Record<string, string[]>) {\n return vi\n .spyOn(globalThis, \"fetch\")\n .mockImplementation(async (input: RequestInfo | URL) => {\n const url = String(input);\n const entry = Object.entries(docs).find(\n ([issuer]) => url === `${issuer}/.well-known/openid-configuration`,\n );\n if (entry === undefined) {\n return new Response(null, { status: 404 });\n }\n return new Response(JSON.stringify(discoveryDoc(...entry)), {\n headers: { \"content-type\": \"application/json\" },\n });\n });\n}\n\ndescribe(\"authplaneProvider\", () => {\n it(\"derives the audience from the resource the server advertises\", async () => {\n mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n const config = await authplaneProvider({\n issuer: ISSUER,\n resource: \"https://coffee.example.com/mcp\",\n });\n\n expect(jwks().audience).toBe(\"https://coffee.example.com/mcp\");\n expect(jwks().issuer).toBe(ISSUER);\n expect(jwks().jwksUri).toBe(`${ISSUER}/.well-known/jwks.json`);\n expect(config.baseUrl).toBe(\"https://coffee.example.com/mcp\");\n });\n\n it.each([\n \"https://coffee.example.com/mcp\",\n \"https://coffee.example.com/v2/mcp\",\n \"https://coffee.example.com/\",\n \"http://localhost:3000/mcp\",\n \"https://coffee.example.com:8443/mcp\",\n ])(\"passes %s through as both resource and audience\", async (resource) => {\n mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n const config = await authplaneProvider({ issuer: ISSUER, resource });\n\n expect(config.baseUrl).toBe(resource);\n expect(jwks().audience).toBe(resource);\n });\n\n // The metadata router serialises the resource identifier through `URL` before\n // advertising it. Anything that serialisation would rewrite is refused here,\n // so the advertised identifier is always the configured one.\n it.each([\n // Pathless origin — gains a root path.\n [\"https://coffee.example.com\", \"https://coffee.example.com/\"],\n [\"http://localhost:3000\", \"http://localhost:3000/\"],\n // Uppercase host — lowercased.\n [\"https://COFFEE.EXAMPLE.COM/mcp\", \"https://coffee.example.com/mcp\"],\n // Explicit default port — dropped.\n [\"https://coffee.example.com:443/mcp\", \"https://coffee.example.com/mcp\"],\n ])(\"rejects %s and names %s instead\", async (resource, published) => {\n const fetchSpy = mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow(\n `is advertised as ${JSON.stringify(published)}`,\n );\n expect(fetchSpy).not.toHaveBeenCalled();\n });\n\n it(\"lets an explicit audience override the default\", async () => {\n mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n await authplaneProvider({\n issuer: ISSUER,\n resource: \"https://coffee.example.com/mcp\",\n audience: \"urn:acme:coffee\",\n });\n\n expect(jwks().audience).toBe(\"urn:acme:coffee\");\n });\n\n it.each([\n [\"coffee.example.com/mcp\", /must be an absolute URL/],\n [\"ftp://coffee.example.com/mcp\", /must use the http or https scheme/],\n [\"https://coffee.example.com/mcp#frag\", /must not include a fragment/],\n ])(\"rejects the malformed resource %s\", async (resource, message) => {\n const fetchSpy = mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow(\n message,\n );\n expect(fetchSpy).not.toHaveBeenCalled();\n });\n\n it(\"rejects a malformed issuer before any discovery\", async () => {\n const fetchSpy = mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n expect(() =>\n authplaneProvider({\n issuer: \"auth.acme.com\",\n resource: \"https://coffee.example.com/mcp\",\n }),\n ).toThrow(/`issuer` must be an absolute URL/);\n expect(fetchSpy).not.toHaveBeenCalled();\n });\n\n it(\"accepts an issuer carrying a trailing slash\", async () => {\n // Discovery resolves the issuer itself; the provider passes it through\n // rather than rewriting the operator's value.\n mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n await authplaneProvider({\n issuer: `${ISSUER}/`,\n resource: \"https://coffee.example.com/mcp\",\n });\n\n expect(jwks().issuer).toBe(ISSUER);\n });\n\n it(\"accepts a local http issuer for development\", async () => {\n const local = \"http://localhost:9000\";\n mockDiscovery({ [local]: [\"checkout\"] });\n\n await authplaneProvider({\n issuer: local,\n resource: \"http://localhost:3000/mcp\",\n });\n\n expect(jwks().issuer).toBe(local);\n expect(jwks().audience).toBe(\"http://localhost:3000/mcp\");\n });\n\n it(\"forwards scopes and the required-scope floor\", async () => {\n mockDiscovery({ [ISSUER]: [\"checkout\", \"profile\"] });\n\n const config = await authplaneProvider({\n issuer: ISSUER,\n resource: \"https://coffee.example.com/mcp\",\n scopes: [\"checkout\"],\n requiredScopes: [\"checkout\"],\n });\n\n expect(config.scopesSupported).toEqual([\"checkout\"]);\n expect(config.requiredScopes).toEqual([\"checkout\"]);\n });\n\n it(\"advertises the registration endpoint for dynamic client registration\", async () => {\n mockDiscovery({ [ISSUER]: [\"checkout\"] });\n\n const config = await authplaneProvider({\n issuer: ISSUER,\n resource: \"https://coffee.example.com/mcp\",\n });\n\n expect(config.oauthMetadata.issuer).toBe(ISSUER);\n expect(config.oauthMetadata.registration_endpoint).toBe(\n `${ISSUER}/register`,\n );\n });\n});\n"]}
|
|
@@ -1,5 +1,42 @@
|
|
|
1
|
+
import type { ExtraClaims } from "../../auth.js";
|
|
1
2
|
import type { OAuthConfig } from "../index.js";
|
|
3
|
+
import type { RegisteredClaims } from "../verify.js";
|
|
2
4
|
import { type CustomProviderOptions } from "./custom.js";
|
|
5
|
+
/**
|
|
6
|
+
* Claims a Clerk session token (v2) carries. All optional: `o` and `act` appear
|
|
7
|
+
* only with organizations and impersonation, and `email` only when a JWT
|
|
8
|
+
* template adds it.
|
|
9
|
+
*
|
|
10
|
+
* @see https://clerk.com/docs/backend-requests/resources/session-tokens
|
|
11
|
+
* @see https://clerk.com/docs/backend-requests/jwt-templates
|
|
12
|
+
*/
|
|
13
|
+
export type ClerkClaims = {
|
|
14
|
+
/** Session id. */
|
|
15
|
+
sid?: string;
|
|
16
|
+
/** Authorized party: the origin the token was issued to. */
|
|
17
|
+
azp?: string;
|
|
18
|
+
/** Session token version. */
|
|
19
|
+
v?: number;
|
|
20
|
+
/** Factor verification age, `[since-first-factor, since-second-factor]`. */
|
|
21
|
+
fva?: number[];
|
|
22
|
+
/** Active organization, when the user has one. */
|
|
23
|
+
o?: {
|
|
24
|
+
/** Organization id. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Organization slug. */
|
|
27
|
+
slg?: string;
|
|
28
|
+
/** Role within the organization. */
|
|
29
|
+
rol?: string;
|
|
30
|
+
/** Permissions within the organization. */
|
|
31
|
+
per?: string;
|
|
32
|
+
/** Feature-permission map backing `per`. */
|
|
33
|
+
fpm?: string;
|
|
34
|
+
};
|
|
35
|
+
/** Actor, present while a user is being impersonated. */
|
|
36
|
+
act?: Record<string, unknown>;
|
|
37
|
+
/** Only when a JWT template adds it; not a default Clerk claim. */
|
|
38
|
+
email?: string;
|
|
39
|
+
};
|
|
3
40
|
/**
|
|
4
41
|
* OAuth provider for Clerk. `domain` is the Frontend API URL (e.g.
|
|
5
42
|
* `acme.clerk.accounts.dev`, or a production custom domain). Requires Dynamic
|
|
@@ -9,6 +46,6 @@ import { type CustomProviderOptions } from "./custom.js";
|
|
|
9
46
|
* Clerk access tokens carry no `aud` claim, so there is no `audience` option —
|
|
10
47
|
* verification is issuer + JWKS only (matching Clerk's own `mcpAuthClerk`).
|
|
11
48
|
*/
|
|
12
|
-
export declare function clerkProvider(opts: {
|
|
49
|
+
export declare function clerkProvider<TCustom extends ExtraClaims = Record<never, never>>(opts: {
|
|
13
50
|
domain: string;
|
|
14
|
-
} & Omit<CustomProviderOptions, "issuer" | "audience">): Promise<OAuthConfig
|
|
51
|
+
} & Omit<CustomProviderOptions, "issuer" | "audience">): Promise<OAuthConfig<ClerkClaims & TCustom & RegisteredClaims>>;
|
|
@@ -11,6 +11,9 @@ import { toIssuerUrl } from "./shared.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export function clerkProvider(opts) {
|
|
13
13
|
const { domain, ...rest } = opts;
|
|
14
|
-
return customProvider({
|
|
14
|
+
return customProvider({
|
|
15
|
+
issuer: toIssuerUrl(domain),
|
|
16
|
+
...rest,
|
|
17
|
+
});
|
|
15
18
|
}
|
|
16
19
|
//# sourceMappingURL=clerk.js.map
|