@crvouga/mockingbird-service-oauth 3.7.0 → 3.8.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog — @crvouga/mockingbird-service-oauth
2
2
 
3
- ## 3.7.0 (2026-10-17)
3
+ ## 3.8.0 (2026-10-19)
4
+
5
+ ### Features
6
+
7
+ - resolve the agent-reported issue queue in one batch ([25c52b6](https://github.com/crvouga/mockingbird/commit/25c52b668a53ddb80b968419601a20abf33f956b))
8
+
9
+ ## 3.7.0 (2026-10-16)
4
10
 
5
11
  ### Features
6
12
 
package/README.md CHANGED
@@ -172,7 +172,7 @@ tapping `oauth-mock-form-post-continue`; with scripts, the page submits the form
172
172
 
173
173
  ### Fidelity and lifecycle
174
174
 
175
- - Authorization-code flow with exact redirect matching, including explicitly registered private-use URI schemes for native apps, state and nonce; duplicate parameters rejected. Invalid clients/callbacks never redirect. Public native clients require S256 PKCE.
175
+ - Authorization-code flow with exact redirect matching, including explicitly registered private-use URI schemes for native apps, state and nonce; duplicate parameters rejected. Invalid clients/callbacks never redirect. Public native clients require S256 PKCE unless the client opts in with `pkceOptional`.
176
176
  - Real RSA-2048 / RS256 ID tokens and independent public JWKS; correct issuer, audience, expiry, auth time and scope-filtered claims. Keys remain stable until explicitly rotated.
177
177
  - Codes expire after 5 minutes and are consumed atomically, including concurrent PKCE redemption. Access/ID tokens last 1 hour. Generic OIDC refresh tokens default to 30 days; Microsoft defaults to 90 days. Apple/Google refresh tokens have no fixed deadline by default; Google inactivity, testing mode and issuance limits still apply. GitHub OAuth app access tokens have no fixed deadline in the emulator. The injected emulator clock controls expiry.
178
178
  - Google `access_type=offline` issues refresh tokens on first consent or `prompt=consent`; generic/Microsoft `offline_access` and Apple issue refresh tokens. Refresh cannot expand scopes. Revocation invalidates related access and refresh tokens; unknown tokens succeed idempotently. Refresh tokens are reusable by default; Microsoft returns a replacement without invalidating the old token. Opt-in strict rotation detects reuse and revokes the token family.
@@ -306,13 +306,26 @@ no grants or account changes. Interactive vendor flows cannot be run unattended
306
306
  provider applications, so the focused contract tests use the official behavior documented in the
307
307
  references below.
308
308
 
309
+ ## Per-client opt-ins
310
+
311
+ Defaults stay strict; these `Client` fields (also accepted by `POST /__admin/clients`) relax one rule each, for one client.
312
+
313
+ | Field | Effect |
314
+ | --- | --- |
315
+ | `pkceOptional: true` | A public client (no `secret`, no `apple`) may omit PKCE, as a [Cognito public app client](https://docs.aws.amazon.com/cognito/latest/developerguide/authorization-endpoint.html) can. A challenge that is sent must still be S256 and is verified at the token endpoint. Conflicts with `requirePkce: true` and with `secret`/`apple`. |
316
+ | `alwaysIssueRefreshToken: true` | Every authorization-code exchange returns a `refresh_token` without `offline_access`, as [Cognito's token endpoint](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html) does. `oidc` and `microsoft` profiles only; Google, Apple and GitHub keep their own rules. |
317
+ | `redirectPolicy: { loopbackAnyPort: true }` | RFC 8252 section 7.3: an `http://` loopback redirect (`127.0.0.1`, `[::1]`, `localhost`) may use any port. It must match a registered loopback URI on the same host, path and query. |
318
+ | `redirectPolicy: { schemes: ["exp:"] }` | For the listed custom schemes the host and port may vary (a dev client's LAN address). The request must still match a registered URI with the same scheme, path and query. `http:`, `https:` and executable schemes are rejected. |
319
+
320
+ A Cognito-shaped public client is `{ id, name, redirectUris, pkceOptional: true, alwaysIssueRefreshToken: true }` on an `oidc` mount (for example `/oauth2`). Extra authorize parameters such as `identity_provider` are ignored. The token exchange still requires the exact `redirect_uri` that authorized the code. These are matching rules for registered URIs, not wildcards: a URI that is not a variant of a registered one is still refused without a redirect.
321
+
309
322
  ## Deliberately not modelled
310
323
 
311
324
  This is a ready-to-use local/test identity provider, **not a production authentication server or a claim that every proprietary provider feature is implemented**. Its ready tier covers the documented OAuth/OIDC login, identity, consent, token, provider-edge-case, and UI surface. Applications should still run a small final check against each real provider before release.
312
325
 
313
326
  Vendor-hosted Google Identity Services/One Tap, native Apple AuthenticationServices, passkeys, MFA, CAPTCHA, password recovery, email delivery/relay forwarding (only Apple's notification of a forwarding change is sent), app-transfer migration, vendor risk engines, tokeninfo/introspection, logout, GitHub Apps installation/device flows, and Microsoft Graph/tenant administration are not implemented. Other configurable OIDC providers can use the generic profile, but their proprietary scopes and claims are not emulated. Scopes are limited to each profile plus explicitly configured additional scopes. Microsoft uses the configured emulator issuer, not real Entra tenant routing. GitHub is the OAuth app login surface, not the full REST API.
314
327
 
315
- No implicit flow, dynamic client registration, wildcard redirect matching, persistent signing-key storage (keys are held in memory; supply them through `signingKeys` or derive them from a seed), or distributed-session coordination is provided. Private-use redirect schemes work only when their complete URI is explicitly registered; executable/local schemes such as `javascript:`, `data:` and `file:` are rejected. Sign-in and consent pages extend CSP `form-action` with the transaction's redirect origin (or custom scheme). Browser navigation responses use a provider-owned handoff document so the callback can relay through another origin without inheriting the consent form's policy; direct programmatic interaction requests retain their HTTP redirect response. The `form_post` hand-off page sends no `form-action` at all: browsers apply that directive to every redirect after the submit, and the callback that receives the POST may redirect to an application origin the provider never sees, so no allow-list can cover it. The rest of that page's policy (`default-src 'none'`, nonce-only scripts and styles, `base-uri 'none'`, `frame-ancestors 'none'`) is unchanged. Discovery, JWKS, token, userinfo and revoke answer `OPTIONS` preflights and reflect the request `Origin`, so browser (SPA + PKCE) clients can redeem codes directly; the interaction pages send no CORS headers. Snapshot restore is for the same runtime/instance; signing keys are not serialized. The default backing store is in-memory and state disappears when the process exits. A secure browser context and Web Crypto, Fetch and standard Web APIs are required; Node 22+, Bun and modern browsers provide them.
328
+ No implicit flow, dynamic client registration, wildcard redirect matching (beyond the opt-in `redirectPolicy`), persistent signing-key storage (keys are held in memory; supply them through `signingKeys` or derive them from a seed), or distributed-session coordination is provided. Private-use redirect schemes work only when their complete URI is explicitly registered; executable/local schemes such as `javascript:`, `data:` and `file:` are rejected. Sign-in and consent pages extend CSP `form-action` with the transaction's redirect origin (or custom scheme). Browser navigation responses use a provider-owned handoff document so the callback can relay through another origin without inheriting the consent form's policy; direct programmatic interaction requests retain their HTTP redirect response. The `form_post` hand-off page sends no `form-action` at all: browsers apply that directive to every redirect after the submit, and the callback that receives the POST may redirect to an application origin the provider never sees, so no allow-list can cover it. The rest of that page's policy (`default-src 'none'`, nonce-only scripts and styles, `base-uri 'none'`, `frame-ancestors 'none'`) is unchanged. Discovery, JWKS, token, userinfo and revoke answer `OPTIONS` preflights and reflect the request `Origin`, so browser (SPA + PKCE) clients can redeem codes directly; the interaction pages send no CORS headers. Snapshot restore is for the same runtime/instance; signing keys are not serialized. The default backing store is in-memory and state disappears when the process exits. A secure browser context and Web Crypto, Fetch and standard Web APIs are required; Node 22+, Bun and modern browsers provide them.
316
329
 
317
330
  References: [Google OpenID Connect](https://developers.google.com/identity/openid-connect/openid-connect), [Apple authorization request](https://developer.apple.com/documentation/signinwithapplerestapi/request-an-authorization-to-the-sign-in-with-apple-server.), and [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html).
318
331
 
@@ -5,12 +5,13 @@ import {
5
5
  createRuntime,
6
6
  normalizeBootControls,
7
7
  normalizeControls,
8
+ normalizeIdBase,
8
9
  ownedTransport,
9
10
  parseDuration,
10
11
  parseFaultHeader,
11
12
  resolveAdminPrefix,
12
13
  restoreClock
13
- } from "./chunk-ANDSQ2HK.js";
14
+ } from "./chunk-VAXKCT3L.js";
14
15
 
15
16
  // src/server.ts
16
17
  import { readFile as readFile6 } from "node:fs/promises";
@@ -19,6 +20,19 @@ import { readFile as readFile6 } from "node:fs/promises";
19
20
  import { readFile as readFile5 } from "node:fs/promises";
20
21
  import { parseArgs } from "node:util";
21
22
 
23
+ // ../../adapters/node/dist/boot-settings.js
24
+ var checkSettings = (target, settings, label) => {
25
+ if (!target.parseSettings)
26
+ throw new Error(`${label}: ${target.name} takes no boot settings`);
27
+ try {
28
+ return target.parseSettings(settings);
29
+ } catch (error) {
30
+ throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`, {
31
+ cause: error
32
+ });
33
+ }
34
+ };
35
+
22
36
  // ../../adapters/node/dist/fleet.js
23
37
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
24
38
  import { createHash as createHash4, randomUUID as randomUUID4 } from "node:crypto";
@@ -2668,6 +2682,9 @@ var fleet_config_schema_default = {
2668
2682
  adminControls: {
2669
2683
  $ref: "#/$defs/controls"
2670
2684
  },
2685
+ idBase: {
2686
+ $ref: "#/$defs/idBase"
2687
+ },
2671
2688
  namespace: {
2672
2689
  type: "string",
2673
2690
  pattern: "^[A-Za-z0-9_.-]{1,64}$"
@@ -2757,6 +2774,12 @@ var fleet_config_schema_default = {
2757
2774
  adminControls: {
2758
2775
  $ref: "#/$defs/controls"
2759
2776
  },
2777
+ idBase: {
2778
+ $ref: "#/$defs/idBase"
2779
+ },
2780
+ settings: {
2781
+ type: "object"
2782
+ },
2760
2783
  fixtures: {
2761
2784
  type: "object"
2762
2785
  },
@@ -2803,6 +2826,18 @@ var fleet_config_schema_default = {
2803
2826
  }
2804
2827
  }
2805
2828
  },
2829
+ idBase: {
2830
+ anyOf: [
2831
+ {
2832
+ type: "integer",
2833
+ minimum: 0
2834
+ },
2835
+ {
2836
+ type: "string",
2837
+ pattern: "^(boot|[0-9]+)$"
2838
+ }
2839
+ ]
2840
+ },
2806
2841
  controls: {
2807
2842
  type: "object",
2808
2843
  additionalProperties: false,
@@ -2952,6 +2987,8 @@ async function preflightFleet(config, options) {
2952
2987
  throw new Error("protected archive policy requires a protected repository; configure its protector before startup");
2953
2988
  resolveAdminPrefix(config.adminPrefix);
2954
2989
  normalizeBootControls(config.adminControls);
2990
+ if (config.idBase !== void 0)
2991
+ normalizeIdBase(config.idBase, "idBase");
2955
2992
  const targets = /* @__PURE__ */ new Map();
2956
2993
  const ports = /* @__PURE__ */ new Set();
2957
2994
  for (const [name, entry] of Object.entries(config.services)) {
@@ -2983,6 +3020,13 @@ async function preflightFleet(config, options) {
2983
3020
  throw new Error("invalid protocol namespace selector");
2984
3021
  }
2985
3022
  }
3023
+ if (entry.idBase !== void 0)
3024
+ normalizeIdBase(entry.idBase, "idBase");
3025
+ if (entry.settings !== void 0) {
3026
+ if (protocol !== "http" || !("create" in target))
3027
+ throw new Error("settings: only HTTP services take boot settings");
3028
+ checkSettings(target, entry.settings, "settings");
3029
+ }
2986
3030
  const port = entry.port ?? target.defaultPort;
2987
3031
  if (port !== 0) {
2988
3032
  const key = `${entry.host ?? "127.0.0.1"}:${port}`;
@@ -3018,7 +3062,7 @@ async function preflightFleet(config, options) {
3018
3062
  break;
3019
3063
  }
3020
3064
  }
3021
- targets.set(name, { ...target, packageVersion: packageVersion ?? "3.7.0" });
3065
+ targets.set(name, { ...target, packageVersion: packageVersion ?? "3.8.0" });
3022
3066
  } catch (error) {
3023
3067
  throw new Error(`service ${name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3024
3068
  }
@@ -3338,6 +3382,7 @@ var httpChild = async (target, entry, options, admission, generation, privateAcc
3338
3382
  options.onLog?.(entry2);
3339
3383
  },
3340
3384
  ...entry.adminControls !== void 0 ? { adminControls: entry.adminControls } : {},
3385
+ ...entry.settings !== void 0 ? { settings: checkSettings(target, entry.settings, "settings") } : {},
3341
3386
  ...entry.fixtures !== void 0 ? { fixtures: entry.fixtures } : {},
3342
3387
  ...(advertisedHttpBase(entry) ?? entry.baseUrl) !== void 0 ? { baseUrl: advertisedHttpBase(entry) ?? entry.baseUrl } : {}
3343
3388
  });
@@ -3356,6 +3401,8 @@ var httpChild = async (target, entry, options, admission, generation, privateAcc
3356
3401
  ].some((method) => typeof created[method] !== "function"))
3357
3402
  throw new Error("HTTP target does not implement the fleet runtime protocol; build/install matching supported service versions");
3358
3403
  const runtime = created;
3404
+ if (entry.idBase !== void 0)
3405
+ runtime.setIdBase?.(entry.idBase);
3359
3406
  const unobserve = runtime.webhooks?.observeDeliveries(observer.delivery);
3360
3407
  if (options.outbound && runtime.webhooks)
3361
3408
  runtime.webhooks.setTransportPolicy(ownedTransport(options.outbound));
@@ -3954,6 +4001,7 @@ async function startFleet(config, options) {
3954
4001
  ...entry,
3955
4002
  adminPrefix: resolveAdminPrefix(entry.adminPrefix ?? adminPrefix),
3956
4003
  ...adminKey !== void 0 ? { adminKey } : {},
4004
+ ...(entry.idBase ?? config.idBase) !== void 0 ? { idBase: entry.idBase ?? config.idBase } : {},
3957
4005
  // Fleet-wide controls, with the service's own keys replacing them.
3958
4006
  ...config.adminControls !== void 0 || entry.adminControls !== void 0 ? { adminControls: { ...config.adminControls, ...entry.adminControls } } : {}
3959
4007
  }, options, (namespace, request) => management?.admit(namespace, request), (namespace) => management?.generation(namespace), (namespace, request) => management?.privateRequest(namespace, request) ?? false, {
@@ -4638,6 +4686,16 @@ var COMMON_SERVE_OPTIONS = {
4638
4686
  value: "<seed>",
4639
4687
  description: "Seed for every random choice (env MOCKINGBIRD_SEED)"
4640
4688
  },
4689
+ "id-base": {
4690
+ type: "string",
4691
+ value: "<n|boot>",
4692
+ description: "Start generated ids at this base so a restart does not reissue the last boot's ids; boot derives one from the clock (env MOCKINGBIRD_ID_BASE; default 0, the same ids every boot)"
4693
+ },
4694
+ settings: {
4695
+ type: "string",
4696
+ value: "<json|file>",
4697
+ description: "Service settings to boot with, as JSON or a JSON file; every namespace starts from them (env MOCKINGBIRD_SETTINGS; only for services that declare settings)"
4698
+ },
4641
4699
  "admin-controls": {
4642
4700
  type: "string",
4643
4701
  value: "<json|file>",
@@ -4732,19 +4790,40 @@ var readJsonFile = async (path, label) => {
4732
4790
  }
4733
4791
  };
4734
4792
  var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4793
+ var readJsonOption = async (source, label) => {
4794
+ const trimmed = source.trim();
4795
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
4796
+ try {
4797
+ return JSON.parse(trimmed);
4798
+ } catch {
4799
+ throw new Error(`${label}: not valid JSON`);
4800
+ }
4801
+ }
4802
+ return readJsonFile(source, label);
4803
+ };
4804
+ var resolveServeIdBase = (values, env = process.env) => {
4805
+ const source = asString(values["id-base"]) ?? env.MOCKINGBIRD_ID_BASE;
4806
+ if (source === void 0 || source.trim() === "")
4807
+ return void 0;
4808
+ normalizeIdBase(source.trim(), "--id-base");
4809
+ return source.trim();
4810
+ };
4811
+ var resolveServeSettings = async (target, values, env = process.env) => {
4812
+ const flag = asString(values.settings);
4813
+ const source = flag ?? env.MOCKINGBIRD_SETTINGS;
4814
+ if (source === void 0 || source.trim() === "")
4815
+ return void 0;
4816
+ const label = flag !== void 0 ? "--settings" : "MOCKINGBIRD_SETTINGS";
4817
+ const parsed = await readJsonOption(source, label);
4818
+ if (!isObject2(parsed))
4819
+ throw new Error(`${label}: expected a JSON object`);
4820
+ return checkSettings(target, parsed, label);
4821
+ };
4735
4822
  var resolveServeControls = async (values, env = process.env, base) => {
4736
4823
  const source = asString(values["admin-controls"]) ?? env.MOCKINGBIRD_ADMIN_CONTROLS;
4737
4824
  let document = isObject2(base) ? { ...base } : {};
4738
4825
  if (source !== void 0 && source.trim() !== "") {
4739
- let parsed;
4740
- if (source.trim().startsWith("{")) {
4741
- try {
4742
- parsed = JSON.parse(source);
4743
- } catch {
4744
- throw new Error("--admin-controls: not valid JSON");
4745
- }
4746
- } else
4747
- parsed = await readJsonFile(source, "--admin-controls");
4826
+ const parsed = source.trim().startsWith("{") ? await readJsonOption(source, "--admin-controls") : await readJsonFile(source, "--admin-controls");
4748
4827
  if (!isObject2(parsed))
4749
4828
  throw new Error("--admin-controls: expected a JSON object");
4750
4829
  document = { ...document, ...parsed };
@@ -4794,8 +4873,11 @@ var start = async (target, values, config) => {
4794
4873
  adminKey: config.adminKey,
4795
4874
  seed: config.seed,
4796
4875
  onLog: formatLog(config.log),
4876
+ ...config.settings !== void 0 ? { settings: config.settings } : {},
4797
4877
  ...config.adminControls !== void 0 ? { adminControls: config.adminControls } : {}
4798
4878
  });
4879
+ if (config.idBase !== void 0)
4880
+ runtime.setIdBase?.(config.idBase);
4799
4881
  const listening = await listen(runtime, { port: config.port, host: config.host });
4800
4882
  target.listening?.(listening);
4801
4883
  console.log(`${target.name} emulator listening on ${listening.url}`);
@@ -4841,6 +4923,11 @@ var serveCommand = (target) => ({
4841
4923
  const adminControls = await resolveServeControls(values, process.env, config.adminControls);
4842
4924
  if (adminControls !== void 0)
4843
4925
  config.adminControls = adminControls;
4926
+ if (values.settings !== void 0)
4927
+ throw new Error("--settings serves a single service; give each service its own `settings` in the config");
4928
+ const idBase = config.idBase ?? resolveServeIdBase(values);
4929
+ if (idBase !== void 0)
4930
+ config.idBase = idBase;
4844
4931
  const fleet = await startFleet(config, {
4845
4932
  load: (name) => loadTarget(name, target),
4846
4933
  onLog: formatLog(config.log ?? log),
@@ -4870,8 +4957,12 @@ var serveCommand = (target) => ({
4870
4957
  let listening;
4871
4958
  try {
4872
4959
  const adminControls = await resolveServeControls(values);
4960
+ const idBase = resolveServeIdBase(values);
4961
+ const settings = await resolveServeSettings(target, values);
4873
4962
  listening = await start(target, values, {
4874
4963
  ...adminControls !== void 0 ? { adminControls } : {},
4964
+ ...idBase !== void 0 ? { idBase } : {},
4965
+ ...settings !== void 0 ? { settings } : {},
4875
4966
  adminPrefix: resolveAdminPrefix(asString(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX),
4876
4967
  port: port === void 0 ? target.defaultPort : Number.parseInt(port, 10),
4877
4968
  host: asString(values.host) ?? "127.0.0.1",
@@ -4962,4 +5053,4 @@ export {
4962
5053
  createMultiServer,
4963
5054
  serveTarget
4964
5055
  };
4965
- //# sourceMappingURL=chunk-4GFNDUAY.js.map
5056
+ //# sourceMappingURL=chunk-AAKIJOPV.js.map