@12-apps/mcp 1.18.0 → 1.20.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,282 @@
1
+ import { registerClient, type RegisterClientInput } from "./clients";
2
+ import type { McpOauthContext } from "./context";
3
+ import type { TokenEndpointAuthMethod } from "./stores";
4
+
5
+ /**
6
+ * RFC 7591 Dynamic Client Registration (12-23, ported from future-pay's
7
+ * `app/api/oauth/register/route.ts`).
8
+ *
9
+ * An external host (a Claude.ai / ChatGPT connector) self-registers by POSTing RFC
10
+ * 7591 client metadata as JSON; on success a public `client_id` (and, for a
11
+ * confidential client, a one-time `client_secret`) is returned so the host can run
12
+ * the Authorization Code + PKCE flow.
13
+ *
14
+ * Security, unchanged:
15
+ * - **The gate answers 403 here, not 404.** Open DCR is an operator opt-in, and
16
+ * RFC 7591 registration explicitly refuses with `access_denied` so a probing
17
+ * host learns the endpoint exists but registration is closed — the documented
18
+ * static-client path is used instead.
19
+ * - **No privilege escalation via metadata:** registration can only set
20
+ * `redirect_uris`, an auth method the token endpoint actually supports, the
21
+ * supported grant types, and a scope SUBSET of the AS's advertised scopes. Any
22
+ * attempt to widen is rejected, never silently coerced. Identity is never
23
+ * client-supplied.
24
+ * - **Secret hygiene:** a confidential client's secret is generated server-side,
25
+ * returned once, and stored only as a SHA-256 hash.
26
+ */
27
+
28
+ /** RFC 7591 §3.2.2 registration error codes this endpoint can emit. */
29
+ type RegistrationErrorCode = "invalid_redirect_uri" | "invalid_client_metadata";
30
+
31
+ /** The RFC 7591 §3.2.1 client-information success response. */
32
+ interface RegistrationSuccessResponse {
33
+ client_id: string;
34
+ client_secret?: string;
35
+ client_id_issued_at: number;
36
+ token_endpoint_auth_method: TokenEndpointAuthMethod;
37
+ redirect_uris: string[];
38
+ grant_types: string[];
39
+ scope: string;
40
+ client_name?: string;
41
+ }
42
+
43
+ /** Auth methods the token endpoint can actually enforce (RFC 7591 §2). */
44
+ const SUPPORTED_AUTH_METHODS: readonly TokenEndpointAuthMethod[] = [
45
+ "none",
46
+ "client_secret_basic",
47
+ ];
48
+
49
+ /** Grant types the AS supports (mirrors the AS discovery metadata). */
50
+ const SUPPORTED_GRANT_TYPES: readonly string[] = ["authorization_code", "refresh_token"];
51
+
52
+ const DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
53
+ const DEFAULT_AUTH_METHOD: TokenEndpointAuthMethod = "none";
54
+
55
+ const JSON_HEADERS = {
56
+ "content-type": "application/json; charset=utf-8",
57
+ "cache-control": "no-store",
58
+ } as const;
59
+
60
+ /** A JSON error response in the RFC 7591 §3.2.2 shape. */
61
+ function registrationError(
62
+ error: RegistrationErrorCode,
63
+ status: number,
64
+ description?: string,
65
+ ): Response {
66
+ const body: { error: RegistrationErrorCode; error_description?: string } = { error };
67
+ if (description) body.error_description = description;
68
+ return new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS } });
69
+ }
70
+
71
+ /** Whether a value is a syntactically valid absolute URI (scheme + authority). */
72
+ function isAbsoluteUri(value: string): boolean {
73
+ try {
74
+ const url = new URL(value);
75
+ // An absolute redirect target must carry a scheme AND an authority — reject
76
+ // opaque/relative forms so an intercepted request can never be re-steered.
77
+ return Boolean(url.protocol) && Boolean(url.host);
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /** The RFC 7591 client-metadata fields this endpoint reads. */
84
+ interface ClientMetadata {
85
+ redirect_uris?: unknown;
86
+ token_endpoint_auth_method?: unknown;
87
+ grant_types?: unknown;
88
+ scope?: unknown;
89
+ client_name?: unknown;
90
+ }
91
+
92
+ /** A validated registration input, or a typed rejection to return verbatim. */
93
+ type ValidationResult =
94
+ | { ok: true; input: RegisterClientInput }
95
+ | { ok: false; response: Response };
96
+
97
+ /** A per-field validator result: the accepted value, or a rejection response. */
98
+ type FieldResult<T> = { ok: true; value: T } | { ok: false; response: Response };
99
+
100
+ function accept<T>(value: T): FieldResult<T> {
101
+ return { ok: true, value };
102
+ }
103
+
104
+ function reject<T>(response: Response): FieldResult<T> {
105
+ return { ok: false, response };
106
+ }
107
+
108
+ /**
109
+ * `redirect_uris` — REQUIRED, a non-empty array whose every entry is an absolute
110
+ * URI. Any failure maps to `invalid_redirect_uri` (RFC 7591 §3.2.2).
111
+ */
112
+ function validateRedirectUris(raw: unknown): FieldResult<string[]> {
113
+ if (
114
+ !Array.isArray(raw) ||
115
+ raw.length === 0 ||
116
+ !raw.every((uri): uri is string => typeof uri === "string" && isAbsoluteUri(uri))
117
+ ) {
118
+ return reject(
119
+ registrationError(
120
+ "invalid_redirect_uri",
121
+ 400,
122
+ "redirect_uris must be a non-empty array of absolute URIs",
123
+ ),
124
+ );
125
+ }
126
+ return accept([...raw]);
127
+ }
128
+
129
+ /** `token_endpoint_auth_method` — optional; defaults to `none`. */
130
+ function validateAuthMethod(raw: unknown): FieldResult<TokenEndpointAuthMethod> {
131
+ if (raw === undefined || raw === null) return accept(DEFAULT_AUTH_METHOD);
132
+ if (
133
+ typeof raw !== "string" ||
134
+ !SUPPORTED_AUTH_METHODS.includes(raw as TokenEndpointAuthMethod)
135
+ ) {
136
+ return reject(
137
+ registrationError(
138
+ "invalid_client_metadata",
139
+ 400,
140
+ `unsupported token_endpoint_auth_method (supported: ${SUPPORTED_AUTH_METHODS.join(", ")})`,
141
+ ),
142
+ );
143
+ }
144
+ return accept(raw as TokenEndpointAuthMethod);
145
+ }
146
+
147
+ /** `grant_types` — optional; defaults to code+refresh. */
148
+ function validateGrantTypes(raw: unknown): FieldResult<string[]> {
149
+ if (raw === undefined || raw === null) return accept([...DEFAULT_GRANT_TYPES]);
150
+ if (
151
+ !Array.isArray(raw) ||
152
+ raw.length === 0 ||
153
+ !raw.every(
154
+ (grant): grant is string =>
155
+ typeof grant === "string" && SUPPORTED_GRANT_TYPES.includes(grant),
156
+ )
157
+ ) {
158
+ return reject(
159
+ registrationError(
160
+ "invalid_client_metadata",
161
+ 400,
162
+ `unsupported grant_types (supported: ${SUPPORTED_GRANT_TYPES.join(", ")})`,
163
+ ),
164
+ );
165
+ }
166
+ return accept([...raw]);
167
+ }
168
+
169
+ /**
170
+ * `scope` — optional space-delimited string; every requested scope must be in the
171
+ * AS's supported set. An explicit empty request falls back to the full set.
172
+ */
173
+ function validateScopes(raw: unknown, supportedScopes: readonly string[]): FieldResult<string[]> {
174
+ if (raw === undefined || raw === null) return accept([...supportedScopes]);
175
+ if (typeof raw !== "string") {
176
+ return reject(
177
+ registrationError("invalid_client_metadata", 400, "scope must be a space-delimited string"),
178
+ );
179
+ }
180
+ const requested = raw.split(/\s+/).filter(Boolean);
181
+ const supported = new Set<string>(supportedScopes);
182
+ if (!requested.every((scope) => supported.has(scope))) {
183
+ return reject(
184
+ registrationError(
185
+ "invalid_client_metadata",
186
+ 400,
187
+ `scope must be a subset of: ${supportedScopes.join(" ")}`,
188
+ ),
189
+ );
190
+ }
191
+ return accept(requested.length > 0 ? requested : [...supportedScopes]);
192
+ }
193
+
194
+ /**
195
+ * Validate RFC 7591 client metadata strictly, by composing the per-field
196
+ * validators. `redirect_uris` failures map to `invalid_redirect_uri`; every other
197
+ * unsupported-metadata failure maps to `invalid_client_metadata`.
198
+ */
199
+ function validateMetadata(
200
+ metadata: ClientMetadata,
201
+ supportedScopes: readonly string[],
202
+ ): ValidationResult {
203
+ const redirectUris = validateRedirectUris(metadata.redirect_uris);
204
+ if (!redirectUris.ok) return redirectUris;
205
+
206
+ const authMethod = validateAuthMethod(metadata.token_endpoint_auth_method);
207
+ if (!authMethod.ok) return authMethod;
208
+
209
+ const grantTypes = validateGrantTypes(metadata.grant_types);
210
+ if (!grantTypes.ok) return grantTypes;
211
+
212
+ const scopes = validateScopes(metadata.scope, supportedScopes);
213
+ if (!scopes.ok) return scopes;
214
+
215
+ const clientNameRaw = metadata.client_name;
216
+ const clientName = typeof clientNameRaw === "string" ? clientNameRaw : null;
217
+
218
+ return {
219
+ ok: true,
220
+ input: {
221
+ redirectUris: redirectUris.value,
222
+ clientName,
223
+ tokenEndpointAuthMethod: authMethod.value,
224
+ grantTypes: grantTypes.value,
225
+ scopes: scopes.value,
226
+ },
227
+ };
228
+ }
229
+
230
+ /** The refusal a closed registration endpoint answers with. */
231
+ export function registrationDisabled(): Response {
232
+ return new Response(
233
+ JSON.stringify({
234
+ error: "access_denied",
235
+ error_description: "dynamic client registration is disabled",
236
+ }),
237
+ { status: 403, headers: { ...JSON_HEADERS } },
238
+ );
239
+ }
240
+
241
+ /** `POST <register>` — the whole endpoint. */
242
+ export async function registerEndpoint(
243
+ context: McpOauthContext,
244
+ request: Request,
245
+ ): Promise<Response> {
246
+ // Parse the JSON body. A malformed body is unusable metadata → 400.
247
+ let metadata: ClientMetadata;
248
+ try {
249
+ const parsed: unknown = await request.json();
250
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
251
+ return registrationError(
252
+ "invalid_client_metadata",
253
+ 400,
254
+ "request body must be a JSON object",
255
+ );
256
+ }
257
+ metadata = parsed as ClientMetadata;
258
+ } catch {
259
+ return registrationError("invalid_client_metadata", 400, "request body must be valid JSON");
260
+ }
261
+
262
+ const validated = validateMetadata(metadata, context.scopes);
263
+ if (!validated.ok) return validated.response;
264
+
265
+ const registered = await registerClient(context.stores.clients, validated.input);
266
+
267
+ const responseBody: RegistrationSuccessResponse = {
268
+ client_id: registered.clientId,
269
+ ...(registered.clientSecret ? { client_secret: registered.clientSecret } : {}),
270
+ client_id_issued_at: Math.floor(Date.now() / 1000),
271
+ token_endpoint_auth_method: registered.tokenEndpointAuthMethod,
272
+ redirect_uris: registered.redirectUris,
273
+ grant_types: registered.grantTypes,
274
+ scope: registered.scopes.join(" "),
275
+ ...(registered.clientName ? { client_name: registered.clientName } : {}),
276
+ };
277
+
278
+ return new Response(JSON.stringify(responseBody), {
279
+ status: 201,
280
+ headers: { ...JSON_HEADERS },
281
+ });
282
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The persistence PORTS of the authorization server (12-23).
3
+ *
4
+ * Three tables back the AS, and the package owns all three (see
5
+ * `prisma/mcp.prisma`): registered clients, rotating refresh tokens, and the
6
+ * per-user record of which AI host is live. What the package does NOT own is the
7
+ * client library used to reach them — so every read and write in the surface goes
8
+ * through these narrow ports, and `createPrismaMcpStores` (in
9
+ * `./prisma-stores.ts`) fills them for the common case in one line.
10
+ *
11
+ * The shapes are deliberately CLOSED and small: a host on something other than
12
+ * Prisma has a finite surface to fill, and the harness fills exactly this with
13
+ * hand-written SQL over a real Postgres.
14
+ */
15
+
16
+ /** The two token-endpoint auth methods the AS accepts (matches the DB CHECK). */
17
+ export type TokenEndpointAuthMethod = "none" | "client_secret_basic";
18
+
19
+ /** A registered OAuth client (an external host app — Claude.ai, ChatGPT…). */
20
+ export interface StoredOAuthClient {
21
+ clientId: string;
22
+ /** SHA-256 hex of the secret; `null` for a public PKCE client. */
23
+ clientSecretHash: string | null;
24
+ /** The EXACT-MATCH allowlist the authorize endpoint validates against. */
25
+ redirectUris: string[];
26
+ clientName: string | null;
27
+ tokenEndpointAuthMethod: string;
28
+ grantTypes: string[];
29
+ scopes: string[];
30
+ }
31
+
32
+ /** What `register` persists (the durable subset of RFC 7591 metadata). */
33
+ export interface NewOAuthClient {
34
+ clientId: string;
35
+ clientSecretHash: string | null;
36
+ redirectUris: string[];
37
+ clientName: string | null;
38
+ tokenEndpointAuthMethod: TokenEndpointAuthMethod;
39
+ grantTypes: string[];
40
+ scopes: string[];
41
+ }
42
+
43
+ export interface OAuthClientStore {
44
+ create(client: NewOAuthClient): Promise<StoredOAuthClient>;
45
+ /** By the PUBLIC `client_id`, or `null` when unknown. */
46
+ findByClientId(clientId: string): Promise<StoredOAuthClient | null>;
47
+ }
48
+
49
+ /** A rotating refresh token, stored HASHED — never plaintext. */
50
+ export interface StoredRefreshToken {
51
+ tokenHash: string;
52
+ userEmail: string;
53
+ /** The original OAuth subject, kept stable across every rotation. */
54
+ userSub: string;
55
+ clientId: string;
56
+ scopes: string[];
57
+ expiresAt: Date;
58
+ /** The prior token's hash — the rotation lineage. `null` for a root token. */
59
+ rotatedFrom: string | null;
60
+ revokedAt: Date | null;
61
+ }
62
+
63
+ /** A token about to be stored (the plaintext never is). */
64
+ export type NewRefreshToken = Omit<StoredRefreshToken, "revokedAt">;
65
+
66
+ export interface RefreshTokenStore {
67
+ create(token: NewRefreshToken): Promise<void>;
68
+ findByHash(tokenHash: string): Promise<StoredRefreshToken | null>;
69
+ /** Whether some token was already rotated FROM this hash (replay detection). */
70
+ hasSuccessor(tokenHash: string): Promise<boolean>;
71
+ /** Every token of one `(userEmail, clientId)` family — the lineage walk's input. */
72
+ listFamily(userEmail: string, clientId: string): Promise<StoredRefreshToken[]>;
73
+ /** Revoke exactly these hashes (idempotent). */
74
+ revokeHashes(tokenHashes: readonly string[], at: Date): Promise<void>;
75
+ /**
76
+ * CLAIM the parent and store the successor, atomically. The whole of OAuth 2.1
77
+ * §4.3.1 replay protection rests on this one method, so read the contract before
78
+ * implementing it.
79
+ *
80
+ * Returns `true` when THIS call is the one that consumed `parentHash`, `false`
81
+ * when another call already had. `false` MUST mean nothing was written: no
82
+ * successor row, no second revocation.
83
+ *
84
+ * An implementation MUST revoke the parent CONDITIONALLY on it still being
85
+ * unrevoked — `updateMany({ where: { tokenHash: parentHash, revokedAt: null } })`,
86
+ * requiring a count of exactly 1 — and create the successor in the SAME
87
+ * transaction. An unconditional `update` is NOT enough: two concurrent rotations
88
+ * of one parent would both succeed, leaving two live successors of one token with
89
+ * no replay ever detected, because the replay rule fires on a THIRD use of the
90
+ * parent that then never comes. That is replay protection defeated by WINNING a
91
+ * race rather than by arriving second — precisely the attack rotation exists to
92
+ * stop, since an attacker holding a stolen refresh token need only fire it
93
+ * alongside the legitimate client to walk away with a live, independently
94
+ * rotating family.
95
+ *
96
+ * Atomicity against a CRASH is necessary too (a half-applied rotation leaves a
97
+ * live parent AND a live child) but it is not sufficient, and it is the easier
98
+ * half to satisfy by accident.
99
+ */
100
+ rotate(successor: NewRefreshToken, parentHash: string, at: Date): Promise<boolean>;
101
+ /**
102
+ * Revoke every LIVE token a user holds for one client; returns how many were
103
+ * actually ended (already-revoked rows are skipped, so a repeat reports 0).
104
+ */
105
+ revokeLiveForClient(userEmail: string, clientId: string): Promise<number>;
106
+ }
107
+
108
+ /** The AI provider a connection is attributed to. */
109
+ export type McpConnectionHost = string;
110
+
111
+ /** A live connection, as the account surface shows it. */
112
+ export interface StoredMcpConnection {
113
+ oauthClientId: string;
114
+ clientName: string | null;
115
+ /** `null` for a pre-attribution connection. */
116
+ host: string | null;
117
+ connectedAt: Date;
118
+ lastActiveAt: Date;
119
+ }
120
+
121
+ export interface McpConnectionStore {
122
+ /** Liveness of one `(user, client)` pair, for the activity throttle. */
123
+ lastActiveAt(userId: string, oauthClientId: string): Promise<Date | null>;
124
+ /**
125
+ * Record (or refresh) liveness. Any activity CLEARS a prior `revokedAt` — the
126
+ * host is talking to us again — and must never blank a known `host` when this
127
+ * grant cannot derive one.
128
+ */
129
+ recordActivity(input: {
130
+ userId: string;
131
+ oauthClientId: string;
132
+ clientName: string | null;
133
+ host: string | null;
134
+ at: Date;
135
+ }): Promise<void>;
136
+ /** A user's active (non-revoked) connections, most-recently-active first. */
137
+ listActive(userId: string): Promise<StoredMcpConnection[]>;
138
+ /**
139
+ * Revoke every live connection of one provider for this user and return the
140
+ * OAuth client ids that were revoked — the caller ends their refresh tokens,
141
+ * which is what actually cuts access.
142
+ */
143
+ revokeByHost(userId: string, host: McpConnectionHost): Promise<string[]>;
144
+ /**
145
+ * The self-report path: attribute this user's just-connected, still-unattributed
146
+ * connection to `host`, or refresh the one already attributed to it. Returns
147
+ * rows touched.
148
+ */
149
+ announce(userId: string, host: McpConnectionHost): Promise<number>;
150
+ }
151
+
152
+ /** The stores the AS needs. `connections` is optional — see the config docs. */
153
+ export interface McpOauthStores {
154
+ clients: OAuthClientStore;
155
+ refreshTokens: RefreshTokenStore;
156
+ connections?: McpConnectionStore;
157
+ }