@shelbyb/daywalker-sdk 0.3.0 → 0.4.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 +31 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { base64ToBytes,
|
|
1
|
+
import { base64ToBytes, base64UrlToBytesStrict, bytesToBase64, bytesToHex, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
|
|
2
2
|
import { computeContentDigest } from "./digest.js";
|
|
3
3
|
import { loadPublicKey, loadSigningKey } from "./keys.js";
|
|
4
4
|
import { assertSecureUrl } from "../config.js";
|
|
@@ -8,21 +8,35 @@ export const SIGNATURE_ALG = "ed25519";
|
|
|
8
8
|
export const MAX_CLOCK_SKEW_SECONDS = 300;
|
|
9
9
|
export const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_\-\.]{1,128}$/;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Splits a request target into its RFC 9421 "@path" value (§2.2.6; dot segments resolved and repeated
|
|
12
|
+
* slashes collapsed per RFC 3986, to prevent path traversal & signature confusion) and its "@query" value
|
|
13
|
+
* (§2.2.7; the query with its leading "?", or "?" alone when there is none). Fragments are dropped.
|
|
12
14
|
*/
|
|
13
|
-
export function
|
|
14
|
-
if (!
|
|
15
|
-
return "/";
|
|
16
|
-
|
|
17
|
-
p = p.replace(/\/+/g, "/");
|
|
15
|
+
export function splitRequestTarget(rawTarget) {
|
|
16
|
+
if (!rawTarget || !rawTarget.trim())
|
|
17
|
+
return { path: "/", query: "?" };
|
|
18
|
+
const t = rawTarget.trim().replace(/^[^?#]*/, (p) => p.replace(/\/+/g, "/"));
|
|
18
19
|
try {
|
|
19
|
-
const parsed = new URL(
|
|
20
|
-
return parsed.pathname
|
|
20
|
+
const parsed = new URL(t, "http://localhost");
|
|
21
|
+
return { path: parsed.pathname, query: parsed.search || "?" };
|
|
21
22
|
}
|
|
22
23
|
catch {
|
|
23
|
-
|
|
24
|
+
const p = t.replace(/[?#].*$/, "");
|
|
25
|
+
return { path: p.startsWith("/") ? p : `/${p}`, query: "?" };
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes an HTTP request path per RFC 3986 into the "@path" component. Path only: the query string is
|
|
30
|
+
* a separate "@query" component (see `splitRequestTarget`).
|
|
31
|
+
*/
|
|
32
|
+
export function normalizePath(rawPath) {
|
|
33
|
+
return splitRequestTarget(rawPath).path;
|
|
34
|
+
}
|
|
35
|
+
/** Methods whose requests always cover "content-digest" (of the empty body when there is none). */
|
|
36
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
37
|
+
/** Derived components this implementation can compute; header field names must be lowercase. */
|
|
38
|
+
const DERIVED_COMPONENTS = new Set(["@method", "@path", "@query", "@authority"]);
|
|
39
|
+
const COMPONENT_NAME_PATTERN = /^@?[a-z0-9][a-z0-9_.-]*$/;
|
|
26
40
|
/**
|
|
27
41
|
* Validates that an identifier (keyId, serviceSlug, etc.) contains only safe alphanumeric/hyphen characters.
|
|
28
42
|
*/
|
|
@@ -31,11 +45,15 @@ export function assertSafeIdentifier(name, value) {
|
|
|
31
45
|
throw new Error(`Invalid ${name}: must be 1-128 safe characters (alphanumeric, '_', '-', '.')`);
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Upper bound on remembered signatures. Unexpired entries are never evicted: once the cache is full of them,
|
|
50
|
+
* new signatures are rejected (fail closed) until entries expire. Entries live 2 x maxClockSkewSeconds
|
|
51
|
+
* (600s by default), so the default sustains ~160 verified requests/s per process.
|
|
52
|
+
*/
|
|
53
|
+
export const REPLAY_CACHE_MAX_ENTRIES = 100_000;
|
|
36
54
|
/**
|
|
37
55
|
* In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
|
|
38
|
-
* same signature collide).
|
|
56
|
+
* same signature collide). Per process only: multi-instance deployments need a shared `ReplayStore`.
|
|
39
57
|
*/
|
|
40
58
|
export class SignatureReplayCache {
|
|
41
59
|
maxEntries;
|
|
@@ -51,18 +69,24 @@ export class SignatureReplayCache {
|
|
|
51
69
|
const expiresAt = this.seen.get(key);
|
|
52
70
|
return expiresAt !== undefined && expiresAt > now;
|
|
53
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Records `key` until `now + ttlMs`. Returns false, recording nothing, when the key is already present
|
|
74
|
+
* or the cache is full of unexpired entries (a still-valid signature is never forgotten early).
|
|
75
|
+
*/
|
|
54
76
|
record(key, ttlMs, now = Date.now()) {
|
|
77
|
+
if (this.has(key, now))
|
|
78
|
+
return false;
|
|
55
79
|
this.seen.delete(key);
|
|
56
|
-
this.seen.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
while (this.seen.size > this.maxEntries) {
|
|
64
|
-
this.seen.delete(this.seen.keys().next().value);
|
|
80
|
+
if (this.seen.size >= this.maxEntries) {
|
|
81
|
+
for (const [k, exp] of this.seen) {
|
|
82
|
+
if (exp <= now)
|
|
83
|
+
this.seen.delete(k);
|
|
84
|
+
}
|
|
85
|
+
if (this.seen.size >= this.maxEntries)
|
|
86
|
+
return false;
|
|
65
87
|
}
|
|
88
|
+
this.seen.set(key, now + ttlMs);
|
|
89
|
+
return true;
|
|
66
90
|
}
|
|
67
91
|
clear() {
|
|
68
92
|
this.seen.clear();
|
|
@@ -72,53 +96,134 @@ export const globalReplayCache = new SignatureReplayCache();
|
|
|
72
96
|
/**
|
|
73
97
|
* Format the RFC 9421 Signature-Input parameters string.
|
|
74
98
|
* Example: `("@method" "@path" "@authority" "content-digest");created=1791561600;keyid="...";alg="ed25519";nonce="..."`
|
|
99
|
+
* Every value is validated, so none can close its quoted string and inject parameters.
|
|
75
100
|
*/
|
|
76
101
|
export function formatSignatureParams(params) {
|
|
77
102
|
const components = params.components ?? ["@method", "@path", "@authority", "content-digest"];
|
|
103
|
+
for (const c of components) {
|
|
104
|
+
if (!COMPONENT_NAME_PATTERN.test(c) || c === "@signature-params") {
|
|
105
|
+
throw new Error(`Invalid covered component: ${JSON.stringify(c)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!Number.isSafeInteger(params.created) || params.created < 0) {
|
|
109
|
+
throw new Error("Invalid created: must be a non-negative integer of unix seconds");
|
|
110
|
+
}
|
|
111
|
+
const alg = params.alg ?? SIGNATURE_ALG;
|
|
112
|
+
assertSafeIdentifier("keyid", params.keyid);
|
|
113
|
+
assertSafeIdentifier("alg", alg);
|
|
78
114
|
const componentsStr = components.map((c) => `"${c}"`).join(" ");
|
|
79
|
-
let out = `(${componentsStr});created=${params.created};keyid="${params.keyid}";alg="${
|
|
115
|
+
let out = `(${componentsStr});created=${params.created};keyid="${params.keyid}";alg="${alg}"`;
|
|
80
116
|
if (params.nonce) {
|
|
117
|
+
assertSafeIdentifier("nonce", params.nonce);
|
|
81
118
|
out += `;nonce="${params.nonce}"`;
|
|
82
119
|
}
|
|
83
120
|
return out;
|
|
84
121
|
}
|
|
85
122
|
/**
|
|
86
|
-
*
|
|
123
|
+
* Parses a Signature-Input member value (RFC 8941 inner list of plain quoted component names, then
|
|
124
|
+
* `;key=value` parameters with integer or string values). Returns an error string when malformed.
|
|
125
|
+
*/
|
|
126
|
+
function parseSignatureParams(input) {
|
|
127
|
+
const listMatch = input.match(/^\(([^()]*)\)(.*)$/);
|
|
128
|
+
if (!listMatch)
|
|
129
|
+
return "Signature-Input missing covered components list";
|
|
130
|
+
const inner = listMatch[1].trim();
|
|
131
|
+
const components = [];
|
|
132
|
+
for (const item of inner ? inner.split(/ +/) : []) {
|
|
133
|
+
const m = item.match(/^"([^"\\]*)"$/);
|
|
134
|
+
if (!m || !COMPONENT_NAME_PATTERN.test(m[1])) {
|
|
135
|
+
return `Unsupported covered component in Signature-Input: ${item}`;
|
|
136
|
+
}
|
|
137
|
+
components.push(m[1]);
|
|
138
|
+
}
|
|
139
|
+
const params = {};
|
|
140
|
+
const paramPattern = /;([a-z*][a-z0-9_.*-]*)=(\d{1,15}|"[^"\\]*")/y;
|
|
141
|
+
let rest = listMatch[2];
|
|
142
|
+
while (rest.length > 0) {
|
|
143
|
+
paramPattern.lastIndex = 0;
|
|
144
|
+
const m = paramPattern.exec(rest);
|
|
145
|
+
if (!m)
|
|
146
|
+
return "Malformed Signature-Input parameters";
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(params, m[1]))
|
|
148
|
+
return `Duplicate Signature-Input parameter: ${m[1]}`;
|
|
149
|
+
params[m[1]] = m[2].startsWith('"') ? m[2].slice(1, -1) : parseInt(m[2], 10);
|
|
150
|
+
rest = rest.slice(m[0].length);
|
|
151
|
+
}
|
|
152
|
+
return { components, params };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Construct canonical RFC 9421 signature base string from the covered components, in order. They are
|
|
156
|
+
* `components` when given, else the list declared in `signatureParams`. "@path" and "@query" come from
|
|
157
|
+
* `path` (which may carry a query string); "content-digest" from `contentDigest`; any other header field
|
|
158
|
+
* from `additionalComponents`. Throws when a covered component has no value.
|
|
87
159
|
*/
|
|
88
160
|
export function createSignatureBase(params) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
161
|
+
const target = splitRequestTarget(params.path);
|
|
162
|
+
let components = params.components;
|
|
163
|
+
if (!components) {
|
|
164
|
+
const parsed = parseSignatureParams(params.signatureParams);
|
|
165
|
+
components =
|
|
166
|
+
typeof parsed !== "string"
|
|
167
|
+
? parsed.components
|
|
168
|
+
: [
|
|
169
|
+
"@method",
|
|
170
|
+
"@path",
|
|
171
|
+
"@authority",
|
|
172
|
+
...(params.contentDigest !== undefined && params.contentDigest !== null ? ["content-digest"] : []),
|
|
173
|
+
...Object.keys(params.additionalComponents ?? {}),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
const additional = {};
|
|
177
|
+
for (const [k, v] of Object.entries(params.additionalComponents ?? {}))
|
|
178
|
+
additional[k.toLowerCase()] = v;
|
|
179
|
+
const lines = [];
|
|
180
|
+
for (const raw of components) {
|
|
181
|
+
const name = raw.toLowerCase();
|
|
182
|
+
let value;
|
|
183
|
+
if (name === "@method")
|
|
184
|
+
value = params.method.toUpperCase();
|
|
185
|
+
else if (name === "@path")
|
|
186
|
+
value = target.path;
|
|
187
|
+
else if (name === "@query")
|
|
188
|
+
value = target.query;
|
|
189
|
+
else if (name === "@authority")
|
|
190
|
+
value = params.authority.toLowerCase();
|
|
191
|
+
else if (name.startsWith("@"))
|
|
192
|
+
throw new Error(`Unsupported derived component: ${name}`);
|
|
193
|
+
else if (name === "content-digest")
|
|
194
|
+
value = params.contentDigest ?? additional[name];
|
|
195
|
+
else
|
|
196
|
+
value = additional[name];
|
|
197
|
+
if (value === undefined || value === null) {
|
|
198
|
+
throw new Error(`Covered component ${name} has no value`);
|
|
103
199
|
}
|
|
200
|
+
value = value.trim();
|
|
201
|
+
if (/[\r\n]/.test(value)) {
|
|
202
|
+
throw new Error(`Covered component ${name} contains a line break`);
|
|
203
|
+
}
|
|
204
|
+
lines.push(`"${name}": ${value}`);
|
|
104
205
|
}
|
|
105
206
|
lines.push(`"@signature-params": ${params.signatureParams}`);
|
|
106
207
|
return lines.join("\n");
|
|
107
208
|
}
|
|
108
209
|
/**
|
|
109
210
|
* Sign an outgoing HTTP request according to RFC 9421 + RFC 9530.
|
|
211
|
+
* Covers "@method", "@path", "@query" (only when `path` has a query string), "@authority" and
|
|
212
|
+
* "content-digest" (whenever there is a body, and always for POST / PUT / PATCH).
|
|
110
213
|
*/
|
|
111
214
|
export async function signRequest(params) {
|
|
112
215
|
const signingKey = await loadSigningKey(params.key);
|
|
113
216
|
const created = params.created ?? Math.floor(Date.now() / 1000);
|
|
114
217
|
const keyid = params.keyid ?? signingKey.publicJwk.kid;
|
|
115
218
|
const alg = params.alg ?? SIGNATURE_ALG;
|
|
116
|
-
|
|
117
|
-
|
|
219
|
+
const method = params.method.toUpperCase();
|
|
220
|
+
const body = params.body ?? (BODY_METHODS.has(method) ? "" : null);
|
|
221
|
+
const contentDigest = body !== null ? await computeContentDigest(body) : null;
|
|
222
|
+
const components = ["@method", "@path"];
|
|
223
|
+
if (splitRequestTarget(params.path).query !== "?") {
|
|
224
|
+
components.push("@query");
|
|
118
225
|
}
|
|
119
|
-
|
|
120
|
-
const contentDigest = hasBody ? await computeContentDigest(params.body) : null;
|
|
121
|
-
const components = ["@method", "@path", "@authority"];
|
|
226
|
+
components.push("@authority");
|
|
122
227
|
if (contentDigest !== null) {
|
|
123
228
|
components.push("content-digest");
|
|
124
229
|
}
|
|
@@ -130,11 +235,12 @@ export async function signRequest(params) {
|
|
|
130
235
|
nonce: params.nonce,
|
|
131
236
|
});
|
|
132
237
|
const signatureBase = createSignatureBase({
|
|
133
|
-
method
|
|
238
|
+
method,
|
|
134
239
|
path: params.path,
|
|
135
240
|
authority: params.authority,
|
|
136
241
|
contentDigest,
|
|
137
242
|
signatureParams,
|
|
243
|
+
components,
|
|
138
244
|
});
|
|
139
245
|
const signatureBuffer = await globalThis.crypto.subtle.sign("Ed25519", signingKey.privateKey, stringToUtf8(signatureBase));
|
|
140
246
|
const signatureBase64 = bytesToBase64(new Uint8Array(signatureBuffer));
|
|
@@ -151,7 +257,10 @@ export async function signRequest(params) {
|
|
|
151
257
|
return headers;
|
|
152
258
|
}
|
|
153
259
|
/**
|
|
154
|
-
* Verify an incoming RFC 9421 HTTP Message Signature.
|
|
260
|
+
* Verify an incoming RFC 9421 HTTP Message Signature. The signature base is built from the components the
|
|
261
|
+
* signature declares, which must include "@method", "@path" and "@authority", plus "@query" when the request
|
|
262
|
+
* has a query string and "content-digest" when it has a body or is a POST / PUT / PATCH.
|
|
263
|
+
* Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
|
|
155
264
|
*/
|
|
156
265
|
export async function verifyRequestSignature(params) {
|
|
157
266
|
const getHeader = (name) => {
|
|
@@ -177,18 +286,31 @@ export async function verifyRequestSignature(params) {
|
|
|
177
286
|
}
|
|
178
287
|
const signatureParams = sigInputMatch[1].trim();
|
|
179
288
|
// Validate parameters and check for component duplicates
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
182
|
-
return { valid: false, error:
|
|
289
|
+
const parsed = parseSignatureParams(signatureParams);
|
|
290
|
+
if (typeof parsed === "string") {
|
|
291
|
+
return { valid: false, error: parsed };
|
|
183
292
|
}
|
|
184
|
-
const
|
|
293
|
+
const components = parsed.components;
|
|
185
294
|
const seenComponents = new Set();
|
|
186
|
-
for (const c of
|
|
295
|
+
for (const c of components) {
|
|
187
296
|
if (seenComponents.has(c)) {
|
|
188
297
|
return { valid: false, error: `Duplicate covered component in Signature-Input: ${c}` };
|
|
189
298
|
}
|
|
190
299
|
seenComponents.add(c);
|
|
191
300
|
}
|
|
301
|
+
for (const required of ["@method", "@path", "@authority"]) {
|
|
302
|
+
if (!seenComponents.has(required)) {
|
|
303
|
+
return { valid: false, error: `Signature does not cover ${required}` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (splitRequestTarget(params.path).query !== "?" && !seenComponents.has("@query")) {
|
|
307
|
+
return { valid: false, error: "Request has a query string but the signature does not cover @query" };
|
|
308
|
+
}
|
|
309
|
+
const body = params.body;
|
|
310
|
+
const hasBody = body !== undefined && body !== null && (typeof body === "string" ? body.length > 0 : body.byteLength > 0);
|
|
311
|
+
if ((hasBody || BODY_METHODS.has(params.method.toUpperCase())) && !seenComponents.has("content-digest")) {
|
|
312
|
+
return { valid: false, error: "Request has a body but the signature does not cover content-digest" };
|
|
313
|
+
}
|
|
192
314
|
// RFC 8941 byte sequence (`:base64:`), standard alphabet with padding only, so each signature
|
|
193
315
|
// has exactly one accepted encoding.
|
|
194
316
|
const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/]*={0,2}):(?=\\s*(?:,|$))`));
|
|
@@ -199,18 +321,20 @@ export async function verifyRequestSignature(params) {
|
|
|
199
321
|
if (bytesToBase64(signatureBytes) !== sigMatch[1]) {
|
|
200
322
|
return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
|
|
201
323
|
}
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
324
|
+
const created = parsed.params.created;
|
|
325
|
+
if (typeof created !== "number") {
|
|
204
326
|
return { valid: false, error: "Signature-Input missing created timestamp" };
|
|
205
327
|
}
|
|
206
|
-
const created = parseInt(createdMatch[1], 10);
|
|
207
328
|
const now = params.currentTime ?? Math.floor(Date.now() / 1000);
|
|
208
329
|
const maxSkew = params.maxClockSkewSeconds ?? MAX_CLOCK_SKEW_SECONDS;
|
|
209
330
|
if (Math.abs(now - created) > maxSkew) {
|
|
210
331
|
return { valid: false, error: `Signature timestamp expired or clock skew too large (created=${created}, now=${now})` };
|
|
211
332
|
}
|
|
212
|
-
const
|
|
213
|
-
|
|
333
|
+
const keyidParam = parsed.params.keyid;
|
|
334
|
+
if (keyidParam !== undefined && typeof keyidParam !== "string") {
|
|
335
|
+
return { valid: false, error: "Signature-Input keyid must be a string" };
|
|
336
|
+
}
|
|
337
|
+
const keyid = keyidParam || undefined;
|
|
214
338
|
if (keyid) {
|
|
215
339
|
try {
|
|
216
340
|
assertSafeIdentifier("keyid", keyid);
|
|
@@ -220,17 +344,17 @@ export async function verifyRequestSignature(params) {
|
|
|
220
344
|
}
|
|
221
345
|
}
|
|
222
346
|
// Entries must outlive the whole acceptance window (created ± maxSkew).
|
|
347
|
+
const replayStore = params.replayStore ?? globalReplayCache;
|
|
223
348
|
const replayKey = `${keyid ?? ""}:${bytesToHex(signatureBytes)}`;
|
|
224
349
|
const replayTtlMs = 2 * Math.max(maxSkew, 1) * 1000;
|
|
225
|
-
if (params.replayCheck !== false &&
|
|
350
|
+
if (params.replayCheck !== false && (await replayStore.has(replayKey))) {
|
|
226
351
|
return { valid: false, error: "Replay detected: request signature was already presented" };
|
|
227
352
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
if (alg && alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa") {
|
|
353
|
+
const alg = parsed.params.alg;
|
|
354
|
+
if (alg !== undefined && (typeof alg !== "string" || (alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa"))) {
|
|
231
355
|
return { valid: false, error: `Unsupported signature algorithm: ${alg}` };
|
|
232
356
|
}
|
|
233
|
-
if (
|
|
357
|
+
if (seenComponents.has("content-digest")) {
|
|
234
358
|
if (!contentDigestHeader) {
|
|
235
359
|
return { valid: false, error: "Signature covers content-digest but Content-Digest header is missing" };
|
|
236
360
|
}
|
|
@@ -239,25 +363,41 @@ export async function verifyRequestSignature(params) {
|
|
|
239
363
|
return { valid: false, error: `Content-Digest mismatch: expected ${computedDigest}, got ${contentDigestHeader}` };
|
|
240
364
|
}
|
|
241
365
|
}
|
|
242
|
-
const signatureBase = createSignatureBase({
|
|
243
|
-
method: params.method,
|
|
244
|
-
path: params.path,
|
|
245
|
-
authority: params.authority,
|
|
246
|
-
contentDigest: signatureParams.includes('"content-digest"') ? contentDigestHeader?.trim() : null,
|
|
247
|
-
signatureParams,
|
|
248
|
-
});
|
|
249
366
|
try {
|
|
250
|
-
const
|
|
367
|
+
const additionalComponents = {};
|
|
368
|
+
for (const c of components) {
|
|
369
|
+
if (c.startsWith("@") || c === "content-digest")
|
|
370
|
+
continue;
|
|
371
|
+
const value = getHeader(c);
|
|
372
|
+
if (value === undefined) {
|
|
373
|
+
return { valid: false, error: `Signature covers ${c} but the request has no such header` };
|
|
374
|
+
}
|
|
375
|
+
additionalComponents[c] = value;
|
|
376
|
+
}
|
|
377
|
+
const signatureBase = createSignatureBase({
|
|
378
|
+
method: params.method,
|
|
379
|
+
path: params.path,
|
|
380
|
+
authority: params.authority,
|
|
381
|
+
contentDigest: contentDigestHeader?.trim() ?? null,
|
|
382
|
+
signatureParams,
|
|
383
|
+
additionalComponents,
|
|
384
|
+
components,
|
|
385
|
+
});
|
|
386
|
+
const { publicKey } = await loadPublicKey(params.publicKey, keyid);
|
|
251
387
|
const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
|
|
252
388
|
if (!isValid) {
|
|
253
389
|
return { valid: false, error: "Cryptographic signature verification failed" };
|
|
254
390
|
}
|
|
255
|
-
// Record only verified signatures
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
391
|
+
// Record only verified signatures. `record` is set-if-absent, so a concurrent copy that won the race,
|
|
392
|
+
// or a store too full to remember this signature, makes it fail closed.
|
|
393
|
+
if (params.replayCheck !== false && !(await replayStore.record(replayKey, replayTtlMs))) {
|
|
394
|
+
const full = !(await replayStore.has(replayKey));
|
|
395
|
+
return {
|
|
396
|
+
valid: false,
|
|
397
|
+
error: full
|
|
398
|
+
? "Replay cache is full: refusing a signature that could not be remembered"
|
|
399
|
+
: "Replay detected: request signature was already presented",
|
|
400
|
+
};
|
|
261
401
|
}
|
|
262
402
|
return { valid: true, keyid };
|
|
263
403
|
}
|
|
@@ -270,7 +410,9 @@ export const JWT_CLOCK_SKEW_SECONDS = 60;
|
|
|
270
410
|
/**
|
|
271
411
|
* Zero-egress offline verification for asymmetric JWT token leases.
|
|
272
412
|
* Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
|
|
273
|
-
* a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
|
|
413
|
+
* a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set
|
|
414
|
+
* (Daywalker documents no fixed issuer, so there is no default). Segments must be canonical unpadded
|
|
415
|
+
* base64url, a `crit` header is rejected, and a JWKS `publicKey` is searched by the header's `kid`.
|
|
274
416
|
*/
|
|
275
417
|
export async function verifyJwtLease(params) {
|
|
276
418
|
try {
|
|
@@ -279,8 +421,17 @@ export async function verifyJwtLease(params) {
|
|
|
279
421
|
return { valid: false, error: "Token is not a valid 3-part JWT" };
|
|
280
422
|
}
|
|
281
423
|
const [headerB64, payloadB64, signatureB64] = parts;
|
|
282
|
-
|
|
283
|
-
|
|
424
|
+
let headerJson;
|
|
425
|
+
let payloadJson;
|
|
426
|
+
let signatureBytes;
|
|
427
|
+
try {
|
|
428
|
+
headerJson = utf8ToString(base64UrlToBytesStrict(headerB64));
|
|
429
|
+
payloadJson = utf8ToString(base64UrlToBytesStrict(payloadB64));
|
|
430
|
+
signatureBytes = base64UrlToBytesStrict(signatureB64);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return { valid: false, error: "JWT segments must be canonical unpadded base64url" };
|
|
434
|
+
}
|
|
284
435
|
const header = JSON.parse(headerJson);
|
|
285
436
|
const payload = JSON.parse(payloadJson);
|
|
286
437
|
if (!header || typeof header !== "object" || !payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
@@ -292,9 +443,15 @@ export async function verifyJwtLease(params) {
|
|
|
292
443
|
if (header.typ !== undefined && String(header.typ).toUpperCase() !== "JWT") {
|
|
293
444
|
return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
|
|
294
445
|
}
|
|
295
|
-
|
|
446
|
+
// RFC 7515 §4.1.11: extensions we don't understand must be rejected; we implement none.
|
|
447
|
+
if (header.crit !== undefined) {
|
|
448
|
+
return { valid: false, error: "Unsupported JWT crit header" };
|
|
449
|
+
}
|
|
450
|
+
if (header.kid !== undefined && typeof header.kid !== "string") {
|
|
451
|
+
return { valid: false, error: "JWT kid must be a string" };
|
|
452
|
+
}
|
|
453
|
+
const { publicKey } = await loadPublicKey(params.publicKey, header.kid);
|
|
296
454
|
const signedData = stringToUtf8(`${headerB64}.${payloadB64}`);
|
|
297
|
-
const signatureBytes = base64UrlToBytes(signatureB64);
|
|
298
455
|
const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, signedData);
|
|
299
456
|
if (!isValid) {
|
|
300
457
|
return { valid: false, error: "JWT signature verification failed" };
|
package/dist/express.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ interface Res {
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function daywalkerAuth(options?: {
|
|
22
22
|
client?: Daywalker;
|
|
23
|
+
serviceSlug?: string;
|
|
23
24
|
}): (req: Req, res: Res, next?: (err?: unknown) => void) => Promise<void>;
|
|
24
25
|
export declare function daywalkerKeys(client?: Daywalker): (req: Req, res: Res, next?: () => void) => Promise<void>;
|
|
25
26
|
export {};
|
package/dist/express.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { daywalker } from "./client.js";
|
|
2
|
+
import { toAuthFailureResponse } from "./middleware.js";
|
|
2
3
|
/**
|
|
3
4
|
* Express / Connect / Node http middleware that validates incoming Daywalker tokens.
|
|
4
5
|
*/
|
|
5
6
|
export function daywalkerAuth(options = {}) {
|
|
6
7
|
const client = options.client ?? daywalker;
|
|
8
|
+
const serviceSlug = options.serviceSlug;
|
|
7
9
|
return async (req, res, next) => {
|
|
8
10
|
const getHeader = (name) => {
|
|
9
11
|
if (typeof req.get === "function")
|
|
@@ -33,19 +35,19 @@ export function daywalkerAuth(options = {}) {
|
|
|
33
35
|
res.end(JSON.stringify({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }));
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
|
-
const result = await client.validateToken(token);
|
|
38
|
+
const result = await client.validateToken(token, { serviceSlug });
|
|
37
39
|
if (!result.ok) {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
res.setHeader("Retry-After", String(
|
|
40
|
+
const { status, ...body } = toAuthFailureResponse(result);
|
|
41
|
+
if (body.retryAfter) {
|
|
42
|
+
res.setHeader("Retry-After", String(body.retryAfter));
|
|
41
43
|
}
|
|
42
44
|
if (typeof res.status === "function" && typeof res.json === "function") {
|
|
43
|
-
res.status(status).json(
|
|
45
|
+
res.status(status).json(body);
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
46
48
|
res.statusCode = status;
|
|
47
49
|
res.setHeader("Content-Type", "application/json");
|
|
48
|
-
res.end(JSON.stringify(
|
|
50
|
+
res.end(JSON.stringify(body));
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
53
|
req.auth = result.data;
|
package/dist/flags/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ContextInput, EvaluationReason, FlagValue } from "./engine/index.js";
|
|
2
|
-
import { type Logger } from "./common/index.js";
|
|
2
|
+
import { SDK_VERSION, type Logger } from "./common/index.js";
|
|
3
3
|
export type { ContextInput, EvaluationReason, FlagValue, Logger };
|
|
4
|
+
export { SDK_VERSION };
|
|
4
5
|
export interface FlagState {
|
|
5
6
|
value: FlagValue;
|
|
6
7
|
variation: number | null;
|
|
@@ -15,7 +16,13 @@ export interface ClientOptions {
|
|
|
15
16
|
baseUrl: string;
|
|
16
17
|
/** Receive changes in real time over Server-Sent Events (default true). */
|
|
17
18
|
streaming?: boolean;
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Never touch the network: serve `bootstrap` (or defaults), send no events, open no stream.
|
|
21
|
+
* identify() only switches the context. Used for server-side rendering; handy in tests.
|
|
22
|
+
* The client is ready immediately when `bootstrap` is given.
|
|
23
|
+
*/
|
|
24
|
+
offline?: boolean;
|
|
25
|
+
/** Initial flag values, e.g. from the Node SDK's allFlagsState().toJSON() for SSR. */
|
|
19
26
|
bootstrap?: FlagSet | Record<string, FlagValue>;
|
|
20
27
|
/** Secure mode hash for the context (HMAC-SHA256 computed on your server). */
|
|
21
28
|
hash?: string;
|
|
@@ -58,16 +65,24 @@ export declare class DevPulseClient {
|
|
|
58
65
|
private rejectReady;
|
|
59
66
|
private initialized;
|
|
60
67
|
private closed;
|
|
68
|
+
/** Bumped by identify(); responses for an older context are ignored. */
|
|
69
|
+
private contextSeq;
|
|
70
|
+
private stale;
|
|
71
|
+
private detachVisibility;
|
|
61
72
|
private readonly logger;
|
|
62
73
|
private readonly base;
|
|
63
74
|
constructor(clientSideId: string, context: ContextInput, options: ClientOptions);
|
|
64
75
|
private query;
|
|
65
76
|
private start;
|
|
77
|
+
/** Fetch flags for the current context and apply them unless identify() moved on (or close()) meanwhile. */
|
|
78
|
+
private loadFlags;
|
|
66
79
|
private fetchFlags;
|
|
67
80
|
private onStreamEvent;
|
|
68
81
|
private replaceAll;
|
|
69
82
|
private flag;
|
|
70
83
|
private markReady;
|
|
84
|
+
/** "stale" when the stream drops after loading (flags keep serving), "recovered" once it reconnects. */
|
|
85
|
+
private setStale;
|
|
71
86
|
private fail;
|
|
72
87
|
private emitChanges;
|
|
73
88
|
private cacheKey;
|
|
@@ -78,22 +93,35 @@ export declare class DevPulseClient {
|
|
|
78
93
|
timeoutMs?: number;
|
|
79
94
|
}): Promise<void>;
|
|
80
95
|
get isInitialized(): boolean;
|
|
96
|
+
/** True after close(); a closed client never updates again. */
|
|
97
|
+
get isClosed(): boolean;
|
|
81
98
|
variation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
82
99
|
variationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
100
|
+
/** @internal Evaluate without recording an analytics event (React hooks record from an effect). */
|
|
101
|
+
peekVariationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
102
|
+
/** @internal Record the analytics event for an evaluation made with peekVariationDetail(). */
|
|
103
|
+
recordEvaluation<T extends FlagValue>(key: string, defaultValue: T, detail: EvaluationDetail<T>): void;
|
|
83
104
|
boolVariation(key: string, defaultValue: boolean): boolean;
|
|
84
105
|
stringVariation(key: string, defaultValue: string): string;
|
|
85
106
|
numberVariation(key: string, defaultValue: number): number;
|
|
86
107
|
jsonVariation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
87
108
|
/** All flag values (doesn't record evaluations). */
|
|
88
109
|
allFlags(): Record<string, FlagValue>;
|
|
89
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Switch to a different context (e.g. after login). Resolves with the new flags. When calls
|
|
112
|
+
* overlap, the last one wins: an earlier call's late response is ignored.
|
|
113
|
+
*/
|
|
90
114
|
identify(context: ContextInput, opts?: {
|
|
91
115
|
hash?: string;
|
|
92
116
|
}): Promise<Record<string, FlagValue>>;
|
|
93
117
|
getContext(): ContextInput;
|
|
94
118
|
/** Record a custom event for metrics and experiments. */
|
|
95
119
|
track(key: string, data?: unknown, metricValue?: number): void;
|
|
96
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid client-side ID), "change" / "change:<key>",
|
|
122
|
+
* "stale" (stream lost; the last flags keep serving) and "recovered".
|
|
123
|
+
*/
|
|
124
|
+
on(event: "ready" | "failed" | "change" | "stale" | "recovered" | `change:${string}`, fn: Listener): () => void;
|
|
97
125
|
off(event: string, fn: Listener): void;
|
|
98
126
|
private emit;
|
|
99
127
|
flush(): Promise<void>;
|