@shelbyb/daywalker-sdk 0.5.0 → 0.7.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/dist/client.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { parseServiceSlugs, resolveConfig } from "./config.js";
2
- import { loadSigningKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.js";
2
+ import { API_KEY_PATTERN, SAFE_IDENTIFIER_PATTERN, SERVICE_SLUG_PATTERN } from "./crypto/signatures.js";
3
+ import { parseStoredSigningKey } from "./keystore/index.js";
4
+ import { loadSigningKey, loadPublicKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, generateDpopKey, createDpopProof, verifyDpopProof, importSigningKey, generateSigningKey, registerPublicKey, revokeSigningKey, keySlotFor, randomInstanceId, calculateJwkThumbprint, base64UrlToBytes, utf8ToString, } from "./crypto/index.js";
5
+ import { webcrypto } from "./crypto/webcrypto.js";
3
6
  import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
4
7
  function parseRetryAfter(headerValue) {
5
8
  if (!headerValue)
@@ -17,7 +20,7 @@ function parseRetryAfter(headerValue) {
17
20
  }
18
21
  function generateNonce() {
19
22
  const bytes = new Uint8Array(16);
20
- globalThis.crypto.getRandomValues(bytes);
23
+ webcrypto.getRandomValues(bytes);
21
24
  let bin = "";
22
25
  for (const b of bytes)
23
26
  bin += String.fromCharCode(b);
@@ -121,10 +124,43 @@ function attemptSignal(caller, timeoutMs) {
121
124
  },
122
125
  };
123
126
  }
124
- /** Reads a response body, giving up (and cancelling it) after `timeoutMs` so a trickling body can't hang us. */
127
+ /**
128
+ * Reads a response body as text, giving up (and cancelling it) after `timeoutMs` so a trickling body can't
129
+ * hang us, and refusing bodies over `MAX_RESPONSE_BYTES` so a huge one can't exhaust memory.
130
+ */
125
131
  async function readText(res, timeoutMs) {
132
+ const declared = Number(res.headers.get("content-length"));
133
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
134
+ res.body?.cancel().catch(() => { });
135
+ throw new DaywalkerError(`Daywalker Auth response is too large (${declared} bytes)`);
136
+ }
137
+ const read = async () => {
138
+ if (!res.body)
139
+ return res.text();
140
+ const reader = res.body.getReader();
141
+ const chunks = [];
142
+ let total = 0;
143
+ for (;;) {
144
+ const { done, value } = await reader.read();
145
+ if (done)
146
+ break;
147
+ total += value.byteLength;
148
+ if (total > MAX_RESPONSE_BYTES) {
149
+ reader.cancel().catch(() => { });
150
+ throw new DaywalkerError(`Daywalker Auth response is larger than ${MAX_RESPONSE_BYTES} bytes`);
151
+ }
152
+ chunks.push(value);
153
+ }
154
+ const bytes = new Uint8Array(total);
155
+ let offset = 0;
156
+ for (const c of chunks) {
157
+ bytes.set(c, offset);
158
+ offset += c.byteLength;
159
+ }
160
+ return utf8ToString(bytes);
161
+ };
126
162
  if (!(timeoutMs > 0))
127
- return res.text();
163
+ return read();
128
164
  let timer;
129
165
  const timeout = new Promise((_resolve, reject) => {
130
166
  timer = setTimeout(() => {
@@ -133,12 +169,25 @@ async function readText(res, timeoutMs) {
133
169
  }, timeoutMs);
134
170
  });
135
171
  try {
136
- return await Promise.race([res.text(), timeout]);
172
+ return await Promise.race([read(), timeout]);
137
173
  }
138
174
  finally {
139
175
  clearTimeout(timer);
140
176
  }
141
177
  }
178
+ /** A JSON object from a response body (bounded, with a timeout), or `{}` when it isn't one. */
179
+ async function readJsonObject(res, timeoutMs) {
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(await readText(res, timeoutMs));
183
+ }
184
+ catch (err) {
185
+ if (err.name === "TimeoutError" || err instanceof DaywalkerError)
186
+ throw err;
187
+ return {};
188
+ }
189
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
190
+ }
142
191
  function isRedirect(res) {
143
192
  return res.type === "opaqueredirect" || REDIRECT_STATUSES.has(res.status);
144
193
  }
@@ -146,6 +195,38 @@ function redirectError(what, res) {
146
195
  res.body?.cancel().catch(() => { });
147
196
  return new DaywalkerError(`Daywalker Auth answered ${what} with a redirect (HTTP ${res.status}); refusing to follow it with credentials`, res.status || undefined, "UNEXPECTED_REDIRECT");
148
197
  }
