@vantageos/mcp-boilerplate 0.2.1 → 0.2.3

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.
@@ -20,15 +20,17 @@
20
20
  * FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
21
21
  * No refresh tokens are issued; access tokens live one hour.
22
22
  */
23
- import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
23
+ import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, resolveClerkSessionFromRequest, } from "@vantageos/cloud-identity";
24
24
  import { getCookie } from "hono/cookie";
25
25
  import { cors } from "hono/cors";
26
+ import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
26
27
  import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
27
28
  import { createClerkBackendCollaborators } from "./clerk.js";
29
+ import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
28
30
  import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
29
31
  import { createInMemoryCodeStore } from "./codeStore.js";
30
32
  import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
31
- import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, readAuthorizeSettings, } from "./settings.js";
33
+ import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
32
34
  import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
33
35
  const REGISTER_LIMIT_PER_MINUTE = 20;
34
36
  const MAX_REDIRECT_URIS = 10;
@@ -36,6 +38,39 @@ function noStore(c) {
36
38
  c.header("Cache-Control", "no-store");
37
39
  c.header("Pragma", "no-cache");
38
40
  }
41
+ /**
42
+ * The rate-limit key of a request: who is really on the other end.
43
+ *
44
+ * `X-Forwarded-For` is client-controlled up to the point a trusted proxy appends to it. With
45
+ * N trusted proxies the real client is the N-th entry from the END (each proxy appended the
46
+ * peer it saw); anything left of it was supplied by the client and is ignored. With none
47
+ * configured the header is not read at all and the socket address decides.
48
+ */
49
+ export function clientKeyOf(c, trustedProxyCount) {
50
+ const socket = c.env?.remoteAddress || "unknown";
51
+ if (trustedProxyCount <= 0)
52
+ return socket;
53
+ const chain = (c.req.header("x-forwarded-for") ?? "")
54
+ .split(",")
55
+ .map((entry) => entry.trim())
56
+ .filter(Boolean);
57
+ return chain.length >= trustedProxyCount
58
+ ? (chain[chain.length - trustedProxyCount] ?? socket)
59
+ : socket;
60
+ }
61
+ /**
62
+ * The instance publishable key: `CLERK_PUBLISHABLE_KEY` when set, else derived from the Frontend
63
+ * API host in `CLERK_DOMAIN` as `pk_<test|live>_` + base64(host + "$"), the format Clerk documents
64
+ * (the prefix follows the secret key's `sk_live_` / `sk_test_`).
65
+ */
66
+ export function publishableKeyFor(env, clerkIssuer) {
67
+ const explicit = env.CLERK_PUBLISHABLE_KEY;
68
+ if (explicit)
69
+ return explicit;
70
+ const live = (env[OAUTH_ENV.clerkSecretKey] ?? "").startsWith("sk_live_");
71
+ const host = new URL(clerkIssuer).host;
72
+ return `pk_${live ? "live" : "test"}_${Buffer.from(`${host}$`).toString("base64")}`;
73
+ }
39
74
  function sessionTokenFrom(c) {
40
75
  const header = c.req.header("authorization");
41
76
  if (header?.toLowerCase().startsWith("bearer ")) {
@@ -47,7 +82,25 @@ function sessionTokenFrom(c) {
47
82
  }
48
83
  export function createAuthorizationServer(deps = {}) {
49
84
  const env = deps.env ?? process.env;
85
+ // Operator knobs are validated here, at construction: a bad value stops the boot.
86
+ const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
87
+ const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
88
+ const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
50
89
  const now = deps.now ?? (() => Date.now());
90
+ // Fail at boot, loudly, rather than lose every code and client on the first restart.
91
+ if (!deps.codeStore || !deps.clientRegistry) {
92
+ if (env.NODE_ENV === "production") {
93
+ if (!allowInMemory) {
94
+ throw new Error(`NODE_ENV=production refuses the in-memory OAuth stores (codes and registered clients are lost on restart and not shared between instances). Inject durable adapters (docs/oauth-stores.md), or set ${OAUTH_ENV.allowInMemoryStores}=1 to accept a single-instance deployment that forgets them on restart.`);
95
+ }
96
+ console.warn(`[oauth] running on in-memory stores in production (${OAUTH_ENV.allowInMemoryStores}=1): single instance only, state is lost on restart`);
97
+ }
98
+ }
99
+ // A warning, never a refusal: a development Clerk instance is legitimate for a remote test once
100
+ // its Home URL points at this server. English then French, the boot has no request locale.
101
+ if (isDevClerkInstanceOnRemoteServer(env)) {
102
+ console.warn(`[oauth] ${t("en", "oauth.devInstanceRemote")}\n[oauth] ${t("fr", "oauth.devInstanceRemote")}`);
103
+ }
51
104
  const codeStore = deps.codeStore ?? createInMemoryCodeStore();
52
105
  const registry = deps.clientRegistry ?? createInMemoryClientRegistry();
53
106
  const injectedClerk = deps.clerk;
@@ -68,6 +121,11 @@ export function createAuthorizationServer(deps = {}) {
68
121
  getUser: injectedClerk?.getUser ?? backendClerk.getUser,
69
122
  });
70
123
  const settingsNow = () => readAuthorizeSettings(env, clerkInjected);
124
+ const assertionVerifier = createClientAssertionVerifier({
125
+ fetchImpl: deps.fetchImpl,
126
+ resolveHost: deps.resolveHost,
127
+ now,
128
+ });
71
129
  const lookupClient = async (clientId) => {
72
130
  if (isUrlShapedClientId(clientId)) {
73
131
  return resolveCimdClient(clientId, {
@@ -134,7 +192,10 @@ export function createAuthorizationServer(deps = {}) {
134
192
  case "org-picker": {
135
193
  c.header("Content-Security-Policy", await pickerContentSecurityPolicy());
136
194
  const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
137
- return c.html(renderOrgPicker(outcome.model, locale));
195
+ return c.html(renderOrgPicker(outcome.model, locale, {
196
+ // Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
197
+ unverified: !isUrlShapedClientId(outcome.model.client.clientId),
198
+ }));
138
199
  }
139
200
  case "refused": {
140
201
  const mapped = oauthErrorFor(outcome.refusal);
@@ -178,7 +239,9 @@ export function createAuthorizationServer(deps = {}) {
178
239
  },
179
240
  mount(app) {
180
241
  app.use("/.well-known/*", cors());
181
- app.use("/register", cors());
242
+ // /register is for native and server-side clients, which send no Origin. A browser origin
243
+ // may call it only when the operator lists it.
244
+ app.use("/register", cors({ origin: (origin) => (registerOrigins.includes(origin) ? origin : null) }));
182
245
  app.use("/token", cors());
183
246
  const metadata = async (c) => {
184
247
  const result = await settingsNow();
@@ -200,7 +263,8 @@ export function createAuthorizationServer(deps = {}) {
200
263
  }
201
264
  return c.json({
202
265
  ...doc.document,
203
- token_endpoint_auth_methods_supported: ["none"],
266
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
267
+ token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGS],
204
268
  // RFC 9207 — /authorize appends `iss` to every authorization response.
205
269
  authorization_response_iss_parameter_supported: true,
206
270
  // Client ID Metadata Documents are fetched SSRF-guarded (clientRegistry.ts).
@@ -216,7 +280,7 @@ export function createAuthorizationServer(deps = {}) {
216
280
  return c.json((await issuerFor(result.settings)).jwks);
217
281
  });
218
282
  app.post("/register", async (c) => {
219
- const key = (c.req.header("x-forwarded-for") ?? "").split(",")[0]?.trim() || "unknown";
283
+ const key = clientKeyOf(c, trustedProxyCount);
220
284
  if (!registerAllowed(key)) {
221
285
  c.header("Retry-After", "60");
222
286
  return c.json({ error: "too_many_requests" }, 429);
@@ -291,7 +355,38 @@ export function createAuthorizationServer(deps = {}) {
291
355
  if (!result.ok)
292
356
  return unavailable(c, result.missing);
293
357
  const { cfg, authDeps } = runtimeFor(result.settings);
294
- const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken: sessionTokenFrom(c) }, cfg, authDeps);
358
+ let sessionToken = sessionTokenFrom(c);
359
+ // On return from Clerk's hosted sign-in the session is not on this host yet (on a
360
+ // development instance it lives on accounts.dev). Clerk's own handshake brings it:
361
+ // the SDK answers `handshake` (redirect + Set-Cookie) and comes back with `__session`.
362
+ // An explicit Bearer token is the signed-in case already; injected collaborators (tests,
363
+ // custom deployments) skip the SDK, which needs the secret key.
364
+ const hasBearer = c.req.header("authorization")?.toLowerCase().startsWith("bearer ");
365
+ if (!hasBearer && !clerkInjected && (env[OAUTH_ENV.clerkSecretKey] ?? "") !== "") {
366
+ // A forged or stale `__clerk_handshake` makes the SDK throw. That is no session,
367
+ // never a 500: the request continues signed out and resumeAuthorize refuses it.
368
+ const session = await resolveClerkSessionFromRequest(c.req.raw, {
369
+ secretKey: env[OAUTH_ENV.clerkSecretKey] ?? "",
370
+ publishableKey: publishableKeyFor(env, result.settings.clerkIssuer),
371
+ authorizedParties: result.settings.authorizedParties,
372
+ }).catch(() => ({ kind: "signed-out" }));
373
+ if (session.kind === "handshake") {
374
+ // A raw Response bypasses the context's headers, so no-store is set on it directly.
375
+ const headers = new Headers(session.headers);
376
+ headers.set("Cache-Control", "no-store");
377
+ headers.set("Pragma", "no-cache");
378
+ return new Response(null, { status: session.status, headers });
379
+ }
380
+ sessionToken = session.kind === "signed-in" ? session.sessionToken : undefined;
381
+ // The handshake return carries the cookies (`__session`, `__client_uat`) the picker's
382
+ // form post needs on this host: hand them to the browser with the response.
383
+ if (session.kind === "signed-in") {
384
+ for (const cookie of session.headers.getSetCookie()) {
385
+ c.header("Set-Cookie", cookie, { append: true });
386
+ }
387
+ }
388
+ }
389
+ const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken }, cfg, authDeps);
295
390
  return respond(c, outcome, result.settings);
296
391
  });
297
392
  // The picker's form post. `consentToken` is the CSRF protection: a cross-site form
@@ -308,12 +403,20 @@ export function createAuthorizationServer(deps = {}) {
308
403
  return c.json({ error: "invalid_request", error_description: "unreadable form" }, 400);
309
404
  }
310
405
  const field = (name) => typeof form[name] === "string" ? form[name] : undefined;
406
+ const { cfg, authDeps } = runtimeFor(result.settings);
407
+ // A denial is the package's `denied` outcome: it verifies state, expiry, client and
408
+ // session, then answers the redirect-to-client with error=access_denied and the
409
+ // client's state. `respond` appends `iss` to it like to every authorization response.
311
410
  if (field("approved") === "false") {
312
- noStore(c);
313
- const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
314
- return c.json({ error: "access_denied", error_description: t(locale, "oauth.denied") }, 403);
411
+ const denied = await resumeAuthorize({
412
+ state: field("state") ?? "",
413
+ sessionToken: sessionTokenFrom(c),
414
+ denied: true,
415
+ // cloud-identity >= 0.18 verifies the consent token on a denial too.
416
+ consentToken: field("consentToken"),
417
+ }, cfg, authDeps);
418
+ return respond(c, denied, result.settings);
315
419
  }
316
- const { cfg, authDeps } = runtimeFor(result.settings);
317
420
  const outcome = await resumeAuthorize({
318
421
  state: field("state") ?? "",
319
422
  sessionToken: sessionTokenFrom(c),
@@ -345,7 +448,22 @@ export function createAuthorizationServer(deps = {}) {
345
448
  if (body.grant_type !== "authorization_code") {
346
449
  return c.json({ error: "unsupported_grant_type" }, 400);
347
450
  }
348
- const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: clientId, } = body;
451
+ const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: sentClientId, } = body;
452
+ // A presented credential is never ignored: an assertion is verified before anything else
453
+ // and yields the client_id. Without an assertion the public-client path applies.
454
+ let clientId = sentClientId;
455
+ if (body.client_assertion !== undefined || body.client_assertion_type !== undefined) {
456
+ const verified = await assertionVerifier.verify({
457
+ assertionType: body.client_assertion_type,
458
+ assertion: body.client_assertion,
459
+ clientId: sentClientId,
460
+ audiences: [`${settings.baseUrl}/token`, settings.baseUrl],
461
+ });
462
+ if (!verified.ok) {
463
+ return c.json({ error: "invalid_client", error_description: verified.reason }, 401);
464
+ }
465
+ clientId = verified.clientId;
466
+ }
349
467
  if (!code || !codeVerifier || !redirectUri || !clientId) {
350
468
  return c.json({
351
469
  error: "invalid_request",
@@ -12,4 +12,8 @@ export interface ClerkCollaborators {
12
12
  listMemberships(userId: string): Promise<readonly ClerkOrgMembership[]>;
13
13
  getUser(userId: string): Promise<ClerkUserLike>;
14
14
  }
15
- export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch): ClerkCollaborators;
15
+ export interface ClerkBackendOptions {
16
+ /** Per-call time budget (JWKS, memberships, user). Default 5 s. */
17
+ timeoutMs?: number;
18
+ }
19
+ export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch, options?: ClerkBackendOptions): ClerkCollaborators;
@@ -9,11 +9,16 @@
9
9
  const CLERK_API = "https://api.clerk.com/v1";
10
10
  const JWKS_CACHE_MS = 10 * 60 * 1000;
11
11
  const MEMBERSHIP_PAGE_LIMIT = 100;
12
- export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
12
+ /** 50 pages x 100 = 5 000 memberships: past it the lookup FAILS rather than truncate. */
13
+ const MEMBERSHIP_MAX_PAGES = 50;
14
+ const CLERK_TIMEOUT_MS = 5000;
15
+ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, options = {}) {
16
+ const timeoutMs = options.timeoutMs ?? CLERK_TIMEOUT_MS;
13
17
  let cachedJwks = null;
14
18
  const api = async (path) => {
15
19
  const res = await fetchImpl(`${CLERK_API}${path}`, {
16
20
  headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
21
+ signal: AbortSignal.timeout(timeoutMs),
17
22
  });
18
23
  if (!res.ok)
19
24
  throw new Error(`Clerk Backend API answered ${res.status}`);
@@ -25,7 +30,9 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
25
30
  if (cachedJwks && cachedJwks.issuer === issuer && now - cachedJwks.at < JWKS_CACHE_MS) {
26
31
  return cachedJwks.keys;
27
32
  }
28
- const res = await fetchImpl(`${issuer}/.well-known/jwks.json`);
33
+ const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, {
34
+ signal: AbortSignal.timeout(timeoutMs),
35
+ });
29
36
  if (!res.ok)
30
37
  throw new Error(`Clerk JWKS answered ${res.status}`);
31
38
  const keys = (await res.json());
@@ -33,15 +40,26 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
33
40
  return keys;
34
41
  },
35
42
  async listMemberships(userId) {
36
- const page = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}`);
37
- return page.data.map((m) => ({
38
- role: m.role,
39
- organization: {
40
- id: m.organization.id,
41
- slug: m.organization.slug ?? null,
42
- name: m.organization.name,
43
- },
44
- }));
43
+ const all = [];
44
+ for (let page = 0; page < MEMBERSHIP_MAX_PAGES; page++) {
45
+ const offset = page * MEMBERSHIP_PAGE_LIMIT;
46
+ const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`);
47
+ all.push(...body.data);
48
+ const last = body.data.length < MEMBERSHIP_PAGE_LIMIT ||
49
+ (typeof body.total_count === "number" && all.length >= body.total_count);
50
+ if (last) {
51
+ return all.map((m) => ({
52
+ role: m.role,
53
+ organization: {
54
+ id: m.organization.id,
55
+ slug: m.organization.slug ?? null,
56
+ name: m.organization.name,
57
+ },
58
+ }));
59
+ }
60
+ }
61
+ // A truncated list would hide organisations the person belongs to: refuse instead.
62
+ throw new Error(`Clerk memberships exceed ${MEMBERSHIP_MAX_PAGES * MEMBERSHIP_PAGE_LIMIT}`);
45
63
  },
46
64
  async getUser(userId) {
47
65
  const user = await api(`/users/${encodeURIComponent(userId)}`);
@@ -0,0 +1,28 @@
1
+ import { type CimdOptions } from "./clientRegistry.js";
2
+ export declare const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
3
+ export declare const ASSERTION_SIGNING_ALGS: readonly ["RS256"];
4
+ export type AssertionResult = {
5
+ ok: true;
6
+ clientId: string;
7
+ } | {
8
+ ok: false;
9
+ reason: string;
10
+ };
11
+ /** Bounded set of accepted jti values, each held until its assertion expires. */
12
+ export declare function createJtiStore(maxEntries?: number): {
13
+ /** True when the jti is new (and is now recorded); false on a replay or when full. */
14
+ accept(clientId: string, jti: string, exp: number, nowSeconds: number): boolean;
15
+ };
16
+ export interface AssertionVerifierOptions extends CimdOptions {
17
+ now?: () => number;
18
+ jtiStore?: ReturnType<typeof createJtiStore>;
19
+ }
20
+ export declare function createClientAssertionVerifier(options?: AssertionVerifierOptions): {
21
+ verify(input: {
22
+ assertionType: string | undefined;
23
+ assertion: string | undefined;
24
+ /** The `client_id` request parameter, when sent. */
25
+ clientId: string | undefined;
26
+ audiences: string[];
27
+ }): Promise<AssertionResult>;
28
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * private_key_jwt client authentication at `/token` (RFC 7523 / OIDC Core 9).
3
+ *
4
+ * A presented assertion is VERIFIED, never ignored and never trusted unverified:
5
+ * - the key set comes from the `jwks_uri` of the client's CIMD document, fetched through the
6
+ * same SSRF guard as the document itself;
7
+ * - alg is pinned to RS256 (a document declaring another signing alg is refused), so `none`
8
+ * and HMAC assertions cannot verify;
9
+ * - `iss` and `sub` equal the client_id; `aud` is this token endpoint or this issuer; `exp`
10
+ * is required (30 s skew); `jti` is required and accepted ONCE.
11
+ */
12
+ import { createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
13
+ import { fetchGuardedJson, isUrlShapedClientId, resolveCimdDocument, } from "./clientRegistry.js";
14
+ export const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
15
+ export const ASSERTION_SIGNING_ALGS = ["RS256"];
16
+ const SKEW_SECONDS = 30;
17
+ const MAX_JWKS_BYTES = 64 * 1024;
18
+ /** Bounded set of accepted jti values, each held until its assertion expires. */
19
+ export function createJtiStore(maxEntries = 10_000) {
20
+ const seen = new Map(); // `${clientId}\n${jti}` -> exp (epoch seconds)
21
+ return {
22
+ /** True when the jti is new (and is now recorded); false on a replay or when full. */
23
+ accept(clientId, jti, exp, nowSeconds) {
24
+ for (const [k, e] of seen)
25
+ if (e + SKEW_SECONDS <= nowSeconds)
26
+ seen.delete(k);
27
+ const key = `${clientId}\n${jti}`;
28
+ if (seen.has(key))
29
+ return false;
30
+ if (seen.size >= maxEntries)
31
+ return false; // fail closed: never evict a live jti
32
+ seen.set(key, exp);
33
+ return true;
34
+ },
35
+ };
36
+ }
37
+ export function createClientAssertionVerifier(options = {}) {
38
+ const now = options.now ?? (() => Date.now());
39
+ const jtiStore = options.jtiStore ?? createJtiStore();
40
+ const fetchOpts = {
41
+ fetchImpl: options.fetchImpl,
42
+ resolveHost: options.resolveHost,
43
+ maxBytes: MAX_JWKS_BYTES,
44
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
45
+ };
46
+ return {
47
+ async verify(input) {
48
+ const fail = (reason) => ({ ok: false, reason });
49
+ if (input.assertionType !== JWT_BEARER_ASSERTION_TYPE || !input.assertion) {
50
+ return fail("unsupported client assertion");
51
+ }
52
+ // The unverified iss only LOCATES the key set; it is re-checked against the verified claims.
53
+ let claimedIss;
54
+ try {
55
+ claimedIss = decodeJwt(input.assertion).iss;
56
+ }
57
+ catch {
58
+ return fail("malformed client assertion");
59
+ }
60
+ if (!claimedIss || !isUrlShapedClientId(claimedIss))
61
+ return fail("unknown client");
62
+ if (input.clientId !== undefined && input.clientId !== claimedIss) {
63
+ return fail("client_id does not match the assertion issuer");
64
+ }
65
+ const clientId = claimedIss;
66
+ const cimd = await resolveCimdDocument(clientId, options);
67
+ if (!cimd?.jwksUri)
68
+ return fail("client has no verifiable key set");
69
+ if (cimd.signingAlg !== undefined &&
70
+ !ASSERTION_SIGNING_ALGS.includes(cimd.signingAlg)) {
71
+ return fail("unsupported signing algorithm");
72
+ }
73
+ let jwksUrl;
74
+ try {
75
+ jwksUrl = new URL(cimd.jwksUri);
76
+ }
77
+ catch {
78
+ return fail("invalid jwks_uri");
79
+ }
80
+ const jwks = (await fetchGuardedJson(jwksUrl, fetchOpts));
81
+ if (!jwks || !Array.isArray(jwks.keys))
82
+ return fail("key set unavailable");
83
+ let payload;
84
+ try {
85
+ const verified = await jwtVerify(input.assertion, createLocalJWKSet(jwks), {
86
+ algorithms: [...ASSERTION_SIGNING_ALGS],
87
+ issuer: clientId,
88
+ subject: clientId,
89
+ audience: input.audiences,
90
+ clockTolerance: SKEW_SECONDS,
91
+ currentDate: new Date(now()),
92
+ requiredClaims: ["iss", "sub", "aud", "exp", "jti"],
93
+ });
94
+ payload = verified.payload;
95
+ }
96
+ catch {
97
+ return fail("client assertion rejected");
98
+ }
99
+ const exp = payload.exp;
100
+ if (!payload.jti || !jtiStore.accept(clientId, payload.jti, exp, Math.floor(now() / 1000))) {
101
+ return fail("client assertion replayed");
102
+ }
103
+ return { ok: true, clientId };
104
+ },
105
+ };
106
+ }
@@ -24,7 +24,19 @@ export interface ClientRegistry {
24
24
  get(clientId: string): Promise<RegisteredClient | null>;
25
25
  register(client: RegisteredClient): Promise<void>;
26
26
  }
27
- export declare function createInMemoryClientRegistry(maxClients?: number): ClientRegistry;
27
+ export interface InMemoryClientRegistryOptions {
28
+ /** Hard cap on stored clients; at the cap the OLDEST client is evicted. Default 10 000. */
29
+ maxClients?: number;
30
+ /** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
31
+ ttlSeconds?: number;
32
+ now?: () => number;
33
+ }
34
+ /**
35
+ * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
36
+ * never stops accepting registrations either: expired clients are swept on every registration,
37
+ * and at the cap the oldest client is evicted (an evicted client re-registers).
38
+ */
39
+ export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
28
40
  /** True for an https URL, or an http URL on a loopback host (native-app redirects). */
29
41
  export declare function isAllowedRedirectUri(raw: unknown): raw is string;
30
42
  /** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
@@ -38,6 +50,20 @@ export interface CimdOptions {
38
50
  }
39
51
  /** True when a client_id is URL-shaped, i.e. a CIMD identifier (valid or not). */
40
52
  export declare function isUrlShapedClientId(clientId: string): boolean;
53
+ /**
54
+ * The one SSRF-guarded fetch shared by the CIMD document and the client's JWKS: https only,
55
+ * public addresses only, no redirects, size and time capped. Returns the parsed JSON, or null
56
+ * for ANY failure.
57
+ */
58
+ export declare function fetchGuardedJson(url: URL, options?: CimdOptions): Promise<unknown>;
59
+ /** A validated CIMD: the client plus what `/token` needs to verify a private_key_jwt assertion. */
60
+ export interface CimdDocument {
61
+ client: AuthorizeClient;
62
+ /** Present only when the document declares an https `jwks_uri`. */
63
+ jwksUri?: string;
64
+ /** The document's `token_endpoint_auth_signing_alg`, when declared. */
65
+ signingAlg?: string;
66
+ }
41
67
  /**
42
68
  * Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
43
69
  * unreachable, oversized, malformed, private-addressed or self-contradicting document is an
@@ -47,4 +73,5 @@ export declare function isUrlShapedClientId(clientId: string): boolean;
47
73
  * the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
48
74
  * injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
49
75
  */
76
+ export declare function resolveCimdDocument(clientId: string, options?: CimdOptions): Promise<CimdDocument | null>;
50
77
  export declare function resolveCimdClient(clientId: string, options?: CimdOptions): Promise<AuthorizeClient | null>;