@shelbyb/daywalker-sdk 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/next.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { daywalker, Daywalker } from "./client.js";
2
- import { toAuthFailureResponse } from "./middleware.js";
2
+ import { readCredentials, toAuthFailureResponse } from "./middleware.js";
3
3
  /**
4
4
  * Real Server Action IDs are hex hashes. Scanners probing for CVE-2025-55182 (React2Shell)
5
5
  * send junk like `Next-Action: x`, which Next.js rejects with an internal error.
@@ -129,17 +129,23 @@ const REDIRECT_ERROR_CODES = new Set([
129
129
  "NETWORK_ERROR",
130
130
  "SERVICE_UNAVAILABLE",
131
131
  "CONFIGURATION_ERROR",
132
+ "SERVICE_NOT_GRANTED",
133
+ "DPOP_REQUIRED",
134
+ "INVALID_DPOP_PROOF",
132
135
  ]);
133
- function extractToken(req) {
134
- const authHeader = req.headers.get("authorization");
135
- if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
136
- return authHeader.slice(7).trim();
137
- }
138
- const apiTokenHeader = req.headers.get("x-api-token");
139
- if (apiTokenHeader) {
140
- return apiTokenHeader.trim();
141
- }
142
- return null;
136
+ function credentialsOf(req) {
137
+ return readCredentials((name) => req.headers.get(name), { method: req.method, url: req.url });
138
+ }
139
+ /**
140
+ * Next's "continue" and redirect responses, built here rather than through a `NextResponse` found on
141
+ * `globalThis`: anything in the process could plant one there and turn a failed check into a pass.
142
+ * `x-middleware-next: 1` is exactly what `NextResponse.next()` sends.
143
+ */
144
+ function continueResponse() {
145
+ return new Response(null, { headers: { "x-middleware-next": "1" } });
146
+ }
147
+ function redirectResponse(url) {
148
+ return Response.redirect(url.toString(), 307);
143
149
  }
144
150
  /**
145
151
  * Hardened Next.js Middleware for root `middleware.ts`.
@@ -147,7 +153,8 @@ function extractToken(req) {
147
153
  * Implements:
148
154
  * 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
149
155
  * 2. Static asset and health check bypass
150
- * 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
156
+ * 3. Per-request token auth: requests without `Authorization: Bearer` / `Authorization: DPoP` (with its
157
+ * proof) / `x-api-token` get 401
151
158
  * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
152
159
  *
153
160
  * Only `/_next/static/`, `/_next/image`, `/favicon.ico` and `bypassPaths` skip auth by default.
@@ -166,21 +173,22 @@ function extractToken(req) {
166
173
  export function createDaywalkerMiddleware(options = {}) {
167
174
  const client = options.client ?? daywalker;
168
175
  return buildMiddleware(options, async (request) => {
169
- const token = extractToken(request);
170
- if (!token) {
171
- return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Missing Authorization header or x-api-token" };
176
+ const credentials = credentialsOf(request);
177
+ if (!credentials.ok) {
178
+ return { ok: false, error: "INVALID_TOKEN", status: 401, message: credentials.message };
172
179
  }
173
- return client.validateToken(token, { serviceSlug: options.serviceSlug });
180
+ return client.validateToken(credentials.token, { serviceSlug: options.serviceSlug, dpop: credentials.dpop });
174
181
  });
175
182
  }
176
183
  /**
177
- * Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
178
- * routes every request to `errorPath` while it is invalid, redirecting back once it heals.
184
+ * Service health gate: checks that this service's OAuth client credentials still get an access token
185
+ * covering its service (not the caller's token) and routes every request to `errorPath` while they don't,
186
+ * redirecting back once it heals.
179
187
  * It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
180
188
  */
181
189
  export function createDaywalkerServiceHealthMiddleware(options = {}) {
182
190
  const client = options.client ?? daywalker;
183
- return buildMiddleware(options, () => client.validateServiceKey({ serviceSlug: options.serviceSlug }));
191
+ return buildMiddleware(options, () => client.validateServiceCredentials({ serviceSlug: options.serviceSlug }));
184
192
  }