198
+ /**
199
+ * A failed client credentials grant or refresh as a validation result. Missing configuration is a 500;
200
+ * anything else (invalid_client, access_denied, an outage) is a 503, since it says nothing about the
201
+ * caller's token. Never cached.
202
+ */
203
+ function credentialFailure(err) {
204
+ if (err.code === "CONFIGURATION_ERROR") {
205
+ return { ok: false, error: "CONFIGURATION_ERROR", status: 500, message: err.message };
206
+ }
207
+ return {
208
+ ok: false,
209
+ error: "SERVICE_UNAVAILABLE",
210
+ status: 503,
211
+ message: redactSecrets(`Could not obtain an OAuth access token${err.code ? ` (${err.code})` : ""}: ${err.message}`),
212
+ };
213
+ }
214
+ /** The payload of a JWT this client was just issued over TLS; not verified, only read. */
215
+ function decodeJwtClaims(jwt) {
216
+ try {
217
+ const claims = JSON.parse(utf8ToString(base64UrlToBytes(jwt.split(".")[1] ?? "")));
218
+ return claims && typeof claims === "object" ? claims : {};
219
+ }
220
+ catch {
221
+ return {};
222
+ }
223
+ }
224
+ /** The `code` (else `error`) of a JSON error answer, read from a copy so the caller still gets the body. */
225
+ async function errorCode(res) {
226
+ const json = (await res.clone().json().catch(() => null));
227
+ const code = json?.code ?? json?.error;
228
+ return typeof code === "string" ? code : undefined;
229
+ }
149
230
  function isRateLimitWindow(value) {
150
231
  if (!value || typeof value !== "object")
151
232
  return false;
@@ -173,54 +254,431 @@ function malformedValidation(json, payloadData) {
173
254
  function cloneResult(result) {
174
255
  return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
175
256
  }
257
+ const DEFAULT_ISSUER = "https://auth.daywalker.dev";
258
+ const TOKEN_PATH = "/api/v1/oauth/token";
259
+ /**
260
+ * Longest a successful validation is cached (5 minutes), whatever `cacheTtlMs` says, so a revoked token
261
+ * can't stay accepted for long.
262
+ */
263
+ export const MAX_CACHE_TTL_MS = 300_000;
264
+ /** Longest incoming token accepted (characters); longer input is refused without calling Daywalker. */
265
+ export const MAX_TOKEN_LENGTH = 8_192;
266
+ /** Printable ASCII with no whitespace, quotes, commas or separators: anything else is not a token. */
267
+ const TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
268
+ /** Access tokens are used for at most this long (seconds), whatever the token endpoint says. */
269
+ const MAX_ACCESS_TOKEN_LIFETIME_SECONDS = 3_600;
270
+ /** Largest response body read from Daywalker Auth. */
271
+ const MAX_RESPONSE_BYTES = 1_048_576;
272
+ /** How long a fetched JWKS (`jwks: "auto"`) is used before it is refetched. */
273
+ export const JWKS_CACHE_TTL_MS = 600_000;
274
+ /**
275
+ * Least time between two JWKS fetches, whatever triggers them (an unknown `kid`, an expired set, a failed
276
+ * fetch), so a flood of tokens naming made-up `kid`s costs at most one request to Daywalker per interval.
277
+ */
278
+ export const JWKS_REFETCH_COOLDOWN_MS = 30_000;
279
+ /** Keys looked at in a fetched JWKS; the rest are ignored. */
280
+ const MAX_JWKS_KEYS = 16;
281
+ /** An Ed25519 public key: 32 bytes, unpadded base64url. */
282
+ const ED25519_X_PATTERN = /^[A-Za-z0-9_-]{43}$/;
283
+ /**
284
+ * The keys of a fetched JWKS that can verify Daywalker access tokens: Ed25519 signing keys for EdDSA with a
285
+ * `kid`, copied to fresh objects with nothing else on them. Anything else (RSA, EC, `alg: "none"`, private
286
+ * key material, encryption keys) is dropped.
287
+ */
288
+ function usableJwks(keys) {
289
+ if (!Array.isArray(keys))
290
+ return [];
291
+ const out = [];
292
+ for (const k of keys.slice(0, MAX_JWKS_KEYS)) {
293
+ if (!k || typeof k !== "object")
294
+ continue;
295
+ const j = k;
296
+ if (j.kty !== "OKP" || j.crv !== "Ed25519" || typeof j.x !== "string" || !ED25519_X_PATTERN.test(j.x))
297
+ continue;
298
+ if ((j.alg !== undefined && j.alg !== "EdDSA") || (j.use !== undefined && j.use !== "sig") || j.d !== undefined)
299
+ continue;
300
+ if (typeof j.kid !== "string" || j.kid === "" || j.kid.length > 256)
301
+ continue;
302
+ out.push({ kty: "OKP", crv: "Ed25519", x: j.x, kid: j.kid, use: "sig", alg: "EdDSA" });
303
+ }
304
+ return out;
305
+ }
306
+ /** The `kid` a JWT's (unverified) header names, used only to pick a key; undefined when there is none. */
307
+ function jwtKid(token) {
308
+ try {
309
+ const header = JSON.parse(utf8ToString(base64UrlToBytes(token.split(".")[0] ?? "")));
310
+ return header && typeof header === "object" && typeof header.kid === "string" ? header.kid : undefined;
311
+ }
312
+ catch {
313
+ return undefined;
314
+ }
315
+ }
316
+ /** A copy of `value` that can't be changed: nested objects and arrays are frozen too. */
317
+ function deepFreeze(value) {
318
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
319
+ Object.freeze(value);
320
+ for (const v of Object.values(value))
321
+ deepFreeze(v);
322
+ }
323
+ return value;
324
+ }
325
+ /** A local key as a signer; its private key never leaves the closure. */
326
+ function localSigner(key, keyId, source) {
327
+ const privateKey = key.privateKey;
328
+ return Object.freeze({
329
+ signer: Object.freeze({
330
+ keyId,
331
+ sign: async (data) => new Uint8Array(await webcrypto.sign("Ed25519", privateKey, data)),
332
+ publicJwk: key.publicJwk,
333
+ }),
334
+ publicJwk: key.publicJwk,
335
+ source,
336
+ });
337
+ }
176
338
  export class Daywalker {
177
- _config;
178
- _rawConfig;
179
- _signingKeyPromise;
180
- _previousSigningKeyPromise;
181
- _cache = new Map();
182
- _inflight = new Map();
339
+ #config;
340
+ /** The secret-free, frozen view handed out by `config`. */
341
+ #publicConfig;
342
+ #fetch;
343
+ #publicOrigin;
344
+ #dpopReplayStore;
345
+ /** `config.signer` and `config.keyStore`, captured (methods bound) at construction so later edits can't swap them. */
346
+ #externalSigner;
347
+ #keyStore;
348
+ #signerPromise = null;
349
+ /** Ephemeral mode: this process's key slot, its current key, the rotation in flight, and when to retry one. */
350
+ #ephemeralSlot;
351
+ #ephemeral = null;
352
+ #ephemeralRotation = null;
353
+ #ephemeralRetryAt = 0;
354
+ /** Bumped whenever the ephemeral key is replaced; tokens requested under an older generation are stale. */
355
+ #keyGeneration = 0;
356
+ #closed = false;
357
+ #signingKeyPromise;
358
+ #previousSigningKeyPromise;
359
+ #cache = new Map();
360
+ #inflight = new Map();
361
+ /** The last JWKS fetched with usable keys (`jwks: "auto"`), kept when a later fetch fails. */
362
+ #jwks = null;
363
+ #jwksFlight = null;
364
+ #jwksAttemptAt = -Infinity;
183
365
  constructor(config = {}) {
184
- this._rawConfig = config;
185
- this._config = resolveConfig(config);
366
+ // Everything is read once: changing the caller's config object later has no effect.
367
+ const resolved = resolveConfig(config);
368
+ resolved.cacheTtlMs = Math.max(0, Math.min(Number.isFinite(resolved.cacheTtlMs) ? resolved.cacheTtlMs : 0, MAX_CACHE_TTL_MS));
369
+ resolved.negativeCacheTtlMs = Math.max(0, Math.min(Number.isFinite(resolved.negativeCacheTtlMs) ? resolved.negativeCacheTtlMs : 0, MAX_CACHE_TTL_MS));
370
+ // Copies, so freezing never touches objects the caller still owns.
371
+ const copyKey = (k) => (k && typeof k === "object" ? { ...k, publicJwk: { ...k.publicJwk } } : k);
372
+ resolved.signingKey = copyKey(resolved.signingKey);
373
+ resolved.previousSigningKey = copyKey(resolved.previousSigningKey);
374
+ if (resolved.daywalkerPublicKey && typeof resolved.daywalkerPublicKey === "object") {
375
+ resolved.daywalkerPublicKey = JSON.parse(JSON.stringify(resolved.daywalkerPublicKey));
376
+ }
377
+ this.#config = deepFreeze(resolved);
378
+ this.#publicConfig = deepFreeze({
379
+ ...resolved,
380
+ serviceSlugs: [...resolved.serviceSlugs],
381
+ retry: { ...resolved.retry },
382
+ problems: [...resolved.problems],
383
+ apiKey: resolved.apiKey ? "[configured]" : null,
384
+ signingKey: resolved.signingKey ? "[configured]" : null,
385
+ previousSigningKey: resolved.previousSigningKey ? "[configured]" : null,
386
+ });
387
+ const doFetch = config.fetch ?? globalThis.fetch;
388
+ if (typeof doFetch !== "function")
389
+ throw new DaywalkerError("No fetch implementation available: pass config.fetch");
390
+ this.#fetch = doFetch;
391
+ this.#publicOrigin = resolved.publicOrigin;
392
+ this.#dpopReplayStore = config.dpopReplayStore;
393
+ const signer = config.signer;
394
+ if (signer) {
395
+ if (typeof signer.sign !== "function" || typeof signer.keyId !== "string" || !SAFE_IDENTIFIER_PATTERN.test(signer.keyId)) {
396
+ throw new DaywalkerError("config.signer needs a sign() function and a keyId of 1-128 safe characters");
397
+ }
398
+ this.#externalSigner = Object.freeze({
399
+ keyId: signer.keyId,
400
+ sign: signer.sign.bind(signer),
401
+ publicJwk: signer.publicJwk ? Object.freeze({ ...signer.publicJwk }) : undefined,
402
+ });
403
+ }
404
+ else {
405
+ this.#externalSigner = null;
406
+ }
407
+ const store = config.keyStore;
408
+ if (store && typeof store.load !== "function")
409
+ throw new DaywalkerError("config.keyStore needs a load() function");
410
+ if (store && resolved.ephemeralKey)
411
+ throw new DaywalkerError("Use either keyStore or ephemeralKey, not both");
412
+ this.#ephemeralSlot = resolved.ephemeralKey ? (resolved.ephemeralKey.instanceId ?? randomInstanceId()) : null;
413
+ this.#keyStore = store
414
+ ? Object.freeze({
415
+ load: store.load.bind(store),
416
+ saveIfAbsent: typeof store.saveIfAbsent === "function" ? store.saveIfAbsent.bind(store) : undefined,
417
+ })
418
+ : null;
419
+ // Instances of this class (not subclasses, which may still add fields) can't gain or change properties.
420
+ if (new.target === Daywalker)
421
+ Object.freeze(this);
186
422
  }
423
+ /** The resolved configuration, frozen, with the API key and private keys shown as "[configured]". */
187
424
  get config() {
188
- return this._config;
425
+ return this.#publicConfig;
189
426
  }
