@crvouga/mockingbird-service-oauth 1.2.1 → 1.4.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,5 +1,21 @@
1
1
  # Changelog — @crvouga/mockingbird-service-oauth
2
2
 
3
+ ## 1.4.0 (2026-09-29)
4
+
5
+ ### Features
6
+
7
+ - opt-in seeded credentials so a seeded run replays byte-for-byte ([7a41166](https://github.com/crvouga/mockingbird/commit/7a41166326791b4fc06627fb544d150d3eaab99c))
8
+
9
+ ### Fixes and improvements
10
+
11
+ - make the seeded-credentials example self-contained ([64eef9c](https://github.com/crvouga/mockingbird/commit/64eef9ccbaa555f3bc2420961b7f8fc2a92f5058))
12
+
13
+ ## 1.3.0 (2026-09-29)
14
+
15
+ ### Features
16
+
17
+ - admin read of an account's grant with a client ([90b0279](https://github.com/crvouga/mockingbird/commit/90b0279f53cb4959b18c8992ae64bb00b5de4f96))
18
+
3
19
  ## 1.2.1 (2026-09-29)
4
20
 
5
21
  ### Fixes and improvements
package/README.md CHANGED
@@ -150,7 +150,7 @@ tapping `oauth-mock-form-post-continue`; with scripts, the page submits the form
150
150
 
151
151
  ### Reproducible provider edge cases
152
152
 
153
- Behavioral randomness is **off by default**. Configure exact scenarios or probabilities; these are test frequencies you choose, not estimates of vendor incidence. OAuth credentials, authorization codes and signing keys always use cryptographic randomness.
153
+ Behavioral randomness is **off by default**. Configure exact scenarios or probabilities; these are test frequencies you choose, not estimates of vendor incidence. OAuth credentials, authorization codes and signing keys use cryptographic randomness unless you opt in to [seeded credentials](#seeded-credentials).
154
154
 
155
155
  ```ts
156
156
  import { createRuntime } from "@crvouga/mockingbird-service-oauth"
@@ -198,6 +198,8 @@ curl -X PUT http://localhost:8810/__admin/behavior -H 'content-type: application
198
198
  curl http://localhost:8810/__admin/behavior
199
199
  curl -X POST http://localhost:8810/__admin/consents/revoke -H 'content-type: application/json' \
200
200
  -d '{"clientId":"app","accountId":"ada"}'
201
+ curl 'http://localhost:8810/__admin/grants?clientId=app&accountId=ada'
202
+ curl 'http://localhost:8810/__admin/grants?clientId=app'
201
203
  curl -X POST http://localhost:8810/__admin/accounts/delete -H 'content-type: application/json' \
202
204
  -d '{"accountId":"ada"}'
203
205
  curl -X POST http://localhost:8810/__admin/relay-forwarding -H 'content-type: application/json' \
@@ -206,6 +208,8 @@ curl -X POST http://localhost:8810/__admin/keys/rotate -H 'content-type: applica
206
208
  -d '{"retainPrevious":true}'
207
209
  ```
208
210
 
211
+ `GET /__admin/grants?clientId=<id>&accountId=<id>` (or `&email=<address>`) reads what that client sees for the account, without running a sign-in: `{ subject, granted, scopes, emailChoice, email, isPrivateEmail, userDisclosed }`. `subject` is the id token `sub` the client gets, or would get on a first authorization (it survives a consent revocation). `granted` says whether a consent exists, so whether the consent card is shown next time. `scopes` are the granted scopes, including Google `include_granted_scopes` merges. `emailChoice` is Apple's `"share"` or `"hide"`, and `null` before the first authorization, after a revocation, and on every other profile. `email` is the address the client receives: the relay for Hide My Email, the real address otherwise. `userDisclosed` is whether Apple's first-use `user` payload was already sent. Without `accountId` or `email` it returns `{ "grants": [...] }`, one row (with `accountId`) per account that currently has a grant with the client. An unknown client or account is `404`. Programmatically, `await runtime.instance().grant(clientId, accountId)` returns the same view, or `null`, and `grants(clientId)` the list. The view is read-only and follows namespaces.
212
+
209
213
  These routes use the shared admin-key and namespace controls. `revokeConsent(clientId, accountId)` removes that client's grants and resets first-use disclosure; it does not disable the account. It is async because it can deliver a [server-to-server notification](#sign-in-with-apple-server-to-server-notifications). `rotateSigningKey(true)` retains up to four previous public keys so existing tokens still verify; `false` withdraws them to test stale JWKS caches. Keys themselves are not included in snapshots, so restoring state does not undo a key rotation.
210
214
 
211
215
  ### Sign in with Apple server-to-server notifications
@@ -220,6 +224,26 @@ Register an Apple client with `apple.notificationUrl` (an HTTP(S) URL; requires
220
224
 
221
225
  Delivery is awaited by the call that caused it, through `webhooks.fetch` (`createRuntime({ webhooks: { fetch } })`, default global `fetch`), so an in-process suite can route it through its own network model. The admin routes and methods return one `{ clientId, type, delivered, status?, error? }` per notification; a non-2xx response or a thrown error is reported there and never fails the call, and Apple's retries are not modelled. Without an `issuer` option, direct method calls need an `issuer` argument (admin routes derive it from the request). Unknown accounts return 404 from the admin routes and reject from the methods.
222
226
 
227
+ ### Seeded credentials
228
+
229
+ For deterministic simulation tests, opt in so every opaque value the provider mints comes from the seed instead of `crypto`, and the same seed with the same requests replays byte-for-byte. It is **off by default**: without it, codes, tokens, transaction ids, nonces, `kid` and signing keys stay cryptographically random.
230
+
231
+ ```ts
232
+ import { createRuntime } from "@crvouga/mockingbird-service-oauth"
233
+
234
+ const runtime = createRuntime({
235
+ seed: "s1",
236
+ deterministicCredentials: true,
237
+ clients: [{ id: "app", name: "App", redirectUris: ["https://app.test/callback"], secret: "fixture-secret" }],
238
+ accounts: [{ id: "ada", name: "Ada Lovelace", email: "ada@example.test" }],
239
+ })
240
+ ```
241
+
242
+ - Seeded: authorization codes, access and refresh tokens, `transaction` ids, session ids, grant families, Apple notification `jti`s, CSP nonces (on every page and the `form_post` response), and the RS256 signing key with its `kid`, including the never-published key behind `tokens.idTokenSigningKey: "unpublished"`. Values are guessable; use only in tests. Pin the clock (`clock`) as well when you compare ID tokens, whose `iat` / `exp` come from it.
243
+ - Signing keys are a pure function of the seed and the rotation index, so JWKS, the ID-token header and (RS256 being deterministic) the signature replay too. Deriving a 2048-bit RSA key costs roughly half a second to a couple of seconds once per seed per process; it is cached afterwards.
244
+ - The draw counter is part of the namespace state: it is snapshotted with it, and `reset` restarts both the counter and the key sequence, so a reset replays the run. The seed is not mixed with the namespace, so two namespaces or mounts with one seed mint the same values.
245
+ - `createRuntime({ randomBytes: (length) => bytes })` replaces `crypto.getRandomValues` for every minted value instead (it wins over the seeded source), and `signingKeys: [{ privateJwk, publicJwk, kid }, ...]` supplies RS256 keys used in order: the first is current and each `POST /__admin/keys/rotate` takes the next. When the list runs out, rotation falls back to the seeded or random source. The `nonce` option still takes precedence for the `form_post` response.
246
+
223
247
  ### Shared service controls
224
248
 
225
249
  The runtime supplies `/health`, `/__admin/reset`, snapshots, mock clock, request journal, metrics, fault injection and namespace isolation. Use `x-mockingbird-namespace` for in-process tests or `/ns/<name>/…` for complete browser flows. Header-selected namespaces alone cannot persist across ordinary browser navigation. State, grants, sessions and consent live in the shared SQLite abstraction; there are no filesystem or Node imports in the main entry.
@@ -230,14 +254,14 @@ The runtime supplies `/health`, `/__admin/reset`, snapshots, mock clock, request
230
254
 
231
255
  - `createRuntime(options?)`: shared service runtime; `fetch`, `instance`, `reset`, `snapshot`, `restore`, clock, faults and journals.
232
256
  - `createMultiRuntime({ mounts })`: exact-path dispatcher for isolated provider runtimes on one origin, with aggregate health and admin controls.
233
- - `OAuthAPI`: standalone portable handler with `fetch`, `reset`, `seedAccount`, `registerClient`, `accounts`, `clients`, `provider`, `configureBehavior`, `behavior`, `revokeConsent`, `rotateSigningKey`.
257
+ - `OAuthAPI`: standalone portable handler with `fetch`, `reset`, `seedAccount`, `registerClient`, `accounts`, `clients`, `provider`, `configureBehavior`, `behavior`, `revokeConsent`, `rotateSigningKey`, `grant`, `grants`.
234
258
  - `OAUTH_PRESETS`: named transport fault presets.
235
259
  - `OAUTH_SCENARIOS`: named provider-behavior scenarios.
236
260
  - `document`, `operationIds`, `supportedOperationIds`: generated OpenAPI metadata.
237
261
  - `createServer(options?)` from `./server`: Node HTTP adapter, returning `url`, `close` and `runtime`.
238
262
  - `createMultiServer({ mounts })` from `./server`: Node HTTP adapter for a multi-provider runtime.
239
263
  - `DEFAULT_PORT`, `serveTarget` from `./server`: CLI defaults and multi-service launcher integration.
240
- - Types: `Account`, `Client`, `Provider`, `OAuthAPIOptions`, `OAuthRuntimeOptions`, `OAuthRuntime`, `OAuthServerOptions`, `OAuthMount`, `OAuthMultiRuntimeOptions`, `OAuthMultiRuntime`, `OAuthMultiServerOptions`, `OAuthBehavior`, `BehaviorInput`, `OAuthScenario`, `EdgeCase`, `BehaviorEvent`.
264
+ - Types: `Account`, `Client`, `GrantView`, `Provider`, `OAuthAPIOptions`, `OAuthRuntimeOptions`, `OAuthRuntime`, `OAuthServerOptions`, `OAuthMount`, `OAuthMultiRuntimeOptions`, `OAuthMultiRuntime`, `OAuthMultiServerOptions`, `OAuthBehavior`, `BehaviorInput`, `OAuthScenario`, `EdgeCase`, `BehaviorEvent`.
241
265
 
242
266
  ## Verification
243
267
 
@@ -256,7 +280,7 @@ This is a ready-to-use local/test identity provider, **not a production authenti
256
280
 
257
281
  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 mock issuer, not real Entra tenant routing. GitHub is the OAuth app login surface, not the full REST API.
258
282
 
259
- No implicit flow, dynamic client registration, wildcard redirect matching, persistent signing-key import, 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) so browsers follow the redirect back to the client. 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.
283
+ 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) so browsers follow the redirect back to the client. 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.
260
284
 
261
285
  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).
262
286
 
@@ -2,7 +2,7 @@ import {
2
2
  OAUTH_SCENARIOS,
3
3
  createMultiRuntime,
4
4
  createRuntime
5
- } from "./chunk-XP2AEPEJ.js";
5
+ } from "./chunk-SYFF4UFD.js";
6
6
 
7
7
  // src/server.ts
8
8
  import { readFile as readFile2 } from "node:fs/promises";
@@ -377,4 +377,4 @@ export {
377
377
  createMultiServer,
378
378
  serveTarget
379
379
  };
380
- //# sourceMappingURL=chunk-2JIADQOV.js.map
380
+ //# sourceMappingURL=chunk-D4IAZYMT.js.map
@@ -1384,7 +1384,7 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1384
1384
  };
1385
1385
 
1386
1386
  // ../core/dist/version.js
1387
- var PACKAGE_VERSION = true ? "1.2.1" : UNRELEASED_VERSION;
1387
+ var PACKAGE_VERSION = true ? "1.4.0" : UNRELEASED_VERSION;
1388
1388
 
1389
1389
  // ../core/dist/webhooks.js
1390
1390
  var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
@@ -2246,18 +2246,22 @@ var encoder = new TextEncoder();
2246
2246
  var base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2247
2247
  var random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
2248
2248
  var hash = async (value) => base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))));
