@shelbyb/daywalker-sdk 0.6.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.
@@ -0,0 +1,38 @@
1
+ export declare const webcrypto: Readonly<{
2
+ digest: (algorithm: AlgorithmIdentifier, data: BufferSource) => Promise<ArrayBuffer>;
3
+ sign: (algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, data: BufferSource) => Promise<ArrayBuffer>;
4
+ verify: (algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise<boolean>;
5
+ importKey: {
6
+ (format: "jwk", keyData: JsonWebKey, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKey>;
7
+ (format: Exclude<KeyFormat, "jwk">, keyData: BufferSource, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKey>;
8
+ (format: "jwk", keyData: JsonWebKey, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKey>;
9
+ (format: Exclude<KeyFormat, "jwk">, keyData: BufferSource, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: Iterable<KeyUsage>): Promise<CryptoKey>;
10
+ };
11
+ exportKey: {
12
+ (format: "jwk", key: CryptoKey): Promise<JsonWebKey>;
13
+ (format: Exclude<KeyFormat, "jwk">, key: CryptoKey): Promise<ArrayBuffer>;
14
+ (format: KeyFormat, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>;
15
+ };
16
+ generateKey: {
17
+ (algorithm: "Ed25519" | {
18
+ name: "Ed25519";
19
+ }, extractable: boolean, keyUsages: ReadonlyArray<"sign" | "verify">): Promise<CryptoKeyPair>;
20
+ (algorithm: "X25519" | {
21
+ name: "X25519";
22
+ }, extractable: boolean, keyUsages: ReadonlyArray<"deriveBits" | "deriveKey">): Promise<CryptoKeyPair>;
23
+ (algorithm: RsaHashedKeyGenParams | EcKeyGenParams, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKeyPair>;
24
+ (algorithm: AesKeyGenParams | HmacKeyGenParams | Pbkdf2Params, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKey>;
25
+ (algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]): Promise<CryptoKeyPair | CryptoKey>;
26
+ (algorithm: "Ed25519" | {
27
+ name: "Ed25519";
28
+ }, extractable: boolean, keyUsages: ReadonlyArray<"sign" | "verify">): Promise<CryptoKeyPair>;
29
+ (algorithm: "X25519" | {
30
+ name: "X25519";
31
+ }, extractable: boolean, keyUsages: ReadonlyArray<"deriveBits" | "deriveKey">): Promise<CryptoKeyPair>;
32
+ (algorithm: RsaHashedKeyGenParams | EcKeyGenParams, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKeyPair>;
33
+ (algorithm: AesKeyGenParams | HmacKeyGenParams | Pbkdf2Params, extractable: boolean, keyUsages: ReadonlyArray<KeyUsage>): Promise<CryptoKey>;
34
+ (algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: Iterable<KeyUsage>): Promise<CryptoKeyPair | CryptoKey>;
35
+ };
36
+ getRandomValues: <T extends Exclude<BufferSource, ArrayBuffer>>(array: T) => T;
37
+ randomUUID: () => `${string}-${string}-${string}-${string}-${string}`;
38
+ }>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * WebCrypto primitives captured once, when this module loads. Every signature, digest and key operation in
3
+ * the SDK goes through these, so code that later replaces `crypto.subtle.verify` (or `crypto.subtle`
4
+ * itself) can't make forged signatures or tokens verify. The object is frozen so the captured functions
5
+ * can't be swapped either. Without WebCrypto each call throws (fail closed) instead of the import failing.
6
+ */
7
+ const cryptoRef = globalThis.crypto;
8
+ const subtleRef = cryptoRef?.subtle;
9
+ function missing() {
10
+ throw new Error("@shelbyb/daywalker-sdk needs WebCrypto (globalThis.crypto.subtle): Node.js 20.19+, Bun, Deno, Workers or a browser");
11
+ }
12
+ function capture(target, name) {
13
+ const fn = target?.[name];
14
+ return (typeof fn === "function" ? fn.bind(target) : missing);
15
+ }
16
+ export const webcrypto = Object.freeze({
17
+ digest: capture(subtleRef, "digest"),
18
+ sign: capture(subtleRef, "sign"),
19
+ verify: capture(subtleRef, "verify"),
20
+ importKey: capture(subtleRef, "importKey"),
21
+ exportKey: capture(subtleRef, "exportKey"),
22
+ generateKey: capture(subtleRef, "generateKey"),
23
+ getRandomValues: capture(cryptoRef, "getRandomValues"),
24
+ randomUUID: capture(cryptoRef, "randomUUID"),
25
+ });
package/dist/express.d.ts CHANGED
@@ -4,6 +4,8 @@ interface Req {
4
4
  method?: string;
5
5
  path?: string;
6
6
  url?: string;
7
+ originalUrl?: string;
8
+ protocol?: string;
7
9
  headers?: Record<string, string | string[] | undefined>;
8
10
  get?: (name: string) => string | undefined;
9
11
  auth?: TokenPayload;
@@ -22,5 +24,4 @@ export declare function daywalkerAuth(options?: {
22
24
  client?: Daywalker;
23
25
  serviceSlug?: string;
24
26
  }): (req: Req, res: Res, next?: (err?: unknown) => void) => Promise<void>;
25
- export declare function daywalkerKeys(client?: Daywalker): (req: Req, res: Res, next?: () => void) => Promise<void>;
26
27
  export {};
package/dist/express.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { daywalker } from "./client.js";
2
- import { toAuthFailureResponse } from "./middleware.js";
2
+ import { readCredentials, toAuthFailureResponse } from "./middleware.js";
3
3
  /**
4
4
  * Express / Connect / Node http middleware that validates incoming Daywalker tokens.
5
5
  */
@@ -8,34 +8,28 @@ export function daywalkerAuth(options = {}) {
8
8
  const serviceSlug = options.serviceSlug;
9
9
  return async (req, res, next) => {
10
10
  const getHeader = (name) => {
11
+ if (req.headers)
12
+ return req.headers[name.toLowerCase()];
11
13
  if (typeof req.get === "function")
12
14
  return req.get(name);
13
- if (req.headers) {
14
- const val = req.headers[name.toLowerCase()] ?? req.headers[name];
15
- return Array.isArray(val) ? val[0] : val;
16
- }
17
15
  return undefined;
18
16
  };
19
- const authHeader = getHeader("authorization");
20
- const apiTokenHeader = getHeader("x-api-token");
21
- let token = null;
22
- if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
23
- token = authHeader.slice(7).trim();
24
- }
25
- else if (apiTokenHeader) {
26
- token = apiTokenHeader.trim();
27
- }
28
- if (!token) {
17
+ const host = typeof getHeader("host") === "string" ? getHeader("host") : "localhost";
18
+ // The origin only matters for DPoP proofs, and is replaced by the client's `publicOrigin` when set.
19
+ const url = `${req.protocol === "https" ? "https" : "http"}://${/^[A-Za-z0-9.:[\]-]+$/.test(host) ? host : "localhost"}${req.originalUrl ?? req.url ?? "/"}`;
20
+ const credentials = readCredentials(getHeader, { method: req.method ?? "GET", url });
21
+ if (!credentials.ok) {
22
+ const body = { error: "INVALID_TOKEN", message: credentials.message };
29
23
  if (typeof res.status === "function" && typeof res.json === "function") {
30
- res.status(401).json({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" });
24
+ res.status(401).json(body);
31
25
  return;
32
26
  }
33
27
  res.statusCode = 401;
34
28
  res.setHeader("Content-Type", "application/json");
35
- res.end(JSON.stringify({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }));
29
+ res.end(JSON.stringify(body));
36
30
  return;
37
31
  }
38
- const result = await client.validateToken(token, { serviceSlug });
32
+ const result = await client.validateToken(credentials.token, { serviceSlug, dpop: credentials.dpop });
39
33
  if (!result.ok) {
40
34
  const { status, ...body } = toAuthFailureResponse(result);
41
35
  if (body.retryAfter) {
@@ -54,17 +48,3 @@ export function daywalkerAuth(options = {}) {
54
48
  next?.();
55
49
  };
56
50
  }
57
- export function daywalkerKeys(client = daywalker) {
58
- return async (req, res, next) => {
59
- const path = req.path ?? (req.url ?? "").split("?")[0];
60
- if ((req.method ?? "GET") === "GET" && path === "/.well-known/daywalker-keys.json") {
61
- const keysDoc = await client.keys();
62
- res.statusCode = 200;
63
- res.setHeader("Content-Type", "application/json");
64
- res.setHeader("Cache-Control", "public, max-age=300");
65
- res.end(JSON.stringify(keysDoc));
66
- return;
67
- }
68
- next?.();
69
- };
70
- }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
1
+ export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, MAX_CACHE_TTL_MS, MAX_TOKEN_LENGTH, JWKS_CACHE_TTL_MS, JWKS_REFETCH_COOLDOWN_MS, } from "./client.js";
2
2
  export type { DaywalkerFetchInit, } from "./client.js";
3
3
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
4
- export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, GetTokensOptions, AccessTokenUser, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
4
+ export type { DaywalkerConfig, RateLimitWindow, TokenPayload, IncomingDpop, ValidateTokenOptions, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, GetTokensOptions, AccessTokenUser, KeyStore, StoredSigningKey, MessageSigner, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
5
5
  export { resolveConfig, parseServiceSlugs, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
6
6
  export type { ResolvedConfig, } from "./config.js";
7
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
8
- export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./crypto/index.js";
7
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, importSigningKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, listSigningKeys, revokeSigningKey, keySlotFor, MIN_KEY_TTL_SECONDS, MAX_KEY_TTL_SECONDS, verifyDpopProof, MAX_DPOP_PROOF_LENGTH, DPOP_MAX_AGE_SECONDS, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
8
+ export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, VerifyDpopProofParams, SigningKeyRecord, KeyApiResult, } from "./crypto/index.js";
9
9
  export { buildTokenCookie, clearTokenCookie, daywalkerCookies, accessTokenUser } from "./cookies.js";
10
10
  export type { TokenCookieOptions } from "./cookies.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
1
+ export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, MAX_CACHE_TTL_MS, MAX_TOKEN_LENGTH, JWKS_CACHE_TTL_MS, JWKS_REFETCH_COOLDOWN_MS, } from "./client.js";
2
2
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
3
3
  export { resolveConfig, parseServiceSlugs, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, importSigningKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, listSigningKeys, revokeSigningKey, keySlotFor, MIN_KEY_TTL_SECONDS, MAX_KEY_TTL_SECONDS, verifyDpopProof, MAX_DPOP_PROOF_LENGTH, DPOP_MAX_AGE_SECONDS, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
5
5
  export { buildTokenCookie, clearTokenCookie, daywalkerCookies, accessTokenUser } from "./cookies.js";
@@ -0,0 +1,14 @@
1
+ import type { KeyStore } from "../types.js";
2
+ export interface FileKeyStoreOptions {
3
+ /** Default `.daywalker/signing-key.json` in the working directory. Keep it out of version control. */
4
+ path?: string;
5
+ }
6
+ /**
7
+ * Keeps the key in a file (Node.js only), for VMs and containers with a persistent volume.
8
+ * - Written to a private 0600 temp file, then hard-linked into place inside a 0700 directory: `link()` fails
9
+ * when the file exists, so of several processes starting at once exactly one publishes its key, and no reader
10
+ * ever sees a half-written file.
11
+ * - Never follows a symlink and refuses a file that isn't a regular file, isn't owned by this user, or is
12
+ * readable or writable by group or others, like ssh does for private keys.
13
+ */
14
+ export declare function fileKeyStore(options?: FileKeyStoreOptions): KeyStore;
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+ import { parseStoredSigningKey } from "./index.js";
5
+ /** Largest key file read. A real one is a few hundred bytes. */
6
+ const MAX_FILE_BYTES = 4096;
7
+ /**
8
+ * Keeps the key in a file (Node.js only), for VMs and containers with a persistent volume.
9
+ * - Written to a private 0600 temp file, then hard-linked into place inside a 0700 directory: `link()` fails
10
+ * when the file exists, so of several processes starting at once exactly one publishes its key, and no reader
11
+ * ever sees a half-written file.
12
+ * - Never follows a symlink and refuses a file that isn't a regular file, isn't owned by this user, or is
13
+ * readable or writable by group or others, like ssh does for private keys.
14
+ */
15
+ export function fileKeyStore(options = {}) {
16
+ const file = path.resolve(options.path ?? path.join(".daywalker", "signing-key.json"));
17
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
18
+ async function load() {
19
+ let handle;
20
+ try {
21
+ handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
22
+ }
23
+ catch (err) {
24
+ const code = err.code;
25
+ if (code === "ENOENT")
26
+ return null;
27
+ if (code === "ELOOP")
28
+ throw new Error(`${file} is a symlink; refusing to read a signing key through it`);
29
+ throw err;
30
+ }
31
+ try {
32
+ const stat = await handle.stat();
33
+ if (!stat.isFile())
34
+ throw new Error(`${file} is not a regular file`);
35
+ if (process.platform !== "win32") {
36
+ if ((stat.mode & 0o077) !== 0)
37
+ throw new Error(`${file} is readable or writable by others (mode ${(stat.mode & 0o777).toString(8)}); chmod 600 it`);
38
+ if (uid !== undefined && stat.uid !== uid)
39
+ throw new Error(`${file} is not owned by this user`);
40
+ }
41
+ if (stat.size > MAX_FILE_BYTES)
42
+ throw new Error(`${file} is too large to be a signing key`);
43
+ return parseStoredSigningKey(JSON.parse(await handle.readFile("utf8")));
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ }
49
+ async function saveIfAbsent(key) {
50
+ const record = parseStoredSigningKey(key);
51
+ const dir = path.dirname(file);
52
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
53
+ // Written in full to a private temp file first, then hard-linked into place: link() fails if the target
54
+ // exists, so publishing is atomic and a reader never sees a half-written key.
55
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
56
+ const handle = await fs.open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
57
+ try {
58
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
59
+ await handle.sync();
60
+ await handle.close();
61
+ try {
62
+ await fs.link(tmp, file);
63
+ return true;
64
+ }
65
+ catch (err) {
66
+ if (err.code === "EEXIST")
67
+ return false;
68
+ throw err;
69
+ }
70
+ }
71
+ finally {
72
+ await handle.close().catch(() => { });
73
+ await fs.rm(tmp, { force: true });
74
+ }
75
+ }
76
+ return Object.freeze({ load, saveIfAbsent });
77
+ }
@@ -0,0 +1,18 @@
1
+ import type { KeyStore, StoredSigningKey } from "../types.js";
2
+ export type { KeyStore, StoredSigningKey } from "../types.js";
3
+ /**
4
+ * Checks that `value` is a well-formed `StoredSigningKey` and returns a clean copy (unknown fields dropped),
5
+ * or throws. Run on everything a store returns, so a corrupted or hand-edited record is refused rather than used.
6
+ */
7
+ export declare function parseStoredSigningKey(value: unknown): StoredSigningKey;
8
+ /**
9
+ * Keeps the key in this process only. Every new process provisions (and registers) a new key, so use it for
10
+ * tests and single long-lived processes, not for serverless or several instances.
11
+ */
12
+ export declare function memoryKeyStore(initial?: StoredSigningKey | null): KeyStore;
13
+ /**
14
+ * Read-only: the key `daywalker init` wrote to DAYWALKER_SIGNING_KEY, DAYWALKER_KEY_ID and DAYWALKER_DOMAIN.
15
+ * It can't save, so a client using it never generates a key; set the variables with `daywalker init` (or
16
+ * your secret manager) first.
17
+ */
18
+ export declare function envKeyStore(env?: Record<string, string | undefined>): KeyStore;
@@ -0,0 +1,71 @@
1
+ import { SAFE_IDENTIFIER_PATTERN } from "../crypto/signatures.js";
2
+ import { publicHostname } from "../config.js";
3
+ /** Longest accepted base64 PKCS#8 private key (an Ed25519 one is 64 characters). */
4
+ const MAX_PKCS8_BASE64_LENGTH = 512;
5
+ /**
6
+ * Checks that `value` is a well-formed `StoredSigningKey` and returns a clean copy (unknown fields dropped),
7
+ * or throws. Run on everything a store returns, so a corrupted or hand-edited record is refused rather than used.
8
+ */
9
+ export function parseStoredSigningKey(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ throw new Error("Stored signing key is not an object");
12
+ const v = value;
13
+ if (v.version !== 1)
14
+ throw new Error("Stored signing key has an unsupported version");
15
+ if (typeof v.keyId !== "string" || !SAFE_IDENTIFIER_PATTERN.test(v.keyId))
16
+ throw new Error("Stored signing key has an invalid keyId");
17
+ if (typeof v.domain !== "string" || publicHostname(v.domain) !== v.domain)
18
+ throw new Error("Stored signing key has an invalid domain");
19
+ if (typeof v.privateKeyPkcs8Base64 !== "string" ||
20
+ v.privateKeyPkcs8Base64.length > MAX_PKCS8_BASE64_LENGTH ||
21
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(v.privateKeyPkcs8Base64)) {
22
+ throw new Error("Stored signing key has an invalid private key");
23
+ }
24
+ if (typeof v.createdAt !== "number" || !Number.isSafeInteger(v.createdAt) || v.createdAt < 0) {
25
+ throw new Error("Stored signing key has an invalid createdAt");
26
+ }
27
+ return Object.freeze({
28
+ version: 1,
29
+ keyId: v.keyId,
30
+ domain: v.domain,
31
+ privateKeyPkcs8Base64: v.privateKeyPkcs8Base64,
32
+ createdAt: v.createdAt,
33
+ });
34
+ }
35
+ /**
36
+ * Keeps the key in this process only. Every new process provisions (and registers) a new key, so use it for
37
+ * tests and single long-lived processes, not for serverless or several instances.
38
+ */
39
+ export function memoryKeyStore(initial = null) {
40
+ let stored = initial ? parseStoredSigningKey(initial) : null;
41
+ return Object.freeze({
42
+ load: () => stored,
43
+ saveIfAbsent: (key) => {
44
+ if (stored)
45
+ return false;
46
+ stored = parseStoredSigningKey(key);
47
+ return true;
48
+ },
49
+ });
50
+ }
51
+ /**
52
+ * Read-only: the key `daywalker init` wrote to DAYWALKER_SIGNING_KEY, DAYWALKER_KEY_ID and DAYWALKER_DOMAIN.
53
+ * It can't save, so a client using it never generates a key; set the variables with `daywalker init` (or
54
+ * your secret manager) first.
55
+ */
56
+ export function envKeyStore(env = typeof process !== "undefined" ? process.env : {}) {
57
+ return Object.freeze({
58
+ load: () => {
59
+ const raw = env.DAYWALKER_SIGNING_KEY?.trim();
60
+ if (!raw)
61
+ return null;
62
+ return parseStoredSigningKey({
63
+ version: 1,
64
+ keyId: env.DAYWALKER_KEY_ID?.trim(),
65
+ domain: publicHostname(env.DAYWALKER_DOMAIN ?? env.DAYWALKER_SIGNING_DOMAIN ?? ""),
66
+ privateKeyPkcs8Base64: raw,
67
+ createdAt: 0,
68
+ });
69
+ },
70
+ });
71
+ }
@@ -1,11 +1,35 @@
1
1
  import { Daywalker } from "./client.js";
2
- import type { ValidateResult } from "./types.js";
2
+ import type { IncomingDpop, ValidateResult } from "./types.js";
3
+ /** Longest Authorization / x-api-token / DPoP header read; longer ones are refused unparsed. */
4
+ export declare const MAX_AUTH_HEADER_LENGTH = 8300;
5
+ export type Credentials = {
6
+ ok: true;
7
+ token: string;
8
+ dpop?: IncomingDpop;
9
+ } | {
10
+ ok: false;
11
+ message: string;
12
+ };
13
+ /**
14
+ * Reads the caller's token from `Authorization: Bearer <token>`, `Authorization: DPoP <token>` (with its
15
+ * `DPoP` proof header) or, when there is no Authorization header, `x-api-token`. An Authorization header
16
+ * with any other scheme, a malformed or oversized header, or a header given twice is refused rather than
17
+ * skipped, so a request can't pick which credential is checked. Shared by the Next, Web/Hono and Express
18
+ * adapters.
19
+ */
20
+ export declare function readCredentials(getHeader: (name: string) => string | string[] | null | undefined, request: {
21
+ method: string;
22
+ url: string;
23
+ }): Credentials;
3
24
  type ValidateFailure = Extract<ValidateResult, {
4
25
  ok: false;
5
26
  }>;
6
27
  export interface AuthFailureResponse {
7
- /** 401 bad/expired/missing token, 429 rate limited, 500 server misconfiguration, 503 auth service unavailable. */
8
- status: 401 | 429 | 500 | 503;
28
+ /**
29
+ * 401 bad/expired/missing token or DPoP proof, 403 token not valid for this service, 429 rate limited,
30
+ * 500 server misconfiguration, 503 auth service unavailable.
31
+ */
32
+ status: 401 | 403 | 429 | 500 | 503;
9
33
  error: string;
10
34
  message: string;
11
35
  retryAfter?: number;
@@ -1,4 +1,46 @@
1
1
  import { daywalker } from "./client.js";
2
+ /** Longest Authorization / x-api-token / DPoP header read; longer ones are refused unparsed. */
3
+ export const MAX_AUTH_HEADER_LENGTH = 8_300;
4
+ /**
5
+ * Reads the caller's token from `Authorization: Bearer <token>`, `Authorization: DPoP <token>` (with its
6
+ * `DPoP` proof header) or, when there is no Authorization header, `x-api-token`. An Authorization header
7
+ * with any other scheme, a malformed or oversized header, or a header given twice is refused rather than
8
+ * skipped, so a request can't pick which credential is checked. Shared by the Next, Web/Hono and Express
9
+ * adapters.
10
+ */
11
+ export function readCredentials(getHeader, request) {
12
+ const one = (name) => {
13
+ const v = getHeader(name);
14
+ if (Array.isArray(v))
15
+ return v.length === 1 ? v[0] : null;
16
+ return v ?? undefined;
17
+ };
18
+ const authorization = one("authorization");
19
+ const apiToken = one("x-api-token");
20
+ if (authorization === null || apiToken === null)
21
+ return { ok: false, message: "Credentials header sent more than once" };
22
+ if (authorization !== undefined && authorization !== "") {
23
+ if (authorization.length > MAX_AUTH_HEADER_LENGTH)
24
+ return { ok: false, message: "Authorization header is too long" };
25
+ const m = /^(Bearer|DPoP) ([^\s,]+)$/i.exec(authorization.trim());
26
+ if (!m)
27
+ return { ok: false, message: "Authorization must be `Bearer <token>` or `DPoP <token>`" };
28
+ if (m[1].toLowerCase() === "bearer")
29
+ return { ok: true, token: m[2] };
30
+ const proof = one("dpop");
31
+ if (proof === null)
32
+ return { ok: false, message: "DPoP header sent more than once" };
33
+ if (proof !== undefined && proof.length > MAX_AUTH_HEADER_LENGTH)
34
+ return { ok: false, message: "DPoP header is too long" };
35
+ return { ok: true, token: m[2], dpop: { proof: proof?.trim(), method: request.method, url: request.url } };
36
+ }
37
+ if (apiToken !== undefined && apiToken.trim() !== "") {
38
+ if (apiToken.length > MAX_AUTH_HEADER_LENGTH)
39
+ return { ok: false, message: "x-api-token header is too long" };
40
+ return { ok: true, token: apiToken.trim() };
41
+ }
42
+ return { ok: false, message: "Missing Authorization header or x-api-token" };
43
+ }
2
44
  /** Error codes that mean Daywalker Auth itself could not be reached or is degraded. */
3
45
  const UNAVAILABLE_ERROR_CODES = new Set(["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]);
4
46
  /**
@@ -23,6 +65,9 @@ export function toAuthFailureResponse(result) {
23
65
  retryAfter,
24
66
  };
25
67
  }
68
+ if (result.status === 403) {
69
+ return { status: 403, error: result.error || "FORBIDDEN", message: result.message || "Forbidden" };
70
+ }
26
71
  return { status: 401, error: result.error || "INVALID_TOKEN", message: result.message || "Unauthorized" };
27
72
  }
28
73
  /**
@@ -41,36 +86,44 @@ export function daywalkerAuth(options = {}) {
41
86
  const client = options.client ?? daywalker;
42
87
  const serviceSlug = options.serviceSlug;
43
88
  return async function daywalkerMiddleware(c, next) {
44
- let authHeader;
45
- let apiTokenHeader;
46
- if (typeof c?.req?.header === "function") {
47
- authHeader = c.req.header("authorization");
48
- apiTokenHeader = c.req.header("x-api-token");
89
+ let getHeader = () => undefined;
90
+ let method = "GET";
91
+ let url = "http://localhost/";
92
+ const raw = c?.req?.raw ?? (c instanceof Request ? c : undefined);
93
+ if (raw instanceof Request) {
94
+ getHeader = (name) => raw.headers.get(name);
95
+ method = raw.method;
96
+ url = raw.url;
97
+ }
98
+ else if (typeof c?.req?.header === "function") {
99
+ getHeader = (name) => c.req.header(name);
100
+ method = String(c.req.method ?? method);
101
+ url = String(c.req.url ?? url);
49
102
  }
50
103
  else if (c?.req?.headers instanceof Headers || c?.headers instanceof Headers) {
51
104
  const headers = (c.req?.headers ?? c.headers);
52
- authHeader = headers.get("authorization") ?? undefined;
53
- apiTokenHeader = headers.get("x-api-token") ?? undefined;
54
- }
55
- else if (typeof c?.req?.headers === "object" || typeof c?.headers === "object") {
56
- const headers = c.req?.headers ?? c.headers;
57
- authHeader = headers["authorization"] || headers["Authorization"];
58
- apiTokenHeader = headers["x-api-token"] || headers["X-Api-Token"];
59
- }
60
- let token = null;
61
- if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
62
- token = authHeader.slice(7).trim();
105
+ getHeader = (name) => headers.get(name);
106
+ method = String(c.req?.method ?? c.method ?? method);
107
+ url = String(c.req?.url ?? c.url ?? url);
63
108
  }
64
- else if (apiTokenHeader) {
65
- token = apiTokenHeader.trim();
109
+ else if (c?.req?.headers && typeof c.req.headers === "object") {
110
+ const headers = c.req.headers;
111
+ getHeader = (name) => {
112
+ const key = Object.keys(headers).filter((k) => k.toLowerCase() === name);
113
+ return key.length > 1 ? [String(headers[key[0]]), String(headers[key[1]])] : key.length === 1 ? headers[key[0]] : undefined;
114
+ };
115
+ method = String(c.req.method ?? method);
116
+ url = String(c.req.url ?? url);
66
117
  }
67
- if (!token) {
118
+ const credentials = readCredentials(getHeader, { method, url });
119
+ if (!credentials.ok) {
120
+ const body = { error: "INVALID_TOKEN", message: credentials.message };
68
121
  if (typeof c?.json === "function") {
69
- return c.json({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }, 401);
122
+ return c.json(body, 401);
70
123
  }
71
- return Response.json({ error: "INVALID_TOKEN", message: "Missing Authorization header or x-api-token" }, { status: 401 });
124
+ return Response.json(body, { status: 401 });
72
125
  }
73
- const result = await client.validateToken(token, { serviceSlug });
126
+ const result = await client.validateToken(credentials.token, { serviceSlug, dpop: credentials.dpop });
74
127
  if (!result.ok) {
75
128
  const { status, ...body } = toAuthFailureResponse(result);
76
129
  const headers = body.retryAfter ? { "Retry-After": String(body.retryAfter) } : undefined;
package/dist/next.d.ts CHANGED
@@ -64,7 +64,8 @@ export declare function sanitizeRedirectPath(path: string): string | null;
64
64
  * Implements:
65
65
  * 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
66
66
  * 2. Static asset and health check bypass
67
- * 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
67
+ * 3. Per-request token auth: requests without `Authorization: Bearer` / `Authorization: DPoP` (with its
68
+ * proof) / `x-api-token` get 401
68
69
  * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
69
70
  *
70
71
  * Only `/_next/static/`, `/_next/image`, `/favicon.ico` and `bypassPaths` skip auth by default.
@@ -82,8 +83,9 @@ export declare function sanitizeRedirectPath(path: string): string | null;
82
83
  */
83
84
  export declare function createDaywalkerMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
84
85
  /**
85
- * Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
86
- * routes every request to `errorPath` while it is invalid, redirecting back once it heals.
86
+ * Service health gate: checks that this service's OAuth client credentials still get an access token
87
+ * covering its service (not the caller's token) and routes every request to `errorPath` while they don't,
88
+ * redirecting back once it heals.
87
89
  * It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
88
90
  */
89
91
  export declare function createDaywalkerServiceHealthMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;