@12-apps/mcp 1.19.0 → 2.0.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.
@@ -0,0 +1,215 @@
1
+ import { SignJWT, jwtVerify, importJWK, type JWTPayload } from "jose";
2
+
3
+ import { issuer } from "./config";
4
+ import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
5
+
6
+ /**
7
+ * Stateless authorization-code mint/verify (12-23, ported from future-pay's
8
+ * `lib/mcp/oauth/authorization-code.ts` — behaviour unchanged; the signing key
9
+ * arrives through a provider instead of an env read).
10
+ *
11
+ * The authorization code is a short-lived (<=60s) ES256-signed JWT — no DB table,
12
+ * no cleanup job. It binds the signed-in user (`sub`/`email`), the `client_id`,
13
+ * the `redirect_uri`, the PKCE `code_challenge`, and the requested `scope`, plus a
14
+ * unique `jti` the token endpoint records once to enforce single-use (replay)
15
+ * semantics on top of the short expiry.
16
+ *
17
+ * The code carries a DISTINCT audience (`oauth:code`) from the access token
18
+ * (`${origin}/api/mcp`), so a code can never be presented to the resource server
19
+ * as a bearer access token (and vice versa): {@link verifyCode} pins
20
+ * `audience: "oauth:code"`, and the access-token verifier pins the resource
21
+ * audience — each rejects the other's blobs.
22
+ */
23
+
24
+ /**
25
+ * Audience pinning the code to the OAuth code-exchange step only. Distinct from
26
+ * the access-token audience so a code cannot be replayed as an access token.
27
+ */
28
+ export const AUTHORIZATION_CODE_AUDIENCE = "oauth:code";
29
+
30
+ /** Authorization-code lifetime — single-use and short-lived (<=60s per spec). */
31
+ export const AUTHORIZATION_CODE_TTL_SECONDS = 60;
32
+
33
+ /** Clock skew tolerated on `exp`/`iat` validation, in seconds. */
34
+ const CLOCK_TOLERANCE_SECONDS = 5;
35
+
36
+ /** Fields bound into a minted authorization code. */
37
+ export interface MintCodeInput {
38
+ /** The OAuth subject bound to the code (identity from the cookie session). */
39
+ sub: string;
40
+ /** The signed-in user's email (the identity all downstream tokens bind to). */
41
+ email: string;
42
+ /** The OAuth client the code is issued to. */
43
+ clientId: string;
44
+ /** The exact registered redirect URI the flow started with. */
45
+ redirectUri: string;
46
+ /** The PKCE S256 `code_challenge` the token endpoint verifies against. */
47
+ codeChallenge: string;
48
+ /** The requested scope (space-delimited), carried through to the token. */
49
+ scope: string;
50
+ /** The deployment origin — derives the code's `iss`. */
51
+ origin: string;
52
+ }
53
+
54
+ /** The bound fields a verified authorization code resolves to. */
55
+ export interface VerifiedAuthorizationCode {
56
+ sub: string;
57
+ email: string;
58
+ clientId: string;
59
+ redirectUri: string;
60
+ codeChallenge: string;
61
+ scope: string;
62
+ /** The one-time identifier the token endpoint records to enforce single-use. */
63
+ jti: string;
64
+ }
65
+
66
+ /** The single failure discriminator for the OAuth token endpoint. */
67
+ export type AuthorizationCodeErrorCode = "invalid_grant";
68
+
69
+ /**
70
+ * A typed authorization-code failure. Every rejection (expired, wrong-audience,
71
+ * wrong-issuer, tampered, bad-signature, unconfigured key) surfaces as
72
+ * `invalid_grant` per RFC 6749 §5.2 for the token endpoint.
73
+ */
74
+ export class AuthorizationCodeError extends Error {
75
+ readonly code: AuthorizationCodeErrorCode;
76
+
77
+ constructor(message?: string) {
78
+ super(message ?? "invalid_grant");
79
+ this.name = "AuthorizationCodeError";
80
+ this.code = "invalid_grant";
81
+ }
82
+ }
83
+
84
+ /** Deterministic-clock option shared by mint + verify. */
85
+ interface ClockOption {
86
+ /** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
87
+ now?: number;
88
+ }
89
+
90
+ /** The JWT claim shape of an authorization code. */
91
+ interface AuthorizationCodeClaims extends JWTPayload {
92
+ email: string;
93
+ client_id: string;
94
+ redirect_uri: string;
95
+ code_challenge: string;
96
+ scope: string;
97
+ }
98
+
99
+ function nowSeconds(now?: number): number {
100
+ return Math.floor((now ?? Date.now()) / 1000);
101
+ }
102
+
103
+ /** Read a required string claim, or `null` when absent/wrong-typed. */
104
+ function stringClaim(payload: JWTPayload, key: string): string | null {
105
+ const value = payload[key];
106
+ return typeof value === "string" ? value : null;
107
+ }
108
+
109
+ /**
110
+ * Extract the bound fields from an already-signature/iss/aud/exp-validated code
111
+ * payload, enforcing that every required claim is a present string (`scope` may be
112
+ * the empty string but must be present). Throws {@link AuthorizationCodeError}
113
+ * when any required bound field is missing or wrong-typed.
114
+ */
115
+ function extractBoundFields(payload: JWTPayload): VerifiedAuthorizationCode {
116
+ const sub = stringClaim(payload, "sub");
117
+ const email = stringClaim(payload, "email");
118
+ const clientId = stringClaim(payload, "client_id");
119
+ const redirectUri = stringClaim(payload, "redirect_uri");
120
+ const codeChallenge = stringClaim(payload, "code_challenge");
121
+ const scope = stringClaim(payload, "scope");
122
+ const jti = stringClaim(payload, "jti");
123
+
124
+ if (!sub || !email || !clientId || !redirectUri || !codeChallenge || scope === null || !jti) {
125
+ throw new AuthorizationCodeError("code is missing required bound fields");
126
+ }
127
+
128
+ return { sub, email, clientId, redirectUri, codeChallenge, scope, jti };
129
+ }
130
+
131
+ /**
132
+ * Mint a single-use, stateless authorization code bound to the flow inputs.
133
+ *
134
+ * Returns `null` when no signing key is configured (safe-by-default: the AS
135
+ * refuses to issue rather than falling back to a weaker mode). Sets the `kid`
136
+ * header so the same key resolves the code at verify time.
137
+ */
138
+ export async function mintCode(
139
+ loadSigningKey: McpSigningKeyProvider,
140
+ input: MintCodeInput,
141
+ options?: ClockOption,
142
+ ): Promise<string | null> {
143
+ const key = await loadSigningKey();
144
+ if (!key) return null;
145
+
146
+ const iat = nowSeconds(options?.now);
147
+ const exp = iat + AUTHORIZATION_CODE_TTL_SECONDS;
148
+
149
+ const claims: AuthorizationCodeClaims = {
150
+ email: input.email,
151
+ client_id: input.clientId,
152
+ redirect_uri: input.redirectUri,
153
+ code_challenge: input.codeChallenge,
154
+ scope: input.scope,
155
+ };
156
+
157
+ return new SignJWT(claims)
158
+ .setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid })
159
+ .setIssuer(issuer(input.origin))
160
+ .setAudience(AUTHORIZATION_CODE_AUDIENCE)
161
+ .setSubject(input.sub)
162
+ .setIssuedAt(iat)
163
+ .setExpirationTime(exp)
164
+ .setJti(crypto.randomUUID())
165
+ .sign(key.privateKey);
166
+ }
167
+
168
+ /** Options for {@link verifyCode}. */
169
+ export interface VerifyCodeOptions extends ClockOption {
170
+ /** The deployment origin — derives the expected `iss`. */
171
+ origin: string;
172
+ }
173
+
174
+ /**
175
+ * Verify a stateless authorization code and return its bound fields.
176
+ *
177
+ * Validates signature (via the public JWK selected by `kid`), `iss`, the
178
+ * `oauth:code` audience, and `exp`. Every failure — expired, wrong-audience (e.g.
179
+ * an access token), wrong-issuer, tampered, bad-signature, or no configured key —
180
+ * throws an {@link AuthorizationCodeError} (`invalid_grant`).
181
+ *
182
+ * The returned `jti` is the one-time identifier the token endpoint records to
183
+ * enforce single-use on top of the short expiry (replay guard).
184
+ */
185
+ export async function verifyCode(
186
+ loadSigningKey: McpSigningKeyProvider,
187
+ code: string,
188
+ options: VerifyCodeOptions,
189
+ ): Promise<VerifiedAuthorizationCode> {
190
+ const key = await loadSigningKey();
191
+ if (!key) {
192
+ // No signing key configured → nothing can verify (safe-by-default).
193
+ throw new AuthorizationCodeError("no signing key configured");
194
+ }
195
+
196
+ const publicKey = await importJWK(key.publicJwk, SIGNING_ALG);
197
+
198
+ let payload: JWTPayload;
199
+ try {
200
+ const result = await jwtVerify(code, publicKey, {
201
+ algorithms: [SIGNING_ALG],
202
+ issuer: issuer(options.origin),
203
+ audience: AUTHORIZATION_CODE_AUDIENCE,
204
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
205
+ currentDate: options.now === undefined ? undefined : new Date(options.now),
206
+ });
207
+ payload = result.payload;
208
+ } catch {
209
+ // jose throws on bad signature, wrong iss/aud, expiry, malformed token,
210
+ // unknown key — all map to a single opaque `invalid_grant`.
211
+ throw new AuthorizationCodeError("code verification failed");
212
+ }
213
+
214
+ return extractBoundFields(payload);
215
+ }
@@ -0,0 +1,260 @@
1
+ import { mintCode } from "./authorization-code";
2
+ import { matchesRedirectUri } from "./clients";
3
+ import type { McpOauthContext } from "./context";
4
+ import { SUPPORTED_CHALLENGE_METHOD } from "./pkce";
5
+ import type { StoredOAuthClient } from "./stores";
6
+
7
+ /**
8
+ * The OAuth 2.1 Authorization Code + PKCE authorization endpoint (12-23, ported
9
+ * from future-pay's `app/api/oauth/authorize/route.ts`).
10
+ *
11
+ * It renders no UI: it authenticates the caller against the host's cookie session
12
+ * (through `resolveSession`), validates the request, and either 302-redirects an
13
+ * unauthenticated caller into the host's sign-in flow (so the flow resumes
14
+ * post-login) or, for a signed-in caller with a valid request, mints a stateless
15
+ * authorization code bound to the SESSION identity and 302-redirects back to the
16
+ * client's registered `redirect_uri` with the code and echoed `state`.
17
+ *
18
+ * Security invariants, unchanged:
19
+ * - **Open-redirect prevention:** `client_id` + `redirect_uri` are validated
20
+ * against the registered client BEFORE anything else; an unknown client or a
21
+ * `redirect_uri` that is not an EXACT registered match yields a 400 plain-text
22
+ * response — the endpoint NEVER redirects an error to an unvalidated URI. Only
23
+ * once the URI is validated do other failures redirect back to it.
24
+ * - **Mandatory PKCE S256:** a missing `code_challenge`, or a method other than
25
+ * `S256` (incl. `plain`), is rejected.
26
+ * - **Identity from the session only:** `sub`/`email` come solely from the
27
+ * verified session; a client can never supply the identity via a query param.
28
+ * - **No key, no code:** an unprovisioned signing key is a `server_error`
29
+ * redirect, never a weaker mode.
30
+ */
31
+
32
+ /** OAuth 2.1 error codes this endpoint can emit on a validated redirect_uri. */
33
+ type AuthorizeErrorCode =
34
+ | "invalid_request"
35
+ | "unsupported_response_type"
36
+ | "invalid_scope"
37
+ /** The resource owner said no — or nobody was asked and nobody approved. */
38
+ | "access_denied"
39
+ | "server_error";
40
+
41
+ /** The parsed, still-untrusted query parameters of an authorize request. */
42
+ interface AuthorizeParams {
43
+ responseType: string | null;
44
+ clientId: string | null;
45
+ redirectUri: string | null;
46
+ codeChallenge: string | null;
47
+ codeChallengeMethod: string | null;
48
+ scope: string | null;
49
+ state: string | null;
50
+ }
51
+
52
+ function parseParams(url: URL): AuthorizeParams {
53
+ const q = url.searchParams;
54
+ return {
55
+ responseType: q.get("response_type"),
56
+ clientId: q.get("client_id"),
57
+ redirectUri: q.get("redirect_uri"),
58
+ codeChallenge: q.get("code_challenge"),
59
+ codeChallengeMethod: q.get("code_challenge_method"),
60
+ scope: q.get("scope"),
61
+ state: q.get("state"),
62
+ };
63
+ }
64
+
65
+ /** A 302 response to `location` with no body. */
66
+ function redirectTo(location: string): Response {
67
+ return new Response(null, { status: 302, headers: { location } });
68
+ }
69
+
70
+ /**
71
+ * A 400 plain-text refusal used ONLY when the `redirect_uri`/`client_id` are
72
+ * themselves invalid — i.e. there is no validated URI to safely redirect an error
73
+ * to (the open-redirect guard).
74
+ */
75
+ function badRequest(message: string): Response {
76
+ return new Response(message, {
77
+ status: 400,
78
+ headers: { "content-type": "text/plain; charset=utf-8" },
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Build an error redirect back to the (already-validated) `redirect_uri`, carrying
84
+ * the OAuth `error` and the echoed `state` per OAuth 2.1 §4.1.2.1.
85
+ */
86
+ function errorRedirect(
87
+ redirectUri: string,
88
+ error: AuthorizeErrorCode,
89
+ state: string | null,
90
+ ): Response {
91
+ const target = new URL(redirectUri);
92
+ target.searchParams.set("error", error);
93
+ if (state !== null) target.searchParams.set("state", state);
94
+ return redirectTo(target.toString());
95
+ }
96
+
97
+ /**
98
+ * Whether every space-delimited requested scope is within `allowed`. An
99
+ * empty/absent scope is permitted (the server applies its default), but any present
100
+ * scope must be in `allowed` — and `allowed` is the SPECIFIC CLIENT's registered
101
+ * scopes, so a client that registered for only `mcp:read` cannot request
102
+ * `mcp:write` and be issued a code for it ("no privilege escalation via metadata",
103
+ * enforced at authorize rather than trusted at registration).
104
+ */
105
+ function scopeIsSupported(scope: string | null, allowed: readonly string[]): boolean {
106
+ if (!scope) return true;
107
+ const requested = scope.split(/\s+/).filter(Boolean);
108
+ const allowedSet = new Set<string>(allowed);
109
+ return requested.every((candidate) => allowedSet.has(candidate));
110
+ }
111
+
112
+ /**
113
+ * Resolve + validate the client and its `redirect_uri` FIRST (the open-redirect
114
+ * guard). Returns the validated URI AND the client's registered scopes, or a plain
115
+ * 400 — NEVER a redirect — when the client or URI is unknown/unregistered, so an
116
+ * error is never steered to an unvalidated URI.
117
+ */
118
+ async function validateClientAndRedirect(
119
+ context: McpOauthContext,
120
+ params: AuthorizeParams,
121
+ ): Promise<{ client: StoredOAuthClient; redirectUri: string } | Response> {
122
+ if (!params.clientId) return badRequest("invalid_request: missing client_id");
123
+ if (!params.redirectUri) return badRequest("invalid_request: missing redirect_uri");
124
+
125
+ const client = await context.stores.clients.findByClientId(params.clientId);
126
+ if (!client) return badRequest("invalid_client: unknown client_id");
127
+ if (!matchesRedirectUri(client, params.redirectUri)) {
128
+ return badRequest("invalid_request: redirect_uri is not registered");
129
+ }
130
+
131
+ return { client, redirectUri: params.redirectUri };
132
+ }
133
+
134
+ /**
135
+ * Validate the response_type, mandatory PKCE S256, and the requested scope against
136
+ * the already-validated `redirectUri`. Returns `null` when the request passes, or an
137
+ * error redirect back to the validated URI on the first failure.
138
+ */
139
+ function validateAuthorizeRequest(
140
+ params: AuthorizeParams,
141
+ redirectUri: string,
142
+ clientScopes: readonly string[],
143
+ ): Response | null {
144
+ const { state } = params;
145
+
146
+ if (params.responseType !== "code") {
147
+ return errorRedirect(redirectUri, "unsupported_response_type", state);
148
+ }
149
+ // Mandatory PKCE S256: reject a missing challenge or any non-S256 method.
150
+ if (!params.codeChallenge || params.codeChallengeMethod !== SUPPORTED_CHALLENGE_METHOD) {
151
+ return errorRedirect(redirectUri, "invalid_request", state);
152
+ }
153
+ if (!scopeIsSupported(params.scope, clientScopes)) {
154
+ return errorRedirect(redirectUri, "invalid_scope", state);
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** The already-validated inputs an authorize request resolves to before minting. */
160
+ interface ValidatedAuthorize {
161
+ /** The registered client — needed by the approval seam, not just its id. */
162
+ client: StoredOAuthClient;
163
+ clientId: string;
164
+ redirectUri: string;
165
+ codeChallenge: string;
166
+ scope: string;
167
+ state: string | null;
168
+ }
169
+
170
+ /**
171
+ * With a validated request, resolve the authenticated session (identity from the
172
+ * session ONLY) and either send the caller through sign-in, mint the code, or
173
+ * `server_error` when no signing key is configured.
174
+ */
175
+ async function authenticateAndMint(
176
+ context: McpOauthContext,
177
+ request: Request,
178
+ url: URL,
179
+ origin: string,
180
+ validated: ValidatedAuthorize,
181
+ ): Promise<Response> {
182
+ const { redirectUri, state } = validated;
183
+
184
+ const session = await context.resolveSession(request);
185
+ if (!session?.email) {
186
+ // No session: send the caller through the host's sign-in flow with a callback
187
+ // back to THIS authorize URL so the flow resumes post-login. No code is minted
188
+ // for an unauthenticated request.
189
+ const loginUrl = new URL(context.loginPath, origin);
190
+ loginUrl.searchParams.set(context.loginCallbackParam, url.pathname + url.search);
191
+ return redirectTo(loginUrl.toString());
192
+ }
193
+
194
+ // CONSENT. A session proves WHO is asking; it never proves they agreed to THIS
195
+ // client holding THESE scopes. Registration is open (RFC 7591), so without this
196
+ // step anyone may register a client carrying their own redirect URI and their own
197
+ // scope ceiling, send a signed-in admin a single link, and have that admin's
198
+ // browser mint them a code — and the two guards that look like they would stop it,
199
+ // exact redirect-URI matching and the per-client scope ceiling, are both checked
200
+ // against the ATTACKER'S OWN registration. Refuses by default; see
201
+ // `resolveApproval` / `preApprovedClientIds`.
202
+ const scopes = validated.scope.split(/\s+/).filter(Boolean);
203
+ if (!(await context.approve(request, validated.client, scopes))) {
204
+ // The answer a human refusal gives, at the URI validated further up.
205
+ return errorRedirect(redirectUri, "access_denied", state);
206
+ }
207
+
208
+ const code = await mintCode(context.signingKey, {
209
+ // The subject is the OAuth `sub` the host resolved, NOT a DB id: downstream
210
+ // guards resolve the user by EMAIL, and the code carries only what the session
211
+ // verified.
212
+ sub: session.subject || session.email,
213
+ email: session.email,
214
+ clientId: validated.clientId,
215
+ redirectUri,
216
+ codeChallenge: validated.codeChallenge,
217
+ scope: validated.scope,
218
+ origin,
219
+ });
220
+
221
+ if (!code) {
222
+ // No signing key configured while the surface is on — refuse to issue rather
223
+ // than fall back to a weaker mode (safe-by-default).
224
+ return errorRedirect(redirectUri, "server_error", state);
225
+ }
226
+
227
+ const success = new URL(redirectUri);
228
+ success.searchParams.set("code", code);
229
+ if (state !== null) success.searchParams.set("state", state);
230
+ return redirectTo(success.toString());
231
+ }
232
+
233
+ /** `GET <authorize>` — the whole endpoint. */
234
+ export async function authorizeEndpoint(
235
+ context: McpOauthContext,
236
+ request: Request,
237
+ ): Promise<Response> {
238
+ const url = new URL(request.url);
239
+ const origin = context.originOf(request);
240
+ const params = parseParams(url);
241
+
242
+ // --- Validate client + redirect_uri FIRST (the open-redirect guard) --------
243
+ const clientResult = await validateClientAndRedirect(context, params);
244
+ if (clientResult instanceof Response) return clientResult;
245
+ const { client, redirectUri } = clientResult;
246
+
247
+ // --- Validate the rest (scope checked against the CLIENT's own registration)
248
+ const requestError = validateAuthorizeRequest(params, redirectUri, client.scopes);
249
+ if (requestError) return requestError;
250
+
251
+ // The guards above guarantee a present client_id + PKCE challenge; narrow them.
252
+ return authenticateAndMint(context, request, url, origin, {
253
+ client,
254
+ clientId: params.clientId as string,
255
+ redirectUri,
256
+ codeChallenge: params.codeChallenge as string,
257
+ scope: params.scope ?? "",
258
+ state: params.state,
259
+ });
260
+ }
@@ -0,0 +1,158 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ OAuthClientStore,
5
+ StoredOAuthClient,
6
+ TokenEndpointAuthMethod,
7
+ } from "./stores";
8
+
9
+ /**
10
+ * Client registration and the open-redirect guard (12-23, ported from
11
+ * future-pay's `lib/mcp/oauth/clients.ts`).
12
+ *
13
+ * A registered client is an external host (a Claude.ai / ChatGPT connector) from
14
+ * RFC 7591 dynamic client registration, or a static registration an operator
15
+ * created out of band. A confidential client's secret is generated HERE, returned
16
+ * exactly once, and stored only as a SHA-256 hash — it is never persisted in
17
+ * plaintext, never logged, and never re-derivable.
18
+ */
19
+
20
+ /** Default grant types for a registered client (OAuth 2.1 code + refresh). */
21
+ const DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
22
+
23
+ /** Bytes of entropy for a generated confidential-client secret (→ 64 hex). */
24
+ const CLIENT_SECRET_BYTES = 32;
25
+
26
+ /** RFC 7591 registration input (the durable subset the store persists). */
27
+ export interface RegisterClientInput {
28
+ /** The exact-match redirect-uri allowlist (open-redirect guard). */
29
+ redirectUris: string[];
30
+ clientName?: string | null;
31
+ /**
32
+ * `none` (public PKCE client, the default) or `client_secret_basic`
33
+ * (confidential — a secret is generated and its hash stored).
34
+ */
35
+ tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
36
+ /** Grant types; defaults to authorization_code + refresh_token. */
37
+ grantTypes?: string[];
38
+ scopes: string[];
39
+ }
40
+
41
+ /**
42
+ * The registration RESULT. `clientSecret` is present (plaintext, ONCE) only for a
43
+ * confidential client — it is never stored and never returned again.
44
+ */
45
+ export interface RegisteredClient {
46
+ clientId: string;
47
+ clientSecret?: string;
48
+ redirectUris: string[];
49
+ clientName: string | null;
50
+ tokenEndpointAuthMethod: TokenEndpointAuthMethod;
51
+ grantTypes: string[];
52
+ scopes: string[];
53
+ }
54
+
55
+ /** SHA-256 hex digest — the at-rest form of the client secret. */
56
+ export function hashSecret(secret: string): string {
57
+ return createHash("sha256").update(secret).digest("hex");
58
+ }
59
+
60
+ /**
61
+ * Register an OAuth client under a generated `clientId`. For a confidential
62
+ * client a random secret is generated and its hash stored; the plaintext is
63
+ * returned once.
64
+ */
65
+ export async function registerClient(
66
+ store: OAuthClientStore,
67
+ input: RegisterClientInput,
68
+ ): Promise<RegisteredClient> {
69
+ const clientId = randomUUID();
70
+ const authMethod: TokenEndpointAuthMethod = input.tokenEndpointAuthMethod ?? "none";
71
+ const grantTypes = input.grantTypes ?? [...DEFAULT_GRANT_TYPES];
72
+
73
+ // Only a confidential client gets a secret; a public PKCE client has none.
74
+ const clientSecret =
75
+ authMethod === "client_secret_basic"
76
+ ? randomBytes(CLIENT_SECRET_BYTES).toString("hex")
77
+ : undefined;
78
+
79
+ const row = await store.create({
80
+ clientId,
81
+ clientSecretHash: clientSecret ? hashSecret(clientSecret) : null,
82
+ redirectUris: input.redirectUris,
83
+ clientName: input.clientName ?? null,
84
+ tokenEndpointAuthMethod: authMethod,
85
+ grantTypes,
86
+ scopes: input.scopes,
87
+ });
88
+
89
+ return {
90
+ clientId: row.clientId,
91
+ ...(clientSecret ? { clientSecret } : {}),
92
+ redirectUris: row.redirectUris,
93
+ clientName: row.clientName,
94
+ tokenEndpointAuthMethod: row.tokenEndpointAuthMethod as TokenEndpointAuthMethod,
95
+ grantTypes: row.grantTypes,
96
+ scopes: row.scopes,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Open-redirect guard: a redirect target is accepted ONLY when it EXACTLY equals a
102
+ * registered `redirect_uri`. No normalization, no prefix, no trailing-slash
103
+ * leniency — an intercepted authorization request must not be steerable to any URI
104
+ * the client did not register.
105
+ */
106
+ export function matchesRedirectUri(
107
+ client: Pick<StoredOAuthClient, "redirectUris">,
108
+ redirectUri: string,
109
+ ): boolean {
110
+ if (!redirectUri) return false;
111
+ return client.redirectUris.includes(redirectUri);
112
+ }
113
+
114
+ /**
115
+ * Provider attribution rules: the canonical root domains that own each host's
116
+ * OAuth callback. A redirect host matches a root only as the exact domain or a
117
+ * real (dot-guarded) subdomain — never a suffix, so `evilchatgpt.com` never
118
+ * matches `chatgpt.com`.
119
+ */
120
+ export interface ProviderAttributionRule {
121
+ roots: readonly string[];
122
+ provider: string;
123
+ }
124
+
125
+ /** future-pay's rules, and a sane default for any host talking to the same two. */
126
+ export const DEFAULT_PROVIDER_ROOTS: readonly ProviderAttributionRule[] = [
127
+ { roots: ["claude.ai", "anthropic.com"], provider: "claude" },
128
+ { roots: ["chatgpt.com", "openai.com"], provider: "chatgpt" },
129
+ ];
130
+
131
+ /** Whether `host` is exactly `root` or a real subdomain of it (dot-guarded). */
132
+ function hostMatchesRoot(host: string, root: string): boolean {
133
+ return host === root || host.endsWith(`.${root}`);
134
+ }
135
+
136
+ /**
137
+ * Best-effort provider attribution from a client's redirect URIs: the host that
138
+ * owns the callback (`claude.ai` → claude, `chatgpt.com` → chatgpt). Returns
139
+ * `null` when nothing matches — the UI then falls back to what the owner
140
+ * completed the flow with, and a self-report (the `announce` path) can attribute
141
+ * it later.
142
+ */
143
+ export function providerFromRedirectUris(
144
+ redirectUris: readonly string[],
145
+ rules: readonly ProviderAttributionRule[] = DEFAULT_PROVIDER_ROOTS,
146
+ ): string | null {
147
+ for (const uri of redirectUris) {
148
+ let host: string;
149
+ try {
150
+ host = new URL(uri).host.toLowerCase();
151
+ } catch {
152
+ continue;
153
+ }
154
+ const match = rules.find((rule) => rule.roots.some((root) => hostMatchesRoot(host, root)));
155
+ if (match) return match.provider;
156
+ }
157
+ return null;
158
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Single-use guard for the stateless authorization codes (12-23, ported from
3
+ * future-pay's `lib/mcp/oauth/token-replay.ts`).
4
+ *
5
+ * A code is a signed blob with a `jti`, so "already redeemed" has to be remembered
6
+ * somewhere. The in-process option remembers it IN THIS PROCESS: a small map of
7
+ * `jti → expiry`, self-pruning once the code that carried it would have expired
8
+ * anyway, so the set never grows without bound.
9
+ *
10
+ * ⚠️ MULTI-INSTANCE LIMITATION (best-effort single-use): that map lives in ONE
11
+ * process. On a horizontally-scaled deployment a code could be replayed against a
12
+ * second instance that has not yet seen the `jti`, within the ≤60s code lifetime.
13
+ * Single-use is therefore strictly guaranteed only on a SINGLE instance — which is
14
+ * why choosing it is explicit and cannot happen by omission: `codeReplay` has no
15
+ * default, so a host either names a shared store or types `'in-process'`. BEFORE
16
+ * running this surface on more than one instance, pass a `codeReplay` store backed
17
+ * by something shared and atomic — a short-TTL row with a unique constraint, or a
18
+ * distributed cache with an atomic set-if-absent. The port exists precisely so
19
+ * that is a config change rather than a patch to the grant handler.
20
+ */
21
+
22
+ export interface CodeReplayStore {
23
+ /**
24
+ * Record a code's `jti` as consumed. `false` means it was ALREADY recorded (a
25
+ * replay); `true` is the first redemption. Must be atomic to be a real guard.
26
+ */
27
+ consume(jti: string, nowMs: number): Promise<boolean> | boolean;
28
+ }
29
+
30
+ /** Retain slightly beyond the 60s code TTL to cover the verify clock tolerance. */
31
+ const RETENTION_MS = 90_000;
32
+
33
+ /**
34
+ * The in-process store — correct on ONE instance, see the caveat above. Reached by
35
+ * passing `codeReplay: 'in-process'`, which is a required acknowledgement rather
36
+ * than a default: the config has no default for this field precisely because the
37
+ * only possible one would fail open on a multi-pod deployment.
38
+ */
39
+ export function inProcessCodeReplayStore(): CodeReplayStore {
40
+ const usedJtis = new Map<string, number>();
41
+ return {
42
+ consume(jti: string, nowMs: number): boolean {
43
+ for (const [seen, expiresAt] of usedJtis) {
44
+ if (expiresAt <= nowMs) usedJtis.delete(seen);
45
+ }
46
+ if (usedJtis.has(jti)) return false;
47
+ usedJtis.set(jti, nowMs + RETENTION_MS);
48
+ return true;
49
+ },
50
+ };
51
+ }