@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,286 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import type { NewRefreshToken, RefreshTokenStore, StoredRefreshToken } from "./stores";
4
+
5
+ /**
6
+ * Refresh-token issue + rotation (12-23, ported from future-pay's
7
+ * `lib/mcp/oauth/refresh.ts` — behaviour unchanged; Prisma calls became the
8
+ * `RefreshTokenStore` port).
9
+ *
10
+ * Refresh tokens are opaque high-entropy strings; only their SHA-256 HASH is ever
11
+ * stored — the plaintext is returned once at issue/rotate time and never
12
+ * persisted, never logged.
13
+ *
14
+ * Rotation-on-use with replay protection:
15
+ * - {@link issueRefreshToken} mints a root token bound to email + sub + client
16
+ * + scopes;
17
+ * - {@link rotateRefreshToken} consumes a token: it issues a NEW token chained
18
+ * via `rotatedFrom` and revokes the parent, so a token is single-use;
19
+ * - reuse of an already-rotated/revoked token is a REPLAY: rejected, AND the
20
+ * whole lineage (every ancestor + descendant reachable through `rotatedFrom`)
21
+ * is revoked — the OAuth 2.1 refresh-token replay rule;
22
+ * - CONCURRENT reuse is the same event and gets the same answer. The store's
23
+ * `rotate` is a claim-once write, so of two simultaneous rotations of one
24
+ * parent exactly one is issued a successor and the other is treated as the
25
+ * replay it is. Without that, replay protection would be bypassable by
26
+ * WINNING a race instead of arriving second (see `RefreshTokenStore.rotate`);
27
+ * - rotate may only NARROW scope (new ⊆ original); broadening is rejected and
28
+ * nothing new is stored.
29
+ */
30
+
31
+ /** Bytes of entropy per opaque refresh token (→ 64 hex chars). */
32
+ const REFRESH_TOKEN_BYTES = 32;
33
+
34
+ /** Refresh-token lifetime — long-lived relative to the 15-min access token. */
35
+ export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
36
+
37
+ /** The single failure discriminator surfaced to the token endpoint. */
38
+ export type RefreshTokenErrorCode = "invalid_grant" | "invalid_scope";
39
+
40
+ /**
41
+ * A typed refresh-token failure. Every rejection — unknown, expired, revoked,
42
+ * already-rotated (replay), wrong client, or a scope-broadening request —
43
+ * surfaces as a discriminated error the token endpoint maps to the RFC 6749
44
+ * error JSON.
45
+ */
46
+ export class RefreshTokenError extends Error {
47
+ readonly code: RefreshTokenErrorCode;
48
+
49
+ constructor(code: RefreshTokenErrorCode, message?: string) {
50
+ super(message ?? code);
51
+ this.name = "RefreshTokenError";
52
+ this.code = code;
53
+ }
54
+ }
55
+
56
+ /** The result of issuing/rotating: the plaintext token (once) + bound scopes. */
57
+ export interface IssuedRefreshToken {
58
+ /** The opaque plaintext refresh token — returned once, never persisted. */
59
+ refreshToken: string;
60
+ scopes: string[];
61
+ }
62
+
63
+ /** SHA-256 hex digest — the at-rest form of an opaque refresh token. */
64
+ export function hashToken(token: string): string {
65
+ return createHash("sha256").update(token).digest("hex");
66
+ }
67
+
68
+ /** Generate a fresh opaque refresh token (high-entropy hex). */
69
+ function generateToken(): string {
70
+ return randomBytes(REFRESH_TOKEN_BYTES).toString("hex");
71
+ }
72
+
73
+ export interface RefreshTokenContext {
74
+ store: RefreshTokenStore;
75
+ /** Lifetime of a newly stored token. Default 30 days. */
76
+ ttlMs?: number;
77
+ }
78
+
79
+ function expiryOf(context: RefreshTokenContext): Date {
80
+ return new Date(Date.now() + (context.ttlMs ?? REFRESH_TOKEN_TTL_MS));
81
+ }
82
+
83
+ /**
84
+ * Issue a fresh (root) refresh token bound to a user (email + OAuth `sub`) +
85
+ * client + scopes. The plaintext is returned once; only its hash is stored.
86
+ */
87
+ export async function issueRefreshToken(
88
+ context: RefreshTokenContext,
89
+ binding: { userEmail: string; userSub: string; clientId: string; scopes: string[] },
90
+ ): Promise<IssuedRefreshToken> {
91
+ const refreshToken = generateToken();
92
+ const row: NewRefreshToken = {
93
+ tokenHash: hashToken(refreshToken),
94
+ userEmail: binding.userEmail,
95
+ userSub: binding.userSub,
96
+ clientId: binding.clientId,
97
+ scopes: binding.scopes,
98
+ expiresAt: expiryOf(context),
99
+ rotatedFrom: null,
100
+ };
101
+ await context.store.create(row);
102
+ return { refreshToken, scopes: binding.scopes };
103
+ }
104
+
105
+ /**
106
+ * A pre-built O(1)-lookup index of one `(userEmail, clientId)` token family:
107
+ * `byHash` resolves a hash to its row (to walk ancestors via `rotatedFrom`), and
108
+ * `childrenOf` is the reverse index mapping a parent hash to its direct successor
109
+ * hashes (to walk descendants). Both are built in a single pass so the lineage
110
+ * traversal never re-scans the family (no O(n²) inner loop).
111
+ */
112
+ interface LineageIndex {
113
+ byHash: Map<string, StoredRefreshToken>;
114
+ childrenOf: Map<string, string[]>;
115
+ }
116
+
117
+ function buildLineageIndex(family: StoredRefreshToken[]): LineageIndex {
118
+ const byHash = new Map<string, StoredRefreshToken>();
119
+ const childrenOf = new Map<string, string[]>();
120
+ for (const row of family) {
121
+ byHash.set(row.tokenHash, row);
122
+ if (!row.rotatedFrom) continue;
123
+ const siblings = childrenOf.get(row.rotatedFrom) ?? [];
124
+ siblings.push(row.tokenHash);
125
+ childrenOf.set(row.rotatedFrom, siblings);
126
+ }
127
+ return { byHash, childrenOf };
128
+ }
129
+
130
+ /**
131
+ * Collect every token hash reachable from `seedHash` — its ancestors (via
132
+ * `rotatedFrom`) and its descendants (via the reverse index) — by a BFS over the
133
+ * pre-built index. Each neighbour lookup is O(1), so the walk is linear in the
134
+ * family size.
135
+ */
136
+ function collectLineage(index: LineageIndex, seedHash: string): Set<string> {
137
+ const lineage = new Set<string>();
138
+ const queue = [seedHash];
139
+ while (queue.length > 0) {
140
+ const hash = queue.shift();
141
+ if (!hash || lineage.has(hash)) continue;
142
+ lineage.add(hash);
143
+
144
+ const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
145
+ if (parent && !lineage.has(parent)) queue.push(parent);
146
+
147
+ const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
148
+ queue.push(...children);
149
+ }
150
+ return lineage;
151
+ }
152
+
153
+ /**
154
+ * Walk a token's rotation lineage (both directions) and revoke every token in it.
155
+ * Called on replay detection, so a leaked refresh token — once reused —
156
+ * invalidates the entire chain it belongs to.
157
+ */
158
+ async function revokeLineage(
159
+ context: RefreshTokenContext,
160
+ scopedTo: Pick<StoredRefreshToken, "userEmail" | "clientId">,
161
+ seedHash: string,
162
+ ): Promise<void> {
163
+ // The lineage is confined to one (userEmail, clientId) pair, so load that set
164
+ // once and walk the `rotatedFrom` links in memory — a small, bounded chain.
165
+ const family = await context.store.listFamily(scopedTo.userEmail, scopedTo.clientId);
166
+ const lineage = collectLineage(buildLineageIndex(family), seedHash);
167
+ await context.store.revokeHashes([...lineage], new Date());
168
+ }
169
+
170
+ /** Reject any requested scope not already on the token (narrow-only). */
171
+ function narrowedScopes(current: StoredRefreshToken, requested?: string[]): string[] {
172
+ const scopes = requested ?? current.scopes;
173
+ const original = new Set(current.scopes);
174
+ for (const scope of scopes) {
175
+ if (!original.has(scope)) {
176
+ throw new RefreshTokenError(
177
+ "invalid_scope",
178
+ `scope '${scope}' broadens the refresh token grant`,
179
+ );
180
+ }
181
+ }
182
+ return scopes;
183
+ }
184
+
185
+ /**
186
+ * Rotate a refresh token on use: validate it (must exist, be BOUND to the
187
+ * presenting client, be unexpired, unrevoked and un-rotated), then issue a NEW
188
+ * token chained via `rotatedFrom` and revoke the consumed one. Optionally NARROW
189
+ * scope; a broadening request is `invalid_scope`.
190
+ *
191
+ * Client binding (OAuth 2.1 §4.3 / RFC 6749 §10.4) is checked BEFORE any rotation
192
+ * or revocation, so client A can never redeem client B's refresh token — nor
193
+ * silently consume B's token by trying: the token stays live for its rightful
194
+ * owner.
195
+ */
196
+ export async function rotateRefreshToken(
197
+ context: RefreshTokenContext,
198
+ plaintext: string,
199
+ expectedClientId: string,
200
+ newScopes?: string[],
201
+ ): Promise<IssuedRefreshToken> {
202
+ const tokenHash = hashToken(plaintext);
203
+ const current = await context.store.findByHash(tokenHash);
204
+
205
+ if (!current) {
206
+ throw new RefreshTokenError("invalid_grant", "unknown refresh token");
207
+ }
208
+ if (current.clientId !== expectedClientId) {
209
+ throw new RefreshTokenError(
210
+ "invalid_grant",
211
+ "refresh token was not issued to this client",
212
+ );
213
+ }
214
+ // Expired → reject (not a replay; no lineage revocation needed beyond the
215
+ // expiry itself).
216
+ if (current.expiresAt.getTime() <= Date.now()) {
217
+ throw new RefreshTokenError("invalid_grant", "refresh token expired");
218
+ }
219
+ // Already revoked OR already used as the parent of a rotation → REPLAY. Revoke
220
+ // the whole lineage and reject.
221
+ if (current.revokedAt || (await context.store.hasSuccessor(tokenHash))) {
222
+ await replay(context, current, tokenHash);
223
+ }
224
+
225
+ const scopes = narrowedScopes(current, newScopes);
226
+ const successorPlaintext = generateToken();
227
+ const claimed = await context.store.rotate(
228
+ {
229
+ tokenHash: hashToken(successorPlaintext),
230
+ userEmail: current.userEmail,
231
+ userSub: current.userSub,
232
+ clientId: current.clientId,
233
+ scopes,
234
+ expiresAt: expiryOf(context),
235
+ rotatedFrom: tokenHash,
236
+ },
237
+ tokenHash,
238
+ new Date(),
239
+ );
240
+ // The checks above are a READ, so a concurrent rotation of the same parent can
241
+ // pass them too; `rotate` is the serialization point and it hands the claim to
242
+ // exactly one caller. Losing it is the SAME event as the replay branch above —
243
+ // one token used twice — so it gets the same answer, deliberately: reject, and
244
+ // revoke the lineage including the winner's fresh successor. Rejecting without
245
+ // revoking would leave a race-winning attacker holding a live family, which is
246
+ // the whole attack; and a client that legitimately double-submits already loses
247
+ // its family in the sequential case, so this is consistent rather than harsher.
248
+ if (!claimed) await replay(context, current, tokenHash);
249
+
250
+ return { refreshToken: successorPlaintext, scopes };
251
+ }
252
+
253
+ /** Detected reuse: revoke the whole lineage and reject. Never returns. */
254
+ async function replay(
255
+ context: RefreshTokenContext,
256
+ current: StoredRefreshToken,
257
+ tokenHash: string,
258
+ ): Promise<never> {
259
+ await revokeLineage(context, current, tokenHash);
260
+ throw new RefreshTokenError(
261
+ "invalid_grant",
262
+ "refresh token already used (replay) — lineage revoked",
263
+ );
264
+ }
265
+
266
+ /** The stable identity a refresh token is bound to. */
267
+ export interface RefreshTokenIdentity {
268
+ /** The user's email — the identity the AS binds to and route guards resolve by. */
269
+ userEmail: string;
270
+ /** The original OAuth subject, kept stable across every rotation. */
271
+ userSub: string;
272
+ }
273
+
274
+ /**
275
+ * Resolve the identity (`email` + original OAuth `sub`) a refresh token is bound
276
+ * to. The token endpoint uses this after rotation to mint the successor access
277
+ * token with the correct email AND the SAME stable `sub` as the initial token (no
278
+ * re-consent, no `sub` drift). `null` if the row is unexpectedly absent.
279
+ */
280
+ export async function getRefreshTokenIdentity(
281
+ context: RefreshTokenContext,
282
+ plaintext: string,
283
+ ): Promise<RefreshTokenIdentity | null> {
284
+ const row = await context.store.findByHash(hashToken(plaintext));
285
+ return row ? { userEmail: row.userEmail, userSub: row.userSub } : null;
286
+ }
@@ -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
+ }