@softure-ai/mcp-access 0.1.5 → 0.1.7
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/CHANGELOG.md +23 -0
- package/README.md +110 -21
- package/adoption/move-app-tables.sql +100 -0
- package/dist/contract.d.ts +13 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +51 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -5
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +28 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +28 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +56 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +28 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +3 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +19 -0
- package/dist/next/actions.js.map +1 -1
- package/dist/next/context.js +2 -2
- package/dist/next/context.js.map +1 -1
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +4 -2
- package/dist/next/index.js.map +1 -1
- package/dist/next/oauth-page.d.ts +7 -0
- package/dist/next/oauth-page.d.ts.map +1 -0
- package/dist/next/oauth-page.js +35 -0
- package/dist/next/oauth-page.js.map +1 -0
- package/dist/next/oauth-routes.d.ts +16 -0
- package/dist/next/oauth-routes.d.ts.map +1 -0
- package/dist/next/oauth-routes.js +50 -0
- package/dist/next/oauth-routes.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +13 -2
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +16 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +28 -1
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +486 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +45 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/endpoint.d.ts +2 -4
- package/dist/server/endpoint.d.ts.map +1 -1
- package/dist/server/endpoint.js +10 -7
- package/dist/server/endpoint.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +5 -3
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/oauth-http.d.ts +113 -0
- package/dist/server/oauth-http.d.ts.map +1 -0
- package/dist/server/oauth-http.js +374 -0
- package/dist/server/oauth-http.js.map +1 -0
- package/dist/server/oauth-validation.d.ts +28 -0
- package/dist/server/oauth-validation.d.ts.map +1 -0
- package/dist/server/oauth-validation.js +88 -0
- package/dist/server/oauth-validation.js.map +1 -0
- package/dist/server/oauth.d.ts +106 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +235 -0
- package/dist/server/oauth.js.map +1 -0
- package/dist/server/options.d.ts +8 -0
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +8 -1
- package/dist/server/options.js.map +1 -1
- package/dist/server/pkce.d.ts +7 -0
- package/dist/server/pkce.d.ts.map +1 -0
- package/dist/server/pkce.js +24 -0
- package/dist/server/pkce.js.map +1 -0
- package/dist/server/privacy.d.ts +7 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +19 -9
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/scopes.d.ts +5 -0
- package/dist/server/scopes.d.ts.map +1 -0
- package/dist/server/scopes.js +6 -0
- package/dist/server/scopes.js.map +1 -0
- package/dist/server/tokens.d.ts +18 -6
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +60 -25
- package/dist/server/tokens.js.map +1 -1
- package/dist/ui/consent-form.d.ts +31 -0
- package/dist/ui/consent-form.d.ts.map +1 -0
- package/dist/ui/consent-form.js +24 -0
- package/dist/ui/consent-form.js.map +1 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +16 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +6 -2
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0002_create_oauth_grants.sql +68 -0
- package/module.json +18 -4
- package/package.json +7 -2
- package/src/contract.ts +16 -1
- package/src/index.ts +28 -4
- package/src/messages/en.ts +28 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +19 -1
- package/src/next/context.ts +2 -2
- package/src/next/index.ts +11 -2
- package/src/next/oauth-page.tsx +67 -0
- package/src/next/oauth-routes.ts +65 -0
- package/src/next/pages.tsx +16 -3
- package/src/options.ts +32 -1
- package/src/schema.ts +51 -3
- package/src/server/endpoint.ts +10 -7
- package/src/server/health.ts +5 -3
- package/src/server/index.ts +50 -0
- package/src/server/oauth-http.ts +431 -0
- package/src/server/oauth-validation.ts +106 -0
- package/src/server/oauth.ts +315 -0
- package/src/server/options.ts +16 -1
- package/src/server/pkce.ts +27 -0
- package/src/server/privacy.ts +26 -9
- package/src/server/scopes.ts +6 -0
- package/src/server/tokens.ts +62 -25
- package/src/ui/consent-form.tsx +103 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/token-manager.tsx +79 -3
package/src/server/endpoint.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The MCP endpoint (
|
|
1
|
+
// The MCP endpoint (an adopting app's MCP route, generalised). It takes a Web
|
|
2
2
|
// `Request` and returns a `Response`, so it runs under any fetch-shaped host; the Next adapter only
|
|
3
3
|
// supplies the context. Order: identify the client, count the request, verify the Bearer token,
|
|
4
4
|
// then hand the request to a fresh server from the app's factory. Each refusal happens before the
|
|
@@ -15,12 +15,11 @@ import {
|
|
|
15
15
|
import { errorLogLabel, getModule } from "@softure-ai/core";
|
|
16
16
|
import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
|
|
17
17
|
import type { McpServerIdentity } from "../contract.js";
|
|
18
|
+
import { getProtectedResourceMetadataUrl, isOAuthEnabled } from "./oauth-http.js";
|
|
19
|
+
import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
|
|
18
20
|
import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
export const MCP_READ_SCOPE = "mcp:read";
|
|
22
|
-
/** A token that may write: issued for writes, and the app allows them. */
|
|
23
|
-
export const MCP_WRITE_SCOPE = "mcp:write";
|
|
22
|
+
export { MCP_READ_SCOPE, MCP_WRITE_SCOPE };
|
|
24
23
|
|
|
25
24
|
/** The security bucket the endpoint counts in; `MCP_RATE_LIMIT_BUCKETS` holds its default. */
|
|
26
25
|
export const MCP_RATE_LIMIT_BUCKET = "mcp";
|
|
@@ -89,10 +88,14 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
|
|
|
89
88
|
/**
|
|
90
89
|
* The verified identity as the SDK's `AuthInfo`, or the challenge response. Unknown, revoked,
|
|
91
90
|
* expired and malformed tokens get the same `401 invalid_token`. Expiry is decided by the query on
|
|
92
|
-
* the module's clock, not by the SDK's own check on the wall clock.
|
|
91
|
+
* the module's clock, not by the SDK's own check on the wall clock. With OAuth on, the challenge
|
|
92
|
+
* names the protected resource metadata (RFC 9728 §5.1): that is how claude.ai and ChatGPT find
|
|
93
|
+
* the authorization server.
|
|
93
94
|
*/
|
|
94
95
|
async function authenticate(ctx: McpAccessContext, header: string | null): Promise<AuthInfo | Response> {
|
|
95
|
-
const challenge =
|
|
96
|
+
const challenge = isOAuthEnabled(ctx.config)
|
|
97
|
+
? { requiredScopes: [MCP_READ_SCOPE], resourceMetadataUrl: getProtectedResourceMetadataUrl(ctx.config) }
|
|
98
|
+
: { requiredScopes: [MCP_READ_SCOPE] };
|
|
96
99
|
if (header === null || !BEARER_PREFIX.test(header)) {
|
|
97
100
|
return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Missing Bearer token"), challenge);
|
|
98
101
|
}
|
package/src/server/health.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table
|
|
2
|
-
// and
|
|
1
|
+
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table (with
|
|
2
|
+
// its grant column) and the grants table exist and answer, i.e. `softure migrate` ran every file.
|
|
3
|
+
// It reads no rows.
|
|
3
4
|
import { ok, type HealthCheck } from "@softure-ai/core";
|
|
4
5
|
import type { Queryable } from "@softure-ai/db";
|
|
5
6
|
import { sql } from "drizzle-orm";
|
|
@@ -7,6 +8,7 @@ import { sql } from "drizzle-orm";
|
|
|
7
8
|
export const checkAccessTokensTable: HealthCheck = async (context) => {
|
|
8
9
|
// Core types `db` as unknown; the health route passes the @softure-ai/db handle.
|
|
9
10
|
const db = context.db as Queryable;
|
|
10
|
-
await db.execute(sql`select
|
|
11
|
+
await db.execute(sql`select grant_id from mcp.access_tokens limit 0`);
|
|
12
|
+
await db.execute(sql`select 1 from mcp.oauth_grants join mcp.oauth_clients on oauth_clients.id = oauth_grants.client_id limit 0`);
|
|
11
13
|
return ok();
|
|
12
14
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -11,6 +11,56 @@ export {
|
|
|
11
11
|
type McpServerFactory,
|
|
12
12
|
} from "./endpoint.js";
|
|
13
13
|
export { checkAccessTokensTable } from "./health.js";
|
|
14
|
+
export {
|
|
15
|
+
CONSENT_CODE_PREFIX,
|
|
16
|
+
CLIENT_SECRET_PREFIX,
|
|
17
|
+
createAuthorizationCode,
|
|
18
|
+
exchangeAuthorizationCode,
|
|
19
|
+
findOAuthClient,
|
|
20
|
+
isClientSecretValid,
|
|
21
|
+
listOAuthGrants,
|
|
22
|
+
pruneOAuthRecords,
|
|
23
|
+
REFRESH_TOKEN_PREFIX,
|
|
24
|
+
refreshMcpGrant,
|
|
25
|
+
registerMcpClient,
|
|
26
|
+
revokeOAuthGrant,
|
|
27
|
+
type CreateAuthorizationCodeInput,
|
|
28
|
+
type ExchangeAuthorizationCodeInput,
|
|
29
|
+
type IssuedOAuthTokens,
|
|
30
|
+
type OAuthClientRegistration,
|
|
31
|
+
type PrunedOAuthRecords,
|
|
32
|
+
type RefreshOAuthGrantInput,
|
|
33
|
+
type RegisteredOAuthClient,
|
|
34
|
+
type RevokeOAuthGrantInput,
|
|
35
|
+
} from "./oauth.js";
|
|
36
|
+
export {
|
|
37
|
+
answerOAuthPreflight,
|
|
38
|
+
AUTHORIZATION_PARAMS,
|
|
39
|
+
AUTHORIZATION_SERVER_METADATA_PATH,
|
|
40
|
+
buildClientRedirect,
|
|
41
|
+
getAuthorizationServerMetadata,
|
|
42
|
+
getConsentPath,
|
|
43
|
+
getOAuthIssuer,
|
|
44
|
+
getProtectedResourceMetadata,
|
|
45
|
+
getProtectedResourceMetadataUrl,
|
|
46
|
+
handleAuthorizationDecision,
|
|
47
|
+
handleClientRegistration,
|
|
48
|
+
handleTokenRequest,
|
|
49
|
+
isAcceptableResource,
|
|
50
|
+
isOAuthEnabled,
|
|
51
|
+
isWriteScopeRequested,
|
|
52
|
+
MCP_OAUTH_RATE_LIMIT_BUCKET,
|
|
53
|
+
OAUTH_SCOPES,
|
|
54
|
+
PROTECTED_RESOURCE_METADATA_PATH,
|
|
55
|
+
readAuthorizationParams,
|
|
56
|
+
serveDiscoveryDocument,
|
|
57
|
+
validateAuthorizationRequest,
|
|
58
|
+
type AuthorizationOutcome,
|
|
59
|
+
type AuthorizationPageError,
|
|
60
|
+
type AuthorizationRequest,
|
|
61
|
+
} from "./oauth-http.js";
|
|
62
|
+
export { describeRedirectUri, isAllowedRedirectUri, MAX_CLIENT_NAME_LENGTH, parseClientRegistration, type ClientRegistrationResult, type RegistrationError } from "./oauth-validation.js";
|
|
63
|
+
export { getCodeChallenge, isPkceVerifierValid, isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
|
|
14
64
|
export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
|
|
15
65
|
export {
|
|
16
66
|
deleteMcpAccessUserData,
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// The OAuth 2.1 authorization server's HTTP layer: discovery metadata (RFC 8414, RFC 9728),
|
|
2
|
+
// dynamic client registration (RFC 7591), the authorization request and the consent decision
|
|
3
|
+
// (RFC 6749 §4.1, PKCE, RFC 9207 `iss`), and the token endpoint with rotated refresh tokens. Each
|
|
4
|
+
// handler takes a Web `Request` and returns a `Response`, so any fetch-shaped host can mount it;
|
|
5
|
+
// the Next adapter only supplies the context and the session.
|
|
6
|
+
import { errorLogLabel, getModule, type SoftureConfig } from "@softure-ai/core";
|
|
7
|
+
import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
|
|
8
|
+
import type { OAuthClientRow } from "../schema.js";
|
|
9
|
+
import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
|
|
10
|
+
import {
|
|
11
|
+
createAuthorizationCode,
|
|
12
|
+
exchangeAuthorizationCode,
|
|
13
|
+
findOAuthClient,
|
|
14
|
+
isClientSecretValid,
|
|
15
|
+
refreshMcpGrant,
|
|
16
|
+
registerMcpClient,
|
|
17
|
+
type IssuedOAuthTokens,
|
|
18
|
+
} from "./oauth.js";
|
|
19
|
+
import { parseClientRegistration } from "./oauth-validation.js";
|
|
20
|
+
import { getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "./options.js";
|
|
21
|
+
import { isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
|
|
22
|
+
import type { McpAccessContext } from "./tokens.js";
|
|
23
|
+
|
|
24
|
+
/** The security bucket OAuth registration and token requests count in. */
|
|
25
|
+
export const MCP_OAUTH_RATE_LIMIT_BUCKET = "mcp-oauth";
|
|
26
|
+
|
|
27
|
+
/** RFC 8414 §3: the authorization server metadata, at the root of the issuer. */
|
|
28
|
+
export const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
29
|
+
/** RFC 9728 §3: protected resource metadata; the endpoint's own variant appends its path. */
|
|
30
|
+
export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
31
|
+
|
|
32
|
+
export const OAUTH_SCOPES = [MCP_READ_SCOPE, MCP_WRITE_SCOPE] as const;
|
|
33
|
+
|
|
34
|
+
/** The parameters the consent form carries to the decision in hidden fields. */
|
|
35
|
+
export const AUTHORIZATION_PARAMS = ["response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "resource"] as const;
|
|
36
|
+
|
|
37
|
+
/** Values longer than this are not echoed into a form or a redirect. */
|
|
38
|
+
const MAX_PARAM_LENGTH = 2048;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* CORS `*` is safe on these routes because none reads a cookie: registration and token
|
|
42
|
+
* authenticate the client by what it sent. A browser-based MCP client cannot sign in without it.
|
|
43
|
+
*/
|
|
44
|
+
const CORS_HEADERS = {
|
|
45
|
+
"access-control-allow-origin": "*",
|
|
46
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
47
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version",
|
|
48
|
+
};
|
|
49
|
+
/** Responses that carry credentials must not be stored (RFC 6749 §5.1). */
|
|
50
|
+
const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" };
|
|
51
|
+
/** Discovery documents change only with a deploy. */
|
|
52
|
+
const DISCOVERY_CACHE = { "cache-control": "public, max-age=300" };
|
|
53
|
+
|
|
54
|
+
function protocolJson(body: unknown, status = 200, extra: Readonly<Record<string, string>> = {}): Response {
|
|
55
|
+
return Response.json(body, { status, headers: { ...CORS_HEADERS, ...NO_STORE, ...extra } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An error in the shape of RFC 6749 §5.2 / RFC 7591 §3.2.2. */
|
|
59
|
+
function protocolError(error: string, description: string, status = 400, extra: Readonly<Record<string, string>> = {}): Response {
|
|
60
|
+
return protocolJson({ error, error_description: description }, status, extra);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `OPTIONS` on the registration, token and discovery routes. */
|
|
64
|
+
export function answerOAuthPreflight(): Response {
|
|
65
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------------------------
|
|
69
|
+
// Discovery
|
|
70
|
+
|
|
71
|
+
/** The issuer: the app's origin, without a path or a trailing slash. */
|
|
72
|
+
export function getOAuthIssuer(config: SoftureConfig): string {
|
|
73
|
+
return new URL(config.appOrigin).origin;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The URL the endpoint's `401` names in `resource_metadata` (RFC 9728 §5.1). */
|
|
77
|
+
export function getProtectedResourceMetadataUrl(config: SoftureConfig): string {
|
|
78
|
+
return `${getOAuthIssuer(config)}${PROTECTED_RESOURCE_METADATA_PATH}${new URL(getMcpEndpointUrl(config)).pathname}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the app turned OAuth on; the OAuth routes answer 404 otherwise. */
|
|
82
|
+
export function isOAuthEnabled(config: SoftureConfig): boolean {
|
|
83
|
+
return getMcpAccessOptions(config).oauth.enabled;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Authorization server metadata (RFC 8414 §2). Tokens are opaque: no `jwks_uri`, no OpenID. */
|
|
87
|
+
export function getAuthorizationServerMetadata(config: SoftureConfig) {
|
|
88
|
+
const issuer = getOAuthIssuer(config);
|
|
89
|
+
const routes = getMcpAccessRoutes(config);
|
|
90
|
+
return {
|
|
91
|
+
issuer,
|
|
92
|
+
authorization_endpoint: `${issuer}${routes.oauthConsent}`,
|
|
93
|
+
token_endpoint: `${issuer}${routes.oauthToken}`,
|
|
94
|
+
registration_endpoint: `${issuer}${routes.oauthRegister}`,
|
|
95
|
+
scopes_supported: [...OAUTH_SCOPES],
|
|
96
|
+
response_types_supported: ["code"],
|
|
97
|
+
response_modes_supported: ["query"],
|
|
98
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
99
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"],
|
|
100
|
+
// MCP clients refuse an authorization server that does not list S256.
|
|
101
|
+
code_challenge_methods_supported: [PKCE_METHOD],
|
|
102
|
+
authorization_response_iss_parameter_supported: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Protected resource metadata (RFC 9728 §2). At the endpoint's own path the resource is the
|
|
108
|
+
* endpoint URL; at the root it is the origin, because RFC 9728 §3.3 ties `resource` to the URL the
|
|
109
|
+
* metadata URL was built from.
|
|
110
|
+
*/
|
|
111
|
+
export function getProtectedResourceMetadata(config: SoftureConfig, variant: "endpoint" | "root") {
|
|
112
|
+
return {
|
|
113
|
+
resource: variant === "endpoint" ? getMcpEndpointUrl(config) : getOAuthIssuer(config),
|
|
114
|
+
authorization_servers: [getOAuthIssuer(config)],
|
|
115
|
+
scopes_supported: [...OAUTH_SCOPES],
|
|
116
|
+
bearer_methods_supported: ["header"],
|
|
117
|
+
resource_name: getMcpAccessOptions(config).serverName,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `GET` of a discovery document; 404 while OAuth is off. */
|
|
122
|
+
export function serveDiscoveryDocument(config: SoftureConfig, document: (config: SoftureConfig) => unknown): Response {
|
|
123
|
+
if (!isOAuthEnabled(config)) return Response.json({ error: "not_found" }, { status: 404, headers: CORS_HEADERS });
|
|
124
|
+
return Response.json(document(config), { headers: { ...CORS_HEADERS, ...DISCOVERY_CACHE } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether `resource` (RFC 8707) names this server: absent (clients from before RFC 8707), the
|
|
129
|
+
* endpoint URL or the origin, each with or without a trailing slash.
|
|
130
|
+
*/
|
|
131
|
+
export function isAcceptableResource(config: SoftureConfig, resource: string | null): boolean {
|
|
132
|
+
if (resource === null || resource === "") return true;
|
|
133
|
+
const endpoint = getMcpEndpointUrl(config).replace(/\/+$/, "");
|
|
134
|
+
const origin = getOAuthIssuer(config);
|
|
135
|
+
return [endpoint, `${endpoint}/`, origin, `${origin}/`].includes(resource);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether the client asks to write. Unknown scopes are ignored (RFC 6749 §3.3); every grant reads. */
|
|
139
|
+
export function isWriteScopeRequested(scope: string | null): boolean {
|
|
140
|
+
return (scope ?? "").split(/\s+/).includes(MCP_WRITE_SCOPE);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------------------------
|
|
144
|
+
// Rate limit
|
|
145
|
+
|
|
146
|
+
/** A missing bucket is a setup bug: thrown, so it reaches the log by name instead of a quiet 503. */
|
|
147
|
+
function assertOAuthRateLimitBucket(config: SoftureConfig): void {
|
|
148
|
+
const options = getModule(config, "security")?.options as { buckets?: Record<string, unknown> } | undefined;
|
|
149
|
+
if (options?.buckets?.[MCP_OAUTH_RATE_LIMIT_BUCKET] === undefined) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`@softure-ai/mcp-access: the security module has no "${MCP_OAUTH_RATE_LIMIT_BUCKET}" bucket; spread MCP_RATE_LIMIT_BUCKETS into security({ buckets })`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Counts the request in the OAuth bucket before any work: per client address, and for the token
|
|
158
|
+
* endpoint per address and client id (an assistant platform refreshes the tokens of all its users
|
|
159
|
+
* from a few shared addresses, and each connection registers its own client). A failing counter
|
|
160
|
+
* closes (503) and says nothing.
|
|
161
|
+
*/
|
|
162
|
+
async function limitProtocolRequest(ctx: McpAccessContext, request: Request, clientId: string | null = null): Promise<Response | null> {
|
|
163
|
+
assertOAuthRateLimitBucket(ctx.config);
|
|
164
|
+
const client = identifyClient(ctx, request.headers);
|
|
165
|
+
if (!client.ok) return protocolError("invalid_request", "The client address could not be identified.", 400);
|
|
166
|
+
const key = clientId === null ? client.value : `${client.value}|${clientId.slice(0, 64)}`;
|
|
167
|
+
try {
|
|
168
|
+
const limit = await consumeRateLimit(ctx, { bucket: MCP_OAUTH_RATE_LIMIT_BUCKET, key });
|
|
169
|
+
if (limit.ok) return null;
|
|
170
|
+
return protocolError("too_many_requests", "Too many requests; try again later.", 429, { "retry-after": String(limit.retryAfterSeconds) });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(`@softure-ai/mcp-access: counting an OAuth request failed: ${errorLogLabel(error)}`);
|
|
173
|
+
return protocolError("temporarily_unavailable", "The server is temporarily unavailable.", 503);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------------------------
|
|
178
|
+
// Registration
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `POST` dynamic client registration (RFC 7591). Public by definition: claude.ai and ChatGPT
|
|
182
|
+
* register before anyone signs in. A registration grants nothing; access needs a person's consent.
|
|
183
|
+
*/
|
|
184
|
+
export async function handleClientRegistration(ctx: McpAccessContext, request: Request): Promise<Response> {
|
|
185
|
+
if (!isOAuthEnabled(ctx.config)) return protocolError("not_found", "OAuth is not enabled.", 404);
|
|
186
|
+
const limited = await limitProtocolRequest(ctx, request);
|
|
187
|
+
if (limited !== null) return limited;
|
|
188
|
+
|
|
189
|
+
let body: unknown;
|
|
190
|
+
try {
|
|
191
|
+
body = await request.json();
|
|
192
|
+
} catch {
|
|
193
|
+
return protocolError("invalid_client_metadata", "The request body must be a JSON object.");
|
|
194
|
+
}
|
|
195
|
+
const parsed = parseClientRegistration(body);
|
|
196
|
+
if (!parsed.ok) return protocolError(parsed.error.error, parsed.error.description);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const { client, clientSecret } = await registerMcpClient(ctx, parsed.value);
|
|
200
|
+
return protocolJson(
|
|
201
|
+
{
|
|
202
|
+
client_id: client.clientId,
|
|
203
|
+
client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000),
|
|
204
|
+
client_name: client.clientName,
|
|
205
|
+
redirect_uris: client.redirectUris,
|
|
206
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
207
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
208
|
+
response_types: ["code"],
|
|
209
|
+
...(clientSecret === null ? {} : { client_secret: clientSecret, client_secret_expires_at: 0 }),
|
|
210
|
+
},
|
|
211
|
+
201,
|
|
212
|
+
);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error(`@softure-ai/mcp-access: registering an OAuth client failed: ${errorLogLabel(error)}`);
|
|
215
|
+
return protocolError("server_error", "The client could not be registered.", 500);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------------------------
|
|
220
|
+
// Token endpoint
|
|
221
|
+
|
|
222
|
+
interface ClientCredentials {
|
|
223
|
+
readonly clientId: string | null;
|
|
224
|
+
readonly clientSecret: string | null;
|
|
225
|
+
readonly isBasic: boolean;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `Basic` (RFC 6749 §2.3.1: both parts form-urlencoded) or the body fields; a public client sends its id only. */
|
|
229
|
+
function readClientCredentials(request: Request, form: URLSearchParams): ClientCredentials {
|
|
230
|
+
const header = request.headers.get("authorization");
|
|
231
|
+
if (header?.toLowerCase().startsWith("basic ") === true) {
|
|
232
|
+
try {
|
|
233
|
+
const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
|
|
234
|
+
const separator = decoded.indexOf(":");
|
|
235
|
+
if (separator > 0) {
|
|
236
|
+
const decode = (part: string) => decodeURIComponent(part.replace(/\+/g, " "));
|
|
237
|
+
return { clientId: decode(decoded.slice(0, separator)), clientSecret: decode(decoded.slice(separator + 1)), isBasic: true };
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// A broken header is no credentials; the caller refuses.
|
|
241
|
+
}
|
|
242
|
+
return { clientId: null, clientSecret: null, isBasic: true };
|
|
243
|
+
}
|
|
244
|
+
return { clientId: form.get("client_id"), clientSecret: form.get("client_secret"), isBasic: false };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function tokenResponse(tokens: IssuedOAuthTokens): Response {
|
|
248
|
+
return protocolJson({
|
|
249
|
+
access_token: tokens.accessToken,
|
|
250
|
+
token_type: "Bearer",
|
|
251
|
+
expires_in: tokens.expiresIn,
|
|
252
|
+
refresh_token: tokens.refreshToken,
|
|
253
|
+
scope: tokens.canWrite ? `${MCP_READ_SCOPE} ${MCP_WRITE_SCOPE}` : MCP_READ_SCOPE,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One answer for every refused code or refresh token: the client restarts either way. */
|
|
258
|
+
const INVALID_GRANT = "The code or refresh token is invalid, expired, used or issued to another client.";
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `POST` token endpoint (RFC 6749 §3.2): a code with its PKCE verifier, or a refresh token with
|
|
262
|
+
* rotation. Secrets come in the body, never in the URL.
|
|
263
|
+
*/
|
|
264
|
+
export async function handleTokenRequest(ctx: McpAccessContext, request: Request): Promise<Response> {
|
|
265
|
+
if (!isOAuthEnabled(ctx.config)) return protocolError("not_found", "OAuth is not enabled.", 404);
|
|
266
|
+
const form = new URLSearchParams(await request.text().catch(() => ""));
|
|
267
|
+
const credentials = readClientCredentials(request, form);
|
|
268
|
+
const limited = await limitProtocolRequest(ctx, request, credentials.clientId);
|
|
269
|
+
if (limited !== null) return limited;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const client = credentials.clientId === null ? null : await findOAuthClient(ctx, credentials.clientId);
|
|
273
|
+
if (client === null || !isClientSecretValid(client, credentials.clientSecret === "" ? null : credentials.clientSecret)) {
|
|
274
|
+
return protocolError("invalid_client", "Unknown client or wrong client credentials.", 401, credentials.isBasic ? { "www-authenticate": 'Basic realm="mcp"' } : {});
|
|
275
|
+
}
|
|
276
|
+
return await answerGrant(ctx, form, client);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error(`@softure-ai/mcp-access: the OAuth token endpoint failed: ${errorLogLabel(error)}`);
|
|
279
|
+
return protocolError("server_error", "The token could not be issued.", 500);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function answerGrant(ctx: McpAccessContext, form: URLSearchParams, client: OAuthClientRow): Promise<Response> {
|
|
284
|
+
if (!isAcceptableResource(ctx.config, form.get("resource"))) {
|
|
285
|
+
return protocolError("invalid_target", "The resource parameter does not name this MCP server.");
|
|
286
|
+
}
|
|
287
|
+
const grantType = form.get("grant_type");
|
|
288
|
+
if (grantType === "authorization_code") {
|
|
289
|
+
const code = form.get("code");
|
|
290
|
+
const codeVerifier = form.get("code_verifier");
|
|
291
|
+
if (code === null || code === "" || codeVerifier === null || codeVerifier === "") {
|
|
292
|
+
return protocolError("invalid_request", "code and code_verifier (PKCE) are required.");
|
|
293
|
+
}
|
|
294
|
+
// A missing redirect_uri is allowed when the client registered exactly one (RFC 6749 §4.1.3).
|
|
295
|
+
const tokens = await exchangeAuthorizationCode(ctx, { code, client, redirectUri: form.get("redirect_uri"), codeVerifier });
|
|
296
|
+
return tokens === null ? protocolError("invalid_grant", INVALID_GRANT) : tokenResponse(tokens);
|
|
297
|
+
}
|
|
298
|
+
if (grantType === "refresh_token") {
|
|
299
|
+
const refreshToken = form.get("refresh_token");
|
|
300
|
+
if (refreshToken === null || refreshToken === "") return protocolError("invalid_request", "refresh_token is required.");
|
|
301
|
+
const tokens = await refreshMcpGrant(ctx, { refreshToken, client });
|
|
302
|
+
return tokens === null ? protocolError("invalid_grant", INVALID_GRANT) : tokenResponse(tokens);
|
|
303
|
+
}
|
|
304
|
+
return protocolError("unsupported_grant_type", "Supported grant types: authorization_code and refresh_token.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------------------------
|
|
308
|
+
// Authorization request and decision
|
|
309
|
+
|
|
310
|
+
export interface AuthorizationRequest {
|
|
311
|
+
readonly client: OAuthClientRow;
|
|
312
|
+
readonly redirectUri: string;
|
|
313
|
+
readonly state: string | null;
|
|
314
|
+
readonly codeChallenge: string;
|
|
315
|
+
/** The client asked for `mcp:write`. */
|
|
316
|
+
readonly isWriteRequested: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Why the consent page shows an error instead of sending one back: the client is not confirmed. */
|
|
320
|
+
export type AuthorizationPageError = "unknown_client" | "redirect_mismatch";
|
|
321
|
+
|
|
322
|
+
export type AuthorizationOutcome =
|
|
323
|
+
| { readonly kind: "show-error"; readonly reason: AuthorizationPageError }
|
|
324
|
+
| { readonly kind: "redirect-error"; readonly location: string; readonly redirectUri: string }
|
|
325
|
+
| { readonly kind: "valid"; readonly request: AuthorizationRequest };
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The client's redirect URI with parameters added, keeping the query it registered (RFC 6749
|
|
329
|
+
* §3.1.2), and always `iss` (RFC 9207), so the client knows the answer came from this server.
|
|
330
|
+
*/
|
|
331
|
+
export function buildClientRedirect(config: SoftureConfig, redirectUri: string, params: Readonly<Record<string, string | null>>): string {
|
|
332
|
+
const url = new URL(redirectUri);
|
|
333
|
+
for (const [key, value] of Object.entries({ ...params, iss: getOAuthIssuer(config) })) {
|
|
334
|
+
if (value !== null) url.searchParams.set(key, value);
|
|
335
|
+
}
|
|
336
|
+
return url.toString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The authorization parameters of a query string or form, each at most 2048 characters. */
|
|
340
|
+
export function readAuthorizationParams(source: { get(name: string): unknown }): URLSearchParams {
|
|
341
|
+
const params = new URLSearchParams();
|
|
342
|
+
for (const name of AUTHORIZATION_PARAMS) {
|
|
343
|
+
const value = source.get(name);
|
|
344
|
+
if (typeof value === "string" && value.length <= MAX_PARAM_LENGTH) params.set(name, value);
|
|
345
|
+
}
|
|
346
|
+
return params;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validates an authorization request (RFC 6749 §4.1.1), for the consent page and the decision.
|
|
351
|
+
* Order is a security rule (RFC 6749 §4.1.2.1): until the client and its redirect URI are
|
|
352
|
+
* confirmed, an error is shown to the person, never redirected, or the server would redirect
|
|
353
|
+
* anyone anywhere. Only then do errors go back to the client.
|
|
354
|
+
*/
|
|
355
|
+
export async function validateAuthorizationRequest(ctx: McpAccessContext, params: URLSearchParams): Promise<AuthorizationOutcome> {
|
|
356
|
+
const clientId = params.get("client_id");
|
|
357
|
+
const client = clientId === null ? null : await findOAuthClient(ctx, clientId);
|
|
358
|
+
if (client === null) return { kind: "show-error", reason: "unknown_client" };
|
|
359
|
+
|
|
360
|
+
const redirectUri = params.get("redirect_uri") ?? (client.redirectUris.length === 1 ? (client.redirectUris[0] ?? null) : null);
|
|
361
|
+
if (redirectUri === null || !client.redirectUris.includes(redirectUri)) return { kind: "show-error", reason: "redirect_mismatch" };
|
|
362
|
+
|
|
363
|
+
const state = params.get("state");
|
|
364
|
+
const back = (error: string, description: string): AuthorizationOutcome => ({
|
|
365
|
+
kind: "redirect-error",
|
|
366
|
+
redirectUri,
|
|
367
|
+
location: buildClientRedirect(ctx.config, redirectUri, { error, error_description: description, state }),
|
|
368
|
+
});
|
|
369
|
+
if (params.get("response_type") !== "code") return back("unsupported_response_type", "Only response_type=code is supported.");
|
|
370
|
+
const codeChallenge = params.get("code_challenge");
|
|
371
|
+
if (codeChallenge === null || !isValidCodeChallenge(codeChallenge) || params.get("code_challenge_method") !== PKCE_METHOD) {
|
|
372
|
+
return back("invalid_request", "PKCE is required: code_challenge with code_challenge_method S256.");
|
|
373
|
+
}
|
|
374
|
+
if (!isAcceptableResource(ctx.config, params.get("resource"))) return back("invalid_target", "The resource parameter does not name this MCP server.");
|
|
375
|
+
return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteScopeRequested(params.get("scope")) } };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The consent page with the request's parameters: where the decision sends the person back. */
|
|
379
|
+
export function getConsentPath(config: SoftureConfig, params: URLSearchParams): string {
|
|
380
|
+
return `${getMcpAccessRoutes(config).oauthConsent}?${params.toString()}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function seeOther(location: string): Response {
|
|
384
|
+
return new Response(null, { status: 303, headers: { location, "cache-control": "no-store" } });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Whether the form was posted from this app: `Origin` must be the app's origin. */
|
|
388
|
+
function isSameOrigin(config: SoftureConfig, request: Request): boolean {
|
|
389
|
+
const origin = request.headers.get("origin");
|
|
390
|
+
return origin !== null && origin === getOAuthIssuer(config);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* `POST` of the consent form, answered with `303`: a plain form post, not a server action, because
|
|
395
|
+
* the answer is a redirect to the client, possibly a native app scheme, and must work without
|
|
396
|
+
* JavaScript. The form must come from the app's origin (the session cookie's `SameSite=Lax` is the
|
|
397
|
+
* first layer), and the request is validated again from the posted fields. `userId` is the
|
|
398
|
+
* session's user, or null: then the person goes back to the consent page, which asks them to sign in.
|
|
399
|
+
*/
|
|
400
|
+
export async function handleAuthorizationDecision(ctx: McpAccessContext, request: Request, userId: string | null): Promise<Response> {
|
|
401
|
+
if (!isOAuthEnabled(ctx.config)) return new Response(null, { status: 404 });
|
|
402
|
+
if (!isSameOrigin(ctx.config, request)) return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
|
|
403
|
+
const form = await request.formData().catch(() => null);
|
|
404
|
+
if (form === null) return new Response(null, { status: 400, headers: { "cache-control": "no-store" } });
|
|
405
|
+
|
|
406
|
+
const params = readAuthorizationParams(form);
|
|
407
|
+
const consent = getConsentPath(ctx.config, params);
|
|
408
|
+
if (userId === null) return seeOther(consent);
|
|
409
|
+
try {
|
|
410
|
+
const outcome = await validateAuthorizationRequest(ctx, params);
|
|
411
|
+
if (outcome.kind === "show-error") return seeOther(consent);
|
|
412
|
+
if (outcome.kind === "redirect-error") return seeOther(outcome.location);
|
|
413
|
+
|
|
414
|
+
const { client, redirectUri, state, codeChallenge, isWriteRequested } = outcome.request;
|
|
415
|
+
if (form.get("decision") !== "allow") {
|
|
416
|
+
return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }));
|
|
417
|
+
}
|
|
418
|
+
const code = await createAuthorizationCode(ctx, {
|
|
419
|
+
clientRowId: client.id,
|
|
420
|
+
userId,
|
|
421
|
+
redirectUri,
|
|
422
|
+
codeChallenge,
|
|
423
|
+
// Writes need the client's request, the app's allowWrites and the person's tick.
|
|
424
|
+
canWrite: isWriteRequested && getMcpAccessOptions(ctx.config).allowWrites && form.get("canWrite") !== null,
|
|
425
|
+
});
|
|
426
|
+
return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error(`@softure-ai/mcp-access: recording an OAuth decision failed: ${errorLogLabel(error)}`);
|
|
429
|
+
return new Response(null, { status: 500, headers: { "cache-control": "no-store" } });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The OAuth boundary: registration metadata, redirect URIs, scopes and `resource`. Everything
|
|
2
|
+
// enters as `unknown` or a raw string and leaves as a result; an expected refusal is a value.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { OAuthTokenEndpointAuthMethod } from "../schema.js";
|
|
5
|
+
import type { OAuthClientRegistration } from "./oauth.js";
|
|
6
|
+
|
|
7
|
+
/** The longest client name: it is also the name of the client's access tokens (1-60). */
|
|
8
|
+
export const MAX_CLIENT_NAME_LENGTH = 60;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schemes never accepted as a redirect URI: they run code or read local resources in the person's
|
|
12
|
+
* browser, or travel unencrypted. Other non-HTTP schemes are native app schemes (RFC 8252 §7.1,
|
|
13
|
+
* e.g. `cursor://`, `vscode://`), which desktop MCP clients return through.
|
|
14
|
+
*/
|
|
15
|
+
const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "vbscript:", "file:", "blob:", "about:", "ws:", "wss:", "ftp:"]);
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
17
|
+
const AUTH_METHODS = ["none", "client_secret_post", "client_secret_basic"] as const satisfies readonly OAuthTokenEndpointAuthMethod[];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a URI may be a `redirect_uri`: `https` anywhere, `http` only on loopback (RFC 8252
|
|
21
|
+
* §7.3), a native app scheme outside the deny list; never with a fragment (RFC 6749 §3.1.2).
|
|
22
|
+
*/
|
|
23
|
+
export function isAllowedRedirectUri(candidate: string): boolean {
|
|
24
|
+
if (candidate.includes("#")) return false;
|
|
25
|
+
let url: URL;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(candidate);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (url.protocol === "https:") return url.hostname !== "";
|
|
32
|
+
if (url.protocol === "http:") return LOOPBACK_HOSTS.has(url.hostname);
|
|
33
|
+
return !FORBIDDEN_SCHEMES.has(url.protocol);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A name without control or format characters, trimmed and cut: it is shown on the consent page. */
|
|
37
|
+
function cleanClientName(name: string): string {
|
|
38
|
+
return name
|
|
39
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
40
|
+
.trim()
|
|
41
|
+
.slice(0, MAX_CLIENT_NAME_LENGTH)
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const registrationSchema = z.object({
|
|
46
|
+
redirect_uris: z.array(z.string().max(2000)).min(1).max(10),
|
|
47
|
+
client_name: z.string().max(200).optional(),
|
|
48
|
+
token_endpoint_auth_method: z.enum(AUTH_METHODS).optional(),
|
|
49
|
+
grant_types: z.array(z.enum(["authorization_code", "refresh_token"])).optional(),
|
|
50
|
+
response_types: z.array(z.literal("code")).optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export interface RegistrationError {
|
|
54
|
+
readonly error: "invalid_redirect_uri" | "invalid_client_metadata";
|
|
55
|
+
readonly description: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Registration metadata (RFC 7591 §2). The default method is `client_secret_basic`, as the RFC
|
|
60
|
+
* says; MCP clients name `none` themselves. Without a name the client is called after the host of
|
|
61
|
+
* its first redirect URI, so the consent page has something to say.
|
|
62
|
+
*/
|
|
63
|
+
export type ClientRegistrationResult =
|
|
64
|
+
| { readonly ok: true; readonly value: OAuthClientRegistration }
|
|
65
|
+
| { readonly ok: false; readonly error: RegistrationError };
|
|
66
|
+
|
|
67
|
+
export function parseClientRegistration(body: unknown): ClientRegistrationResult {
|
|
68
|
+
const parsed = registrationSchema.safeParse(body);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
const isRedirectIssue = parsed.error.issues.some((issue) => issue.path[0] === "redirect_uris");
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: isRedirectIssue
|
|
74
|
+
? { error: "invalid_redirect_uri", description: "redirect_uris must list 1 to 10 URIs." }
|
|
75
|
+
: { error: "invalid_client_metadata", description: `Invalid client metadata: ${parsed.error.issues.map((issue) => issue.path.join(".")).join(", ")}.` },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const redirectUris = parsed.data.redirect_uris;
|
|
79
|
+
if (!redirectUris.every(isAllowedRedirectUri)) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: {
|
|
83
|
+
error: "invalid_redirect_uri",
|
|
84
|
+
description: "A redirect URI must be https, http on a loopback host or a native app scheme, without a fragment.",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const name = cleanClientName(parsed.data.client_name ?? "");
|
|
89
|
+
// The first URI parsed above; a native scheme without a host falls back to its scheme.
|
|
90
|
+
const firstUri = new URL(redirectUris[0] ?? "");
|
|
91
|
+
const fallbackName = cleanClientName(firstUri.host === "" ? firstUri.protocol.replace(/:$/, "") : firstUri.host);
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
value: {
|
|
95
|
+
clientName: name !== "" ? name : fallbackName,
|
|
96
|
+
redirectUris,
|
|
97
|
+
tokenEndpointAuthMethod: parsed.data.token_endpoint_auth_method ?? "client_secret_basic",
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The URL a native client returns to, as it may be shown: the host for http(s), else the whole URI. */
|
|
103
|
+
export function describeRedirectUri(redirectUri: string): string {
|
|
104
|
+
const url = new URL(redirectUri);
|
|
105
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.host : redirectUri;
|
|
106
|
+
}
|