@aooth/auth 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/authz.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
2
- import { a as PendingAuthorizationStore, i as DEFAULT_PENDING_TTL_MS, n as AuthCodeStoreMemory, o as PendingAuthorizationStoreMemory, t as AuthCodeStore } from "./auth-code-store-Dqc4rEOx.mjs";
2
+ import { c as PendingAuthorizationStoreMemory, i as AuthCodeStoreMemory, n as DynamicClientStoreMemory, o as DEFAULT_PENDING_TTL_MS, r as AuthCodeStore, s as PendingAuthorizationStore, t as DynamicClientStore } from "./dynamic-client-store-DLRfntHr.mjs";
3
3
  import { timingSafeEqual } from "node:crypto";
4
4
  import { SignJWT, exportJWK, importPKCS8, importSPKI } from "jose";
5
5
  //#region src/authz/authz-errors.ts
@@ -115,9 +115,14 @@ var RegisteredClientPolicy = class {
115
115
  idToken,
116
116
  accessToken: client.accessToken === true,
117
117
  tokenPolicy: client.tokenPolicy ? structuredClone(client.tokenPolicy) : {},
118
+ ...client.clientName !== void 0 && { clientName: client.clientName },
118
119
  ...scope !== void 0 && { scope }
119
120
  };
120
121
  }