185
193
  function buildMiddleware(options, authenticate) {
186
194
  const bypassPaths = options.bypassPaths ?? DEFAULT_BYPASS_PATHS;
@@ -215,9 +223,7 @@ function buildMiddleware(options, authenticate) {
215
223
  // 1. Bypass static files and health check probes
216
224
  if (pathname !== null &&
217
225
  (bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) || isStaticNormalized(pathname, matchesExtension))) {
218
- return globalThis.NextResponse?.next
219
- ? globalThis.NextResponse.next()
220
- : new Response(null, { headers: { "x-middleware-next": "1" } });
226
+ return continueResponse();
221
227
  }
222
228
  // 2. Authenticate (caller's token, or the service key for the health gate)
223
229
  const authResult = await authenticate(request);
@@ -226,14 +232,10 @@ function buildMiddleware(options, authenticate) {
226
232
  if (authResult.ok) {
227
233
  // Authorized again: heal/redirect back to home
228
234
  const redirectUrl = new URL(homePath, request.url);
229
- return globalThis.NextResponse?.redirect
230
- ? globalThis.NextResponse.redirect(redirectUrl)
231
- : Response.redirect(redirectUrl.toString(), 307);
235
+ return redirectResponse(redirectUrl);
232
236
  }
233
237
  // Allow rendering the error page
234
- return globalThis.NextResponse?.next
235
- ? globalThis.NextResponse.next()
236
- : new Response(null, { headers: { "x-middleware-next": "1" } });
238
+ return continueResponse();
237
239
  }
238
240
  // 4. Handle invalid token for all other routes
239
241
  if (!authResult.ok) {
@@ -250,16 +252,14 @@ function buildMiddleware(options, authenticate) {
250
252
  "Content-Type": "application/json",
251
253
  "Access-Control-Allow-Origin": "*",
252
254
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
253
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Token",
255
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, DPoP, X-Api-Token",
254
256
  ...(failure.retryAfter ? { "Retry-After": String(failure.retryAfter) } : {}),
255
257
  },
256
258
  });
257
259
  }
258
260
  // If user only wanted API protection, allow web routes through
259
261
  if (options.protectApiOnly) {
260
- return globalThis.NextResponse?.next
261
- ? globalThis.NextResponse.next()
262
- : new Response(null, { headers: { "x-middleware-next": "1" } });
262
+ return continueResponse();
263
263
  }
264
264
  // Web UI routes: redirect to diagnostic service error screen. Only a fixed error code and
265
265
  // status are passed: the free-text message may carry server details and would let anyone
@@ -274,13 +274,9 @@ function buildMiddleware(options, authenticate) {
274
274
  redirectUrl.searchParams.set("from", sanitized);
275
275
  }
276
276
  }
277
- return globalThis.NextResponse?.redirect
278
- ? globalThis.NextResponse.redirect(redirectUrl)
279
- : Response.redirect(redirectUrl.toString(), 307);
277
+ return redirectResponse(redirectUrl);
280
278
  }
281
- return globalThis.NextResponse?.next
282
- ? globalThis.NextResponse.next()
283
- : new Response(null, { headers: { "x-middleware-next": "1" } });
279
+ return continueResponse();
284
280
  };
285
281
  }
