@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/README.md +168 -22
- package/dist/cli.js +102 -16
- package/dist/client.d.ts +81 -50
- package/dist/client.js +1015 -201
- package/dist/config.d.ts +12 -1
- package/dist/config.js +34 -2
- package/dist/crypto/digest.js +3 -2
- package/dist/crypto/dpop.d.ts +38 -1
- package/dist/crypto/dpop.js +124 -6
- package/dist/crypto/encoding.js +2 -0
- package/dist/crypto/index.d.ts +6 -4
- package/dist/crypto/index.js +4 -3
- package/dist/crypto/key-api.d.ts +69 -0
- package/dist/crypto/key-api.js +217 -0
- package/dist/crypto/keys.d.ts +5 -0
- package/dist/crypto/keys.js +25 -15
- package/dist/crypto/signatures.d.ts +13 -24
- package/dist/crypto/signatures.js +41 -84
- package/dist/crypto/webcrypto.d.ts +39 -0
- package/dist/crypto/webcrypto.js +26 -0
- package/dist/express.d.ts +2 -1
- package/dist/express.js +12 -32
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/keystore/file.d.ts +14 -0
- package/dist/keystore/file.js +77 -0
- package/dist/keystore/index.d.ts +18 -0
- package/dist/keystore/index.js +71 -0
- package/dist/middleware.d.ts +27 -3
- package/dist/middleware.js +75 -22
- package/dist/next.d.ts +5 -3
- package/dist/next.js +38 -42
- package/dist/types.d.ts +129 -4
- package/package.json +11 -1
- package/dist/signing.d.ts +0 -3
- package/dist/signing.js +0 -2
package/dist/client.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { parseServiceSlugs, resolveConfig } from "./config.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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([
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
425
|
+
return this.#publicConfig;
|
|
190
426
|
}
|
|
191
427
|
toJSON() {
|
|
192
428
|
return {
|
|
193
|
-
serviceSlug: this.
|
|
194
|
-
domain: this.
|
|
195
|
-
authUrl: this.
|
|
196
|
-
keyId: this.
|
|
197
|
-
apiKey: maskSecret(this.
|
|
198
|
-
signingKey: this.
|
|
199
|
-
daywalkerPublicKey: this.
|
|
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.
|
|
443
|
+
async #getLoadedSigningKey() {
|
|
444
|
+
if (!this.#config.signingKey)
|
|
208
445
|
return null;
|
|
209
|
-
if (typeof this.
|
|
210
|
-
return this.
|
|
446
|
+
if (typeof this.#config.signingKey !== "string" && "privateKey" in this.#config.signingKey) {
|
|
447
|
+
return this.#config.signingKey;
|
|
211
448
|
}
|
|
212
|
-
if (!this
|
|
213
|
-
this
|
|
449
|
+
if (!this.#signingKeyPromise) {
|
|
450
|
+
this.#signingKeyPromise = loadSigningKey(this.#config.signingKey);
|
|
214
451
|
}
|
|
215
|
-
return this
|
|
452
|
+
return this.#signingKeyPromise;
|
|
216
453
|
}
|
|
217
|
-
|
|
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
|
|
671
|
+
#getDpopKey() {
|
|
672
|
+
return (this.#dpopKey ??= generateDpopKey());
|
|
221
673
|
}
|
|
222
|
-
async getLoadedPreviousSigningKey() {
|
|
223
|
-
const raw = this.
|
|
674
|
+
async #getLoadedPreviousSigningKey() {
|
|
675
|
+
const raw = this.#config.previousSigningKey;
|
|
224
676
|
if (!raw)
|
|
225
677
|
return null;
|
|
226
|
-
if (!this
|
|
227
|
-
this
|
|
678
|
+
if (!this.#previousSigningKeyPromise) {
|
|
679
|
+
this.#previousSigningKeyPromise = loadSigningKey(raw);
|
|
228
680
|
}
|
|
229
|
-
return this
|
|
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 `
|
|
241
|
-
*
|
|
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.
|
|
699
|
+
const authUrl = this.#config.authUrl;
|
|
245
700
|
const url = new URL(path, authUrl);
|
|
246
|
-
// Absolute URLs to other hosts would otherwise receive
|
|
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
|
|
707
|
+
const doFetch = this.#fetch;
|
|
253
708
|
const { retry: _retry, timeoutMs: _timeoutMs, idempotencyKey, body: rawBody, signal: callerSignal, ...requestInit } = init ?? {};
|
|
254
|
-
const retryOpts = { ...this.
|
|
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.
|
|
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
|
|
275
|
-
const body = await prepareBody(rawBody, Boolean(
|
|
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 (
|
|
287
|
-
headers.set("authorization", `
|
|
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
|
-
|
|
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
|
|
756
|
+
headers.set("dpop", await createDpopProof(await this.#getDpopKey(), { method, url: url.toString(), accessToken }));
|
|
299
757
|
}
|
|
300
|
-
if (
|
|
301
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
const entry = this.
|
|
830
|
+
#cacheGet(key) {
|
|
831
|
+
const entry = this.#cache.get(key);
|
|
365
832
|
if (!entry)
|
|
366
833
|
return undefined;
|
|
367
|
-
this.
|
|
834
|
+
this.#cache.delete(key);
|
|
368
835
|
if (entry.expiresAt <= Date.now())
|
|
369
836
|
return undefined;
|
|
370
|
-
this.
|
|
371
|
-
return entry
|
|
837
|
+
this.#cache.set(key, entry); // most recently used goes last
|
|
838
|
+
return entry;
|
|
372
839
|
}
|
|
373
|
-
|
|
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.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
*
|
|
385
|
-
*
|
|
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
|
|
393
|
-
|
|
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: "
|
|
859
|
+
error: "DPOP_REQUIRED",
|
|
413
860
|
status: 401,
|
|
414
|
-
message: "
|
|
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
|
|
914
|
+
const cached = forceRefresh ? undefined : this.#cacheGet(cacheKey);
|
|
420
915
|
if (cached) {
|
|
421
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
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:
|
|
930
|
+
publicKey: offlineKey,
|
|
428
931
|
expectedServiceSlug: serviceSlug || undefined,
|
|
429
|
-
expectedIssuer: this.
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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.
|
|
455
|
-
// Never cache a
|
|
456
|
-
this
|
|
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
|
|
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.
|
|
994
|
+
const pending = this.#inflight.get(inflightKey);
|
|
480
995
|
if (pending)
|
|
481
996
|
return pending.then(cloneResult);
|
|
482
|
-
const run = this
|
|
483
|
-
this.
|
|
997
|
+
const run = this.#validateOnline(token, serviceSlug, cacheKey).finally(() => {
|
|
998
|
+
this.#inflight.delete(inflightKey);
|
|
484
999
|
});
|
|
485
|
-
this.
|
|
1000
|
+
this.#inflight.set(inflightKey, run);
|
|
486
1001
|
return run.then(cloneResult);
|
|
487
1002
|
}
|
|
488
|
-
async
|
|
489
|
-
const negTtl = this.
|
|
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
|
|
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:
|
|
1022
|
+
body: { token },
|
|
509
1023
|
// Validation is read-only, so repeating it after a lost response is safe.
|
|
510
|
-
retry: { retryNetworkErrors: this.
|
|
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
|
|
513
|
-
const text = await readText(res, this.
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
services:
|
|
556
|
-
};
|
|
557
|
-
const result = {
|
|
558
|
-
ok: true,
|
|
559
|
-
data,
|
|
1086
|
+
offlineVerified: false,
|
|
1087
|
+
services: services.length > 0 ? services : [servedSlug],
|
|
560
1088
|
};
|
|
561
|
-
|
|
562
|
-
|
|
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:
|
|
597
|
-
*
|
|
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
|
|
600
|
-
|
|
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: "
|
|
604
|
-
status:
|
|
605
|
-
message:
|
|
1151
|
+
error: "NETWORK_ERROR",
|
|
1152
|
+
status: 503,
|
|
1153
|
+
message: redactSecrets(`Could not reach Daywalker Auth: ${err.message}`),
|
|
606
1154
|
};
|
|
607
1155
|
}
|
|
608
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
|
635
|
-
|
|
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:
|
|
639
|
-
expiresAt
|
|
640
|
-
expiresIn:
|
|
641
|
-
refreshExpiresIn
|
|
642
|
-
scope:
|
|
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
|
|
650
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
688
|
-
this
|
|
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
|
|
1322
|
+
this.#tokenFlight = null;
|
|
702
1323
|
});
|
|
703
1324
|
}
|
|
704
|
-
return (await this
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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.
|
|
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.
|
|
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
|
|
747
|
-
const keys =
|
|
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
|
|
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.
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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.
|
|
775
|
-
serviceSlugs: this.
|
|
776
|
-
domain: this.
|
|
777
|
-
signing
|
|
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
|
});
|