@shelbyb/daywalker-sdk 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, generateDpopKey, createDpopProof, } 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, base64ToBytes, hexToBytes, 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;
@@ -174,59 +255,430 @@ function cloneResult(result) {
174
255
  return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
175
256
  }
176
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
+ }
177
338
  export class Daywalker {
178
- _config;
179
- _rawConfig;
180
- _signingKeyPromise;
181
- _previousSigningKeyPromise;
182
- _cache = new Map();
183
- _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;
184
365
  constructor(config = {}) {
185
- this._rawConfig = config;
186
- 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);
187
422
  }
423
+ /** The resolved configuration, frozen, with the API key and private keys shown as "[configured]". */
188
424
  get config() {
189
- return this._config;
425
+ return this.#publicConfig;
190
426
  }
191
427
  toJSON() {
192
428
  return {
193
- serviceSlug: this._config.serviceSlug,
194
- domain: this._config.domain,
195
- authUrl: this._config.authUrl,
196
- keyId: this._config.keyId,
197
- apiKey: maskSecret(this._config.apiKey),
198
- signingKey: this._config.signingKey ? "[configured]" : null,
199
- 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,
200
437
  };
201
438
  }
202
439
  // Node.js custom inspect masking
203
440
  [Symbol.for("nodejs.util.inspect.custom")]() {
204
441
  return this.toJSON();
205
442
  }