286
282
  /**
@@ -303,11 +299,11 @@ export function withDaywalkerAuth(handler, clientOrOptions) {
303
299
  : clientOrOptions?.client ?? daywalker;
304
300
  const serviceSlug = !(clientOrOptions instanceof Daywalker) ? clientOrOptions?.serviceSlug : undefined;
305
301
  return async function daywalkerAuthHandler(req, context) {
306
- const token = extractToken(req);
307
- if (!token) {
308
- return Response.json({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }, { status: 401 });
302
+ const credentials = credentialsOf(req);
303
+ if (!credentials.ok) {
304
+ return Response.json({ error: "INVALID_TOKEN", message: credentials.message }, { status: 401 });
309
305
  }
310
- const result = await client.validateToken(token, { serviceSlug });
306
+ const result = await client.validateToken(credentials.token, { serviceSlug, dpop: credentials.dpop });
311
307
  if (!result.ok) {
312
308
  const { status, ...body } = toAuthFailureResponse(result);
313
309
  return Response.json(body, {
package/dist/types.d.ts CHANGED
@@ -20,7 +20,25 @@ export interface TokenPayload {
20
20
  offlineVerified?: boolean;
21
21
  [key: string]: unknown;
22
22
  }
23
- export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "SERVICE_UNAVAILABLE" | "CONFIGURATION_ERROR" | string;
23
+ /** The DPoP proof that came with an incoming `Authorization: DPoP <token>` request. */
24
+ export interface IncomingDpop {
25
+ /** The `DPoP` header. */
26
+ proof: string | null | undefined;
27
+ method: string;
28
+ /** The full request URL (its origin is replaced by `publicOrigin` when configured). */
29
+ url: string;
30
+ }
31
+ export interface ValidateTokenOptions {
32
+ serviceSlug?: string;
33
+ forceRefresh?: boolean;
34
+ offlineOnly?: boolean;
35
+ /**
36
+ * The request's DPoP proof. Required to accept a DPoP-bound (`cnf.jkt`) access token; the adapters pass
37
+ * it for you. A bound token without a valid proof is refused with DPOP_REQUIRED / INVALID_DPOP_PROOF.
38
+ */
39
+ dpop?: IncomingDpop;
40
+ }
41
+ export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "SERVICE_UNAVAILABLE" | "CONFIGURATION_ERROR" | "SERVICE_NOT_GRANTED" | "DPOP_REQUIRED" | "INVALID_DPOP_PROOF" | string;
24
42
  /**
25
43
  * Failures that say nothing about the token: Daywalker Auth was unreachable, timed out, answered 5xx
26
44
  * or answered with something that isn't a validation result. Both carry status 503 and are never cached;
@@ -72,7 +90,49 @@ export interface RetryOptions {
72
90
  */
73
91
  retryNetworkErrors?: boolean;
74
92
  }
