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