206
- async getLoadedSigningKey() {
207
- if (!this._config.signingKey)
443
+ async #getLoadedSigningKey() {
444
+ if (!this.#config.signingKey)
208
445
  return null;
209
- if (typeof this._config.signingKey !== "string" && "privateKey" in this._config.signingKey) {
210
- return this._config.signingKey;
446
+ if (typeof this.#config.signingKey !== "string" && "privateKey" in this.#config.signingKey) {
447
+ return this.#config.signingKey;
211
448
  }
212
- if (!this._signingKeyPromise) {
213
- this._signingKeyPromise = loadSigningKey(this._config.signingKey);
449
+ if (!this.#signingKeyPromise) {
450
+ this.#signingKeyPromise = loadSigningKey(this.#config.signingKey);
214
451
  }
215
- return this._signingKeyPromise;
452
+ return this.#signingKeyPromise;
216
453
  }
217
- _dpopKey = null;
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;
218
670
  /** The client's DPoP key, generated on first use. Issued tokens are bound to it for this client's life. */
219
- getDpopKey() {
220
- return (this._dpopKey ??= generateDpopKey());
671
+ #getDpopKey() {
672
+ return (this.#dpopKey ??= generateDpopKey());
221
673
  }
222
- async getLoadedPreviousSigningKey() {
223
- const raw = this._config.previousSigningKey;
674
+ async #getLoadedPreviousSigningKey() {
675
+ const raw = this.#config.previousSigningKey;
224
676
  if (!raw)
225
677
  return null;
226
- if (!this._previousSigningKeyPromise) {
227
- this._previousSigningKeyPromise = loadSigningKey(raw);
678
+ if (!this.#previousSigningKeyPromise) {
679
+ this.#previousSigningKeyPromise = loadSigningKey(raw);
228
680
  }
229
- return this._previousSigningKeyPromise;
681
+ return this.#previousSigningKeyPromise;
230
682
  }
231
683
  /**
232
684
  * Signs and executes a request to Daywalker Auth (same origin as `authUrl` only).
@@ -237,21 +689,24 @@ export class Daywalker {
237
689
  * - 429s are retried with exponential backoff, honoring `Retry-After` up to `retry.maxRetryAfterMs`. Network
238
690
  * errors and timeouts are retried only for GET / HEAD / OPTIONS, with an `idempotencyKey`, or when
239
691
  * `retry.retryNetworkErrors` is true. Options merge as defaults < `config.retry` < `init.retry`.
240
- * - An explicit `Authorization` header is sent as given; otherwise `Bearer <apiKey>` is added. The signature
241
- * 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.
242
697
  */
243
698
  async fetch(path, init) {
244
- const authUrl = this._config.authUrl;
699
+ const authUrl = this.#config.authUrl;
245
700
  const url = new URL(path, authUrl);
246
- // 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.
247
702
  const authOrigin = new URL(authUrl).origin;
248
703
  if (url.origin !== authOrigin) {
249
704
  throw new DaywalkerError(`Refusing to send Daywalker credentials to ${url.origin}; fetch() only targets ${authOrigin}`);
250
705
  }
251
706
  const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
252
- const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
707
+ const doFetch = this.#fetch;
253
708
  const { retry: _retry, timeoutMs: _timeoutMs, idempotencyKey, body: rawBody, signal: callerSignal, ...requestInit } = init ?? {};
254
- const retryOpts = { ...this._config.retry };
709
+ const retryOpts = { ...this.#config.retry };
255
710
  for (const [k, v] of Object.entries(init?.retry ?? {})) {
256
711
  if (v !== undefined)
257
712
  retryOpts[k] = v;
@@ -263,7 +718,7 @@ export class Daywalker {
263
718
  const jitter = retryOpts.jitter ?? true;
264
719
  const maxRetryAfterMs = retryOpts.maxRetryAfterMs ?? MAX_RETRY_AFTER_MS;
265
720
  const retryNetworkErrors = retryOpts.retryNetworkErrors ?? (IDEMPOTENT_METHODS.has(method) || Boolean(idempotencyKey));
266
- const timeoutMs = init?.timeoutMs ?? this._config.timeoutMs;
721
+ const timeoutMs = init?.timeoutMs ?? this.#config.timeoutMs;
267
722
  const backoff = (n) => {
268
723
  let delayMs = initialDelayMs * Math.pow(backoffFactor, n - 1);
269
724
  if (jitter)
@@ -271,8 +726,12 @@ export class Daywalker {
271
726
  return Math.min(delayMs, maxDelayMs);
272
727
  };
273
728
  callerSignal?.throwIfAborted();
274
- const signingKey = await this.getLoadedSigningKey();
275
- 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;
276
735
  let attempt = 0;
277
736
  while (true) {
278
737
  callerSignal?.throwIfAborted();
@@ -283,8 +742,8 @@ export class Daywalker {
283
742
  if (!headers.has("accept")) {
284
743
  headers.set("accept", "application/json");
285
744
  }
286
- if (this._config.apiKey && !headers.has("authorization")) {
287
- headers.set("authorization", `Bearer ${this._config.apiKey}`);
745
+ if (autoAuth) {
746
+ headers.set("authorization", `DPoP ${await this.accessToken()}`);
288
747
  }
289
748
  if (idempotencyKey && !headers.has("idempotency-key")) {
290
749
  headers.set("idempotency-key", idempotencyKey);
@@ -292,20 +751,18 @@ export class Daywalker {
292
751
  // DPoP (RFC 9449) is mandatory on the token endpoint and when presenting a DPoP access token.
293
752
  const auth = headers.get("authorization") ?? "";
294
753
  const isDpopAuth = /^dpop\s+/i.test(auth);
295
- const isTokenEndpoint = url.pathname === "/api/v1/oauth/token";
296
- if (!headers.has("dpop") && (isTokenEndpoint || isDpopAuth)) {
754
+ if ((autoAuth || !headers.has("dpop")) && (isTokenEndpoint || isDpopAuth)) {
297
755
  const accessToken = isDpopAuth ? auth.replace(/^dpop\s+/i, "") : undefined;
298
- headers.set("dpop", await createDpopProof(await this.getDpopKey(), { method, url: url.toString(), accessToken }));
756
+ headers.set("dpop", await createDpopProof(await this.#getDpopKey(), { method, url: url.toString(), accessToken }));
299
757
  }
300
- if (signingKey) {
301
- // apiKey is left out so the signer can't replace an explicit Authorization header.
758
+ if (active) {
759
+ // Signing never touches Authorization, so an explicit header is kept as given.
302
760
  const signedHeaders = await signRequest({
303
761
  method,
304
762
  path: url.pathname + url.search,
305
763
  authority: url.host,
306
764
  body: body.bytes,
307
- key: signingKey,
308
- keyid: this._config.keyId ?? undefined,
765
+ signer: active.signer,
309
766
  nonce: generateNonce(),
310
767
  });
311
768
  for (const [k, v] of Object.entries(signedHeaders)) {
@@ -321,6 +778,8 @@ export class Daywalker {
321
778
  headers,
322
779
  body: body.send ?? undefined,
323
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",
324
783
  signal,
325
784
  };
326
785
  let res;
@@ -343,6 +802,14 @@ export class Daywalker {
343
802
  if (isRedirect(res)) {
344
803
  throw redirectError(url.pathname, res);
345
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
+ }
346
813
  if (res.status === 429 && body.replayable && attempt < maxRetries) {
347
814
  const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
348
815
  let delayMs = backoff(attempt + 1);
@@ -360,100 +827,139 @@ export class Daywalker {
360
827
  return res;
361
828
  }
362
829
  }
363
- _cacheGet(key) {
364
- const entry = this._cache.get(key);
830
+ #cacheGet(key) {
831
+ const entry = this.#cache.get(key);
365
832
  if (!entry)
366
833
  return undefined;
367
- this._cache.delete(key);
834
+ this.#cache.delete(key);
368
835
  if (entry.expiresAt <= Date.now())
369
836
  return undefined;
370
- this._cache.set(key, entry); // most recently used goes last
371
- return entry.result;
837
+ this.#cache.set(key, entry); // most recently used goes last
838
+ return entry;
372
839
  }
373
- _cacheSet(key, result, ttlMs, notAfterMs = Infinity) {
840
+ #cacheSet(key, result, ttlMs, notAfterMs = Infinity, jkt) {
374
841
  const expiresAt = Math.min(Date.now() + ttlMs, notAfterMs);
375
842
  if (expiresAt <= Date.now())
376
843
  return;
377
- this._cache.delete(key);
378
- this._cache.set(key, { result, expiresAt });
379
- while (this._cache.size > MAX_VALIDATION_CACHE_ENTRIES) {
380
- 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);
381
849
  }
382
850
  }
383
851
  /**
384
- * Validates an incoming bearer token with memory caching and discriminated union return.
385
- * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
386
- * A missing token is always rejected (401); it never falls back to this service's own API key —
387
- * use `validateServiceKey()` for that.
388
- * Online validation fails closed: only a 2xx JSON answer with `valid: true` and a sane shape is accepted.
389
- * Outages (network errors, timeouts, 5xx, malformed answers) return status 503 with error NETWORK_ERROR or
390
- * 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.
391
854
  */
392
- async validateToken(tokenOrOptions, options) {
393
- let token;
394
- let serviceSlug;
395
- let forceRefresh = false;
396
- let offlineOnly = false;
397
- if (typeof tokenOrOptions === "string") {
398
- token = tokenOrOptions;
399
- serviceSlug = (options?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
400
- forceRefresh = options?.forceRefresh ?? false;
401
- offlineOnly = options?.offlineOnly ?? false;
402
- }
403
- else {
404
- token = tokenOrOptions?.token ?? undefined;
405
- serviceSlug = (tokenOrOptions?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
406
- forceRefresh = tokenOrOptions?.forceRefresh ?? false;
407
- offlineOnly = tokenOrOptions?.offlineOnly ?? false;
408
- }
409
- if (!token) {
855
+ async #checkDpop(token, jkt, dpop) {
856
+ if (!dpop || typeof dpop.proof !== "string" || dpop.proof === "") {
410
857
  return {
411
858
  ok: false,
412
- error: "INVALID_TOKEN",
859
+ error: "DPOP_REQUIRED",
413
860
  status: 401,
414
- message: "Missing token to validate",
861
+ message: "This access token is DPoP-bound: present it as `Authorization: DPoP <token>` with a DPoP proof",
415
862
  };
416
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
+ }
417
912
  // Keyed by a hash so raw tokens are never held as cache keys.
418
913
  const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
419
- const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
914
+ const cached = forceRefresh ? undefined : this.#cacheGet(cacheKey);
420
915
  if (cached) {
421
- 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);
422
922
  }
423
- // Check for offline JWT lease verification
424
- 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) {
425
928
  const jwtResult = await verifyJwtLease({
426
929
  token,
427
- publicKey: this._config.daywalkerPublicKey,
930
+ publicKey: offlineKey,
428
931
  expectedServiceSlug: serviceSlug || undefined,
429
- expectedIssuer: this._config.leaseIssuer ?? DEFAULT_ISSUER,
932
+ expectedIssuer: this.#config.leaseIssuer ?? DEFAULT_ISSUER,
430
933
  });
431
934
  if (jwtResult.valid && jwtResult.payload) {
432
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);
433
946
  const result = {
434
947
  ok: true,
435
948
  data: {
436
- serviceSlug: serviceSlug || (payload.serviceSlug || payload.service || ""),
437
- tenantId: (payload.tenantId || payload.sub || ""),
438
- rateLimit: payload.rateLimit ?? {
439
- limit: 1000,
440
- remaining: 999,
441
- reset: Math.floor(Date.now() / 1000) + 60,
442
- },
443
- rateLimitHourly: payload.rateLimitHourly,
444
- tokenId: (payload.tokenId || payload.sub),
445
- tokenName: payload.tokenName,
446
- userId: payload.userId,
447
- stage: payload.stage,
448
- offlineVerified: true,
949
+ // Claims first: the verified fields below can't be overridden by a claim of the same name.
449
950
  ...payload,
450
- // Every service the lease covers: its `services` claim, else its audience.
451
- 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,
452
958
  },
453
959
  };
454
- if (this._config.cacheTtlMs > 0) {
455
- // Never cache a lease past its own expiry.
456
- 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);
457
963
  }
458
964
  return cloneResult(result);
459
965
  }
@@ -466,30 +972,39 @@ export class Daywalker {
466
972
  };
467
973
  }
468
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
+ }
469
984
  else if (offlineOnly) {
470
985
  return {
471
986
  ok: false,
472
987
  error: "CONFIGURATION_ERROR",
473
988
  status: 500,
474
- 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)",
475
990
  };
476
991
  }
477
992
  // Online verification, deduplicated across concurrent callers
478
993
  const inflightKey = `${cacheKey}`;
479
- const pending = this._inflight.get(inflightKey);
994
+ const pending = this.#inflight.get(inflightKey);
480
995
  if (pending)
481
996
  return pending.then(cloneResult);
482
- const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
483
- this._inflight.delete(inflightKey);
997
+ const run = this.#validateOnline(token, serviceSlug, cacheKey).finally(() => {
998
+ this.#inflight.delete(inflightKey);
484
999
  });
485
- this._inflight.set(inflightKey, run);
1000
+ this.#inflight.set(inflightKey, run);
486
1001
  return run.then(cloneResult);
487
1002
  }
488
- async _validateOnline(token, serviceSlug, cacheKey) {
489
- const negTtl = this._config.negativeCacheTtlMs;
1003
+ async #validateOnline(token, serviceSlug, cacheKey) {
1004
+ const negTtl = this.#config.negativeCacheTtlMs;
490
1005
  const fail = (result) => {
491
1006
  if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR" && result.error !== "SERVICE_UNAVAILABLE") {
492
- this._cacheSet(cacheKey, result, negTtl);
1007
+ this.#cacheSet(cacheKey, result, negTtl);
493
1008
  }
494
1009
  return result;
495
1010
  };
@@ -502,15 +1017,14 @@ export class Daywalker {
502
1017
  });
503
1018
  try {
504
1019
  // No service slug is sent: the caller is identified by its credentials, not by naming a service
505
- const validateBody = { token };
506
1020
  const res = await this.fetch("/api/v1/tokens/validate", {
507
1021
  method: "POST",
508
- body: validateBody,
1022
+ body: { token },
509
1023
  // Validation is read-only, so repeating it after a lost response is safe.
510
- retry: { retryNetworkErrors: this._config.retry.retryNetworkErrors ?? true },
1024
+ retry: { retryNetworkErrors: this.#config.retry.retryNetworkErrors ?? true },
511
1025
  });
512
- // A body that times out is an outage (thrown to NETWORK_ERROR below), never an empty answer.
513
- 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);
514
1028
  let parsed;
515
1029
  try {
516
1030
  parsed = JSON.parse(text);
@@ -542,8 +1056,26 @@ export class Daywalker {
542
1056
  if (problem) {
543
1057
  return unavailable(`Daywalker Auth returned a malformed validation result (${problem})`);
544
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
+ }
545
1075
  const data = {
546
- serviceSlug: json.serviceSlug || serviceSlug,
1076
+ // Server fields first: the normalized fields below always win.
1077
+ ...payloadData,
1078
+ serviceSlug: serviceSlug || servedSlug || services[0],
547
1079
  tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
548
1080
  rateLimit: (payloadData.rateLimit || json.rateLimit || { limit: 0, remaining: 0, reset: 0 }),
549
1081
  rateLimitHourly: (payloadData.rateLimitHourly || json.rateLimitHourly),
@@ -551,15 +1083,12 @@ export class Daywalker {
551
1083
  tokenName: (payloadData.tokenName || json.tokenName),
552
1084
  userId: (payloadData.userId || json.userId),
553
1085
  stage: (payloadData.stage || json.stage),
554
- ...payloadData,
555
- services: (payloadData.services ?? json.services) ?? [json.serviceSlug || serviceSlug],
556
- };
557
- const result = {
558
- ok: true,
559
- data,
1086
+ offlineVerified: false,
1087
+ services: services.length > 0 ? services : [servedSlug],
560
1088
  };
561
- if (this._config.cacheTtlMs > 0) {
562
- 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);
563
1092
  }
564
1093
  return result;
565
1094
  }
@@ -568,7 +1097,7 @@ export class Daywalker {
568
1097
  return unavailable(`Daywalker Auth returned no validation result (HTTP ${res.status})`);
569
1098
  }
570
1099
  let error = "INVALID_TOKEN";
571
- 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);
572
1101
  if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
573
1102
  error = "EXPIRED";
574
1103
  }
@@ -584,6 +1113,9 @@ export class Daywalker {
584
1113
  });
585
1114
  }
586
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);
587
1119
  return {
588
1120
  ok: false,
589
1121
  error: "NETWORK_ERROR",
@@ -593,19 +1125,58 @@ export class Daywalker {
593
1125
  }
594
1126
  }
595
1127
  /**
596
- * Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
597
- * 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.
598
1132
  */
599
- async validateServiceKey(options) {
600
- 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);
601
1149
  return {
602
1150
  ok: false,
603
- error: "CONFIGURATION_ERROR",
604
- status: 500,
605
- message: "DAYWALKER_API_KEY is not set",
1151
+ error: "NETWORK_ERROR",
1152
+ status: 503,
1153
+ message: redactSecrets(`Could not reach Daywalker Auth: ${err.message}`),
606
1154
  };
607
1155
  }
608
- return this.validateToken(this._config.apiKey, options);
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",
1164
+ };
1165
+ }
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
+ };
609
1180
  }
610
1181
  /**
611
1182
  * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
@@ -620,46 +1191,90 @@ export class Daywalker {
620
1191
  }
621
1192
  return result.data;
622
1193
  }
623
- _tokens = null;
624
- _tokenFlight = null;
625
- async _tokenRequest(body) {
626
- const res = await this.fetch("/api/v1/oauth/token", { method: "POST", body });
627
- const json = (await res.json().catch(() => ({})));
628
- if (res.status !== 200) {
629
- throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Token request failed with HTTP ${res.status}`)), res.status, json.code ?? json.error);
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}`;
630
1214
  }
631
- if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string") {
632
- throw new DaywalkerAuthError("Daywalker Auth returned a malformed token response", res.status, "SERVICE_UNAVAILABLE");
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));
633
1220
  }
634
- const services = Array.isArray(json.services) ? json.services.filter((x) => typeof x === "string") : [];
635
- return {
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
+ this.#rememberLicenses(json, services);
1241
+ return deepFreeze({
636
1242
  accessToken: json.access_token,
637
1243
  refreshToken: json.refresh_token,
638
- tokenType: String(json.token_type ?? "DPoP"),
639
- expiresAt: Number(json.expires_at) || Math.floor(Date.now() / 1000) + Number(json.expires_in ?? 0),
640
- expiresIn: Number(json.expires_in ?? 0),
641
- refreshExpiresIn: Number(json.refresh_expires_in ?? 0),
642
- scope: String(json.scope ?? services.join(" ")),
1244
+ tokenType: "DPoP",
1245
+ expiresAt,
1246
+ expiresIn: lifetime,
1247
+ refreshExpiresIn,
1248
+ scope: services.join(" "),
643
1249
  services,
644
- jwksUri: typeof json.jwks_uri === "string" ? json.jwks_uri : undefined,
645
- };
1250
+ jwksUri: typeof json.jwks_uri === "string" && json.jwks_uri.startsWith(`${this.#config.authUrl}/`) ? json.jwks_uri : undefined,
1251
+ });
646
1252
  }
647
1253
  /**
648
1254
  * Client credentials grant (RFC 6749 §4.4): the signed request (API key + registered Ed25519 key) is
649
- * exchanged for a short-lived access token JWT listing every service the credentials cover, and a
650
- * rotating refresh token. No service slug is sent. The tokens are remembered for `accessToken()`.
1255
+ * exchanged for a short-lived access token JWT and a rotating refresh token. By default the grant is
1256
+ * narrowed to the configured service slug(s) (least privilege); with none configured it covers every
1257
+ * service the credentials do. The tokens are remembered for `accessToken()`. The result is frozen.
651
1258
  */
652
1259
  async getTokens(options = {}) {
653
- const scope = options.scope ? parseServiceSlugs(options.scope) : undefined;
1260
+ const scope = options.scope ? parseServiceSlugs(options.scope) : this.#config.serviceSlugs.length > 0 ? [...this.#config.serviceSlugs] : undefined;
654
1261
  if (options.scope && (!scope || scope.length === 0)) {
655
1262
  throw new DaywalkerAuthError("getTokens: scope must list at least one service slug", 400, "INVALID_REQUEST");
656
1263
  }
657
- const tokens = await this._tokenRequest({
1264
+ if (scope && !scope.every((slug) => SERVICE_SLUG_PATTERN.test(slug))) {
1265
+ throw new DaywalkerAuthError("getTokens: scope must list Daywalker service slugs", 400, "INVALID_REQUEST");
1266
+ }
1267
+ if (options.expiresIn !== undefined && (!Number.isInteger(options.expiresIn) || options.expiresIn < 300 || options.expiresIn > 900)) {
1268
+ throw new DaywalkerAuthError("getTokens: expiresIn must be a whole number of seconds from 300 to 900", 400, "INVALID_REQUEST");
1269
+ }
1270
+ const generation = this.#keyGeneration;
1271
+ const tokens = await this.#tokenRequest({
658
1272
  grant_type: "client_credentials",
659
1273
  ...(scope ? { scope: scope.join(" ") } : {}),
660
1274
  ...(options.expiresIn ? { expires_in: options.expiresIn } : {}),
661
1275
  });
662
- this._tokens = tokens;
1276
+ this.#tokens = tokens;
1277
+ this.#tokenGeneration = generation;
663
1278
  return tokens;
664
1279
  }
665
1280
  /**
@@ -668,11 +1283,16 @@ export class Daywalker {
668
1283
  * are serialized per client and the new refresh token must replace the stored one.
669
1284
  */
670
1285
  async refreshTokens(refreshToken) {
671
- const token = refreshToken ?? this._tokens?.refreshToken;
1286
+ const token = refreshToken ?? this.#tokens?.refreshToken;
672
1287
  if (!token)
673
1288
  throw new DaywalkerAuthError("refreshTokens: no refresh token (call getTokens() first)", 400, "INVALID_REQUEST");
674
- const tokens = await this._tokenRequest({ grant_type: "refresh_token", refresh_token: token });
675
- this._tokens = tokens;
1289
+ if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) {
1290
+ throw new DaywalkerAuthError("refreshTokens: malformed refresh token", 400, "INVALID_REQUEST");
1291
+ }
1292
+ const generation = this.#keyGeneration;
1293
+ const tokens = await this.#tokenRequest({ grant_type: "refresh_token", refresh_token: token });
1294
+ this.#tokens = tokens;
1295
+ this.#tokenGeneration = generation;
676
1296
  return tokens;
677
1297
  }
678
1298
  /**
@@ -681,11 +1301,12 @@ export class Daywalker {
681
1301
  * refresh token is never presented twice.
682
1302
  */
683
1303
  async accessToken() {
684
- const current = this._tokens;
1304
+ // Tokens from before the signing key was replaced belong to the old key's session: start a new one.
1305
+ const current = this.#tokenGeneration === this.#keyGeneration ? this.#tokens : null;
685
1306
  if (current && current.expiresAt - 30 > Math.floor(Date.now() / 1000))
686
1307
  return current.accessToken;
687
- if (!this._tokenFlight) {
688
- this._tokenFlight = (async () => {
1308
+ if (!this.#tokenFlight) {
1309
+ this.#tokenFlight = (async () => {
689
1310
  if (current) {
690
1311
  try {
691
1312
  return await this.refreshTokens();
@@ -698,23 +1319,136 @@ export class Daywalker {
698
1319
  }
699
1320
  return this.getTokens();
700
1321
  })().finally(() => {
701
- this._tokenFlight = null;
1322
+ this.#tokenFlight = null;
702
1323
  });
703
1324
  }
704
- return (await this._tokenFlight).accessToken;
1325
+ return (await this.#tokenFlight).accessToken;
1326
+ }
1327
+ /** Licenses from the client credentials grant, by service slug. Kept out of TokenSet so they are never serialized. */
1328
+ #licenses = new Map();
1329
+ #rememberLicenses(json, services) {
1330
+ const found = new Map();
1331
+ const add = (slug, key, expires) => {
1332
+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key) || typeof expires !== "string" || !Number.isFinite(Date.parse(expires)))
1333
+ return;
1334
+ if (!SERVICE_SLUG_PATTERN.test(slug))
1335
+ return;
1336
+ found.set(slug, { serviceSlug: slug, licenseKey: key.toLowerCase(), expiresAt: expires });
1337
+ };
1338
+ if (json.licenses && typeof json.licenses === "object") {
1339
+ for (const [slug, v] of Object.entries(json.licenses))
1340
+ add(slug.toLowerCase(), v?.licenseKey, v?.expiresAt);
1341
+ }
1342
+ const primary = services[0];
1343
+ if (primary && !found.has(primary))
1344
+ add(primary, json.licenseKey ?? json.license_key, json.licenseExpiresAt ?? json.license_expires_at);
1345
+ // A refresh carries no licenses; keep the ones we hold until they expire.
1346
+ for (const [slug, license] of found)
1347
+ this.#licenses.set(slug, license);
1348
+ }
1349
+ #licenseCached(slug) {
1350
+ const license = this.#licenses.get(slug);
1351
+ // A minute of margin so a key never expires between fetching it and using it.
1352
+ return license && Date.parse(license.expiresAt) - 60_000 > Date.now() ? license : null;
1353
+ }
1354
+ /**
1355
+ * The service's license key from Daywalker (issued with the client credentials grant, valid for one hour,
1356
+ * cached in memory until a minute before it expires). A revoked or suspended service gets no license: this
1357
+ * throws the DaywalkerAuthError Daywalker answered with (`SERVICE_REVOKED`, `SERVICE_SUSPENDED`,
1358
+ * `DOMAIN_REVOKED`, ...). `LICENSE_UNAVAILABLE` (403) means Daywalker granted the service without a license.
1359
+ * Never log the key.
1360
+ */
1361
+ async getLicense(options = {}) {
1362
+ const slug = String(options.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
1363
+ if (!slug || !SERVICE_SLUG_PATTERN.test(slug)) {
1364
+ throw new DaywalkerAuthError("getLicense: a valid serviceSlug is required", 500, "CONFIGURATION_ERROR");
1365
+ }
1366
+ if (options.forceRefresh !== true) {
1367
+ await this.accessToken();
1368
+ const cached = this.#licenseCached(slug);
1369
+ if (cached)
1370
+ return { ...cached };
1371
+ }
1372
+ const tokens = await this.getTokens();
1373
+ if (!tokens.services.includes(slug)) {
1374
+ throw new DaywalkerAuthError(`The OAuth client credentials don't cover ${slug}`, 403, "SERVICE_NOT_GRANTED");
1375
+ }
1376
+ const license = this.#licenseCached(slug);
1377
+ if (!license)
1378
+ throw new DaywalkerAuthError(`Daywalker Auth issued no license for ${slug}`, 403, "LICENSE_UNAVAILABLE");
1379
+ return { ...license };
1380
+ }
1381
+ /**
1382
+ * Fetches the config bundle (`POST /api/v1/services/:slug/bundle`) and opens it with the license key
1383
+ * (AES-256-GCM). The bundle can only be read by a client that got the license from Daywalker. At a window
1384
+ * rollover the key is refreshed once and the bundle opened again. Cached until shortly before it expires.
1385
+ */
1386
+ async fetchBundle(options = {}) {
1387
+ const slug = String(options.serviceSlug ?? this.#config.serviceSlug ?? "").toLowerCase();
1388
+ if (!slug || !SERVICE_SLUG_PATTERN.test(slug)) {
1389
+ throw new DaywalkerAuthError("fetchBundle: a valid serviceSlug is required", 500, "CONFIGURATION_ERROR");
1390
+ }
1391
+ const cached = options.forceRefresh === true ? undefined : this.#bundles.get(slug);
1392
+ if (cached && Date.parse(cached.expiresAt) - 60_000 > Date.now())
1393
+ return structuredClone(cached);
1394
+ let license = await this.getLicense(options);
1395
+ for (let attempt = 0;; attempt++) {
1396
+ const res = await this.fetch(`/api/v1/services/${slug}/bundle`, { method: "POST", body: {} });
1397
+ const json = await readJsonObject(res, this.#config.timeoutMs);
1398
+ const str = (v) => (typeof v === "string" ? v.slice(0, 500) : undefined);
1399
+ if (res.status !== 200) {
1400
+ throw new DaywalkerAuthError(redactSecrets(str(json.message) ?? str(json.error) ?? `Bundle request failed with HTTP ${res.status}`), res.status, str(json.code) ?? str(json.error));
1401
+ }
1402
+ const sealed = json.bundle;
1403
+ if (!sealed || typeof sealed.iv !== "string" || typeof sealed.tag !== "string" || typeof sealed.ciphertext !== "string") {
1404
+ throw new DaywalkerAuthError("Daywalker Auth returned a malformed bundle", 502, "SERVICE_UNAVAILABLE");
1405
+ }
1406
+ let bundle = null;
1407
+ try {
1408
+ bundle = await this.#openBundle(license.licenseKey, sealed, slug);
1409
+ }
1410
+ catch {
1411
+ bundle = null;
1412
+ }
1413
+ if (bundle) {
1414
+ this.#bundles.set(slug, bundle);
1415
+ return structuredClone(bundle);
1416
+ }
1417
+ if (attempt >= 1)
1418
+ throw new DaywalkerAuthError("The bundle could not be opened with the license key", 502, "BUNDLE_UNREADABLE");
1419
+ license = await this.getLicense({ ...options, forceRefresh: true });
1420
+ }
1421
+ }
1422
+ #bundles = new Map();
1423
+ async #openBundle(licenseKey, sealed, slug) {
1424
+ const key = await webcrypto.importKey("raw", hexToBytes(licenseKey), { name: "AES-GCM" }, false, ["decrypt"]);
1425
+ const tag = base64ToBytes(sealed.tag);
1426
+ const data = base64ToBytes(sealed.ciphertext);
1427
+ const joined = new Uint8Array(data.length + tag.length);
1428
+ joined.set(data);
1429
+ joined.set(tag, data.length);
1430
+ const plain = await webcrypto.decrypt({ name: "AES-GCM", iv: base64ToBytes(sealed.iv), tagLength: 128 }, key, joined);
1431
+ const parsed = JSON.parse(utf8ToString(new Uint8Array(plain)));
1432
+ if (!parsed || typeof parsed !== "object" || parsed.serviceSlug !== slug || typeof parsed.dataKeySalt !== "string")
1433
+ throw new Error("bad bundle");
1434
+ return deepFreeze(parsed);
705
1435
  }
706
1436
  /**
707
1437
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
708
1438
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
709
1439
  */
710
- async fetchJwks() {
711
- const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
712
- const { signal, cleanup } = attemptSignal(undefined, this._config.timeoutMs);
1440
+ fetchJwks() {
1441
+ return this.#fetchJwks();
1442
+ }
1443
+ async #fetchJwks() {
1444
+ const doFetch = this.#fetch;
1445
+ const { signal, cleanup } = attemptSignal(undefined, this.#config.timeoutMs);
713
1446
  let res;
714
1447
  try {
715
- res = await doFetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
1448
+ res = await doFetch(`${this.#config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
716
1449
  headers: { Accept: "application/json" },
717
1450
  redirect: "manual",
1451
+ cache: "no-store",
718
1452
  signal,
719
1453
  });
720
1454
  }
@@ -727,7 +1461,7 @@ export class Daywalker {
727
1461
  throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
728
1462
  let json = null;
729
1463
  try {
730
- json = JSON.parse(await readText(res, this._config.timeoutMs));
1464
+ json = JSON.parse(await readText(res, this.#config.timeoutMs));
731
1465
  }
732
1466
  catch (err) {
733
1467
  if (err.name === "TimeoutError")
@@ -738,49 +1472,124 @@ export class Daywalker {
738
1472
  }
739
1473
  return { keys: json.keys };
740
1474
  }
1475
+ /**
1476
+ * The key set to verify a token naming `kid` with (`jwks: "auto"`), or null when none could be obtained.
1477
+ * The set is refetched once it is older than `JWKS_CACHE_TTL_MS`, or when it has no key with this `kid`
1478
+ * (Daywalker rotated its key), but never sooner than `JWKS_REFETCH_COOLDOWN_MS` after the last attempt.
1479
+ * Concurrent callers share one fetch. A failed fetch, or one with no usable key, keeps the previous set.
1480
+ * Only `${authUrl}/.well-known/jwks.json` is ever fetched: a `jwks_uri` from anywhere is not followed.
1481
+ */
1482
+ async #jwksFor(kid) {
1483
+ const current = this.#jwks;
1484
+ const stale = !current || Date.now() - current.fetchedAt >= JWKS_CACHE_TTL_MS;
1485
+ const unknownKid = Boolean(current && kid !== undefined && !current.set.keys.some((k) => k.kid === kid));
1486
+ if ((stale || unknownKid) && (this.#jwksFlight || Date.now() - this.#jwksAttemptAt >= JWKS_REFETCH_COOLDOWN_MS)) {
1487
+ await this.#refreshJwks();
1488
+ }
1489
+ return this.#jwks?.set ?? null;
1490
+ }
1491
+ #refreshJwks() {
1492
+ if (!this.#jwksFlight) {
1493
+ this.#jwksAttemptAt = Date.now();
1494
+ this.#jwksFlight = this.#fetchJwks()
1495
+ .then(({ keys }) => {
1496
+ const usable = usableJwks(keys);
1497
+ if (usable.length > 0)
1498
+ this.#jwks = { set: deepFreeze({ keys: usable }), fetchedAt: Date.now() };
1499
+ }, () => {
1500
+ // Keep the last good set; the cooldown spaces out the next attempt.
1501
+ })
1502
+ .finally(() => {
1503
+ this.#jwksFlight = null;
1504
+ });
1505
+ }
1506
+ return this.#jwksFlight;
1507
+ }
741
1508
  /**
742
1509
  * The public JWK(s) configured for this service: the current signing key, then the previous one
743
1510
  * (`previousSigningKey`) during a rotation, so requests from instances still on the old key keep verifying.
744
1511
  */
745
1512
  async keys() {
746
- const key = await this.getLoadedSigningKey();
747
- const keys = key ? [key.publicJwk] : [];
1513
+ const active = await this.#getSigner();
1514
+ const keys = active?.publicJwk ? [active.publicJwk] : [];
748
1515
  // A broken previous key must not take the current one offline; selfCheck() reports it.
749
- const previous = await this.getLoadedPreviousSigningKey().catch(() => null);
1516
+ const previous = await this.#getLoadedPreviousSigningKey().catch(() => null);
750
1517
  if (previous && !keys.some((k) => k.kid === previous.publicJwk.kid)) {
751
1518
  keys.push(previous.publicJwk);
752
1519
  }
753
1520
  return { keys };
754
1521
  }
755
1522
  /**
756
- * Check configuration and verify signature readiness.
1523
+ * Check configuration and verify signature readiness. Never provisions or registers a key: with a
1524
+ * `keyStore` it only reads what is stored and reports what first use would need.
757
1525
  */
758
1526
  async selfCheck() {
759
- const problems = [...this._config.problems];
760
- const signingKey = await this.getLoadedSigningKey().catch((err) => {
761
- problems.push(`Signing key load error: ${err.message}`);
762
- return null;
763
- });
764
- if (!signingKey) {
765
- problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
1527
+ const problems = [...this.#config.problems];
1528
+ let keyId = null;
1529
+ let signing = false;
1530
+ if (this.#externalSigner) {
1531
+ signing = true;
1532
+ keyId = this.#externalSigner.keyId;
1533
+ }
1534
+ else if (this.#config.signingKey) {
1535
+ const signingKey = await this.#getLoadedSigningKey().catch((err) => {
1536
+ problems.push(`Signing key load error: ${err.message}`);
1537
+ return null;
1538
+ });
1539
+ signing = Boolean(signingKey);
1540
+ keyId = this.#config.keyId ?? signingKey?.publicJwk.kid ?? null;
1541
+ }
1542
+ else if (this.#config.ephemeralKey) {
1543
+ signing = Boolean(this.#config.domain);
1544
+ keyId = this.#ephemeral?.keyId ?? null;
1545
+ if (!this.#config.domain)
1546
+ problems.push("No public domain detected: set DAYWALKER_DOMAIN so the ephemeral signing key can be registered");
1547
+ }
1548
+ else if (this.#keyStore) {
1549
+ const store = this.#keyStore;
1550
+ try {
1551
+ const stored = await this.#loadStored(store);
1552
+ if (stored) {
1553
+ await this.#useStored(stored);
1554
+ keyId = stored.keyId;
1555
+ signing = true;
1556
+ }
1557
+ else if (!store.saveIfAbsent) {
1558
+ problems.push("The key store is read-only and holds no signing key: provision one with `npx daywalker init`");
1559
+ }
1560
+ else if (!this.#config.domain) {
1561
+ problems.push("No public domain detected: set DAYWALKER_DOMAIN so a signing key can be registered on first use");
1562
+ }
1563
+ else {
1564
+ signing = true; // provisioned and registered on first use
1565
+ }
1566
+ }
1567
+ catch (err) {
1568
+ problems.push(`Key store error: ${err.message}`);
1569
+ }
1570
+ }
1571
+ else {
1572
+ problems.push("No signing key: set DAYWALKER_SIGNING_KEY (npx daywalker init), a keyStore for auto-provisioning, or a signer");
766
1573
  }
767
- await this.getLoadedPreviousSigningKey().catch((err) => {
1574
+ await this.#getLoadedPreviousSigningKey().catch((err) => {
768
1575
  problems.push(`Previous signing key load error: ${err.message}`);
769
1576
  });
770
- const keyId = signingKey?.publicJwk.kid ?? null;
771
- const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
772
1577
  return {
773
1578
  ok: problems.length === 0,
774
- serviceSlug: this._config.serviceSlug,
775
- serviceSlugs: this._config.serviceSlugs,
776
- domain: this._config.domain,
777
- signing: Boolean(signingKey),
1579
+ serviceSlug: this.#config.serviceSlug,
1580
+ serviceSlugs: this.#config.serviceSlugs,
1581
+ domain: this.#config.domain,
1582
+ signing,
778
1583
  keyId,
779
- offlineVerificationReady,
1584
+ offlineVerificationReady: Boolean(this.#config.daywalkerPublicKey) || this.#config.jwks === "auto",
780
1585
  problems,
781
1586
  };
782
1587
  }
783
1588
  }
1589
+ // Methods can't be replaced on the class or (by assignment) on an instance, so code elsewhere in the process
1590
+ // can't swap validateToken() for one that always says yes.
1591
+ Object.freeze(Daywalker.prototype);
1592
+ Object.freeze(Daywalker);
784
1593
  /**
785
1594
  * Factory function for creating instantiable Daywalker client instances.
786
1595
  */
@@ -811,4 +1620,9 @@ export const daywalker = new Proxy((() => getDefaultInstance()), {
811
1620
  }
812
1621
  return getDefaultInstance();
813
1622
  },
1623
+ // The shared default client can't be patched through the proxy either.
1624
+ set: () => false,
1625
+ defineProperty: () => false,
1626
+ deleteProperty: () => false,
1627
+ setPrototypeOf: () => false,
814
1628
  });