190
427
  toJSON() {
191
428
  return {
192
- serviceSlug: this._config.serviceSlug,
193
- domain: this._config.domain,
194
- authUrl: this._config.authUrl,
195
- keyId: this._config.keyId,
196
- apiKey: maskSecret(this._config.apiKey),
197
- signingKey: this._config.signingKey ? "[configured]" : null,
198
- daywalkerPublicKey: this._config.daywalkerPublicKey ? "[configured]" : null,
429
+ serviceSlug: this.#config.serviceSlug,
430
+ domain: this.#config.domain,
431
+ authUrl: this.#config.authUrl,
432
+ keyId: this.#config.keyId,
433
+ apiKey: maskSecret(this.#config.apiKey),
434
+ signingKey: this.#config.signingKey ? "[configured]" : null,
435
+ daywalkerPublicKey: this.#config.daywalkerPublicKey ? "[configured]" : null,
436
+ jwks: this.#config.jwks,
199
437
  };
200
438
  }
201
439
  // Node.js custom inspect masking
202
440
  [Symbol.for("nodejs.util.inspect.custom")]() {
203
441
  return this.toJSON();
204
442
  }
205
- async getLoadedSigningKey() {
206
- if (!this._config.signingKey)
443
+ async #getLoadedSigningKey() {
444
+ if (!this.#config.signingKey)
207
445
  return null;
208
- if (typeof this._config.signingKey !== "string" && "privateKey" in this._config.signingKey) {
209
- return this._config.signingKey;
446
+ if (typeof this.#config.signingKey !== "string" && "privateKey" in this.#config.signingKey) {
447
+ return this.#config.signingKey;
210
448
  }
211
- if (!this._signingKeyPromise) {
212
- this._signingKeyPromise = loadSigningKey(this._config.signingKey);
449
+ if (!this.#signingKeyPromise) {
450
+ this.#signingKeyPromise = loadSigningKey(this.#config.signingKey);
213
451
  }
214
- return this._signingKeyPromise;
452
+ return this.#signingKeyPromise;
215
453
  }
216
- async getLoadedPreviousSigningKey() {
217
- const raw = this._config.previousSigningKey;
454
+ /**
455
+ * What signs this client's requests, resolved once: `config.signer` (KMS / HSM), else `signingKey`
456
+ * (DAYWALKER_SIGNING_KEY), else the key in `keyStore`, provisioned on first use. Null when none is set up.
457
+ * A failure isn't remembered, so the next request tries again.
458
+ */
459
+ #getSigner() {
460
+ if (this.#closed)
461
+ return Promise.reject(new DaywalkerError("This Daywalker client was closed", undefined, "CLIENT_CLOSED"));
462
+ if (this.#config.ephemeralKey && !this.#externalSigner && !this.#config.signingKey)
463
+ return this.#ephemeralSigner();
464
+ if (!this.#signerPromise) {
465
+ this.#signerPromise = this.#resolveSigner().catch((err) => {
466
+ this.#signerPromise = null;
467
+ throw err;
468
+ });
469
+ }
470
+ return this.#signerPromise;
471
+ }
472
+ async #resolveSigner() {
473
+ const external = this.#externalSigner;
474
+ if (external) {
475
+ if (external.publicJwk) {
476
+ // Prove once that the signer's key is the one claimed, before any request depends on it.
477
+ const { publicKey } = await loadPublicKey(external.publicJwk);
478
+ const challenge = webcrypto.getRandomValues(new Uint8Array(32));
479
+ const signature = await external.sign(challenge);
480
+ if (!(signature instanceof Uint8Array) || !(await webcrypto.verify("Ed25519", publicKey, signature, challenge))) {
481
+ throw new DaywalkerAuthError("config.signer does not sign with the key in its publicJwk", 500, "CONFIGURATION_ERROR");
482
+ }
483
+ }
484
+ return Object.freeze({ signer: external, publicJwk: external.publicJwk, source: "signer" });
485
+ }
486
+ const local = await this.#getLoadedSigningKey();
487
+ if (local)
488
+ return localSigner(local, this.#config.keyId ?? local.publicJwk.kid, "signingKey");
489
+ if (this.#keyStore) {
490
+ const key = await this.#provisionFromStore(this.#keyStore);
491
+ return localSigner(key, key.publicJwk.kid, "keyStore");
492
+ }
493
+ return null;
494
+ }
495
+ async #loadStored(store) {
496
+ const raw = await store.load();
497
+ return raw === null || raw === undefined ? null : parseStoredSigningKey(raw);
498
+ }
499
+ /** Imports a stored key (non-extractable) after checking it belongs to this service and matches its keyId. */
500
+ async #useStored(stored) {
501
+ if (this.#config.domain && stored.domain !== this.#config.domain) {
502
+ throw new DaywalkerAuthError(`The stored signing key is registered for ${stored.domain}, but this service is ${this.#config.domain}. Remove it from the key store to provision one for ${this.#config.domain}.`, 500, "CONFIGURATION_ERROR");
503
+ }
504
+ const key = await importSigningKey(stored.privateKeyPkcs8Base64);
505
+ if (key.publicJwk.kid !== stored.keyId) {
506
+ throw new DaywalkerAuthError("The stored signing key does not match its keyId (corrupted or tampered with)", 500, "CONFIGURATION_ERROR");
507
+ }
508
+ return key;
509
+ }
510
+ /**
511
+ * Auto-provisioning. Loads the key from the store; when there is none, generates one, registers it for this
512
+ * service's domain (`POST /api/v1/keys/register`, with the API key) and saves it with `saveIfAbsent`. Only
513
+ * registered keys are ever stored, so a key planted in the store is never registered by this process. If
514
+ * another instance saved first, its key is used and the one registered here is reported for revocation.
515
+ */
516
+ async #provisionFromStore(store) {
517
+ const existing = await this.#loadStored(store);
518
+ if (existing)
519
+ return this.#useStored(existing);
520
+ if (!store.saveIfAbsent) {
521
+ throw new DaywalkerAuthError("The key store is read-only and holds no signing key: provision one with `npx daywalker init`", 500, "CONFIGURATION_ERROR");
522
+ }
523
+ if (!this.#config.apiKey) {
524
+ throw new DaywalkerAuthError("DAYWALKER_API_KEY is needed to register a signing key on first use", 500, "CONFIGURATION_ERROR");
525
+ }
526
+ const domain = this.#config.domain;
527
+ if (!domain) {
528
+ throw new DaywalkerAuthError("No public domain detected to register a signing key for: set DAYWALKER_DOMAIN", 500, "CONFIGURATION_ERROR");
529
+ }
530
+ const generated = await generateSigningKey();
531
+ // Another instance may have finished while we generated: prefer its key over registering a second one.
532
+ const raced = await this.#loadStored(store);
533
+ if (raced)
534
+ return this.#useStored(raced);
535
+ // Its own slot, so registering it never demotes a key another instance is already using.
536
+ await this.#register(generated.publicJwk, { instanceId: keySlotFor(generated.publicJwk.kid) });
537
+ const record = {
538
+ version: 1,
539
+ keyId: generated.publicJwk.kid,
540
+ domain,
541
+ privateKeyPkcs8Base64: generated.privateKeyPkcs8Base64,
542
+ createdAt: Math.floor(Date.now() / 1000),
543
+ };
544
+ if (await store.saveIfAbsent(record))
545
+ return this.#useStored(record);
546
+ const winner = await this.#loadStored(store);
547
+ if (!winner)
548
+ throw new DaywalkerAuthError("The key store refused the signing key but holds none", 500, "CONFIGURATION_ERROR");
549
+ // Another instance stored its key first: ours was registered but will never be used, so revoke it.
550
+ const revoked = await revokeSigningKey({ authUrl: this.#config.authUrl, apiKey: this.#config.apiKey, keyId: record.keyId, fetchFn: this.#fetch });
551
+ if (!revoked.ok) {
552
+ console.warn(`[daywalker] Unused signing key ${record.keyId} (${domain}) could not be revoked (${revoked.status} ${revoked.error}); revoke it with \`npx daywalker revoke ${record.keyId}\`.`);
553
+ }
554
+ return this.#useStored(winner);
555
+ }
556
+ /**
557
+ * Ephemeral mode: the current in-memory key, replaced in the same slot once a quarter of its lifetime is left.
558
+ * While the old key is still good for more than a minute, requests keep using it and the replacement registers
559
+ * in the background (Daywalker keeps the old one valid for 10 more minutes). A failed replacement is retried
560
+ * at most every 30 seconds.
561
+ */
562
+ async #ephemeralSigner() {
563
+ const now = Math.floor(Date.now() / 1000);
564
+ const ttl = this.#config.ephemeralKey.ttlSeconds;
565
+ const current = this.#ephemeral;
566
+ if (current && current.expiresAt - now > ttl / 4)
567
+ return current.signer;
568
+ const usable = current && current.expiresAt - now > 60;
569
+ if (usable && Date.now() < this.#ephemeralRetryAt)
570
+ return current.signer;
571
+ if (!this.#ephemeralRotation) {
572
+ this.#ephemeralRotation = this.#rotateEphemeral()
573
+ .catch((err) => {
574
+ this.#ephemeralRetryAt = Date.now() + 30_000;
575
+ throw err;
576
+ })
577
+ .finally(() => {
578
+ this.#ephemeralRotation = null;
579
+ });
580
+ }
581
+ if (usable) {
582
+ this.#ephemeralRotation.catch(() => { });
583
+ return current.signer;
584
+ }
585
+ return this.#ephemeralRotation;
586
+ }
587
+ /** Generates a non-extractable key in memory and registers it in this process's slot with a lifetime. */
588
+ async #rotateEphemeral() {
589
+ if (!this.#config.apiKey) {
590
+ throw new DaywalkerAuthError("DAYWALKER_API_KEY is needed to register an ephemeral signing key", 500, "CONFIGURATION_ERROR");
591
+ }
592
+ if (!this.#config.domain) {
593
+ throw new DaywalkerAuthError("No public domain detected to register a signing key for: set DAYWALKER_DOMAIN", 500, "CONFIGURATION_ERROR");
594
+ }
595
+ const ttl = this.#config.ephemeralKey.ttlSeconds;
596
+ const pair = (await webcrypto.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]));
597
+ const { x } = (await webcrypto.exportKey("jwk", pair.publicKey));
598
+ const kid = await calculateJwkThumbprint({ crv: "Ed25519", kty: "OKP", x });
599
+ const publicJwk = { kty: "OKP", crv: "Ed25519", x, kid, use: "sig", alg: "EdDSA" };
600
+ const registration = await this.#register(publicJwk, { instanceId: this.#ephemeralSlot, ttlSeconds: ttl });
601
+ if (this.#closed) {
602
+ // close() ran while this registered: don't leave the key behind.
603
+ await revokeSigningKey({ authUrl: this.#config.authUrl, apiKey: this.#config.apiKey, keyId: kid, fetchFn: this.#fetch });
604
+ throw new DaywalkerError("This Daywalker client was closed", undefined, "CLIENT_CLOSED");
605
+ }
606
+ const now = Math.floor(Date.now() / 1000);
607
+ const expiresAt = Math.min(registration.key?.expiresAt ?? Infinity, now + ttl);
608
+ const signer = localSigner({ privateKey: pair.privateKey, publicJwk }, kid, "ephemeral");
609
+ const replaced = this.#ephemeral !== null;
610
+ this.#ephemeral = { signer, keyId: kid, expiresAt };
611
+ // OAuth sessions are tied to the key that started them: tokens from before this point are now stale, even a
612
+ // grant still in flight, so the next accessToken() starts a session with the new key.
613
+ if (replaced)
614
+ this.#keyGeneration++;
615
+ return signer;
616
+ }
617
+ /**
618
+ * Shuts the client down: revokes its ephemeral signing key (which also ends the OAuth sessions it started)
619
+ * and forgets its tokens. Afterwards every call that needs Daywalker fails with CLIENT_CLOSED. Safe to call
620
+ * twice. Keys from `signingKey`, `keyStore` or `signer` are long-lived and are left alone.
621
+ */
622
+ async close() {
623
+ if (this.#closed)
624
+ return { revoked: [], failed: [] };
625
+ this.#closed = true;
626
+ await this.#ephemeralRotation?.catch(() => { });
627
+ const current = this.#ephemeral;
628
+ this.#ephemeral = null;
629
+ this.#tokens = null;
630
+ if (!current || !this.#config.apiKey)
631
+ return { revoked: [], failed: [] };
632
+ const result = await revokeSigningKey({ authUrl: this.#config.authUrl, apiKey: this.#config.apiKey, keyId: current.keyId, fetchFn: this.#fetch });
633
+ return result.ok ? { revoked: [current.keyId], failed: [] } : { revoked: [], failed: [current.keyId] };
634
+ }
635
+ /**
636
+ * Registers a public key with Daywalker for this service's domain. Failures become errors the validation
637
+ * paths map to 503 (an outage, or credentials Daywalker refuses), with Daywalker's code in the message and
638
+ * what to do about a full domain.
639
+ */
640
+ async #register(publicJwk, options) {
641
+ const registration = await registerPublicKey({
642
+ authUrl: this.#config.authUrl,
643
+ apiKey: this.#config.apiKey,
644
+ domain: this.#config.domain,
645
+ serviceSlug: this.#config.serviceSlugs.length > 0 ? this.#config.serviceSlugs.join(",") : undefined,
646
+ publicJwk,
647
+ instanceId: options.instanceId,
648
+ ttlSeconds: options.ttlSeconds,
649
+ fetchFn: this.#fetch,
650
+ });
651
+ if (registration.ok)
652
+ return registration;
653
+ const hint = registration.code === "KEY_LIMIT_REACHED"
654
+ ? " The domain has 20 live keys: list them with `npx daywalker keys` and revoke unused ones with `npx daywalker revoke <keyId>`."
655
+ : "";
656
+ const message = `Registering the signing key failed (${registration.status || "network"}${registration.code ? ` ${registration.code}` : ""}): ${registration.error ?? "unknown error"}.${hint}`;
657
+ if (registration.status === 0 || registration.status >= 500)
658
+ throw new DaywalkerError(message, registration.status || undefined);
659
+ throw new DaywalkerAuthError(message, 503, "KEY_REGISTRATION_FAILED");
660
+ }
661
+ /**
662
+ * Resolves the signing key now instead of on the first request: loads it, or provisions and registers it
663
+ * (see `keyStore`). Call it at startup to fail fast. Returns where the key came from and its id.
664
+ */
665
+ async provisionSigningKey() {
666
+ const active = await this.#getSigner();
667
+ return active ? { source: active.source, keyId: active.signer.keyId } : null;
668
+ }
669
+ #dpopKey = null;
670
+ /** The client's DPoP key, generated on first use. Issued tokens are bound to it for this client's life. */
671
+ #getDpopKey() {
672
+ return (this.#dpopKey ??= generateDpopKey());
673
+ }
674
+ async #getLoadedPreviousSigningKey() {
675
+ const raw = this.#config.previousSigningKey;
218
676
  if (!raw)
219
677
  return null;
220
- if (!this._previousSigningKeyPromise) {
221
- this._previousSigningKeyPromise = loadSigningKey(raw);
678
+ if (!this.#previousSigningKeyPromise) {
679
+ this.#previousSigningKeyPromise = loadSigningKey(raw);
222
680
  }
223
- return this._previousSigningKeyPromise;
681
+ return this.#previousSigningKeyPromise;
224
682
  }
225
683
  /**
226
684
  * Signs and executes a request to Daywalker Auth (same origin as `authUrl` only).
@@ -231,21 +689,24 @@ export class Daywalker {
231
689
  * - 429s are retried with exponential backoff, honoring `Retry-After` up to `retry.maxRetryAfterMs`. Network
232
690
  * errors and timeouts are retried only for GET / HEAD / OPTIONS, with an `idempotencyKey`, or when
233
691
  * `retry.retryNetworkErrors` is true. Options merge as defaults < `config.retry` < `init.retry`.
234
- * - An explicit `Authorization` header is sent as given; otherwise `Bearer <apiKey>` is added. The signature
235
- * covers method, path, query, authority and Content-Digest, never Authorization.
692
+ * - An explicit `Authorization` header is sent as given; otherwise `DPoP <access token>` from the OAuth
693
+ * client credentials flow (`accessToken()`) is added, with a DPoP proof bound to it. The API key is never
694
+ * sent here: it only authenticates the client credentials grant. An answer of 401 ACCESS_TOKEN_EXPIRED
695
+ * refreshes the token and retries once. The signature covers method, path, query, authority and
696
+ * Content-Digest, never Authorization.
236
697
  */
237
698
  async fetch(path, init) {
238
- const authUrl = this._config.authUrl;
699
+ const authUrl = this.#config.authUrl;
239
700
  const url = new URL(path, authUrl);
240
- // Absolute URLs to other hosts would otherwise receive the API key and a valid signature.
701
+ // Absolute URLs to other hosts would otherwise receive an access token and a valid signature.
241
702
  const authOrigin = new URL(authUrl).origin;
242
703
  if (url.origin !== authOrigin) {
243
704
  throw new DaywalkerError(`Refusing to send Daywalker credentials to ${url.origin}; fetch() only targets ${authOrigin}`);
244
705
  }
245
706
  const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
246
- const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
707
+ const doFetch = this.#fetch;
247
708
  const { retry: _retry, timeoutMs: _timeoutMs, idempotencyKey, body: rawBody, signal: callerSignal, ...requestInit } = init ?? {};
248
- const retryOpts = { ...this._config.retry };
709
+ const retryOpts = { ...this.#config.retry };
249
710
  for (const [k, v] of Object.entries(init?.retry ?? {})) {
250
711
  if (v !== undefined)
251
712
  retryOpts[k] = v;
@@ -257,7 +718,7 @@ export class Daywalker {
257
718
  const jitter = retryOpts.jitter ?? true;
258
719
  const maxRetryAfterMs = retryOpts.maxRetryAfterMs ?? MAX_RETRY_AFTER_MS;
259
720
  const retryNetworkErrors = retryOpts.retryNetworkErrors ?? (IDEMPOTENT_METHODS.has(method) || Boolean(idempotencyKey));
260
- const timeoutMs = init?.timeoutMs ?? this._config.timeoutMs;
721
+ const timeoutMs = init?.timeoutMs ?? this.#config.timeoutMs;
261
722
  const backoff = (n) => {
262
723
  let delayMs = initialDelayMs * Math.pow(backoffFactor, n - 1);
263
724
  if (jitter)
@@ -265,8 +726,12 @@ export class Daywalker {
265
726
  return Math.min(delayMs, maxDelayMs);
266
727
  };
267
728
  callerSignal?.throwIfAborted();
268
- const signingKey = await this.getLoadedSigningKey();
269
- const body = await prepareBody(rawBody, Boolean(signingKey));
729
+ const active = await this.#getSigner();
730
+ const body = await prepareBody(rawBody, Boolean(active));
731
+ // The token endpoint authenticates itself (see _tokenRequest); everything else gets an OAuth access token.
732
+ const isTokenEndpoint = url.pathname === TOKEN_PATH;
733
+ const autoAuth = !isTokenEndpoint && !new Headers(init?.headers).has("authorization");
734
+ let refreshedExpired = false;
270
735
  let attempt = 0;
271
736
  while (true) {
272
737
  callerSignal?.throwIfAborted();
@@ -277,21 +742,27 @@ export class Daywalker {
277
742
  if (!headers.has("accept")) {
278
743
  headers.set("accept", "application/json");
279
744
  }
280
- if (this._config.apiKey && !headers.has("authorization")) {
281
- headers.set("authorization", `Bearer ${this._config.apiKey}`);
745
+ if (autoAuth) {
746
+ headers.set("authorization", `DPoP ${await this.accessToken()}`);
282
747
  }
283
748
  if (idempotencyKey && !headers.has("idempotency-key")) {
284
749
  headers.set("idempotency-key", idempotencyKey);
285
750
  }
286
- if (signingKey) {
287
- // apiKey is left out so the signer can't replace an explicit Authorization header.
751
+ // DPoP (RFC 9449) is mandatory on the token endpoint and when presenting a DPoP access token.
752
+ const auth = headers.get("authorization") ?? "";
753
+ const isDpopAuth = /^dpop\s+/i.test(auth);
754
+ if ((autoAuth || !headers.has("dpop")) && (isTokenEndpoint || isDpopAuth)) {
755
+ const accessToken = isDpopAuth ? auth.replace(/^dpop\s+/i, "") : undefined;
756
+ headers.set("dpop", await createDpopProof(await this.#getDpopKey(), { method, url: url.toString(), accessToken }));
757
+ }
758
+ if (active) {
759
+ // Signing never touches Authorization, so an explicit header is kept as given.
288
760
  const signedHeaders = await signRequest({
289
761
  method,
290
762
  path: url.pathname + url.search,
291
763
  authority: url.host,
292
764
  body: body.bytes,
293
- key: signingKey,
294
- keyid: this._config.keyId ?? undefined,
765
+ signer: active.signer,
295
766
  nonce: generateNonce(),
296
767
  });
297
768
  for (const [k, v] of Object.entries(signedHeaders)) {
@@ -307,6 +778,8 @@ export class Daywalker {
307
778
  headers,
308
779
  body: body.send ?? undefined,
309
780
  redirect: requestInit.redirect === "error" ? "error" : "manual",
781
+ // Never served from or stored in an HTTP cache (Next.js patches fetch to cache by default).
782
+ cache: "no-store",
310
783
  signal,
311
784
  };
312
785
  let res;
@@ -329,6 +802,14 @@ export class Daywalker {
329
802
  if (isRedirect(res)) {
330
803
  throw redirectError(url.pathname, res);
331
804
  }
805
+ if (res.status === 401 && autoAuth && !refreshedExpired && body.replayable && (await errorCode(res)) === "ACCESS_TOKEN_EXPIRED") {
806
+ // The server's clock says the token expired before ours did: refresh it and try once more.
807
+ refreshedExpired = true;
808
+ if (this.#tokens)
809
+ this.#tokens = { ...this.#tokens, expiresAt: 0 };
810
+ res.body?.cancel().catch(() => { });
811
+ continue;
812
+ }
332
813
  if (res.status === 429 && body.replayable && attempt < maxRetries) {
333
814
  const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
334
815
  let delayMs = backoff(attempt + 1);
@@ -346,100 +827,139 @@ export class Daywalker {
346
827
  return res;
347
828
  }
348
829
  }
349
- _cacheGet(key) {
350
- const entry = this._cache.get(key);
830
+ #cacheGet(key) {
831
+ const entry = this.#cache.get(key);
351
832
  if (!entry)
352
833
  return undefined;
353
- this._cache.delete(key);
834
+ this.#cache.delete(key);
354
835
  if (entry.expiresAt <= Date.now())
355
836
  return undefined;
356
- this._cache.set(key, entry); // most recently used goes last
357
- return entry.result;
837
+ this.#cache.set(key, entry); // most recently used goes last
838
+ return entry;
358
839
  }
359
- _cacheSet(key, result, ttlMs, notAfterMs = Infinity) {
840
+ #cacheSet(key, result, ttlMs, notAfterMs = Infinity, jkt) {
360
841
  const expiresAt = Math.min(Date.now() + ttlMs, notAfterMs);
361
842
  if (expiresAt <= Date.now())
362
843
  return;
363
- this._cache.delete(key);
364
- this._cache.set(key, { result, expiresAt });
365
- while (this._cache.size > MAX_VALIDATION_CACHE_ENTRIES) {
366
- this._cache.delete(this._cache.keys().next().value);
844
+ this.#cache.delete(key);
845
+ // Frozen, so nothing holding the entry can change what later callers get.
846
+ this.#cache.set(key, Object.freeze({ result: deepFreeze(cloneResult(result)), expiresAt, jkt }));
847
+ while (this.#cache.size > MAX_VALIDATION_CACHE_ENTRIES) {
848
+ this.#cache.delete(this.#cache.keys().next().value);
367
849
  }
368
850
  }
369
851
  /**
370
- * Validates an incoming bearer token with memory caching and discriminated union return.
371
- * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
372
- * A missing token is always rejected (401); it never falls back to this service's own API key —
373
- * use `validateServiceKey()` for that.
374
- * Online validation fails closed: only a 2xx JSON answer with `valid: true` and a sane shape is accepted.
375
- * Outages (network errors, timeouts, 5xx, malformed answers) return status 503 with error NETWORK_ERROR or
376
- * SERVICE_UNAVAILABLE and are never cached. Each call gets its own copy of the (cached) result.
852
+ * Checks the DPoP proof a DPoP-bound (`cnf.jkt`) access token must come with. Run on every request, cached
853
+ * token or not: a proof is single-use, so a copied token is worthless without the client's private key.
377
854
  */
378
- async validateToken(tokenOrOptions, options) {
379
- let token;
380
- let serviceSlug;
381
- let forceRefresh = false;
382
- let offlineOnly = false;
383
- if (typeof tokenOrOptions === "string") {
384
- token = tokenOrOptions;
385
- serviceSlug = (options?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
386
- forceRefresh = options?.forceRefresh ?? false;
387
- offlineOnly = options?.offlineOnly ?? false;
388
- }
389
- else {
390
- token = tokenOrOptions?.token ?? undefined;
391
- serviceSlug = (tokenOrOptions?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
392
- forceRefresh = tokenOrOptions?.forceRefresh ?? false;
393
- offlineOnly = tokenOrOptions?.offlineOnly ?? false;
394
- }
395
- if (!token) {
855
+ async #checkDpop(token, jkt, dpop) {
856
+ if (!dpop || typeof dpop.proof !== "string" || dpop.proof === "") {
396
857
  return {
397
858
  ok: false,
398
- error: "INVALID_TOKEN",
859
+ error: "DPOP_REQUIRED",
399
860
  status: 401,
400
- message: "Missing token to validate",
861
+ message: "This access token is DPoP-bound: present it as `Authorization: DPoP <token>` with a DPoP proof",
401
862
  };
402
863
  }
864
+ let url = dpop.url;
865
+ if (this.#publicOrigin) {
866
+ try {
867
+ const u = new URL(dpop.url);
868
+ url = `${this.#publicOrigin}${u.pathname}${u.search}`;
869
+ }
870
+ catch {
871
+ url = "";
872
+ }
873
+ }
874
+ const res = await verifyDpopProof({
875
+ proof: dpop.proof,
876
+ method: String(dpop.method ?? ""),
877
+ url,
878
+ accessToken: token,
879
+ expectedJkt: jkt,
880
+ replayStore: this.#dpopReplayStore,
881
+ });
882
+ return res.valid ? null : { ok: false, error: "INVALID_DPOP_PROOF", status: 401, message: res.error ?? "Invalid DPoP proof" };
883
+ }
884
+ /**
885
+ * Validates an incoming token with memory caching and a discriminated union result.
886
+ * - Access tokens (JWTs) are verified offline when Daywalker's public key is configured. A DPoP-bound
887
+ * token (`cnf.jkt`) is only accepted with a valid DPoP proof (`options.dpop`; the adapters pass it).
888
+ * - Other tokens are validated online with `POST /api/v1/tokens/validate`, authenticated with this
889
+ * service's own DPoP-bound access token. The answer must name the expected service.
890
+ * - A missing, oversized or malformed token is rejected (401) without calling Daywalker. A token is never
891
+ * replaced by this service's own credentials: use `validateServiceCredentials()` for that.
892
+ * - Online validation fails closed: only a 2xx JSON answer with `valid: true` and a sane shape is accepted.
893
+ * Outages (network errors, timeouts, 5xx, malformed answers) return status 503 with error NETWORK_ERROR or
894
+ * SERVICE_UNAVAILABLE and are never cached. Each call gets its own copy of the (cached) result.
895
+ */
896
+ async validateToken(tokenOrOptions, options) {
897
+ const opts = typeof tokenOrOptions === "string" ? { ...options, token: tokenOrOptions } : { ...(tokenOrOptions ?? {}) };
898
+ const token = opts.token;
899
+ const forceRefresh = opts.forceRefresh === true;
900
+ const offlineOnly = opts.offlineOnly === true;
901
+ if (token === undefined || token === null || token === "") {
902
+ return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Missing token to validate" };
903
+ }
904
+ if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) {
905
+ // Never forwarded, hashed or cached: it can't be a Daywalker token.
906
+ return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Malformed token" };
907
+ }
908
+ const serviceSlug = String(opts.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
909
+ if (serviceSlug && !SERVICE_SLUG_PATTERN.test(serviceSlug)) {
910
+ return { ok: false, error: "CONFIGURATION_ERROR", status: 500, message: "serviceSlug is not a valid Daywalker service slug" };
911
+ }
403
912
  // Keyed by a hash so raw tokens are never held as cache keys.
404
913
  const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
405
- const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
914
+ const cached = forceRefresh ? undefined : this.#cacheGet(cacheKey);
406
915
  if (cached) {
407
- return cloneResult(cached);
916
+ if (cached.jkt) {
917
+ const failure = await this.#checkDpop(token, cached.jkt, opts.dpop);
918
+ if (failure)
919
+ return failure;
920
+ }
921
+ return cloneResult(cached.result);
408
922
  }
409
- // Check for offline JWT lease verification
410
- if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
923
+ // Offline verification of Daywalker access tokens: against the fetched JWKS with `jwks: "auto"`, else
924
+ // (or while no key set can be obtained) against the configured public key.
925
+ const isJwt = token.split(".").length === 3;
926
+ const offlineKey = isJwt ? ((this.#config.jwks === "auto" ? await this.#jwksFor(jwtKid(token)) : null) ?? this.#config.daywalkerPublicKey) : null;
927
+ if (offlineKey) {
411
928
  const jwtResult = await verifyJwtLease({
412
929
  token,
413
- publicKey: this._config.daywalkerPublicKey,
930
+ publicKey: offlineKey,
414
931
  expectedServiceSlug: serviceSlug || undefined,
415
- expectedIssuer: this._config.leaseIssuer ?? undefined,
932
+ expectedIssuer: this.#config.leaseIssuer ?? DEFAULT_ISSUER,
416
933
  });
417
934
  if (jwtResult.valid && jwtResult.payload) {
418
935
  const payload = jwtResult.payload;
936
+ const cnf = payload.cnf;
937
+ const jkt = typeof cnf?.jkt === "string" ? cnf.jkt : undefined;
938
+ if (jkt) {
939
+ const failure = await this.#checkDpop(token, jkt, opts.dpop);
940
+ if (failure)
941
+ return failure;
942
+ }
943
+ const user = payload.user && typeof payload.user === "object" ? payload.user : {};
944
+ const userId = typeof user.id === "string" ? user.id : typeof payload.sub === "string" ? payload.sub : undefined;
945
+ const services = leaseServices(payload);
419
946
  const result = {
420
947
  ok: true,
421
948
  data: {
422
- serviceSlug: serviceSlug || (payload.serviceSlug || payload.service || ""),
423
- tenantId: (payload.tenantId || payload.sub || ""),
424
- rateLimit: payload.rateLimit ?? {
425
- limit: 1000,
426
- remaining: 999,
427
- reset: Math.floor(Date.now() / 1000) + 60,
428
- },
429
- rateLimitHourly: payload.rateLimitHourly,
430
- tokenId: (payload.tokenId || payload.sub),
431
- tokenName: payload.tokenName,
432
- userId: payload.userId,
433
- stage: payload.stage,
434
- offlineVerified: true,
949
+ // Claims first: the verified fields below can't be overridden by a claim of the same name.
435
950
  ...payload,
436
- // Every service the lease covers: its `services` claim, else its audience.
437
- services: leaseServices(payload),
951
+ serviceSlug: serviceSlug || services[0] || "",
952
+ tenantId: userId ?? "",
953
+ userId,
954
+ tokenId: typeof payload.jti === "string" ? payload.jti : undefined,
955
+ rateLimit: { limit: 0, remaining: 0, reset: 0 },
956
+ offlineVerified: true,
957
+ services,
438
958
  },
439
959
  };
440
- if (this._config.cacheTtlMs > 0) {
441
- // Never cache a lease past its own expiry.
442
- this._cacheSet(cacheKey, result, this._config.cacheTtlMs, payload.exp * 1000);
960
+ if (this.#config.cacheTtlMs > 0) {
961
+ // Never cache a token past its own expiry.
962
+ this.#cacheSet(cacheKey, result, this.#config.cacheTtlMs, payload.exp * 1000, jkt);
443
963
  }
444
964
  return cloneResult(result);
445
965
  }
@@ -452,30 +972,39 @@ export class Daywalker {
452
972
  };
453
973
  }
454
974
  }
975
+ else if (offlineOnly && isJwt && this.#config.jwks === "auto") {
976
+ // Fail closed: no key set, no offline verdict.
977
+ return {
978
+ ok: false,
979
+ error: "SERVICE_UNAVAILABLE",
980
+ status: 503,
981
+ message: "Offline verification failed: Daywalker's JWKS could not be fetched",
982
+ };
983
+ }
455
984
  else if (offlineOnly) {
456
985
  return {
457
986
  ok: false,
458
987
  error: "CONFIGURATION_ERROR",
459
988
  status: 500,
460
- message: "Offline verification requires DAYWALKER_PUBLIC_KEY and a valid JWT token lease",
989
+ message: "Offline verification requires DAYWALKER_PUBLIC_KEY or DAYWALKER_JWKS=auto and a Daywalker access token (JWT)",
461
990
  };
462
991
  }
463
992
  // Online verification, deduplicated across concurrent callers
464
993
  const inflightKey = `${cacheKey}`;
465
- const pending = this._inflight.get(inflightKey);
994
+ const pending = this.#inflight.get(inflightKey);
466
995
  if (pending)
467
996
  return pending.then(cloneResult);
468
- const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
469
- this._inflight.delete(inflightKey);
997
+ const run = this.#validateOnline(token, serviceSlug, cacheKey).finally(() => {
998
+ this.#inflight.delete(inflightKey);
470
999
  });
471
- this._inflight.set(inflightKey, run);
1000
+ this.#inflight.set(inflightKey, run);
472
1001
  return run.then(cloneResult);
473
1002
  }
474
- async _validateOnline(token, serviceSlug, cacheKey) {
475
- const negTtl = this._config.negativeCacheTtlMs;
1003
+ async #validateOnline(token, serviceSlug, cacheKey) {
1004
+ const negTtl = this.#config.negativeCacheTtlMs;
476
1005
  const fail = (result) => {
477
1006
  if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR" && result.error !== "SERVICE_UNAVAILABLE") {
478
- this._cacheSet(cacheKey, result, negTtl);
1007
+ this.#cacheSet(cacheKey, result, negTtl);
479
1008
  }
480
1009
  return result;
481
1010
  };
@@ -487,18 +1016,15 @@ export class Daywalker {
487
1016
  message: redactSecrets(message),
488
1017
  });
489
1018
  try {
490
- const validateBody = { token };
491
- if (serviceSlug) {
492
- validateBody.serviceSlug = serviceSlug;
493
- }
1019
+ // No service slug is sent: the caller is identified by its credentials, not by naming a service
494
1020
  const res = await this.fetch("/api/v1/tokens/validate", {
495
1021
  method: "POST",
496
- body: validateBody,
1022
+ body: { token },
497
1023
  // Validation is read-only, so repeating it after a lost response is safe.
498
- retry: { retryNetworkErrors: this._config.retry.retryNetworkErrors ?? true },
1024
+ retry: { retryNetworkErrors: this.#config.retry.retryNetworkErrors ?? true },
499
1025
  });
500
- // A body that times out is an outage (thrown to NETWORK_ERROR below), never an empty answer.
501
- const text = await readText(res, this._config.timeoutMs);
1026
+ // A body that times out or is too large is an outage (thrown to NETWORK_ERROR below), never an answer.
1027
+ const text = await readText(res, this.#config.timeoutMs);
502
1028
  let parsed;
503
1029
  try {
504
1030
  parsed = JSON.parse(text);
@@ -530,8 +1056,26 @@ export class Daywalker {
530
1056
  if (problem) {
531
1057
  return unavailable(`Daywalker Auth returned a malformed validation result (${problem})`);
532
1058
  }
1059
+ const servedSlug = (payloadData.serviceSlug ?? json.serviceSlug)?.toLowerCase();
1060
+ const reported = (payloadData.services ?? json.services);
1061
+ const services = Array.from(new Set((reported ?? (servedSlug ? [servedSlug] : [])).map((s) => s.toLowerCase())));
1062
+ if (services.length === 0 && !servedSlug) {
1063
+ return unavailable("Daywalker Auth returned a malformed validation result (no service named)");
1064
+ }
1065
+ // The token was validated for whichever service it shares with our credentials: make sure that
1066
+ // includes this one, so a token for a sibling service is never accepted here.
1067
+ if (serviceSlug && servedSlug !== serviceSlug && !services.includes(serviceSlug)) {
1068
+ return fail({
1069
+ ok: false,
1070
+ error: "SERVICE_NOT_GRANTED",
1071
+ status: 403,
1072
+ message: `This token is not valid for ${serviceSlug}`,
1073
+ });
1074
+ }
533
1075
  const data = {
534
- serviceSlug: json.serviceSlug || serviceSlug,
1076
+ // Server fields first: the normalized fields below always win.
1077
+ ...payloadData,
1078
+ serviceSlug: serviceSlug || servedSlug || services[0],
535
1079
  tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
536
1080
  rateLimit: (payloadData.rateLimit || json.rateLimit || { limit: 0, remaining: 0, reset: 0 }),
537
1081
  rateLimitHourly: (payloadData.rateLimitHourly || json.rateLimitHourly),
@@ -539,15 +1083,12 @@ export class Daywalker {
539
1083
  tokenName: (payloadData.tokenName || json.tokenName),
540
1084
  userId: (payloadData.userId || json.userId),
541
1085
  stage: (payloadData.stage || json.stage),
542
- ...payloadData,
543
- services: (payloadData.services ?? json.services) ?? [json.serviceSlug || serviceSlug],
544
- };
545
- const result = {
546
- ok: true,
547
- data,
1086
+ offlineVerified: false,
1087
+ services: services.length > 0 ? services : [servedSlug],
548
1088
  };
549
- if (this._config.cacheTtlMs > 0) {
550
- this._cacheSet(cacheKey, result, this._config.cacheTtlMs);
1089
+ const result = { ok: true, data };
1090
+ if (this.#config.cacheTtlMs > 0) {
1091
+ this.#cacheSet(cacheKey, result, this.#config.cacheTtlMs);
551
1092
  }
552
1093
  return result;
553
1094
  }
@@ -556,7 +1097,7 @@ export class Daywalker {
556
1097
  return unavailable(`Daywalker Auth returned no validation result (HTTP ${res.status})`);
557
1098
  }
558
1099
  let error = "INVALID_TOKEN";
559
- const errMsg = String((typeof json.message === "string" && json.message) || (typeof json.error === "string" && json.error) || "");
1100
+ const errMsg = String((typeof json.message === "string" && json.message) || (typeof json.error === "string" && json.error) || "").slice(0, 500);
560
1101
  if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
561
1102
  error = "EXPIRED";
562
1103
  }
@@ -572,6 +1113,9 @@ export class Daywalker {
572
1113
  });
573
1114
  }
574
1115
  catch (err) {
1116
+ // This service couldn't get its own access token: says nothing about the caller's token.
1117
+ if (err instanceof DaywalkerAuthError)
1118
+ return credentialFailure(err);
575
1119
  return {
576
1120
  ok: false,
577
1121
  error: "NETWORK_ERROR",
@@ -581,19 +1125,58 @@ export class Daywalker {
581
1125
  }
582
1126
  }
583
1127
  /**
584
- * Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
585
- * Never use this to authenticate an incoming request.
1128
+ * Service health check: runs this service's OAuth client credentials flow (`accessToken()`) and checks
1129
+ * that the granted services include `serviceSlug` (default: the configured one; with none configured, at
1130
+ * least one service must be granted). Never use this to authenticate an incoming request. `forceRefresh`
1131
+ * starts a new client credentials grant.
586
1132
  */
587
- async validateServiceKey(options) {
588
- if (!this._config.apiKey) {
1133
+ async validateServiceCredentials(options) {
1134
+ const serviceSlug = String(options?.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
1135
+ if (serviceSlug && !SERVICE_SLUG_PATTERN.test(serviceSlug)) {
1136
+ return { ok: false, error: "CONFIGURATION_ERROR", status: 500, message: "serviceSlug is not a valid Daywalker service slug" };
1137
+ }
1138
+ let tokens;
1139
+ try {
1140
+ if (options?.forceRefresh === true)
1141
+ await this.getTokens();
1142
+ else
1143
+ await this.accessToken();
1144
+ tokens = this.#tokens;
1145
+ }
1146
+ catch (err) {
1147
+ if (err instanceof DaywalkerAuthError)
1148
+ return credentialFailure(err);
589
1149
  return {
590
1150
  ok: false,
591
- error: "CONFIGURATION_ERROR",
592
- status: 500,
593
- message: "DAYWALKER_API_KEY is not set",
1151
+ error: "NETWORK_ERROR",
1152
+ status: 503,
1153
+ message: redactSecrets(`Could not reach Daywalker Auth: ${err.message}`),
1154
+ };
1155
+ }
1156
+ if (serviceSlug ? !tokens.services.includes(serviceSlug) : tokens.services.length === 0) {
1157
+ return {
1158
+ ok: false,
1159
+ error: "SERVICE_NOT_GRANTED",
1160
+ status: 403,
1161
+ message: serviceSlug
1162
+ ? `The OAuth client credentials don't cover ${serviceSlug} (granted: ${tokens.services.join(", ") || "none"})`
1163
+ : "The OAuth client credentials were granted no service",
594
1164
  };
595
1165
  }
596
- return this.validateToken(this._config.apiKey, options);
1166
+ const claims = decodeJwtClaims(tokens.accessToken);
1167
+ const user = claims.user && typeof claims.user === "object" ? claims.user : {};
1168
+ const userId = typeof user.id === "string" ? user.id : typeof claims.sub === "string" ? claims.sub : undefined;
1169
+ return {
1170
+ ok: true,
1171
+ data: {
1172
+ serviceSlug: serviceSlug || tokens.services[0],
1173
+ tenantId: userId ?? "",
1174
+ userId,
1175
+ rateLimit: { limit: 0, remaining: 0, reset: 0 },
1176
+ services: [...tokens.services],
1177
+ expiresAt: tokens.expiresAt,
1178
+ },
1179
+ };
597
1180
  }
598
1181
  /**
599
1182
  * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
@@ -608,44 +1191,154 @@ export class Daywalker {
608
1191
  }
609
1192
  return result.data;
610
1193
  }
1194
+ #tokens = null;
1195
+ /** The key generation that was current when `#tokens` was requested. */
1196
+ #tokenGeneration = 0;
1197
+ #tokenFlight = null;
1198
+ /**
1199
+ * POST /api/v1/oauth/token. The client credentials grant is the one request that carries the API key
1200
+ * (`Authorization: Bearer`, next to the RFC 9421 signature); a refresh is authenticated by its refresh
1201
+ * token and DPoP proof alone. The answer is checked before it is trusted: it must be a DPoP-bound token
1202
+ * pair of sane shape, and its lifetime is capped so a bad `expires_at` can't keep a token alive.
1203
+ */
1204
+ async #tokenRequest(body) {
1205
+ const headers = {};
1206
+ if (body.grant_type === "client_credentials") {
1207
+ if (!this.#config.apiKey) {
1208
+ throw new DaywalkerAuthError("DAYWALKER_API_KEY is not set: it is the client credential for the OAuth token endpoint", 500, "CONFIGURATION_ERROR");
1209
+ }
1210
+ if (!API_KEY_PATTERN.test(this.#config.apiKey)) {
1211
+ throw new DaywalkerAuthError("DAYWALKER_API_KEY contains characters an API key can't have", 500, "CONFIGURATION_ERROR");
1212
+ }
1213
+ headers.authorization = `Bearer ${this.#config.apiKey}`;
1214
+ }
1215
+ const res = await this.fetch(TOKEN_PATH, { method: "POST", body, headers });
1216
+ const json = await readJsonObject(res, this.#config.timeoutMs);
1217
+ const str = (v) => (typeof v === "string" ? v.slice(0, 500) : undefined);
1218
+ if (res.status !== 200) {
1219
+ throw new DaywalkerAuthError(redactSecrets(str(json.message) ?? str(json.error) ?? `Token request failed with HTTP ${res.status}`), res.status, str(json.code) ?? str(json.error));
1220
+ }
1221
+ const malformed = (why) => new DaywalkerAuthError(`Daywalker Auth returned a malformed token response (${why})`, 502, "SERVICE_UNAVAILABLE");
1222
+ const isToken = (v) => typeof v === "string" && v.length <= MAX_TOKEN_LENGTH && TOKEN_PATTERN.test(v);
1223
+ if (!isToken(json.access_token) || !isToken(json.refresh_token))
1224
+ throw malformed("tokens missing or not tokens");
1225
+ // Every token Daywalker issues is sender-constrained; an unbound (bearer) token is refused, not used.
1226
+ if (typeof json.token_type !== "string" || json.token_type.toLowerCase() !== "dpop")
1227
+ throw malformed("token_type is not DPoP");
1228
+ const now = Math.floor(Date.now() / 1000);
1229
+ const expiresIn = Math.floor(Number(json.expires_in));
1230
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0)
1231
+ throw malformed("expires_in is not a positive number");
1232
+ const lifetime = Math.min(expiresIn, MAX_ACCESS_TOKEN_LIFETIME_SECONDS);
1233
+ const expiresAtClaim = Number(json.expires_at);
1234
+ // Earliest of what the server says and what expires_in allows (a past expires_at means already expired).
1235
+ const expiresAt = Number.isFinite(expiresAtClaim) && json.expires_at !== undefined ? Math.min(Math.floor(expiresAtClaim), now + lifetime) : now + lifetime;
1236
+ const services = Array.isArray(json.services)
1237
+ ? Array.from(new Set(json.services.filter((x) => typeof x === "string" && SERVICE_SLUG_PATTERN.test(x.toLowerCase())).map((x) => x.toLowerCase())))
1238
+ : [];
1239
+ const refreshExpiresIn = Math.max(0, Math.floor(Number(json.refresh_expires_in) || 0));
1240
+ return deepFreeze({
1241
+ accessToken: json.access_token,
1242
+ refreshToken: json.refresh_token,
1243
+ tokenType: "DPoP",
1244
+ expiresAt,
1245
+ expiresIn: lifetime,
1246
+ refreshExpiresIn,
1247
+ scope: services.join(" "),
1248
+ services,
1249
+ jwksUri: typeof json.jwks_uri === "string" && json.jwks_uri.startsWith(`${this.#config.authUrl}/`) ? json.jwks_uri : undefined,
1250
+ });
1251
+ }
611
1252
  /**
612
- * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
613
- * The lease lists every service slug the API key covers (`services`, also the JWT's `services` and `aud`
614
- * claims), so one set of credentials serves all of them. Pass `{ services: [...] }` to narrow it.
1253
+ * Client credentials grant (RFC 6749 §4.4): the signed request (API key + registered Ed25519 key) is
1254
+ * exchanged for a short-lived access token JWT and a rotating refresh token. By default the grant is
1255
+ * narrowed to the configured service slug(s) (least privilege); with none configured it covers every
1256
+ * service the credentials do. The tokens are remembered for `accessToken()`. The result is frozen.
615
1257
  */
616
- async issueLease(options) {
617
- const opts = typeof options === "number" ? { ttlSeconds: options } : (options ?? {});
618
- const services = opts.services ? parseServiceSlugs(opts.services) : undefined;
619
- if (opts.services && (!services || services.length === 0)) {
620
- throw new DaywalkerAuthError("issueLease: services must list at least one service slug", 400, "INVALID_REQUEST");
621
- }
622
- const res = await this.fetch("/api/v1/leases/issue", {
623
- method: "POST",
624
- body: { ...(opts.ttlSeconds ? { ttlSeconds: opts.ttlSeconds } : {}), ...(services ? { services } : {}) },
1258
+ async getTokens(options = {}) {
1259
+ const scope = options.scope ? parseServiceSlugs(options.scope) : this.#config.serviceSlugs.length > 0 ? [...this.#config.serviceSlugs] : undefined;
1260
+ if (options.scope && (!scope || scope.length === 0)) {
1261
+ throw new DaywalkerAuthError("getTokens: scope must list at least one service slug", 400, "INVALID_REQUEST");
1262
+ }
1263
+ if (scope && !scope.every((slug) => SERVICE_SLUG_PATTERN.test(slug))) {
1264
+ throw new DaywalkerAuthError("getTokens: scope must list Daywalker service slugs", 400, "INVALID_REQUEST");
1265
+ }
1266
+ if (options.expiresIn !== undefined && (!Number.isInteger(options.expiresIn) || options.expiresIn < 300 || options.expiresIn > 900)) {
1267
+ throw new DaywalkerAuthError("getTokens: expiresIn must be a whole number of seconds from 300 to 900", 400, "INVALID_REQUEST");
1268
+ }
1269
+ const generation = this.#keyGeneration;
1270
+ const tokens = await this.#tokenRequest({
1271
+ grant_type: "client_credentials",
1272
+ ...(scope ? { scope: scope.join(" ") } : {}),
1273
+ ...(options.expiresIn ? { expires_in: options.expiresIn } : {}),
625
1274
  });
626
- const json = (await res.json().catch(() => ({})));
627
- if (res.status !== 201 && res.status !== 200) {
628
- throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Lease issue failed with HTTP ${res.status}`)), res.status, json.error);
1275
+ this.#tokens = tokens;
1276
+ this.#tokenGeneration = generation;
1277
+ return tokens;
1278
+ }
1279
+ /**
1280
+ * Refresh token rotation: trades the refresh token for a NEW access token and a NEW refresh token; the
1281
+ * old refresh token stops working. Presenting an old one again revokes the whole session, so refreshes
1282
+ * are serialized per client and the new refresh token must replace the stored one.
1283
+ */
1284
+ async refreshTokens(refreshToken) {
1285
+ const token = refreshToken ?? this.#tokens?.refreshToken;
1286
+ if (!token)
1287
+ throw new DaywalkerAuthError("refreshTokens: no refresh token (call getTokens() first)", 400, "INVALID_REQUEST");
1288
+ if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) {
1289
+ throw new DaywalkerAuthError("refreshTokens: malformed refresh token", 400, "INVALID_REQUEST");
629
1290
  }
630
- if (typeof json.lease !== "string") {
631
- throw new DaywalkerAuthError("Daywalker Auth returned a malformed lease response (no lease)", res.status, "SERVICE_UNAVAILABLE");
1291
+ const generation = this.#keyGeneration;
1292
+ const tokens = await this.#tokenRequest({ grant_type: "refresh_token", refresh_token: token });
1293
+ this.#tokens = tokens;
1294
+ this.#tokenGeneration = generation;
1295
+ return tokens;
1296
+ }
1297
+ /**
1298
+ * A valid access token, refreshing (or re-authenticating with the client credentials once the session
1299
+ * ended) when it is within 30 seconds of expiry. Concurrent callers share one request, so the single-use
1300
+ * refresh token is never presented twice.
1301
+ */
1302
+ async accessToken() {
1303
+ // Tokens from before the signing key was replaced belong to the old key's session: start a new one.
1304
+ const current = this.#tokenGeneration === this.#keyGeneration ? this.#tokens : null;
1305
+ if (current && current.expiresAt - 30 > Math.floor(Date.now() / 1000))
1306
+ return current.accessToken;
1307
+ if (!this.#tokenFlight) {
1308
+ this.#tokenFlight = (async () => {
1309
+ if (current) {
1310
+ try {
1311
+ return await this.refreshTokens();
1312
+ }
1313
+ catch (err) {
1314
+ if (!(err instanceof DaywalkerAuthError) || err.status !== 400)
1315
+ throw err;
1316
+ // invalid_grant: the session ended or was revoked; start a new one
1317
+ }
1318
+ }
1319
+ return this.getTokens();
1320
+ })().finally(() => {
1321
+ this.#tokenFlight = null;
1322
+ });
632
1323
  }
633
- // Servers from before multi-service tokens don't list services; their lease covers the one configured slug.
634
- const listed = Array.isArray(json.services) ? json.services.filter((s) => typeof s === "string") : [];
635
- return { ...json, lease: json.lease, services: listed.length > 0 ? listed : this._config.serviceSlug ? [this._config.serviceSlug] : [] };
1324
+ return (await this.#tokenFlight).accessToken;
636
1325
  }
637
1326
  /**
638
1327
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
639
1328
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
640
1329
  */
641
- async fetchJwks() {
642
- const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
643
- const { signal, cleanup } = attemptSignal(undefined, this._config.timeoutMs);
1330
+ fetchJwks() {
1331
+ return this.#fetchJwks();
1332
+ }
1333
+ async #fetchJwks() {
1334
+ const doFetch = this.#fetch;
1335
+ const { signal, cleanup } = attemptSignal(undefined, this.#config.timeoutMs);
644
1336
  let res;
645
1337
  try {
646
- res = await doFetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
1338
+ res = await doFetch(`${this.#config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
647
1339
  headers: { Accept: "application/json" },
648
1340
  redirect: "manual",
1341
+ cache: "no-store",
649
1342
  signal,
650
1343
  });
651
1344
  }
@@ -658,7 +1351,7 @@ export class Daywalker {
658
1351
  throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
659
1352
  let json = null;
660
1353
  try {
661
- json = JSON.parse(await readText(res, this._config.timeoutMs));
1354
+ json = JSON.parse(await readText(res, this.#config.timeoutMs));
662
1355
  }
663
1356
  catch (err) {
664
1357
  if (err.name === "TimeoutError")
@@ -669,49 +1362,124 @@ export class Daywalker {
669
1362
  }
670
1363
  return { keys: json.keys };
671
1364
  }
1365
+ /**
1366
+ * The key set to verify a token naming `kid` with (`jwks: "auto"`), or null when none could be obtained.
1367
+ * The set is refetched once it is older than `JWKS_CACHE_TTL_MS`, or when it has no key with this `kid`
1368
+ * (Daywalker rotated its key), but never sooner than `JWKS_REFETCH_COOLDOWN_MS` after the last attempt.
1369
+ * Concurrent callers share one fetch. A failed fetch, or one with no usable key, keeps the previous set.
1370
+ * Only `${authUrl}/.well-known/jwks.json` is ever fetched: a `jwks_uri` from anywhere is not followed.
1371
+ */
1372
+ async #jwksFor(kid) {
1373
+ const current = this.#jwks;
1374
+ const stale = !current || Date.now() - current.fetchedAt >= JWKS_CACHE_TTL_MS;
1375
+ const unknownKid = Boolean(current && kid !== undefined && !current.set.keys.some((k) => k.kid === kid));
1376
+ if ((stale || unknownKid) && (this.#jwksFlight || Date.now() - this.#jwksAttemptAt >= JWKS_REFETCH_COOLDOWN_MS)) {
1377
+ await this.#refreshJwks();
1378
+ }
1379
+ return this.#jwks?.set ?? null;
1380
+ }
1381
+ #refreshJwks() {
1382
+ if (!this.#jwksFlight) {
1383
+ this.#jwksAttemptAt = Date.now();
1384
+ this.#jwksFlight = this.#fetchJwks()
1385
+ .then(({ keys }) => {
1386
+ const usable = usableJwks(keys);
1387
+ if (usable.length > 0)
1388
+ this.#jwks = { set: deepFreeze({ keys: usable }), fetchedAt: Date.now() };
1389
+ }, () => {
1390
+ // Keep the last good set; the cooldown spaces out the next attempt.
1391
+ })
1392
+ .finally(() => {
1393
+ this.#jwksFlight = null;
1394
+ });
1395
+ }
1396
+ return this.#jwksFlight;
1397
+ }
672
1398
  /**
673
1399
  * The public JWK(s) configured for this service: the current signing key, then the previous one
674
1400
  * (`previousSigningKey`) during a rotation, so requests from instances still on the old key keep verifying.
675
1401
  */
676
1402
  async keys() {
677
- const key = await this.getLoadedSigningKey();
678
- const keys = key ? [key.publicJwk] : [];
1403
+ const active = await this.#getSigner();
1404
+ const keys = active?.publicJwk ? [active.publicJwk] : [];
679
1405
  // A broken previous key must not take the current one offline; selfCheck() reports it.
680
- const previous = await this.getLoadedPreviousSigningKey().catch(() => null);
1406
+ const previous = await this.#getLoadedPreviousSigningKey().catch(() => null);
681
1407
  if (previous && !keys.some((k) => k.kid === previous.publicJwk.kid)) {
682
1408
  keys.push(previous.publicJwk);
683
1409
  }
684
1410
  return { keys };
685
1411
  }
686
1412
  /**
687
- * Check configuration and verify signature readiness.
1413
+ * Check configuration and verify signature readiness. Never provisions or registers a key: with a
1414
+ * `keyStore` it only reads what is stored and reports what first use would need.
688
1415
  */
689
1416
  async selfCheck() {
690
- const problems = [...this._config.problems];
691
- const signingKey = await this.getLoadedSigningKey().catch((err) => {
692
- problems.push(`Signing key load error: ${err.message}`);
693
- return null;
694
- });
695
- if (!signingKey) {
696
- problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
1417
+ const problems = [...this.#config.problems];
1418
+ let keyId = null;
1419
+ let signing = false;
1420
+ if (this.#externalSigner) {
1421
+ signing = true;
1422
+ keyId = this.#externalSigner.keyId;
1423
+ }
1424
+ else if (this.#config.signingKey) {
1425
+ const signingKey = await this.#getLoadedSigningKey().catch((err) => {
1426
+ problems.push(`Signing key load error: ${err.message}`);
1427
+ return null;
1428
+ });
1429
+ signing = Boolean(signingKey);
1430
+ keyId = this.#config.keyId ?? signingKey?.publicJwk.kid ?? null;
1431
+ }
1432
+ else if (this.#config.ephemeralKey) {
1433
+ signing = Boolean(this.#config.domain);
1434
+ keyId = this.#ephemeral?.keyId ?? null;
1435
+ if (!this.#config.domain)
1436
+ problems.push("No public domain detected: set DAYWALKER_DOMAIN so the ephemeral signing key can be registered");
1437
+ }
1438
+ else if (this.#keyStore) {
1439
+ const store = this.#keyStore;
1440
+ try {
1441
+ const stored = await this.#loadStored(store);
1442
+ if (stored) {
1443
+ await this.#useStored(stored);
1444
+ keyId = stored.keyId;
1445
+ signing = true;
1446
+ }
1447
+ else if (!store.saveIfAbsent) {
1448
+ problems.push("The key store is read-only and holds no signing key: provision one with `npx daywalker init`");
1449
+ }
1450
+ else if (!this.#config.domain) {
1451
+ problems.push("No public domain detected: set DAYWALKER_DOMAIN so a signing key can be registered on first use");
1452
+ }
1453
+ else {
1454
+ signing = true; // provisioned and registered on first use
1455
+ }
1456
+ }
1457
+ catch (err) {
1458
+ problems.push(`Key store error: ${err.message}`);
1459
+ }
1460
+ }
1461
+ else {
1462
+ problems.push("No signing key: set DAYWALKER_SIGNING_KEY (npx daywalker init), a keyStore for auto-provisioning, or a signer");
697
1463
  }
698
- await this.getLoadedPreviousSigningKey().catch((err) => {
1464
+ await this.#getLoadedPreviousSigningKey().catch((err) => {
699
1465
  problems.push(`Previous signing key load error: ${err.message}`);
700
1466
  });
701
- const keyId = signingKey?.publicJwk.kid ?? null;
702
- const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
703
1467
  return {
704
1468
  ok: problems.length === 0,
705
- serviceSlug: this._config.serviceSlug,
706
- serviceSlugs: this._config.serviceSlugs,
707
- domain: this._config.domain,
708
- signing: Boolean(signingKey),
1469
+ serviceSlug: this.#config.serviceSlug,
1470
+ serviceSlugs: this.#config.serviceSlugs,
1471
+ domain: this.#config.domain,
1472
+ signing,
709
1473
  keyId,
710
- offlineVerificationReady,
1474
+ offlineVerificationReady: Boolean(this.#config.daywalkerPublicKey) || this.#config.jwks === "auto",
711
1475
  problems,
712
1476
  };
713
1477
  }
714
1478
  }
1479
+ // Methods can't be replaced on the class or (by assignment) on an instance, so code elsewhere in the process
1480
+ // can't swap validateToken() for one that always says yes.
1481
+ Object.freeze(Daywalker.prototype);
1482
+ Object.freeze(Daywalker);
715
1483
  /**
716
1484
  * Factory function for creating instantiable Daywalker client instances.
717
1485
  */
@@ -742,4 +1510,9 @@ export const daywalker = new Proxy((() => getDefaultInstance()), {
742
1510
  }
743
1511
  return getDefaultInstance();
744
1512
  },
1513
+ // The shared default client can't be patched through the proxy either.
1514
+ set: () => false,
1515
+ defineProperty: () => false,
1516
+ deleteProperty: () => false,
1517
+ setPrototypeOf: () => false,
745
1518
  });