122
+ /** Known-ness probe for `CompositeClientPolicy` dispatch (static registry, sync). */
123
+ hasClient(clientId) {
124
+ return this.clients.has(clientId);
125
+ }
121
126
  authenticateClient(args) {
122
127
  const client = this.requireClient(args.clientId);
123
128
  if ((client.type ?? "public") !== "confidential") return;
@@ -161,29 +166,290 @@ function timingSafeEqualStr(a, b) {
161
166
  //#endregion
162
167
  //#region src/authz/composite-client-policy.ts
163
168
  /**
164
- * Runs Tier-1 and Tier-2 side by side, dispatching on the **presence of
165
- * `client_id`** (AUTH-SERVER.md §10): a request with a `client_id` is a
166
- * registered client, one without is a loopback CLI. The split is the safety
167
- * boundary a registered client is routed only to {@link RegisteredClientPolicy}
168
- * (which enforces ITS redirect allowlist, so it cannot smuggle a loopback
169
- * redirect), and a no-`client_id` request is routed only to the loopback policy
170
- * (so it cannot claim to be a registered client). Each sub-policy still owns its
171
- * own redirect validation; this only picks which one runs.
169
+ * Runs the tiers side by side, dispatching on the **presence and ownership of
170
+ * `client_id`** (AUTH-SERVER.md §10, OAUTH.md R2): a request without a
171
+ * `client_id` is a loopback CLI; one with a `client_id` belongs to the static
172
+ * registry when it knows the id (`hasClient`), else to the dynamic policy. The
173
+ * split is the safety boundary each sub-policy still enforces ITS OWN
174
+ * redirect allowlist (so a registered client cannot smuggle a loopback
175
+ * redirect and a no-`client_id` request cannot claim to be registered), and
176
+ * static-first dispatch means a dynamic registration can never shadow a static
177
+ * client id. `authenticateClient` routes through the SAME picker: a dynamic
178
+ * `client_id` must be authenticated by the dynamic policy (existence check,
179
+ * PKCE binds), never rejected by the static registry that doesn't know it.
172
180
  */
173
181
  var CompositeClientPolicy = class {
174
182
  loopback;
175
183
  registered;
184
+ dynamic;
176
185
  constructor(opts) {
186
+ if (!opts.registered && !opts.dynamic) throw new Error("CompositeClientPolicy requires at least one of `registered` / `dynamic` — with only loopback there is nothing to compose");
187
+ if (opts.registered && opts.dynamic && typeof opts.registered.hasClient !== "function") throw new Error("CompositeClientPolicy: `registered` must implement hasClient() when composed with `dynamic`");
177
188
  this.loopback = opts.loopback;
178
189
  this.registered = opts.registered;
190
+ this.dynamic = opts.dynamic;
179
191
  }
180
192
  resolveClient(args) {
181
- return args.clientId ? this.registered.resolveClient(args) : this.loopback.resolveClient(args);
193
+ if (!args.clientId) return this.loopback.resolveClient(args);
194
+ const picked = this.pickForClientId(args.clientId);
195
+ return picked instanceof Promise ? picked.then((p) => p.resolveClient(args)) : picked.resolveClient(args);
182
196
  }
183
197
  authenticateClient(args) {
184
- if (args.clientId) return this.registered.authenticateClient?.(args);
198
+ if (!args.clientId) return;
199
+ const picked = this.pickForClientId(args.clientId);
200
+ return picked instanceof Promise ? picked.then((p) => p.authenticateClient?.(args)) : picked.authenticateClient?.(args);
201
+ }
202
+ /**
203
+ * Ownership dispatch for a presented `client_id` — static registry first
204
+ * (when it knows the id), dynamic otherwise. Sync when the probe is sync
205
+ * (the static registry's is), preserving the callers' sync fast path.
206
+ */
207
+ pickForClientId(clientId) {
208
+ if (!this.registered) return this.dynamic;
209
+ if (!this.dynamic) return this.registered;
210
+ const known = this.registered.hasClient(clientId);
211
+ return known instanceof Promise ? known.then((k) => k ? this.registered : this.dynamic) : known ? this.registered : this.dynamic;
212
+ }
213
+ };
214
+ //#endregion
215
+ //#region src/authz/client-registration.ts
216
+ /** A typed RFC 7591 registration failure; `message` becomes `error_description`. */
217
+ var ClientRegistrationError = class extends Error {
218
+ code;
219
+ constructor(code, message) {
220
+ super(message);
221
+ this.name = "ClientRegistrationError";
222
+ this.code = code;
223
+ }
224
+ };
225
+ /** Grant/response types the v1 authorization server supports (auth-code + PKCE only). */
226
+ const SUPPORTED_GRANT_TYPES = ["authorization_code"];
227
+ const SUPPORTED_RESPONSE_TYPES = ["code"];
228
+ /** RFC 6749 §3.3 scope-token charset: printable ASCII minus space, `"` and `\`. */
229
+ const SCOPE_TOKEN_RE = /^[\x21\x23-\x5B\x5D-\x7E]+$/u;
230
+ const DEFAULT_MAX_REDIRECT_URIS = 5;
231
+ const DEFAULT_MAX_REDIRECT_URI_LENGTH = 512;
232
+ const DEFAULT_MAX_CLIENT_NAME_LENGTH = 128;
233
+ const DEFAULT_MAX_SCOPE_LENGTH = 256;
234
+ /**
235
+ * `client_name` is attacker-supplied text that ends up on the consent prompt.
236
+ * Strip every control (Cc) and format (Cf) character — that kills CR/LF, the
237
+ * Unicode bidi overrides (U+202A–202E, U+2066–2069) used to visually reverse
238
+ * a name, and zero-width characters used to forge lookalikes — then trim and
239
+ * cap. Rendering it as a TEXT node (never markup) is the consent form's job;
240
+ * this is defense in depth, not the whole defense.
241
+ */
242
+ function sanitizeClientName(name, maxLength) {
243
+ const cleaned = name.replace(/[\p{Cc}\p{Cf}]/gu, "").trim();
244
+ if (cleaned.length === 0) return void 0;
245
+ return cleaned.slice(0, maxLength);
246
+ }
247
+ function asStringArray(value, field) {
248
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) throw new ClientRegistrationError("invalid_client_metadata", `${field} must be an array of strings`);
249
+ return value;
250
+ }
251
+ /**
252
+ * `true` when `uri` is an acceptable dynamic-client redirect target (OAUTH.md
253
+ * R2): an `https://` URL with an explicit host (stored for EXACT matching — no
254
+ * wildcards, no prefixes) or a loopback literal per the Tier-1 RFC 8252 rules.
255
+ * Custom schemes are rejected in v1. A fragment is rejected outright (RFC 6749
256
+ * §3.1.2: the redirection endpoint URI MUST NOT include a fragment component).
257
+ */
258
+ function isAcceptableRedirectUri(uri) {
259
+ let url;
260
+ try {
261
+ url = new URL(uri);
262
+ } catch {
263
+ return false;
264
+ }
265
+ if (url.hash !== "") return false;
266
+ if (isLoopbackRedirectUri(uri)) return true;
267
+ return url.protocol === "https:" && url.hostname !== "" && url.username === "" && url.password === "";
268
+ }
269
+ /**
270
+ * Validate + normalize an RFC 7591 registration request body into a
271
+ * {@link NewDynamicClient}, or THROW a {@link ClientRegistrationError}.
272
+ *
273
+ * Normalization is allowed by RFC 7591 §2 (the server MAY replace requested
274
+ * metadata with its own values) and is load-bearing for real connectors:
275
+ * `grant_types` / `response_types` are INTERSECTED with what the server
276
+ * supports (a connector requesting `["authorization_code", "refresh_token"]`
277
+ * registers with `["authorization_code"]` — the 201 echo of the narrowed set
278
+ * is the contract) rather than rejected. `token_endpoint_auth_method` defaults
279
+ * to `"none"` when absent (v1 is public-clients-only, so the RFC's
280
+ * `client_secret_basic` default is unsupported), but an EXPLICIT non-`"none"`
281
+ * ask is rejected — never silently downgrade a client that asked for a secret.
282
+ * Unknown fields are ignored and never echoed.
283
+ */
284
+ function validateClientRegistration(body, opts) {
285
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new ClientRegistrationError("invalid_client_metadata", "registration request must be a JSON object");
286
+ const req = body;
287
+ const maxUris = opts?.maxRedirectUris ?? DEFAULT_MAX_REDIRECT_URIS;
288
+ const maxUriLength = opts?.maxRedirectUriLength ?? DEFAULT_MAX_REDIRECT_URI_LENGTH;
289
+ if (req.redirect_uris === void 0) throw new ClientRegistrationError("invalid_redirect_uri", "redirect_uris is required");
290
+ const redirectUris = [...new Set(asStringArray(req.redirect_uris, "redirect_uris"))];
291
+ if (redirectUris.length === 0) throw new ClientRegistrationError("invalid_redirect_uri", "redirect_uris must be non-empty");
292
+ if (redirectUris.length > maxUris) throw new ClientRegistrationError("invalid_redirect_uri", `redirect_uris accepts at most ${maxUris} entries`);
293
+ for (const uri of redirectUris) if (uri.length > maxUriLength || !isAcceptableRedirectUri(uri)) throw new ClientRegistrationError("invalid_redirect_uri", "each redirect_uri must be an https URL with an explicit host or a loopback literal, without a fragment");
294
+ if ((req.token_endpoint_auth_method ?? "none") !== "none") throw new ClientRegistrationError("invalid_client_metadata", "only token_endpoint_auth_method \"none\" is supported");
295
+ const grantTypes = req.grant_types === void 0 ? [...SUPPORTED_GRANT_TYPES] : asStringArray(req.grant_types, "grant_types").filter((g) => SUPPORTED_GRANT_TYPES.includes(g));
296
+ if (grantTypes.length === 0) throw new ClientRegistrationError("invalid_client_metadata", "grant_types must include authorization_code");
297
+ const responseTypes = req.response_types === void 0 ? [...SUPPORTED_RESPONSE_TYPES] : asStringArray(req.response_types, "response_types").filter((r) => SUPPORTED_RESPONSE_TYPES.includes(r));
298
+ if (responseTypes.length === 0) throw new ClientRegistrationError("invalid_client_metadata", "response_types must include code");
299
+ let clientName;
300
+ if (req.client_name !== void 0) {
301
+ if (typeof req.client_name !== "string") throw new ClientRegistrationError("invalid_client_metadata", "client_name must be a string");
302
+ clientName = sanitizeClientName(req.client_name, opts?.maxClientNameLength ?? DEFAULT_MAX_CLIENT_NAME_LENGTH);
303
+ }
304
+ let scope;
305
+ if (req.scope !== void 0) {
306
+ if (typeof req.scope !== "string") throw new ClientRegistrationError("invalid_client_metadata", "scope must be a string");
307
+ if (req.scope.length > (opts?.maxScopeLength ?? DEFAULT_MAX_SCOPE_LENGTH)) throw new ClientRegistrationError("invalid_client_metadata", "scope is too long");
308
+ const tokens = req.scope.split(/\s+/u).filter(Boolean);
309
+ if (tokens.some((t) => !SCOPE_TOKEN_RE.test(t))) throw new ClientRegistrationError("invalid_client_metadata", "scope contains invalid characters");
310
+ const kept = opts?.allowedScopes ? tokens.filter((t) => opts.allowedScopes.includes(t)) : tokens;
311
+ if (kept.length > 0) scope = kept.join(" ");
312
+ }
313
+ return {
314
+ redirectUris,
315
+ tokenEndpointAuthMethod: "none",
316
+ grantTypes,
317
+ responseTypes,
318
+ ...clientName !== void 0 && { clientName },
319
+ ...scope !== void 0 && { scope }
320
+ };
321
+ }
322
+ const DEFAULT_MAX_CLIENTS = 1e3;
323
+ /**
324
+ * The RFC 7591 registration operation behind `POST {issuer}/register`
325
+ * (OAUTH.md R2): validate → guard → lazy GC of never-used rows → hard cap →
326
+ * persist. Framework-free — `@aooth/auth-moost`'s controller endpoint is a
327
+ * thin HTTP adapter over `register()`, and non-moost servers can call it
328
+ * directly.
329
+ */
330
+ var DynamicClientRegistration = class {
331
+ store;
332
+ maxClients;
333
+ unusedClientTtlMs;
334
+ guard;
335
+ validation;
336
+ clock;
337
+ constructor(opts) {
338
+ this.store = opts.store;
339
+ this.maxClients = opts.maxClients ?? DEFAULT_MAX_CLIENTS;
340
+ this.unusedClientTtlMs = opts.unusedClientTtlMs;
341
+ this.guard = opts.guard;
342
+ this.validation = opts.validation;
343
+ this.clock = opts.clock ?? defaultClock;
344
+ }
345
+ /** Validate and persist a registration request body; returns the minted client. */
346
+ async register(body) {
347
+ const metadata = validateClientRegistration(body, this.validation);
348
+ await this.guard?.({ metadata });
349
+ if (this.unusedClientTtlMs !== void 0) await this.store.deleteUnusedBefore(this.clock.now() - this.unusedClientTtlMs);
350
+ if (await this.store.count() >= this.maxClients) throw new ClientRegistrationError("invalid_client_metadata", "registration limit reached");
351
+ return this.store.create(metadata);
352
+ }
353
+ };
354
+ //#endregion
355
+ //#region src/authz/dynamic-client-policy.ts
356
+ const DEFAULT_DYNAMIC_TOKEN_POLICY = {
357
+ kind: "dynamic-session",
358
+ ttl: 720 * 60 * 6e4
359
+ };
360
+ /**
361
+ * Policy for RFC 7591 dynamically-registered public clients (OAUTH.md R2) on
362
+ * the `ClientRedirectPolicy` seam. `resolveClient` authorizes the client +
363
+ * `redirect_uri` against ITS registered allowlist and resolves the granted
364
+ * scope + token policy; `authenticateClient` re-checks existence at `/token`
365
+ * (`token_endpoint_auth_method: "none"` ⇒ no secret — PKCE is the binding, and
366
+ * a registration garbage-collected mid-flight fails closed). Dynamic clients
367
+ * receive an access token only — NO `id_token` in v1 (OAUTH.md R6).
368
+ *
369
+ * INVARIANT: the returned {@link ResolvedClient} always carries `clientId`, so
370
+ * the minted code records it and the token endpoint's symmetric client binding
371
+ * applies — a dynamic code redeemed without (or with another) `client_id` is
372
+ * rejected exactly like a Tier-2 code.
373
+ */
374
+ var DynamicClientPolicy = class {
375
+ store;
376
+ tokenPolicy;
377
+ allowedScopes;
378
+ clock;
379
+ constructor(opts) {
380
+ this.store = opts.store;
381
+ this.tokenPolicy = opts.tokenPolicy ?? DEFAULT_DYNAMIC_TOKEN_POLICY;
382
+ this.allowedScopes = opts.allowedScopes;
383
+ this.clock = opts.clock ?? defaultClock;
384
+ }
385
+ async resolveClient(args) {
386
+ const client = await this.requireClient(args.clientId);
387
+ if (!redirectAllowed(client, args.redirectUri)) throw new AuthorizeError("invalid_redirect", "redirect_uri is not registered for this client");
388
+ const scope = this.grantScope(client, args.scope);
389
+ try {
390
+ await this.store.touch(client.clientId, this.clock.now());
391
+ } catch {}
392
+ return {
393
+ clientId: client.clientId,
394
+ redirectUri: args.redirectUri,
395
+ accessToken: true,
396
+ tokenPolicy: structuredClone(this.tokenPolicy),
397
+ ...client.clientName !== void 0 && { clientName: client.clientName },
398
+ ...scope !== void 0 && { scope }
399
+ };
400
+ }
401
+ /**
402
+ * `/token`-side check: the client must still exist (fail closed when the
403
+ * registration was deleted/GC'd between authorize and redemption). No secret
404
+ * is checked — public client, PKCE is the binding; a spurious
405
+ * `client_secret` is ignored.
406
+ */
407
+ async authenticateClient(args) {
408
+ await this.requireClient(args.clientId);
409
+ }
410
+ /** Known-ness probe for `CompositeClientPolicy` dispatch. */
411
+ async hasClient(clientId) {
412
+ return await this.store.get(clientId) !== null;
413
+ }
414
+ async requireClient(clientId) {
415
+ const client = clientId ? await this.store.get(clientId) : null;
416
+ if (!client) throw new AuthorizeError("invalid_client", "unknown client");
417
+ return client;
418
+ }
419
+ grantScope(client, requested) {
420
+ if (!requested) return void 0;
421
+ let granted = requested.split(/\s+/u).filter(Boolean);
422
+ if (this.allowedScopes) granted = granted.filter((s) => this.allowedScopes.includes(s));
423
+ if (client.scope !== void 0) {
424
+ const registered = new Set(client.scope.split(/\s+/u).filter(Boolean));
425
+ granted = granted.filter((s) => registered.has(s));
426
+ }
427
+ return granted.length > 0 ? granted.join(" ") : void 0;
185
428
  }
186
429
  };
430
+ /**
431
+ * Exact match against the registered allowlist, with ONE relaxation: for a
432
+ * loopback redirect the PORT is ignored (RFC 8252 §7.3 — native/CLI clients
433
+ * bind an ephemeral port per run, so the AS MUST allow a variable port).
434
+ * Scheme, host, path and query still must match a registered loopback entry
435
+ * exactly; `https` entries never get the relaxation.
436
+ */
437
+ function redirectAllowed(client, uri) {
438
+ if (client.redirectUris.includes(uri)) return true;
439
+ if (!isLoopbackRedirectUri(uri)) return false;
440
+ let presented;
441
+ try {
442
+ presented = new URL(uri);
443
+ } catch {
444
+ return false;
445
+ }
446
+ if (presented.hash !== "") return false;
447
+ return client.redirectUris.some((registered) => {
448
+ if (!isLoopbackRedirectUri(registered)) return false;
449
+ const reg = new URL(registered);
450
+ return reg.protocol === presented.protocol && reg.hostname === presented.hostname && reg.pathname === presented.pathname && reg.search === presented.search;
451
+ });
452
+ }
187
453
  //#endregion
188
454
  //#region src/authz/id-token-signer.ts
189
455
  /**
@@ -252,4 +518,92 @@ var IdTokenSigner = class {
252
518
  }
253
519
  };
254
520
  //#endregion
255
- export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, CompositeClientPolicy, DEFAULT_PENDING_TTL_MS, IdTokenSigner, LoopbackClientPolicy, NoopOidcClaimsResolver, OidcClaimsResolver, PendingAuthorizationStore, PendingAuthorizationStoreMemory, RegisteredClientPolicy, isLoopbackRedirectUri, scopeGrants };
521
+ //#region src/authz/server-metadata.ts
522
+ /**
523
+ * Strip a trailing slash from an issuer identifier. RFC 8414 mandates EXACT
524
+ * (byte-identical) string comparison of issuer values, so every document that
525
+ * spells the issuer must canonicalize it the same way — same rule as
526
+ * `IdTokenSigner`. Shared by the AS metadata and protected-resource builders.
527
+ */
528
+ function canonicalizeIssuer(issuer) {
529
+ return issuer.replace(/\/$/u, "");
530
+ }
531
+ /**
532
+ * Build the RFC 8414 discovery document for this authorization server. Pure —
533
+ * no I/O, no framework: mount the result on any HTTP stack (including the
534
+ * RFC 8414 path-insertion form rooted at the HTTP origin,
535
+ * `/.well-known/oauth-authorization-server/<issuer-path>`).
536
+ *
537
+ * Capability fields are fixed to what the server actually implements:
538
+ * `response_types_supported` `["code"]`, `grant_types_supported`
539
+ * `["authorization_code"]`, `code_challenge_methods_supported` `["S256"]`
540
+ * (PKCE is mandatory — it is the binding for public clients). Optional fields
541
+ * are omitted entirely (no `undefined` keys) so the serialized JSON carries
542
+ * only what was configured.
543
+ */
544
+ function buildAuthorizationServerMetadata(opts) {
545
+ const issuer = canonicalizeIssuer(opts.issuer);
546
+ const authMethods = opts.tokenEndpointAuthMethodsSupported ?? ["none", "client_secret_post"];
547
+ return {
548
+ issuer,
549
+ authorization_endpoint: opts.authorizationEndpoint ?? `${issuer}/authorize`,
550
+ token_endpoint: opts.tokenEndpoint ?? `${issuer}/token`,
551
+ ...opts.registrationEndpoint !== void 0 && { registration_endpoint: opts.registrationEndpoint },
552
+ ...opts.jwksUri !== void 0 && { jwks_uri: opts.jwksUri },
553
+ response_types_supported: ["code"],
554
+ grant_types_supported: ["authorization_code"],
555
+ code_challenge_methods_supported: ["S256"],
556
+ token_endpoint_auth_methods_supported: authMethods.includes("none") ? authMethods : ["none", ...authMethods],
557
+ ...opts.scopesSupported !== void 0 && { scopes_supported: opts.scopesSupported }
558
+ };
559
+ }
560
+ //#endregion
561
+ //#region src/authz/resource-metadata.ts
562
+ /**
563
+ * Build the RFC 9728 protected-resource document. Pure — no I/O, no framework;
564
+ * mount it on any HTTP stack. Optional fields are omitted entirely (no
565
+ * `undefined` keys) so the serialized JSON carries only what was configured.
566
+ */
567
+ function buildProtectedResourceMetadata(opts) {
568
+ return {
569
+ resource: opts.resource,
570
+ authorization_servers: opts.authorizationServers.map((iss) => canonicalizeIssuer(iss)),
571
+ bearer_methods_supported: opts.bearerMethodsSupported ?? ["header"],
572
+ ...opts.scopesSupported !== void 0 && { scopes_supported: opts.scopesSupported },
573
+ ...opts.resourceName !== void 0 && { resource_name: opts.resourceName }
574
+ };
575
+ }
576
+ /**
577
+ * Header-injection defense: challenge parameter values can carry
578
+ * attacker-influenced input (an `error_description` echoing request data, a
579
+ * scope from a token). A raw CR/LF would terminate the header and let that
580
+ * input inject arbitrary response headers (response splitting), so CR/LF — and
581
+ * every other C0 control char plus DEL — are STRIPPED outright. `\` and `"`
582
+ * are then backslash-escaped per the RFC 7235 quoted-string grammar so a value
583
+ * cannot close its quote and smuggle extra challenge params.
584
+ */
585
+ function sanitizeQuotedStringValue(value) {
586
+ return value.replace(/[\u0000-\u001F\u007F]/gu, "").replace(/(["\\])/gu, "\\$1");
587
+ }
588
+ /**
589
+ * Build the VALUE of an RFC 6750 `WWW-Authenticate` Bearer challenge, e.g.
590
+ * `Bearer resource_metadata="https://...", error="invalid_token"`. With no
591
+ * options (or all absent) it degrades to the bare scheme `Bearer`. Every
592
+ * parameter is emitted as a quoted-string (RFC 6750 style) in a fixed order:
593
+ * `realm`, `error`, `error_description`, `resource_metadata`, `scope`. All
594
+ * values pass through {@link sanitizeQuotedStringValue} — see its security note.
595
+ */
596
+ function buildWwwAuthenticateBearerChallenge(opts) {
597
+ const params = [];
598
+ const push = (name, value) => {
599
+ if (value !== void 0) params.push(`${name}="${sanitizeQuotedStringValue(value)}"`);
600
+ };
601
+ push("realm", opts?.realm);
602
+ push("error", opts?.error);
603
+ push("error_description", opts?.errorDescription);
604
+ push("resource_metadata", opts?.resourceMetadataUrl);
605
+ push("scope", opts?.scope);
606
+ return params.length === 0 ? "Bearer" : `Bearer ${params.join(", ")}`;
607
+ }
608
+ //#endregion
609
+ export { AuthCodeStore, AuthCodeStoreMemory, AuthorizeError, ClientRegistrationError, CompositeClientPolicy, DEFAULT_PENDING_TTL_MS, DynamicClientPolicy, DynamicClientRegistration, DynamicClientStore, DynamicClientStoreMemory, IdTokenSigner, LoopbackClientPolicy, NoopOidcClaimsResolver, OidcClaimsResolver, PendingAuthorizationStore, PendingAuthorizationStoreMemory, RegisteredClientPolicy, buildAuthorizationServerMetadata, buildProtectedResourceMetadata, buildWwwAuthenticateBearerChallenge, canonicalizeIssuer, isLoopbackRedirectUri, scopeGrants, validateClientRegistration };
@@ -35,7 +35,9 @@ var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
35
35
  createdAt: now,
36
36
  expiresAt: now + this.ttlMs,
37
37
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
38
+ ...rec.clientName !== void 0 && { clientName: rec.clientName },
38
39
  ...rec.clientState !== void 0 && { clientState: rec.clientState },
40
+ ...rec.resource !== void 0 && { resource: rec.resource },
39
41
  ...rec.scope !== void 0 && { scope: rec.scope },
40
42
  ...rec.nonce !== void 0 && { nonce: rec.nonce },
41
43
  ...rec.idToken !== void 0 && { idToken: rec.idToken },
@@ -100,6 +102,7 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
100
102
  expiresAt: this.clock.now() + this.ttlMs,
101
103
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
102
104
  ...rec.scope !== void 0 && { scope: rec.scope },
105
+ ...rec.resource !== void 0 && { resource: rec.resource },
103
106
  ...rec.nonce !== void 0 && { nonce: rec.nonce },
104
107
  ...rec.idToken !== void 0 && { idToken: rec.idToken },
105
108
  ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
@@ -117,6 +120,65 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
117
120
  }
118
121
  };
119
122
  //#endregion
123
+ //#region src/authz/dynamic-client-store.ts
124
+ /**
125
+ * Storage seam for dynamically-registered clients (OAUTH.md R2). Long-lived
126
+ * (unlike the pending-auth / auth-code stores): a connector caches its
127
+ * `client_id` and reuses it across grants, so rows survive until deleted or
128
+ * garbage-collected as never-used. An in-memory impl ships for single-process
129
+ * apps + tests; a durable impl (atscript-db) slots under the same
130
+ * `DYNAMIC_CLIENT_STORE_TOKEN` (from `@aooth/auth-moost`).
131
+ */
132
+ var DynamicClientStore = class {};
133
+ /**
134
+ * In-memory {@link DynamicClientStore} — the reference impl for a
135
+ * single-process app + tests. `structuredClone` on read/write isolates callers.
136
+ */
137
+ var DynamicClientStoreMemory = class extends DynamicClientStore {
138
+ store = /* @__PURE__ */ new Map();
139
+ clock;
140
+ constructor(opts) {
141
+ super();
142
+ this.clock = opts?.clock ?? require_clock.defaultClock;
143
+ }
144
+ async create(rec) {
145
+ const row = {
146
+ clientId: (0, node_crypto.randomUUID)(),
147
+ redirectUris: [...rec.redirectUris],
148
+ tokenEndpointAuthMethod: rec.tokenEndpointAuthMethod,
149
+ grantTypes: [...rec.grantTypes],
150
+ responseTypes: [...rec.responseTypes],
151
+ createdAt: this.clock.now(),
152
+ ...rec.clientName !== void 0 && { clientName: rec.clientName },
153
+ ...rec.scope !== void 0 && { scope: rec.scope }
154
+ };
155
+ this.store.set(row.clientId, structuredClone(row));
156
+ return structuredClone(row);
157
+ }
158
+ async get(clientId) {
159
+ const row = this.store.get(clientId);
160
+ return row ? structuredClone(row) : null;
161
+ }
162
+ async delete(clientId) {
163
+ return this.store.delete(clientId);
164
+ }
165
+ async count() {
166
+ return this.store.size;
167
+ }
168
+ async touch(clientId, at) {
169
+ const row = this.store.get(clientId);
170
+ if (row) row.lastUsedAt = at;
171
+ }
172
+ async deleteUnusedBefore(cutoff) {
173
+ let removed = 0;
174
+ for (const [id, row] of this.store) if (row.lastUsedAt === void 0 && row.createdAt < cutoff) {
175
+ this.store.delete(id);
176
+ removed++;
177
+ }
178
+ return removed;
179
+ }
180
+ };
181
+ //#endregion
120
182
  Object.defineProperty(exports, "AuthCodeStore", {
121
183
  enumerable: true,
122
184
  get: function() {
@@ -141,6 +203,18 @@ Object.defineProperty(exports, "DEFAULT_PENDING_TTL_MS", {
141
203
  return DEFAULT_PENDING_TTL_MS;
142
204
  }
143
205
  });
206
+ Object.defineProperty(exports, "DynamicClientStore", {
207
+ enumerable: true,
208
+ get: function() {
209
+ return DynamicClientStore;
210
+ }
211
+ });
212
+ Object.defineProperty(exports, "DynamicClientStoreMemory", {
213
+ enumerable: true,
214
+ get: function() {
215
+ return DynamicClientStoreMemory;
216
+ }
217
+ });
144
218
  Object.defineProperty(exports, "PendingAuthorizationStore", {
145
219
  enumerable: true,
146
220
  get: function() {
@@ -35,7 +35,9 @@ var PendingAuthorizationStoreMemory = class extends PendingAuthorizationStore {
35
35
  createdAt: now,
36
36
  expiresAt: now + this.ttlMs,
37
37
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
38
+ ...rec.clientName !== void 0 && { clientName: rec.clientName },
38
39
  ...rec.clientState !== void 0 && { clientState: rec.clientState },
40
+ ...rec.resource !== void 0 && { resource: rec.resource },
39
41
  ...rec.scope !== void 0 && { scope: rec.scope },
40
42
  ...rec.nonce !== void 0 && { nonce: rec.nonce },
41
43
  ...rec.idToken !== void 0 && { idToken: rec.idToken },
@@ -100,6 +102,7 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
100
102
  expiresAt: this.clock.now() + this.ttlMs,
101
103
  ...rec.clientId !== void 0 && { clientId: rec.clientId },
102
104
  ...rec.scope !== void 0 && { scope: rec.scope },
105
+ ...rec.resource !== void 0 && { resource: rec.resource },
103
106
  ...rec.nonce !== void 0 && { nonce: rec.nonce },
104
107
  ...rec.idToken !== void 0 && { idToken: rec.idToken },
105
108
  ...rec.accessToken !== void 0 && { accessToken: rec.accessToken },
@@ -117,4 +120,63 @@ var AuthCodeStoreMemory = class extends AuthCodeStore {
117
120
  }
118
121
  };
119
122
  //#endregion
120
- export { PendingAuthorizationStore as a, DEFAULT_PENDING_TTL_MS as i, AuthCodeStoreMemory as n, PendingAuthorizationStoreMemory as o, DEFAULT_CODE_TTL_MS as r, AuthCodeStore as t };
123
+ //#region src/authz/dynamic-client-store.ts
124
+ /**
125
+ * Storage seam for dynamically-registered clients (OAUTH.md R2). Long-lived
126
+ * (unlike the pending-auth / auth-code stores): a connector caches its
127
+ * `client_id` and reuses it across grants, so rows survive until deleted or
128
+ * garbage-collected as never-used. An in-memory impl ships for single-process
129
+ * apps + tests; a durable impl (atscript-db) slots under the same
130
+ * `DYNAMIC_CLIENT_STORE_TOKEN` (from `@aooth/auth-moost`).
131
+ */
132
+ var DynamicClientStore = class {};
133
+ /**
134
+ * In-memory {@link DynamicClientStore} — the reference impl for a
135
+ * single-process app + tests. `structuredClone` on read/write isolates callers.
136
+ */
137
+ var DynamicClientStoreMemory = class extends DynamicClientStore {
138
+ store = /* @__PURE__ */ new Map();
139
+ clock;
140
+ constructor(opts) {
141
+ super();
142
+ this.clock = opts?.clock ?? defaultClock;
143
+ }
144
+ async create(rec) {
145
+ const row = {
146
+ clientId: randomUUID(),
147
+ redirectUris: [...rec.redirectUris],
148
+ tokenEndpointAuthMethod: rec.tokenEndpointAuthMethod,
149
+ grantTypes: [...rec.grantTypes],
150
+ responseTypes: [...rec.responseTypes],
151
+ createdAt: this.clock.now(),
152
+ ...rec.clientName !== void 0 && { clientName: rec.clientName },
153
+ ...rec.scope !== void 0 && { scope: rec.scope }
154
+ };
155
+ this.store.set(row.clientId, structuredClone(row));
156
+ return structuredClone(row);
157
+ }
158
+ async get(clientId) {
159
+ const row = this.store.get(clientId);
160
+ return row ? structuredClone(row) : null;
161
+ }
162
+ async delete(clientId) {
163
+ return this.store.delete(clientId);
164
+ }
165
+ async count() {
166
+ return this.store.size;
167
+ }
168
+ async touch(clientId, at) {
169
+ const row = this.store.get(clientId);
170
+ if (row) row.lastUsedAt = at;
171
+ }
172
+ async deleteUnusedBefore(cutoff) {
173
+ let removed = 0;
174
+ for (const [id, row] of this.store) if (row.lastUsedAt === void 0 && row.createdAt < cutoff) {
175
+ this.store.delete(id);
176
+ removed++;
177
+ }
178
+ return removed;
179
+ }
180
+ };
181
+ //#endregion
182
+ export { DEFAULT_CODE_TTL_MS as a, PendingAuthorizationStoreMemory as c, AuthCodeStoreMemory as i, DynamicClientStoreMemory as n, DEFAULT_PENDING_TTL_MS as o, AuthCodeStore as r, PendingAuthorizationStore as s, DynamicClientStore as t };