@shelbyb/daywalker-sdk 0.3.0 → 0.4.1
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 +64 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +52 -8
- package/dist/next.js +151 -37
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +56 -4
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +32 -14
- package/assets/logo-512.png +0 -0
package/dist/client.js
CHANGED
|
@@ -25,10 +25,156 @@ function generateNonce() {
|
|
|
25
25
|
}
|
|
26
26
|
/** Validation results kept in memory; the least recently used are evicted past this. */
|
|
27
27
|
export const MAX_VALIDATION_CACHE_ENTRIES = 1000;
|
|
28
|
+
/** Longest 429 `Retry-After` waited out by default (`retry.maxRetryAfterMs`); longer ones return the 429. */
|
|
29
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
30
|
+
/** Methods retried after a network error or timeout by default: repeating them can't change server state. */
|
|
31
|
+
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
32
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
33
|
+
function isPlainJsonValue(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return true;
|
|
36
|
+
const proto = Object.getPrototypeOf(value);
|
|
37
|
+
return proto === Object.prototype || proto === null;
|
|
38
|
+
}
|
|
39
|
+
async function prepareBody(raw, signing) {
|
|
40
|
+
if (raw === undefined || raw === null)
|
|
41
|
+
return { bytes: null, send: null, replayable: true };
|
|
42
|
+
if (typeof raw === "string")
|
|
43
|
+
return { bytes: raw, send: raw, replayable: true };
|
|
44
|
+
if (raw instanceof Uint8Array)
|
|
45
|
+
return { bytes: raw, send: raw, replayable: true };
|
|
46
|
+
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
|
|
47
|
+
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
48
|
+
return { bytes, send: bytes, replayable: true };
|
|
49
|
+
}
|
|
50
|
+
if (raw instanceof URLSearchParams) {
|
|
51
|
+
const str = raw.toString();
|
|
52
|
+
return { bytes: str, send: str, contentType: "application/x-www-form-urlencoded;charset=UTF-8", replayable: true };
|
|
53
|
+
}
|
|
54
|
+
if (typeof Blob !== "undefined" && raw instanceof Blob) {
|
|
55
|
+
if (!signing)
|
|
56
|
+
return { bytes: null, send: raw, replayable: true };
|
|
57
|
+
const bytes = new Uint8Array(await raw.arrayBuffer());
|
|
58
|
+
return { bytes, send: bytes, contentType: raw.type || undefined, replayable: true };
|
|
59
|
+
}
|
|
60
|
+
const isFormData = typeof FormData !== "undefined" && raw instanceof FormData;
|
|
61
|
+
const isStream = typeof ReadableStream !== "undefined" && raw instanceof ReadableStream;
|
|
62
|
+
if (isFormData || isStream) {
|
|
63
|
+
if (signing) {
|
|
64
|
+
throw new DaywalkerError(`${isFormData ? "FormData" : "ReadableStream"} bodies can't be signed: their exact bytes aren't known up front, so no Content-Digest can be computed. Serialize the body to a string or Uint8Array first.`);
|
|
65
|
+
}
|
|
66
|
+
return { bytes: null, send: raw, replayable: !isStream };
|
|
67
|
+
}
|
|
68
|
+
if (typeof raw === "object" && isPlainJsonValue(raw)) {
|
|
69
|
+
const json = JSON.stringify(raw);
|
|
70
|
+
return { bytes: json, send: json, contentType: "application/json", replayable: true };
|
|
71
|
+
}
|
|
72
|
+
throw new DaywalkerError(`Unsupported fetch() body (${Object.prototype.toString.call(raw)}): pass a plain object or array (sent as JSON), a string, Uint8Array, ArrayBuffer, URLSearchParams or Blob`);
|
|
73
|
+
}
|
|
74
|
+
/** Resolves after `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
75
|
+
function sleep(ms, signal) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
if (signal?.aborted)
|
|
78
|
+
return reject(signal.reason);
|
|
79
|
+
const onAbort = () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(signal.reason);
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
signal?.removeEventListener("abort", onAbort);
|
|
85
|
+
resolve();
|
|
86
|
+
}, ms);
|
|
87
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The signal for one attempt: the caller's signal combined with a `timeoutMs` timer, via `AbortSignal.any`
|
|
92
|
+
* (Node >= 20.3) or a manual fallback. `cleanup()` clears the timer once response headers arrive.
|
|
93
|
+
*/
|
|
94
|
+
function attemptSignal(caller, timeoutMs) {
|
|
95
|
+
const signals = caller ? [caller] : [];
|
|
96
|
+
let timer;
|
|
97
|
+
if (timeoutMs > 0) {
|
|
98
|
+
const timeout = new AbortController();
|
|
99
|
+
timer = setTimeout(() => timeout.abort(new DOMException(`Request to Daywalker Auth timed out after ${timeoutMs}ms`, "TimeoutError")), timeoutMs);
|
|
100
|
+
signals.push(timeout.signal);
|
|
101
|
+
}
|
|
102
|
+
const clear = () => clearTimeout(timer);
|
|
103
|
+
if (signals.length <= 1)
|
|
104
|
+
return { signal: signals[0], cleanup: clear };
|
|
105
|
+
if (typeof AbortSignal.any === "function")
|
|
106
|
+
return { signal: AbortSignal.any(signals), cleanup: clear };
|
|
107
|
+
const combined = new AbortController();
|
|
108
|
+
const onAbort = (e) => combined.abort(e.target.reason);
|
|
109
|
+
for (const s of signals) {
|
|
110
|
+
if (s.aborted)
|
|
111
|
+
combined.abort(s.reason);
|
|
112
|
+
else
|
|
113
|
+
s.addEventListener("abort", onAbort, { once: true });
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
signal: combined.signal,
|
|
117
|
+
cleanup: () => {
|
|
118
|
+
clear();
|
|
119
|
+
for (const s of signals)
|
|
120
|
+
s.removeEventListener("abort", onAbort);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Reads a response body, giving up (and cancelling it) after `timeoutMs` so a trickling body can't hang us. */
|
|
125
|
+
async function readText(res, timeoutMs) {
|
|
126
|
+
if (!(timeoutMs > 0))
|
|
127
|
+
return res.text();
|
|
128
|
+
let timer;
|
|
129
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
130
|
+
timer = setTimeout(() => {
|
|
131
|
+
res.body?.cancel().catch(() => { });
|
|
132
|
+
reject(new DOMException(`Reading the Daywalker Auth response timed out after ${timeoutMs}ms`, "TimeoutError"));
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
return await Promise.race([res.text(), timeout]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function isRedirect(res) {
|
|
143
|
+
return res.type === "opaqueredirect" || REDIRECT_STATUSES.has(res.status);
|
|
144
|
+
}
|
|
145
|
+
function redirectError(what, res) {
|
|
146
|
+
res.body?.cancel().catch(() => { });
|
|
147
|
+
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
|
+
}
|
|
149
|
+
function isRateLimitWindow(value) {
|
|
150
|
+
if (!value || typeof value !== "object")
|
|
151
|
+
return false;
|
|
152
|
+
const w = value;
|
|
153
|
+
return ["limit", "remaining", "reset"].every((k) => typeof w[k] === "number" && Number.isFinite(w[k]));
|
|
154
|
+
}
|
|
155
|
+
/** Why a `valid: true` answer can't be trusted as a validation result, or null when its shape is sane. */
|
|
156
|
+
function malformedValidation(json, payloadData) {
|
|
157
|
+
for (const src of [json, payloadData]) {
|
|
158
|
+
for (const k of ["serviceSlug", "tenantId", "tokenId", "tokenName", "userId", "stage"]) {
|
|
159
|
+
if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
|
|
160
|
+
return `${k} is not a string`;
|
|
161
|
+
}
|
|
162
|
+
for (const k of ["rateLimit", "rateLimitHourly"]) {
|
|
163
|
+
if (src[k] !== undefined && src[k] !== null && !isRateLimitWindow(src[k]))
|
|
164
|
+
return `${k} is not a rate limit window`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** Results are handed out as copies, so a caller mutating one can't alter what other callers get from the cache. */
|
|
170
|
+
function cloneResult(result) {
|
|
171
|
+
return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
|
|
172
|
+
}
|
|
28
173
|
export class Daywalker {
|
|
29
174
|
_config;
|
|
30
175
|
_rawConfig;
|
|
31
176
|
_signingKeyPromise;
|
|
177
|
+
_previousSigningKeyPromise;
|
|
32
178
|
_cache = new Map();
|
|
33
179
|
_inflight = new Map();
|
|
34
180
|
constructor(config = {}) {
|
|
@@ -64,8 +210,26 @@ export class Daywalker {
|
|
|
64
210
|
}
|
|
65
211
|
return this._signingKeyPromise;
|
|
66
212
|
}
|
|
213
|
+
async getLoadedPreviousSigningKey() {
|
|
214
|
+
const raw = this._config.previousSigningKey;
|
|
215
|
+
if (!raw)
|
|
216
|
+
return null;
|
|
217
|
+
if (!this._previousSigningKeyPromise) {
|
|
218
|
+
this._previousSigningKeyPromise = loadSigningKey(raw);
|
|
219
|
+
}
|
|
220
|
+
return this._previousSigningKeyPromise;
|
|
221
|
+
}
|
|
67
222
|
/**
|
|
68
|
-
* Signs and executes
|
|
223
|
+
* Signs and executes a request to Daywalker Auth (same origin as `authUrl` only).
|
|
224
|
+
* - Redirects are never followed: a 3xx throws a DaywalkerError (code UNEXPECTED_REDIRECT), so the API key,
|
|
225
|
+
* signature and body never reach another URL and no redirect target's answer is trusted.
|
|
226
|
+
* - Each attempt times out after `timeoutMs` (default 5000ms) of waiting for response headers; the caller's
|
|
227
|
+
* `signal` aborts immediately, including during backoff, and is never retried.
|
|
228
|
+
* - 429s are retried with exponential backoff, honoring `Retry-After` up to `retry.maxRetryAfterMs`. Network
|
|
229
|
+
* errors and timeouts are retried only for GET / HEAD / OPTIONS, with an `idempotencyKey`, or when
|
|
230
|
+
* `retry.retryNetworkErrors` is true. Options merge as defaults < `config.retry` < `init.retry`.
|
|
231
|
+
* - An explicit `Authorization` header is sent as given; otherwise `Bearer <apiKey>` is added. The signature
|
|
232
|
+
* covers method, path, query, authority and Content-Digest, never Authorization.
|
|
69
233
|
*/
|
|
70
234
|
async fetch(path, init) {
|
|
71
235
|
const authUrl = this._config.authUrl;
|
|
@@ -77,33 +241,35 @@ export class Daywalker {
|
|
|
77
241
|
}
|
|
78
242
|
const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
|
|
79
243
|
const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
jitter: init?.retry?.jitter ?? true,
|
|
86
|
-
...init?.retry,
|
|
87
|
-
};
|
|
88
|
-
const rawBody = init?.body;
|
|
89
|
-
let bodyString = null;
|
|
90
|
-
if (rawBody !== undefined && rawBody !== null) {
|
|
91
|
-
if (typeof rawBody === "string") {
|
|
92
|
-
bodyString = rawBody;
|
|
93
|
-
}
|
|
94
|
-
else if (rawBody instanceof Uint8Array) {
|
|
95
|
-
bodyString = rawBody;
|
|
96
|
-
}
|
|
97
|
-
else if (typeof rawBody === "object") {
|
|
98
|
-
bodyString = JSON.stringify(rawBody);
|
|
99
|
-
}
|
|
244
|
+
const { retry: _retry, timeoutMs: _timeoutMs, idempotencyKey, body: rawBody, signal: callerSignal, ...requestInit } = init ?? {};
|
|
245
|
+
const retryOpts = { ...this._config.retry };
|
|
246
|
+
for (const [k, v] of Object.entries(init?.retry ?? {})) {
|
|
247
|
+
if (v !== undefined)
|
|
248
|
+
retryOpts[k] = v;
|
|
100
249
|
}
|
|
250
|
+
const maxRetries = retryOpts.maxRetries ?? 3;
|
|
251
|
+
const initialDelayMs = retryOpts.initialDelayMs ?? 500;
|
|
252
|
+
const maxDelayMs = retryOpts.maxDelayMs ?? 30_000;
|
|
253
|
+
const backoffFactor = retryOpts.backoffFactor ?? 2;
|
|
254
|
+
const jitter = retryOpts.jitter ?? true;
|
|
255
|
+
const maxRetryAfterMs = retryOpts.maxRetryAfterMs ?? MAX_RETRY_AFTER_MS;
|
|
256
|
+
const retryNetworkErrors = retryOpts.retryNetworkErrors ?? (IDEMPOTENT_METHODS.has(method) || Boolean(idempotencyKey));
|
|
257
|
+
const timeoutMs = init?.timeoutMs ?? this._config.timeoutMs;
|
|
258
|
+
const backoff = (n) => {
|
|
259
|
+
let delayMs = initialDelayMs * Math.pow(backoffFactor, n - 1);
|
|
260
|
+
if (jitter)
|
|
261
|
+
delayMs = delayMs * (0.8 + Math.random() * 0.4);
|
|
262
|
+
return Math.min(delayMs, maxDelayMs);
|
|
263
|
+
};
|
|
264
|
+
callerSignal?.throwIfAborted();
|
|
101
265
|
const signingKey = await this.getLoadedSigningKey();
|
|
266
|
+
const body = await prepareBody(rawBody, Boolean(signingKey));
|
|
102
267
|
let attempt = 0;
|
|
103
268
|
while (true) {
|
|
269
|
+
callerSignal?.throwIfAborted();
|
|
104
270
|
const headers = new Headers(init?.headers);
|
|
105
|
-
if (
|
|
106
|
-
headers.set("content-type",
|
|
271
|
+
if (body.contentType && !headers.has("content-type")) {
|
|
272
|
+
headers.set("content-type", body.contentType);
|
|
107
273
|
}
|
|
108
274
|
if (!headers.has("accept")) {
|
|
109
275
|
headers.set("accept", "application/json");
|
|
@@ -111,15 +277,18 @@ export class Daywalker {
|
|
|
111
277
|
if (this._config.apiKey && !headers.has("authorization")) {
|
|
112
278
|
headers.set("authorization", `Bearer ${this._config.apiKey}`);
|
|
113
279
|
}
|
|
280
|
+
if (idempotencyKey && !headers.has("idempotency-key")) {
|
|
281
|
+
headers.set("idempotency-key", idempotencyKey);
|
|
282
|
+
}
|
|
114
283
|
if (signingKey) {
|
|
284
|
+
// apiKey is left out so the signer can't replace an explicit Authorization header.
|
|
115
285
|
const signedHeaders = await signRequest({
|
|
116
286
|
method,
|
|
117
|
-
path: url.pathname,
|
|
287
|
+
path: url.pathname + url.search,
|
|
118
288
|
authority: url.host,
|
|
119
|
-
body:
|
|
289
|
+
body: body.bytes,
|
|
120
290
|
key: signingKey,
|
|
121
291
|
keyid: this._config.keyId ?? undefined,
|
|
122
|
-
apiKey: this._config.apiKey ?? undefined,
|
|
123
292
|
nonce: generateNonce(),
|
|
124
293
|
});
|
|
125
294
|
for (const [k, v] of Object.entries(signedHeaders)) {
|
|
@@ -128,37 +297,47 @@ export class Daywalker {
|
|
|
128
297
|
}
|
|
129
298
|
}
|
|
130
299
|
}
|
|
300
|
+
const { signal, cleanup } = attemptSignal(callerSignal, timeoutMs);
|
|
131
301
|
const reqInit = {
|
|
132
|
-
...
|
|
302
|
+
...requestInit,
|
|
133
303
|
method,
|
|
134
304
|
headers,
|
|
135
|
-
body:
|
|
305
|
+
body: body.send ?? undefined,
|
|
306
|
+
redirect: requestInit.redirect === "error" ? "error" : "manual",
|
|
307
|
+
signal,
|
|
136
308
|
};
|
|
137
309
|
let res;
|
|
138
310
|
try {
|
|
139
311
|
res = await doFetch(url.toString(), reqInit);
|
|
140
312
|
}
|
|
141
313
|
catch (err) {
|
|
142
|
-
if (
|
|
314
|
+
if (callerSignal?.aborted)
|
|
315
|
+
throw err;
|
|
316
|
+
if (retryNetworkErrors && body.replayable && attempt < maxRetries) {
|
|
143
317
|
attempt++;
|
|
144
|
-
|
|
145
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
318
|
+
await sleep(backoff(attempt), callerSignal);
|
|
146
319
|
continue;
|
|
147
320
|
}
|
|
148
321
|
throw err;
|
|
149
322
|
}
|
|
150
|
-
|
|
151
|
-
|
|
323
|
+
finally {
|
|
324
|
+
cleanup();
|
|
325
|
+
}
|
|
326
|
+
if (isRedirect(res)) {
|
|
327
|
+
throw redirectError(url.pathname, res);
|
|
328
|
+
}
|
|
329
|
+
if (res.status === 429 && body.replayable && attempt < maxRetries) {
|
|
152
330
|
const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
|
|
153
|
-
let delayMs = (
|
|
154
|
-
if (retryOpts.jitter) {
|
|
155
|
-
delayMs = delayMs * (0.8 + Math.random() * 0.4);
|
|
156
|
-
}
|
|
331
|
+
let delayMs = backoff(attempt + 1);
|
|
157
332
|
if (retryAfterSec !== undefined) {
|
|
333
|
+
// Retrying sooner than the server asked is pointless; waiting longer than this is the caller's call.
|
|
334
|
+
if (retryAfterSec * 1000 > maxRetryAfterMs)
|
|
335
|
+
return res;
|
|
158
336
|
delayMs = Math.max(delayMs, retryAfterSec * 1000);
|
|
159
337
|
}
|
|
160
|
-
|
|
161
|
-
|
|
338
|
+
attempt++;
|
|
339
|
+
res.body?.cancel().catch(() => { });
|
|
340
|
+
await sleep(delayMs, callerSignal);
|
|
162
341
|
continue;
|
|
163
342
|
}
|
|
164
343
|
return res;
|
|
@@ -189,6 +368,9 @@ export class Daywalker {
|
|
|
189
368
|
* Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
|
|
190
369
|
* A missing token is always rejected (401); it never falls back to this service's own API key —
|
|
191
370
|
* use `validateServiceKey()` for that.
|
|
371
|
+
* Online validation fails closed: only a 2xx JSON answer with `valid: true` and a sane shape is accepted.
|
|
372
|
+
* Outages (network errors, timeouts, 5xx, malformed answers) return status 503 with error NETWORK_ERROR or
|
|
373
|
+
* SERVICE_UNAVAILABLE and are never cached. Each call gets its own copy of the (cached) result.
|
|
192
374
|
*/
|
|
193
375
|
async validateToken(tokenOrOptions, options) {
|
|
194
376
|
let token;
|
|
@@ -219,7 +401,7 @@ export class Daywalker {
|
|
|
219
401
|
const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
|
|
220
402
|
const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
|
|
221
403
|
if (cached) {
|
|
222
|
-
return cached;
|
|
404
|
+
return cloneResult(cached);
|
|
223
405
|
}
|
|
224
406
|
// Check for offline JWT lease verification
|
|
225
407
|
if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
|
|
@@ -254,7 +436,7 @@ export class Daywalker {
|
|
|
254
436
|
// Never cache a lease past its own expiry.
|
|
255
437
|
this._cacheSet(cacheKey, result, this._config.cacheTtlMs, payload.exp * 1000);
|
|
256
438
|
}
|
|
257
|
-
return result;
|
|
439
|
+
return cloneResult(result);
|
|
258
440
|
}
|
|
259
441
|
else if (offlineOnly) {
|
|
260
442
|
return {
|
|
@@ -277,21 +459,28 @@ export class Daywalker {
|
|
|
277
459
|
const inflightKey = `${cacheKey}`;
|
|
278
460
|
const pending = this._inflight.get(inflightKey);
|
|
279
461
|
if (pending)
|
|
280
|
-
return pending;
|
|
462
|
+
return pending.then(cloneResult);
|
|
281
463
|
const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
|
|
282
464
|
this._inflight.delete(inflightKey);
|
|
283
465
|
});
|
|
284
466
|
this._inflight.set(inflightKey, run);
|
|
285
|
-
return run;
|
|
467
|
+
return run.then(cloneResult);
|
|
286
468
|
}
|
|
287
469
|
async _validateOnline(token, serviceSlug, cacheKey) {
|
|
288
470
|
const negTtl = this._config.negativeCacheTtlMs;
|
|
289
471
|
const fail = (result) => {
|
|
290
|
-
if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
|
|
472
|
+
if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR" && result.error !== "SERVICE_UNAVAILABLE") {
|
|
291
473
|
this._cacheSet(cacheKey, result, negTtl);
|
|
292
474
|
}
|
|
293
475
|
return result;
|
|
294
476
|
};
|
|
477
|
+
// Says nothing about the token, so never cached.
|
|
478
|
+
const unavailable = (message) => ({
|
|
479
|
+
ok: false,
|
|
480
|
+
error: "SERVICE_UNAVAILABLE",
|
|
481
|
+
status: 503,
|
|
482
|
+
message: redactSecrets(message),
|
|
483
|
+
});
|
|
295
484
|
try {
|
|
296
485
|
const validateBody = { token };
|
|
297
486
|
if (serviceSlug) {
|
|
@@ -300,10 +489,42 @@ export class Daywalker {
|
|
|
300
489
|
const res = await this.fetch("/api/v1/tokens/validate", {
|
|
301
490
|
method: "POST",
|
|
302
491
|
body: validateBody,
|
|
492
|
+
// Validation is read-only, so repeating it after a lost response is safe.
|
|
493
|
+
retry: { retryNetworkErrors: this._config.retry.retryNetworkErrors ?? true },
|
|
303
494
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
495
|
+
// A body that times out is an outage (thrown to NETWORK_ERROR below), never an empty answer.
|
|
496
|
+
const text = await readText(res, this._config.timeoutMs);
|
|
497
|
+
let parsed;
|
|
498
|
+
try {
|
|
499
|
+
parsed = JSON.parse(text);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
parsed = undefined;
|
|
503
|
+
}
|
|
504
|
+
const json = (parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
|
|
505
|
+
const isObject = json === parsed;
|
|
506
|
+
if (res.status === 429) {
|
|
507
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? (typeof json.retryAfter === "number" ? json.retryAfter : undefined);
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: "RATE_LIMITED",
|
|
511
|
+
status: 429,
|
|
512
|
+
message: redactSecrets((typeof json.message === "string" && json.message) || "Rate limit exceeded"),
|
|
513
|
+
retryAfter,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (res.status >= 500) {
|
|
517
|
+
return unavailable(`Daywalker Auth is unavailable (HTTP ${res.status})`);
|
|
518
|
+
}
|
|
519
|
+
if (res.ok && isObject && json.valid === true) {
|
|
520
|
+
const payloadData = (json.data === undefined ? json : json.data);
|
|
521
|
+
if (!payloadData || typeof payloadData !== "object" || Array.isArray(payloadData)) {
|
|
522
|
+
return unavailable("Daywalker Auth returned a malformed validation result (data is not an object)");
|
|
523
|
+
}
|
|
524
|
+
const problem = malformedValidation(json, payloadData);
|
|
525
|
+
if (problem) {
|
|
526
|
+
return unavailable(`Daywalker Auth returned a malformed validation result (${problem})`);
|
|
527
|
+
}
|
|
307
528
|
const data = {
|
|
308
529
|
serviceSlug: json.serviceSlug || serviceSlug,
|
|
309
530
|
tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
|
|
@@ -324,18 +545,12 @@ export class Daywalker {
|
|
|
324
545
|
}
|
|
325
546
|
return result;
|
|
326
547
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
ok: false,
|
|
331
|
-
error: "RATE_LIMITED",
|
|
332
|
-
status: 429,
|
|
333
|
-
message: redactSecrets(json.message || "Rate limit exceeded"),
|
|
334
|
-
retryAfter,
|
|
335
|
-
};
|
|
548
|
+
// A 2xx is only a token verdict when it says so; anything else (HTML, `{}`, a captive portal) fails closed.
|
|
549
|
+
if (res.ok && !(isObject && (json.valid === false || typeof json.error === "string"))) {
|
|
550
|
+
return unavailable(`Daywalker Auth returned no validation result (HTTP ${res.status})`);
|
|
336
551
|
}
|
|
337
552
|
let error = "INVALID_TOKEN";
|
|
338
|
-
const errMsg = (json.message || json.error || "");
|
|
553
|
+
const errMsg = String((typeof json.message === "string" && json.message) || (typeof json.error === "string" && json.error) || "");
|
|
339
554
|
if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
|
|
340
555
|
error = "EXPIRED";
|
|
341
556
|
}
|
|
@@ -345,7 +560,8 @@ export class Daywalker {
|
|
|
345
560
|
return fail({
|
|
346
561
|
ok: false,
|
|
347
562
|
error,
|
|
348
|
-
|
|
563
|
+
// `{ valid: false }` may come with a 200; never hand adapters a success status for a rejection.
|
|
564
|
+
status: res.ok ? 401 : res.status,
|
|
349
565
|
message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
|
|
350
566
|
});
|
|
351
567
|
}
|
|
@@ -402,19 +618,52 @@ export class Daywalker {
|
|
|
402
618
|
}
|
|
403
619
|
/**
|
|
404
620
|
* Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
|
|
621
|
+
* Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
|
|
405
622
|
*/
|
|
406
623
|
async fetchJwks() {
|
|
407
|
-
const
|
|
624
|
+
const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
|
|
625
|
+
const { signal, cleanup } = attemptSignal(undefined, this._config.timeoutMs);
|
|
626
|
+
let res;
|
|
627
|
+
try {
|
|
628
|
+
res = await doFetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
|
|
629
|
+
headers: { Accept: "application/json" },
|
|
630
|
+
redirect: "manual",
|
|
631
|
+
signal,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
cleanup();
|
|
636
|
+
}
|
|
637
|
+
if (isRedirect(res))
|
|
638
|
+
throw redirectError("/.well-known/jwks.json", res);
|
|
408
639
|
if (!res.ok)
|
|
409
640
|
throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
|
|
410
|
-
|
|
641
|
+
let json = null;
|
|
642
|
+
try {
|
|
643
|
+
json = JSON.parse(await readText(res, this._config.timeoutMs));
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
if (err.name === "TimeoutError")
|
|
647
|
+
throw err;
|
|
648
|
+
}
|
|
649
|
+
if (!json || typeof json !== "object" || !Array.isArray(json.keys)) {
|
|
650
|
+
throw new DaywalkerError("JWKS response is not a JWK Set");
|
|
651
|
+
}
|
|
652
|
+
return { keys: json.keys };
|
|
411
653
|
}
|
|
412
654
|
/**
|
|
413
|
-
* The public JWK(s) configured for this service
|
|
655
|
+
* The public JWK(s) configured for this service: the current signing key, then the previous one
|
|
656
|
+
* (`previousSigningKey`) during a rotation, so requests from instances still on the old key keep verifying.
|
|
414
657
|
*/
|
|
415
658
|
async keys() {
|
|
416
659
|
const key = await this.getLoadedSigningKey();
|
|
417
|
-
|
|
660
|
+
const keys = key ? [key.publicJwk] : [];
|
|
661
|
+
// A broken previous key must not take the current one offline; selfCheck() reports it.
|
|
662
|
+
const previous = await this.getLoadedPreviousSigningKey().catch(() => null);
|
|
663
|
+
if (previous && !keys.some((k) => k.kid === previous.publicJwk.kid)) {
|
|
664
|
+
keys.push(previous.publicJwk);
|
|
665
|
+
}
|
|
666
|
+
return { keys };
|
|
418
667
|
}
|
|
419
668
|
/**
|
|
420
669
|
* Check configuration and verify signature readiness.
|
|
@@ -428,6 +677,9 @@ export class Daywalker {
|
|
|
428
677
|
if (!signingKey) {
|
|
429
678
|
problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
|
|
430
679
|
}
|
|
680
|
+
await this.getLoadedPreviousSigningKey().catch((err) => {
|
|
681
|
+
problems.push(`Previous signing key load error: ${err.message}`);
|
|
682
|
+
});
|
|
431
683
|
const keyId = signingKey?.publicJwk.kid ?? null;
|
|
432
684
|
const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
|
|
433
685
|
return {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
import type { DaywalkerConfig, PublicJwk, SigningKey } from "./types.js";
|
|
1
|
+
import type { DaywalkerConfig, PublicJwk, PublicJwks, RetryOptions, SigningKey } from "./types.js";
|
|
2
2
|
export interface ResolvedConfig {
|
|
3
3
|
apiKey: string | null;
|
|
4
4
|
signingKey: string | SigningKey | null;
|
|
5
|
+
/** Never signs; `keys()` publishes its public JWK alongside the current key during rotation. */
|
|
5
6
|
previousSigningKey: string | SigningKey | null;
|
|
6
7
|
keyId: string | null;
|
|
7
8
|
serviceSlug: string | null;
|
|
8
9
|
domain: string | null;
|
|
9
10
|
domainSource: string | null;
|
|
10
|
-
daywalkerPublicKey: string | PublicJwk | null;
|
|
11
|
+
daywalkerPublicKey: string | PublicJwk | PublicJwks | null;
|
|
11
12
|
leaseIssuer: string | null;
|
|
12
13
|
authUrl: string;
|
|
13
14
|
cacheTtlMs: number;
|
|
14
15
|
negativeCacheTtlMs: number;
|
|
15
16
|
maxRetries: number;
|
|
17
|
+
/** `config.retry` with undefined fields dropped; per-request `init.retry` is layered on top. */
|
|
18
|
+
retry: RetryOptions;
|
|
19
|
+
timeoutMs: number;
|
|
16
20
|
problems: string[];
|
|
17
21
|
}
|
|
22
|
+
/** Default per-attempt timeout for requests to Daywalker Auth. */
|
|
23
|
+
export declare const DEFAULT_TIMEOUT_MS = 5000;
|
|
18
24
|
/**
|
|
19
25
|
* A public hostname from a URL or bare host, or null for anything that can't be fetched over
|
|
20
26
|
* the internet (localhost, 0.0.0.0, IP addresses, single labels).
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Default per-attempt timeout for requests to Daywalker Auth. */
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
1
3
|
const URL_ENV_VARS = [
|
|
2
4
|
"APP_URL",
|
|
3
5
|
"NEXTAUTH_URL",
|
|
@@ -105,6 +107,16 @@ export function resolveConfig(options = {}) {
|
|
|
105
107
|
const cacheTtlMs = options.cacheTtlMs ?? 60_000;
|
|
106
108
|
const negativeCacheTtlMs = options.negativeCacheTtlMs ?? 3_000;
|
|
107
109
|
const maxRetries = options.maxRetries ?? (options.retry?.maxRetries ?? 3);
|
|
110
|
+
const retry = {};
|
|
111
|
+
for (const [k, v] of Object.entries(options.retry ?? {})) {
|
|
112
|
+
if (v !== undefined)
|
|
113
|
+
retry[k] = v;
|
|
114
|
+
}
|
|
115
|
+
retry.maxRetries = maxRetries;
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
117
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
118
|
+
throw new Error(`timeoutMs must be a non-negative number of milliseconds (got ${timeoutMs})`);
|
|
119
|
+
}
|
|
108
120
|
return {
|
|
109
121
|
apiKey,
|
|
110
122
|
signingKey,
|
|
@@ -119,6 +131,8 @@ export function resolveConfig(options = {}) {
|
|
|
119
131
|
cacheTtlMs,
|
|
120
132
|
negativeCacheTtlMs,
|
|
121
133
|
maxRetries,
|
|
134
|
+
retry,
|
|
135
|
+
timeoutMs,
|
|
122
136
|
problems,
|
|
123
137
|
};
|
|
124
138
|
}
|
|
@@ -8,6 +8,11 @@ export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
|
8
8
|
export declare function base64ToBytes(b64: string): Uint8Array;
|
|
9
9
|
export declare function bytesToBase64Url(bytes: Uint8Array): string;
|
|
10
10
|
export declare function base64UrlToBytes(b64url: string): Uint8Array;
|
|
11
|
+
/**
|
|
12
|
+
* Decodes unpadded base64url (RFC 7515 §2) strictly: only `[A-Za-z0-9_-]` and only the canonical encoding
|
|
13
|
+
* of the bytes, so a token or signature has exactly one accepted spelling. Throws otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export declare function base64UrlToBytesStrict(b64url: string): Uint8Array;
|
|
11
16
|
export declare function bytesToHex(bytes: Uint8Array): string;
|
|
12
17
|
export declare function hexToBytes(hex: string): Uint8Array;
|
|
13
18
|
/**
|
package/dist/crypto/encoding.js
CHANGED
|
@@ -47,6 +47,20 @@ export function base64UrlToBytes(b64url) {
|
|
|
47
47
|
}
|
|
48
48
|
return base64ToBytes(b64);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Decodes unpadded base64url (RFC 7515 §2) strictly: only `[A-Za-z0-9_-]` and only the canonical encoding
|
|
52
|
+
* of the bytes, so a token or signature has exactly one accepted spelling. Throws otherwise.
|
|
53
|
+
*/
|
|
54
|
+
export function base64UrlToBytesStrict(b64url) {
|
|
55
|
+
if (!/^[A-Za-z0-9_-]*$/.test(b64url) || b64url.length % 4 === 1) {
|
|
56
|
+
throw new Error("Invalid base64url encoding");
|
|
57
|
+
}
|
|
58
|
+
const bytes = base64UrlToBytes(b64url);
|
|
59
|
+
if (bytesToBase64Url(bytes) !== b64url) {
|
|
60
|
+
throw new Error("Non-canonical base64url encoding");
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
50
64
|
export function bytesToHex(bytes) {
|
|
51
65
|
let hex = "";
|
|
52
66
|
for (let i = 0; i < bytes.length; i++) {
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
|
|
1
|
+
export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
|
|
2
2
|
export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
|
|
3
3
|
export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
|
|
4
|
-
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
|
|
5
|
-
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./signatures.js";
|
|
4
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
|
|
5
|
+
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./signatures.js";
|
package/dist/crypto/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
|
|
1
|
+
export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
|
|
2
2
|
export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
|
|
3
3
|
export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
|
|
4
|
-
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
|
|
4
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
|
package/dist/crypto/keys.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PublicJwk, SigningKey } from "../types.js";
|
|
1
|
+
import type { PublicJwk, PublicJwks, SigningKey } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Calculate the RFC 7638 JWK thumbprint for an Ed25519 public key.
|
|
4
4
|
*/
|
|
@@ -17,9 +17,9 @@ export declare function generateSigningKey(): Promise<SigningKey>;
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function loadSigningKey(raw: string | SigningKey): Promise<SigningKey>;
|
|
19
19
|
/**
|
|
20
|
-
* Load an Ed25519 public key from JWK, PEM, Base64 SPKI, or Base64url raw public key.
|
|
20
|
+
* Load an Ed25519 public key from JWK, JWKS (selected by `kid`), PEM, Base64 SPKI, or Base64url raw public key.
|
|
21
21
|
*/
|
|
22
|
-
export declare function loadPublicKey(raw: string | PublicJwk | CryptoKey): Promise<{
|
|
22
|
+
export declare function loadPublicKey(raw: string | PublicJwk | PublicJwks | CryptoKey, kid?: string): Promise<{
|
|
23
23
|
publicKey: CryptoKey;
|
|
24
24
|
publicJwk: PublicJwk;
|
|
25
25
|
}>;
|