2249
+ var RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
2249
2250
  var Signer = class {
2250
- pair = crypto.subtle.generateKey(
2251
- {
2252
- name: "RSASSA-PKCS1-v1_5",
2253
- modulusLength: 2048,
2254
- publicExponent: new Uint8Array([1, 0, 1]),
2255
- hash: "SHA-256"
2256
- },
2257
- true,
2258
- ["sign", "verify"]
2259
- );
2260
- kid = random();
2251
+ pair;
2252
+ kid;
2253
+ /** Generates a fresh key pair unless `key` supplies one; `kid` names a generated pair. */
2254
+ constructor(key, kid = random()) {
2255
+ this.kid = key?.kid ?? kid;
2256
+ this.pair = key ? Promise.all([
2257
+ crypto.subtle.importKey("jwk", key.privateJwk, RSA, true, ["sign"]),
2258
+ crypto.subtle.importKey("jwk", key.publicJwk, RSA, true, ["verify"])
2259
+ ]).then(([privateKey, publicKey]) => ({ privateKey, publicKey })) : crypto.subtle.generateKey(
2260
+ { ...RSA, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
2261
+ true,
2262
+ ["sign", "verify"]
2263
+ );
2264
+ }
2261
2265
  async jwks() {
2262
2266
  const pair = await this.pair;
2263
2267
  return {
@@ -2317,6 +2321,152 @@ async function verifyAppleSecret(token, clientId, apple, now) {
2317
2321
  }
2318
2322
  }
2319
2323
 
2324
+ // src/credentials.ts
2325
+ var seededBytes = (label, length) => {
2326
+ let a = seedFrom(`${label}:0`);
2327
+ let b = seedFrom(`${label}:1`);
2328
+ let c = seedFrom(`${label}:2`);
2329
+ let d = seedFrom(`${label}:3`);
2330
+ const next = () => {
2331
+ const t = (a + b | 0) + d | 0;
2332
+ d = d + 1 | 0;
2333
+ a = b ^ b >>> 9;
2334
+ b = c + (c << 3) | 0;
2335
+ c = (c << 21 | c >>> 11) + t | 0;
2336
+ return t >>> 0;
2337
+ };
2338
+ for (let i = 0; i < 12; i++) next();
2339
+ const out = new Uint8Array(length);
2340
+ for (let i = 0; i < length; i += 4) {
2341
+ const word = next();
2342
+ for (let j = 0; j < 4 && i + j < length; j++) out[i + j] = word >>> 8 * j & 255;
2343
+ }
2344
+ return out;
2345
+ };
2346
+ var toBigInt = (bytes) => BigInt(`0x${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`);
2347
+ var fromBigInt = (value) => {
2348
+ const hex = value.toString(16);
2349
+ const padded = hex.length % 2 ? `0${hex}` : hex;
2350
+ return Uint8Array.from(padded.match(/../g) ?? [], (h) => Number.parseInt(h, 16));
2351
+ };
2352
+ var modPow = (base, exponent, modulus) => {
2353
+ let result = 1n;
2354
+ let b = base % modulus;
2355
+ for (let e = exponent; e > 0n; e >>= 1n) {
2356
+ if (e & 1n) result = result * b % modulus;
2357
+ b = b * b % modulus;
2358
+ }
2359
+ return result;
2360
+ };
2361
+ var modInverse = (value, modulus) => {
2362
+ let [r0, r1, t0, t1] = [modulus, value % modulus, 0n, 1n];
2363
+ while (r1) {
2364
+ const q = r0 / r1;
2365
+ [r0, r1] = [r1, r0 - q * r1];
2366
+ [t0, t1] = [t1, t0 - q * t1];
2367
+ }
2368
+ return (t0 % modulus + modulus) % modulus;
2369
+ };
2370
+ var SMALL_PRIMES = Array.from({ length: 2e3 }, (_, i) => i + 2).filter((n, _, all) => all.every((m) => m * m > n || n % m)).map(BigInt);
2371
+ var probablePrime = (n) => {
2372
+ if (SMALL_PRIMES.some((p) => n % p === 0n)) return false;
2373
+ let d = n - 1n;
2374
+ let s = 0;
2375
+ while (!(d & 1n)) {
2376
+ d >>= 1n;
2377
+ s++;
2378
+ }
2379
+ return SMALL_PRIMES.slice(0, 16).every((a) => {
2380
+ let x = modPow(a, d, n);
2381
+ if (x === 1n || x === n - 1n) return true;
2382
+ for (let i = 1; i < s; i++) {
2383
+ x = x * x % n;
2384
+ if (x === n - 1n) return true;
2385
+ }
2386
+ return false;
2387
+ });
2388
+ };
2389
+ var E = 65537n;
2390
+ var seededPrime = (label) => {
2391
+ for (let attempt = 0; ; attempt++) {
2392
+ const candidate = toBigInt(seededBytes(`${label}:${attempt}`, 128)) | 3n << 1022n | 1n;
2393
+ if ((candidate - 1n) % E !== 0n && probablePrime(candidate)) return candidate;
2394
+ }
2395
+ };
2396
+ var keyCache = /* @__PURE__ */ new Map();
2397
+ var seededRsa = (label) => {
2398
+ const cached = keyCache.get(label);
2399
+ if (cached) return cached;
2400
+ let p = seededPrime(`${label}:p`);
2401
+ let q = seededPrime(`${label}:q`);
2402
+ for (let i = 0; q === p; i++) q = seededPrime(`${label}:q${i}`);
2403
+ if (p < q) [p, q] = [q, p];
2404
+ const d = modInverse(E, (p - 1n) * (q - 1n));
2405
+ const enc = (v) => base64url(fromBigInt(v));
2406
+ const publicJwk = { kty: "RSA", n: enc(p * q), e: enc(E) };
2407
+ const privateJwk = {
2408
+ ...publicJwk,
2409
+ d: enc(d),
2410
+ p: enc(p),
2411
+ q: enc(q),
2412
+ dp: enc(d % (p - 1n)),
2413
+ dq: enc(d % (q - 1n)),
2414
+ qi: enc(modInverse(q, p))
2415
+ };
2416
+ const pair = { privateJwk, publicJwk };
2417
+ keyCache.set(label, pair);
2418
+ return pair;
2419
+ };
2420
+ var Credentials = class {
2421
+ constructor(sqlite, namespace, seed, options) {
2422
+ this.options = options;
2423
+ this.draws = new Collection(sqlite, namespace, "oauth_credential_draws");
2424
+ this.seed = String(seed ?? 0);
2425
+ }
2426
+ options;
2427
+ draws;
2428
+ seed;
2429
+ keyIndex = 0;
2430
+ bytes(length) {
2431
+ if (this.options.randomBytes) return this.options.randomBytes(length);
2432
+ if (this.options.deterministicCredentials)
2433
+ return seededBytes(`${this.seed}:${this.draws.nextSequence()}`, length);
2434
+ return crypto.getRandomValues(new Uint8Array(length));
2435
+ }
2436
+ /** Codes, access/refresh tokens, transaction ids, session ids and grant families. */
2437
+ token() {
2438
+ return base64url(this.bytes(32));
2439
+ }
2440
+ /** CSP nonce for interaction pages; a UUID unless credentials are controlled. */
2441
+ nonce() {
2442
+ return this.options.randomBytes || this.options.deterministicCredentials ? base64url(this.bytes(16)) : crypto.randomUUID();
2443
+ }
2444
+ /** The next signing key: injected, else derived from the seed, else freshly generated. */
2445
+ signer() {
2446
+ const index = this.keyIndex++;
2447
+ const injected = this.options.signingKeys?.[index];
2448
+ if (injected) return new Signer(injected);
2449
+ if (!this.options.deterministicCredentials || this.options.randomBytes)
2450
+ return new Signer(void 0, this.token());
2451
+ const kid = this.token();
2452
+ return new Signer({ ...seededRsa(`${this.seed}:key:${index}`), kid });
2453
+ }
2454
+ /**
2455
+ * The key that signs ID tokens but is never published. Never taken from `signingKeys` (those are
2456
+ * published); under a seed it is derived without touching the draw counter.
2457
+ */
2458
+ unpublishedSigner() {
2459
+ if (!this.options.deterministicCredentials || this.options.randomBytes)
2460
+ return new Signer(void 0, this.token());
2461
+ const label = `${this.seed}:unpublished`;
2462
+ return new Signer({ ...seededRsa(label), kid: base64url(seededBytes(`${label}:kid`, 32)) });
2463
+ }
2464
+ /** Start the signing-key sequence over; the draw counter is cleared with the namespace. */
2465
+ restart() {
2466
+ this.keyIndex = 0;
2467
+ }
2468
+ };
2469
+
2320
2470
  // src/ui.ts
2321
2471
  var escapeHtml = (value) => value.replace(
2322
2472
  /[&<>"']/g,
@@ -2346,7 +2496,7 @@ function page(title, body, status = 200, action = "'self'", nonce = crypto.rando
2346
2496
  }
2347
2497
  );
2348
2498
  }
2349
- function loginPage(transaction, client, accounts, base, action, signup = false, error = "") {
2499
+ function loginPage(transaction, client, accounts, base, action, signup = false, error = "", nonce) {
2350
2500
  const hidden = `<input type="hidden" name="transaction" value="${escapeHtml(transaction)}">`;
2351
2501
  const endpoint = escapeHtml(`${base}/interaction`);
2352
2502
  const intro = `<span class="eyebrow">Sign in</span><h1 id="title">${signup ? "Create your account" : "Choose an account"}</h1><p>${signup ? "Create an account to continue to" : "Choose an account to continue to"}<br><span class="app">${escapeHtml(client)}</span></p>${error ? `<div class="error" role="alert" data-testid="oauth-mock-${signup ? "signup" : "chooser"}-error">${escapeHtml(error)}</div>` : ""}`;
@@ -2356,11 +2506,11 @@ function loginPage(transaction, client, accounts, base, action, signup = false,
2356
2506
  `${intro}${body}<div class="divider"></div><form method="post" action="${endpoint}">${hidden}<button class="secondary" data-testid="oauth-mock-deny" name="action" value="deny">Cancel sign-in</button></form>`,
2357
2507
  error ? 400 : 200,
2358
2508
  action,
2359
- void 0,
2509
+ nonce,
2360
2510
  signup ? "oauth-mock-signup" : "oauth-mock-chooser"
2361
2511
  );
2362
2512
  }
2363
- function consentPage(transaction, client, account, scopes2, base, action, privacy) {
2513
+ function consentPage(transaction, client, account, scopes2, base, action, privacy, nonce) {
2364
2514
  const labels = {
2365
2515
  openid: "Confirm your identity",
2366
2516
  email: "View your email address",
@@ -2378,7 +2528,7 @@ function consentPage(transaction, client, account, scopes2, base, action, privac
2378
2528
  )}</ul><p>You can cancel now without sharing anything.</p><form method="post" action="${escapeHtml(base)}/interaction"><input type="hidden" name="transaction" value="${escapeHtml(transaction)}">${privacyFields}<button class="primary" data-testid="oauth-mock-allow" name="action" value="allow">Allow &amp; continue</button><button class="secondary" data-testid="oauth-mock-deny" name="action" value="deny">Cancel</button></form>`,
2379
2529
  200,
2380
2530
  action,
2381
- void 0,
2531
+ nonce,
2382
2532
  "oauth-mock-consent"
2383
2533
  );
2384
2534
  }
@@ -2472,6 +2622,24 @@ function createRuntime2(options = {}) {
2472
2622
  );
2473
2623
  return Response.json({ forwarding: input.forwarding, notifications });
2474
2624
  },
2625
+ "GET /grants": async ({ namespace, url }) => {
2626
+ const api = runtime.instance(namespace);
2627
+ const clientId = url.searchParams.get("clientId");
2628
+ const accountId = url.searchParams.get("accountId");
2629
+ const email = url.searchParams.get("email")?.toLowerCase().trim();
2630
+ if (!clientId || accountId !== null && email !== void 0)
2631
+ return Response.json(
2632
+ { error: "clientId is required, with at most one of accountId or email" },
2633
+ { status: 400 }
2634
+ );
2635
+ if (accountId === null && email === void 0) {
2636
+ const grants = await api.grants(clientId);
2637
+ return grants ? Response.json({ grants }) : Response.json({ error: "Unknown client" }, { status: 404 });
2638
+ }
2639
+ const id = accountId ?? api.accounts.list({ where: (a) => a.email.toLowerCase() === email })[0]?.value.id ?? "";
2640
+ const grant = await api.grant(clientId, id);
2641
+ return grant ? Response.json(grant) : Response.json({ error: "Unknown client or account" }, { status: 404 });
2642
+ },
2475
2643
  "POST /keys/rotate": ({ namespace, body }) => {
2476
2644
  const input = body;
2477
2645
  if (input?.retainPrevious !== void 0 && typeof input.retainPrevious !== "boolean")
@@ -2670,6 +2838,8 @@ var OAuthAPI = class {
2670
2838
  options.seed ?? 0,
2671
2839
  options.behavior ?? {}
2672
2840
  );
2841
+ this.credentials = new Credentials(this.sqlite, this.namespace, options.seed, options);
2842
+ this.signer = this.credentials.signer();
2673
2843
  this.seed();
2674
2844
  }
2675
2845
  options;
@@ -2680,7 +2850,8 @@ var OAuthAPI = class {
2680
2850
  tokens;
2681
2851
  sessions;
2682
2852
  consents;
2683
- signer = new Signer();
2853
+ signer;
2854
+ credentials;
2684
2855
  previousSigners = [];
2685
2856
  /** Never published: signs ID tokens for `tokens.idTokenSigningKey: "unpublished"`. */
2686
2857
  unpublishedSigner;
@@ -2737,6 +2908,12 @@ var OAuthAPI = class {
2737
2908
  async reset() {
2738
2909
  clearNamespace(this.sqlite, this.namespace);
2739
2910
  this.behavior.configure(this.options.behavior ?? {});
2911
+ if (this.options.deterministicCredentials || this.options.signingKeys) {
2912
+ this.credentials.restart();
2913
+ this.previousSigners = [];
2914
+ this.unpublishedSigner = void 0;
2915
+ this.signer = this.credentials.signer();
2916
+ }
2740
2917
  this.seed();
2741
2918
  }
2742
2919
  issuer(request) {
@@ -2748,7 +2925,7 @@ var OAuthAPI = class {
2748
2925
  }
2749
2926
  rotateSigningKey(retainPrevious = true) {
2750
2927
  this.previousSigners = retainPrevious ? [this.signer, ...this.previousSigners].slice(0, 4) : [];
2751
- this.signer = new Signer();
2928
+ this.signer = this.credentials.signer();
2752
2929
  return { kid: this.signer.kid };
2753
2930
  }
2754
2931
  /**
@@ -2810,7 +2987,7 @@ var OAuthAPI = class {
2810
2987
  iss: iss.replace(/\/$/, ""),
2811
2988
  aud: client.id,
2812
2989
  iat: Math.floor(this.now() / 1e3),
2813
- jti: random(),
2990
+ jti: this.credentials.token(),
2814
2991
  events: JSON.stringify({
2815
2992
  type,
2816
2993
  sub,
@@ -2857,6 +3034,39 @@ var OAuthAPI = class {
2857
3034
  }))
2858
3035
  this.tokens.delete(row.id);
2859
3036
  }
3037
+ /**
3038
+ * What `clientId` sees for `accountId` (a test control mirroring private provider state), or
3039
+ * `null` for an unknown client or account. For a client that never authorized the account,
3040
+ * `subject` is the id token `sub` it would receive and `email` the real address.
3041
+ */
3042
+ async grant(clientId, accountId) {
3043
+ const client = this.clients.get(clientId);
3044
+ const account = this.accounts.get(accountId);
3045
+ if (!client || !account) return null;
3046
+ const key = this.identityKey(client, account.id);
3047
+ const identity = this.identities.get(key);
3048
+ const consent = this.consents.get(JSON.stringify([clientId, accountId]));
3049
+ return {
3050
+ subject: identity?.sub ?? (this.provider === "apple" || this.provider === "microsoft" ? await hash(key) : account.id),
3051
+ granted: consent !== void 0,
3052
+ scopes: [...scopes(consent?.scope ?? "")],
3053
+ emailChoice: this.provider === "apple" && identity ? identity.privateEmail ? "hide" : "share" : null,
3054
+ email: identity?.email ?? account.email,
3055
+ isPrivateEmail: identity?.privateEmail ?? false,
3056
+ userDisclosed: this.appleDisclosures.has(key)
3057
+ };
3058
+ }
3059
+ /** The grant view of every account that has authorized `clientId`, oldest account first. */
3060
+ async grants(clientId) {
3061
+ if (!this.clients.has(clientId)) return null;
3062
+ const rows = [];
3063
+ for (const { id, value } of this.accounts.list({ order: "oldest" })) {
3064
+ if (!this.consents.has(JSON.stringify([clientId, id]))) continue;
3065
+ const view = await this.grant(clientId, value.id);
3066
+ if (view) rows.push({ accountId: value.id, ...view });
3067
+ }
3068
+ return rows;
3069
+ }
2860
3070
  paths() {
2861
3071
  const paths = {
2862
3072
  google: {
@@ -2986,7 +3196,7 @@ var OAuthAPI = class {
2986
3196
  const skew = tokens?.idTokenClockSkewSeconds ?? 0;
2987
3197
  const iat = Math.floor(this.now() / 1e3) + skew;
2988
3198
  const unpublished = tokens?.idTokenSigningKey === "unpublished";
2989
- if (unpublished) this.unpublishedSigner ??= new Signer();
3199
+ if (unpublished) this.unpublishedSigner ??= this.credentials.unpublishedSigner();
2990
3200
  return (unpublished && this.unpublishedSigner || this.signer).sign({
2991
3201
  ...this.profile(account, grant),
2992
3202
  iss: issuer,
@@ -3189,7 +3399,10 @@ var OAuthAPI = class {
3189
3399
  if (request.method === "GET" && path === "/")
3190
3400
  return page(
3191
3401
  "Identity sandbox",
3192
- '<span class="eyebrow">Mockingbird Identity</span><h1 id="title">Make sign-in<br>feel real.</h1><p>Your identity sandbox is ready. Start sign-in from your application to choose an account, create a new identity, and review access.</p><div class="account"><span class="avatar" aria-hidden="true">\u2713</span><span class="identity"><strong>Ready when you are</strong><small>OAuth 2.0 \xB7 OpenID Connect</small></span></div>'
3402
+ '<span class="eyebrow">Mockingbird Identity</span><h1 id="title">Make sign-in<br>feel real.</h1><p>Your identity sandbox is ready. Start sign-in from your application to choose an account, create a new identity, and review access.</p><div class="account"><span class="avatar" aria-hidden="true">\u2713</span><span class="identity"><strong>Ready when you are</strong><small>OAuth 2.0 \xB7 OpenID Connect</small></span></div>',
3403
+ 200,
3404
+ void 0,
3405
+ this.credentials.nonce()
3193
3406
  );
3194
3407
  return fail("not_found", "Unknown endpoint", 404);
3195
3408
  }
@@ -3285,7 +3498,7 @@ var OAuthAPI = class {
3285
3498
  if (!granted) return error("consent_required", "Consent is required");
3286
3499
  return this.finish({ ...auth, accountId: current.id, authTime: session.authTime }, issuer);
3287
3500
  }
3288
- const id = random();
3501
+ const id = this.credentials.token();
3289
3502
  if (current && session && !prompt.has("login") && !prompt.has("select_account")) {
3290
3503
  auth.accountId = current.id;
3291
3504
  auth.authTime = session.authTime;
@@ -3300,7 +3513,16 @@ var OAuthAPI = class {
3300
3513
  accounts.sort(
3301
3514
  (a, b) => Number(b.email === hint || b.id === hint) - Number(a.email === hint || a.id === hint)
3302
3515
  );
3303
- return loginPage(id, client.name, accounts, issuer, formAction(auth.redirectUri));
3516
+ return loginPage(
3517
+ id,
3518
+ client.name,
3519
+ accounts,
3520
+ issuer,
3521
+ formAction(auth.redirectUri),
3522
+ false,
3523
+ "",
3524
+ this.credentials.nonce()
3525
+ );
3304
3526
  }
3305
3527
  consent(id, client, account, auth, issuer) {
3306
3528
  const existing = this.identities.get(this.identityKey(client, account.id));
@@ -3316,7 +3538,8 @@ var OAuthAPI = class {
3316
3538
  this.provider === "apple" && scopes(auth.scope).has("email") && !existing ? {
3317
3539
  hideEmail: account.privateEmail ?? (this.behavior.config.apple?.emailMode === "hide" || (this.behavior.config.apple?.emailMode ?? "choose") === "choose" && auth.decisions.hideEmail),
3318
3540
  choice: (this.behavior.config.apple?.emailMode ?? "choose") === "choose"
3319
- } : void 0
3541
+ } : void 0,
3542
+ this.credentials.nonce()
3320
3543
  );
3321
3544
  }
3322
3545
  async interact(request, issuer) {
@@ -3333,7 +3556,7 @@ var OAuthAPI = class {
3333
3556
  '<span class="eyebrow">Let\u2019s try again</span><h1 id="title">This sign-in expired</h1><p>Return to your application and start sign-in again.</p>',
3334
3557
  400,
3335
3558
  void 0,
3336
- void 0,
3559
+ this.credentials.nonce(),
3337
3560
  "oauth-mock-error"
3338
3561
  );
3339
3562
  const accounts = () => this.accounts.list({ order: "oldest", where: (a) => !a.disabled }).map((a) => a.value);
@@ -3344,7 +3567,9 @@ var OAuthAPI = class {
3344
3567
  accounts(),
3345
3568
  issuer,
3346
3569
  formAction(auth.redirectUri),
3347
- p.get("screen") === "signup"
3570
+ p.get("screen") === "signup",
3571
+ "",
3572
+ this.credentials.nonce()
3348
3573
  );
3349
3574
  const action = p.get("action");
3350
3575
  if (action === "deny") {
@@ -3364,7 +3589,8 @@ var OAuthAPI = class {
3364
3589
  issuer,
3365
3590
  formAction(auth.redirectUri),
3366
3591
  true,
3367
- "Enter a full name and a valid email address."
3592
+ "Enter a full name and a valid email address.",
3593
+ this.credentials.nonce()
3368
3594
  );
3369
3595
  if (accounts().some((a) => a.email === email) || this.accounts.list().some((a) => a.value.email === email))
3370
3596
  return loginPage(
@@ -3374,9 +3600,15 @@ var OAuthAPI = class {
3374
3600
  issuer,
3375
3601
  formAction(auth.redirectUri),
3376
3602
  true,
3377
- "This email already has an account. Go back to choose it."
3603
+ "This email already has an account. Go back to choose it.",
3604
+ this.credentials.nonce()
3378
3605
  );
3379
- const account = this.seedAccount({ id: random(), email, name, emailVerified: true });
3606
+ const account = this.seedAccount({
3607
+ id: this.credentials.token(),
3608
+ email,
3609
+ name,
3610
+ emailVerified: true
3611
+ });
3380
3612
  auth.accountId = account.id;
3381
3613
  auth.authTime = this.now();
3382
3614
  this.transactions.update(id, auth);
@@ -3392,7 +3624,8 @@ var OAuthAPI = class {
3392
3624
  issuer,
3393
3625
  formAction(auth.redirectUri),
3394
3626
  false,
3395
- "Choose an available account."
3627
+ "Choose an available account.",
3628
+ this.credentials.nonce()
3396
3629
  );
3397
3630
  auth.accountId = account.id;
3398
3631
  auth.authTime = this.now();
@@ -3428,7 +3661,7 @@ var OAuthAPI = class {
3428
3661
  return fail("invalid_request", "Invalid interaction action");
3429
3662
  }
3430
3663
  startSession(result, accountId, authTime, issuer) {
3431
- const session = random();
3664
+ const session = this.credentials.token();
3432
3665
  this.sessions.insert(session, { accountId, authTime, expires: this.now() + 864e5 });
3433
3666
  result.headers.append(
3434
3667
  this.options.cookieHeaders?.response ?? "set-cookie",
@@ -3465,14 +3698,14 @@ var OAuthAPI = class {
3465
3698
  });
3466
3699
  const googleRefresh = behavior.google?.refreshToken ?? "first-consent";
3467
3700
  const issueRefresh = this.provider === "google" ? auth.offline && googleRefresh !== "never" && (googleRefresh === "always" || auth.forceConsent || !previous) : this.provider === "apple" || scopes(scope).has("offline_access");
3468
- const code = random();
3701
+ const code = this.credentials.token();
3469
3702
  const grant = {
3470
3703
  ...auth,
3471
3704
  scope,
3472
3705
  identity,
3473
3706
  issueRefresh,
3474
3707
  expires: this.now() + (behavior.tokens?.codeTtlSeconds ?? 300) * 1e3,
3475
- family: random()
3708
+ family: this.credentials.token()
3476
3709
  };
3477
3710
  this.codes.insert(code, grant);
3478
3711
  this.consents.insert(key, { scope: [...scopes(`${previous?.scope ?? ""} ${scope}`)].join(" ") });
@@ -3501,7 +3734,7 @@ var OAuthAPI = class {
3501
3734
  if (auth.state) values.state = auth.state;
3502
3735
  const redirect = new URL(auth.redirectUri);
3503
3736
  if (auth.responseMode === "form_post") {
3504
- const nonce = this.options.nonce?.() ?? crypto.randomUUID();
3737
+ const nonce = this.options.nonce?.() ?? this.credentials.nonce();
3505
3738
  const result = page(
3506
3739
  "Continue to your app",
3507
3740
  `<span class="eyebrow">All set</span><h1 id="title">Back to your app</h1><p>Your sign-in response is ready.</p><form id="callback" method="post" action="${escapeHtml(auth.redirectUri)}">${Object.entries(
@@ -3645,7 +3878,7 @@ var OAuthAPI = class {
3645
3878
  if (type === "refresh_token")
3646
3879
  this.tokens.update(key, { ...stored, kind: "refresh", lastUsed: this.now() });
3647
3880
  const accessTtl = behavior.tokens?.accessTtlSeconds ?? 3600;
3648
- const access = random();
3881
+ const access = this.credentials.token();
3649
3882
  const result = {
3650
3883
  access_token: access,
3651
3884
  token_type: "Bearer",
@@ -3674,7 +3907,7 @@ var OAuthAPI = class {
3674
3907
  const refreshKey = JSON.stringify([grant.clientId, grant.accountId]);
3675
3908
  const googleEligible = this.provider !== "google" || behavior.google?.refreshToken === "always" || grant.forceConsent || !this.refreshIssued.has(refreshKey);
3676
3909
  if (type === "authorization_code" && grant.issueRefresh && googleEligible || rotating || microsoftRefresh) {
3677
- const refresh = random();
3910
+ const refresh = this.credentials.token();
3678
3911
  const basicScopes = [
3679
3912
  "openid",
3680
3913
  "email",
@@ -3735,4 +3968,4 @@ export {
3735
3968
  createMultiRuntime,
3736
3969
  OAuthAPI
3737
3970
  };
3738
- //# sourceMappingURL=chunk-XP2AEPEJ.js.map
3971
+ //# sourceMappingURL=chunk-SYFF4UFD.js.map