93
+ /** A provisioned signing key as a `KeyStore` keeps it. Only keys already registered with Daywalker are stored. */
94
+ export interface StoredSigningKey {
95
+ /** Record format version (1). */
96
+ version: 1;
97
+ /** RFC 7638 thumbprint of the public key; checked against the key on every load. */
98
+ keyId: string;
99
+ /** The domain the key is registered for. */
100
+ domain: string;
101
+ /** The Ed25519 private key, PKCS#8 DER in base64. */
102
+ privateKeyPkcs8Base64: string;
103
+ /** Unix seconds when it was registered. */
104
+ createdAt: number;
105
+ }
106
+ /**
107
+ * Where an auto-provisioned signing key lives between processes. On first use the client loads it; when
108
+ * there is none it generates a key, registers it with Daywalker (`POST /api/v1/keys/register`) and saves it
109
+ * with `saveIfAbsent`, which must be atomic (create-if-absent): when several instances start at once exactly
110
+ * one save wins and the others load the winner's key. A store without `saveIfAbsent` is read-only.
111
+ * Built in: `memoryKeyStore()` / `envKeyStore()` (`@shelbyb/daywalker-sdk/keystore`) and `fileKeyStore()`
112
+ * (`@shelbyb/daywalker-sdk/keystore/file`); Redis (`SET key value NX`), KV or a secrets manager fit too.
113
+ */
114
+ export interface KeyStore {
115
+ load(): Promise<StoredSigningKey | null> | StoredSigningKey | null;
116
+ /** Saves `key` unless one is already stored. True when this call saved it, false when another key was there. */
117
+ saveIfAbsent?(key: StoredSigningKey): Promise<boolean> | boolean;
118
+ }
119
+ /**
120
+ * Signs with a key held elsewhere, typically a KMS or HSM, so the private key never exists in this process,
121
+ * its environment or its files. `sign` receives the RFC 9421 signature base and must return the raw 64-byte
122
+ * Ed25519 signature. Register the public key once (`registerPublicKey`); pass `publicJwk` so the first
123
+ * signature is checked against it before anything is sent.
124
+ */
125
+ export interface MessageSigner {
126
+ /** The registered key's id (its RFC 7638 thumbprint, or the id you registered it under). */
127
+ keyId: string;
128
+ sign(data: Uint8Array): Promise<Uint8Array>;
129
+ publicJwk?: PublicJwk;
130
+ }
75
131
  export interface DaywalkerConfig {
132
+ /**
133
+ * The OAuth client credential (env DAYWALKER_API_KEY). Sent only on the client credentials grant to
134
+ * `POST /api/v1/oauth/token`; every other request carries a DPoP-bound access token.
135
+ */
76
136
  apiKey?: string;
77
137
  signingKey?: string | SigningKey;
78
138
  /**
@@ -91,6 +151,12 @@ export interface DaywalkerConfig {
91
151
  domain?: string;
92
152
  /** A JWK, a JWKS (`{ keys: [...] }`, selected by the lease's `kid`), PEM, or Base64 SPKI / raw key. */
93
153
  daywalkerPublicKey?: string | PublicJwk | PublicJwks;
154
+ /**
155
+ * `"auto"` (env DAYWALKER_JWKS=auto) verifies access tokens offline against Daywalker's published key set,
156
+ * fetched from `${authUrl}/.well-known/jwks.json` only and cached, so signing-key rotation needs no
157
+ * redeploy. When no key set can be obtained, `daywalkerPublicKey` (if set) is used instead. Default off.
158
+ */
159
+ jwks?: "auto" | "off";
94
160
  /**
95
161
  * Expected `iss` of offline-verified JWT leases (env DAYWALKER_LEASE_ISSUER). Daywalker documents no fixed
96
162
  * issuer, so `iss` is unchecked when unset; set it whenever leases from other issuers could share your key.
@@ -104,6 +170,34 @@ export interface DaywalkerConfig {
104
170
  maxRetries?: number;
105
171
  /** Defaults for `fetch()` retries; per-request `init.retry` overrides individual fields. */
106
172
  retry?: RetryOptions;
173
+ /**
174
+ * Origin clients use to reach this service (e.g. `https://api.example.com`), for checking the `htu` of
175
+ * DPoP proofs that come with bound access tokens. Defaults to the request URL's origin; set it behind a
176
+ * proxy or load balancer that rewrites the host.
177
+ */
178
+ publicOrigin?: string;
179
+ /** Shared store for DPoP proof `jti`s (default: per-process memory). Needed with several instances. */
180
+ dpopReplayStore?: ReplayStore;
181
+ /**
182
+ * Auto-provisioning: where the signing key is kept when `signingKey` / DAYWALKER_SIGNING_KEY isn't set.
183
+ * On first use the client loads it from here, or generates one, registers it for `domain` with the API key
184
+ * and saves it. Ignored when `signingKey` or `signer` is set.
185
+ */
186
+ keyStore?: KeyStore;
187
+ /**
188
+ * Ephemeral signing keys (env DAYWALKER_EPHEMERAL_KEY=true): the key is generated in memory, non-extractable,
189
+ * registered for `domain` with a lifetime (`ttlSeconds`, default 6 hours; env DAYWALKER_KEY_TTL_SECONDS) in
190
+ * this process's own slot (`instanceId`, default random; env DAYWALKER_INSTANCE_ID), and replaced in the
191
+ * same slot before it expires. Nothing is stored anywhere. `close()` revokes it at shutdown. Daywalker allows
192
+ * 20 live keys per domain, so give long-lived instances a stable `instanceId` (a restart then replaces its
193
+ * previous key) and prefer a shared `keyStore` for serverless. Can't be combined with `keyStore`.
194
+ */
195
+ ephemeralKey?: boolean | {
196
+ ttlSeconds?: number;
197
+ instanceId?: string;
198
+ };
199
+ /** Signs requests with an external key (KMS / HSM) instead of a local one. Takes precedence over everything. */
200
+ signer?: MessageSigner;
107
201
  /** Per-attempt timeout for requests to Daywalker Auth. Default 5000ms; 0 disables. */
108
202
  timeoutMs?: number;
109
203
  fetch?: typeof fetch;
@@ -126,6 +220,36 @@ export interface TokenSet {
126
220
  services: string[];
127
221
  jwksUri?: string;
128
222
  }
223
+ /** A service's license key from Daywalker: derived per service, signing key and one-hour window. */
224
+ export interface License {
225
+ serviceSlug: string;
226
+ /** 64 hex characters (32 bytes). A secret: never log it or send it anywhere. */
227
+ licenseKey: string;
228
+ /** ISO 8601: the end of the window the key is valid for. */
229
+ expiresAt: string;
230
+ }
231
+ /** The config bundle Daywalker seals under the license key. Fields beyond these are passed through. */
232
+ export interface ConfigBundle {
233
+ serviceSlug: string;
234
+ /** Random per issue; derive per-process secrets from it (and the license key), never store it. */
235
+ dataKeySalt: string;
236
+ features: {
237
+ exclusiveTier?: boolean;
238
+ stage?: string;
239
+ rateLimitPerMinute?: number | null;
240
+ rateLimitPerHour?: number | null;
241
+ [key: string]: unknown;
242
+ };
243
+ issuedAt: string;
244
+ expiresAt: string;
245
+ [key: string]: unknown;
246
+ }
247
+ export interface LicenseOptions {
248
+ /** Default: the configured service slug. */
249
+ serviceSlug?: string;
250
+ /** Ignore the cached license and start a new client credentials grant. */
251
+ forceRefresh?: boolean;
252
+ }
129
253
  export interface GetTokensOptions {
130
254
  /** Narrow the grant to these service slugs (least privilege); default: every service the credentials cover. */
131
255
  scope?: string[];
@@ -162,12 +286,13 @@ export interface SignRequestParams {
162
286
  path: string;
163
287
  authority: string;
164
288
  body?: string | Uint8Array | null;
165
- key: SigningKey | string;
289
+ /** The local key to sign with. Not needed (and ignored) when `signer` is given. */
290
+ key?: SigningKey | string;
291
+ /** Signs with an external key instead (KMS / HSM); `keyid` defaults to `signer.keyId`. */
292
+ signer?: MessageSigner;
166
293
  created?: number;
167
294
  keyid?: string;
168
295
  alg?: string;
169
- /** Sets `Authorization: Bearer <apiKey>` on the returned headers (not covered by the signature). */
170
- apiKey?: string;
171
296
  /** Must be 1-128 of `[A-Za-z0-9_.-]` so it can't inject Signature-Input parameters. */
172
297
  nonce?: string;
173
298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one package with a single runtime dependency (re2js).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,16 @@
26
26
  "import": "./dist/express.js",
27
27
  "default": "./dist/express.js"
28
28
  },
29
+ "./keystore": {
30
+ "types": "./dist/keystore/index.d.ts",
31
+ "import": "./dist/keystore/index.js",
32
+ "default": "./dist/keystore/index.js"
33
+ },
34
+ "./keystore/file": {
35
+ "types": "./dist/keystore/file.d.ts",
36
+ "import": "./dist/keystore/file.js",
37
+ "default": "./dist/keystore/file.js"
38
+ },
29
39
  "./flags": {
30
40
  "types": "./dist/flags/client.d.ts",
31
41
  "import": "./dist/flags/client.js",
package/dist/signing.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./crypto/index.js";
2
- export declare const KEYS_PATH = "/.well-known/daywalker-keys.json";
3
- export type { PublicJwk, SigningKey, SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./types.js";
package/dist/signing.js DELETED
@@ -1,2 +0,0 @@
1
- export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./crypto/index.js";
2
- export const KEYS_PATH = "/.well-known/daywalker-keys.json";