@shelbyb/daywalker-sdk 0.4.1 → 0.6.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 CHANGED
@@ -48,7 +48,7 @@ DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --do
48
48
 
49
49
  `daywalker init`:
50
50
  - Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
51
- - Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register` (`Authorization: Bearer <API key>`, body `{ keyId, domain, publicKey }`; responds `201`)
51
+ - Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register` (`Authorization: Bearer <API key>`, body `{ keyId, domain, publicKey, serviceSlug }`; responds `201`, or `403 service_not_covered` when the API key doesn't cover the service)
52
52
  - Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local` (atomically, mode `0600`), and only after registration succeeds. `--skip-register` writes without registering.
53
53
  - `--force` replaces an existing key and keeps the old one as `DAYWALKER_SIGNING_KEY_PREVIOUS`
54
54
  - No route injectors or public `/.well-known/` endpoints needed
@@ -61,15 +61,15 @@ The private key is never printed unless you pass `--print-key`.
61
61
 
62
62
  | Variable | Required | Purpose |
63
63
  |---|---|---|
64
- | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or JWT lease |
65
- | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`) |
64
+ | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or access token |
65
+ | `DAYWALKER_SERVICE_SLUG` | ✅ | The service slug to validate against (e.g. `sonalyze`). One API key can cover several services: a comma-separated list (`gigpulse,devpulse`) is accepted and the first is the default. See [One API key, several services](#one-api-key-several-services) |
66
66
  | `DAYWALKER_SIGNING_KEY` | for signed requests | Ed25519 private key from `daywalker init` (PKCS#8 Base64 DER or PEM) |
67
67
  | `DAYWALKER_KEY_ID` | optional | RFC 7638 JWK thumbprint derived during key generation |
68
68
  | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
69
69
  | `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
70
70
  | `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
71
71
  | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
72
- | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWT leases |
72
+ | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWTs (default `https://auth.daywalker.dev`) |
73
73
 
74
74
  ---
75
75
 
@@ -198,6 +198,70 @@ app.get("/api/me", (req, res) => res.json({ auth: req.auth }));
198
198
 
199
199
  ---
200
200
 
201
+ ## One API Key, Several Services
202
+
203
+ A Daywalker API token can cover several services (say `gigpulse` and `devpulse`) and carry one allowlist of domains and IPs, so you manage a single set of client credentials instead of one per service. Ask for the extra services when you create the token in the Daywalker dashboard (**Also cover these services**), or with `additionalServiceIds` on `POST /api/v1/tokens` and `PATCH /api/v1/tokens/:id`.
204
+
205
+ **Each app keeps its own service slug, with the same API key.** Every app (domain) generates and registers its own signing key:
206
+
207
+ ```bash
208
+ # gigpulse app
209
+ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service gigpulse --domain gigpulse.daywalker.dev
210
+ # devpulse app, same API key
211
+ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain devpulse.daywalker.dev
212
+ ```
213
+
214
+ `init` and `rotate` take no service: the API key decides which services your credentials cover (`--service` is optional and only sets the audience for offline verification, `DAYWALKER_SERVICE_SLUG`).
215
+
216
+ **Client credentials flow (the JWT lists every service).** Your backend authenticates with its client credentials (API key + registered signing key) and gets a short-lived access token (5 to 15 minutes) plus a rotating refresh token. No request names a service; the token lists them:
217
+
218
+ ```ts
219
+ const tokens = await client.getTokens();
220
+ tokens.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
221
+
222
+ // Least privilege: only what this process needs (403 SERVICE_NOT_GRANTED for a service the key doesn't cover)
223
+ await client.getTokens({ scope: ["devpulse"], expiresIn: 600 });
224
+
225
+ // Always-fresh access token: refreshes (and re-authenticates if the session ended) automatically
226
+ const accessToken = await client.accessToken();
227
+ ```
228
+
229
+ **Refresh token rotation.** `client.refreshTokens()` swaps the refresh token for a *new* access token and a *new* refresh token; the old one stops working. Daywalker keeps each session's refresh tokens as a family: presenting an old one again is treated as theft and revokes the whole family, so your client has to start over with its credentials. `accessToken()` serializes refreshes so concurrent requests never present the same token twice. Don't refresh from several processes with one session.
230
+
231
+ **The access token** is an EdDSA JWT (RFC 9068, `typ: at+jwt`) with `iss`, `aud`, `jti`, `iat`, `nbf`, `exp`, `services`, and a `user` object: the user's public UUID v7 `id`, `name` and `email`. Read it with `accessTokenUser(payload)` **after** verifying:
232
+
233
+ ```ts
234
+ import { verifyJwtLease, accessTokenUser } from "@shelbyb/daywalker-sdk";
235
+ const res = await verifyJwtLease({ token, publicKey: jwks, expectedServiceSlug: "devpulse", expectedIssuer: "https://auth.daywalker.dev" });
236
+ if (res.valid) accessTokenUser(res.payload!); // { id: "018f…", name: "Ada", email: "ada@example.com" }
237
+ ```
238
+
239
+ Verification is strict: only `EdDSA` (never `none` or HMAC, so no algorithm confusion), canonical base64url, `typ` `at+jwt`, issuer, your audience, required `jti` and `exp`, and at most 30 seconds of clock skew. Never decode a token without verifying it.
240
+
241
+ **Web apps: HttpOnly cookies.** Keep tokens on your server. If the browser must hold them, send them from your backend in cookies scripts can't read:
242
+
243
+ ```ts
244
+ import { daywalkerCookies, clearTokenCookie } from "@shelbyb/daywalker-sdk";
245
+ const tokens = await client.getTokens();
246
+ const res = new Response("ok");
247
+ for (const c of daywalkerCookies(tokens, { refreshPath: "/api/auth/refresh" })) res.headers.append("Set-Cookie", c);
248
+ // HttpOnly; Secure; SameSite=Strict. The refresh token cookie is only sent to /api/auth/refresh.
249
+ ```
250
+
251
+ Services that are inactive, in `DRAFT`, `TESTING` (unless an admin owns the token), or no longer entitled to the token's owner are left out. Check `tokens.services` rather than assuming.
252
+
253
+ Whoever receives the access token verifies it for **their own** service. It only passes when that slug is in the token's `services` and `aud`:
254
+
255
+ ```ts
256
+ const verifier = new Daywalker({ serviceSlug: "devpulse", daywalkerPublicKey: process.env.DAYWALKER_PUBLIC_KEY });
257
+ const result = await verifier.validateToken(incomingJwt);
258
+ if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this token covers
259
+ ```
260
+
261
+ Online validation of an `srv_live_…` token reports the same list in `result.data.services` (a server that predates multi-service tokens yields just the validated slug).
262
+
263
+ ---
264
+
201
265
  ## Zero-Egress Offline Verification
202
266
 
203
267
  When high-throughput internal microservices need zero latency and zero external egress, Daywalker Auth can issue asymmetric JWT leases. Configure `DAYWALKER_PUBLIC_KEY` (PEM or standard JWK) and token validation runs entirely in memory without network calls:
@@ -214,7 +278,7 @@ const client = new Daywalker({
214
278
  const result = await client.validateToken(incomingJwt);
215
279
  ```
216
280
 
217
- Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your service slug, so `serviceSlug` must be configured. Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
281
+ Tokens must have a numeric `exp`, a `jti`, and an `aud` (or `serviceSlug`) containing your service slug, so `serviceSlug` must be configured. A lease that lists `services` must list yours too. `result.data.services` is every service the lease covers (see [One API key, several services](#one-api-key-several-services)). Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
218
282
 
219
283
  ---
220
284
 
@@ -222,9 +286,9 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
222
286
 
223
287
  | Command | Description |
224
288
  |---|---|
225
- | `npx daywalker init --service <slug> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
289
+ | `npx daywalker init --service <slug[,slug…]> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
226
290
  | `npx daywalker check` | Validate configuration, signature readiness, and connection |
227
- | `npx daywalker rotate [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
291
+ | `npx daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
228
292
 
229
293
  ---
230
294
 
@@ -233,8 +297,8 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
233
297
  | Endpoint | SDK usage | Notes |
234
298
  |---|---|---|
235
299
  | `POST /api/v1/tokens/validate` | `client.validateToken()` | Body `{ token, serviceSlug }`; signed with `Signature-Input`, `Signature`, `Content-Digest`. Errors: `400` (`DIGEST_MISMATCH`/missing fields), `401` (`INVALID_SIGNATURE`/invalid token), `403`, `429`. Response includes `valid`, `tokenId`, `tokenName`, `userId`, `stage`, `rateLimit`, `rateLimitHourly` and `X-RateLimit-*` / `X-RateLimit-Hourly-*` headers |
236
- | `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer auth; body `{ keyId, domain, publicKey }` |
237
- | `POST /api/v1/leases/issue` | `client.issueLease(ttlSeconds?)` | Signed request returns `{ lease, tokenType, expiresAt, jwksUri }` (EdDSA JWT) |
300
+ | `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer auth; body `{ keyId, domain, publicKey, serviceSlug? }`; `403 service_not_covered` if the key doesn't cover `serviceSlug` |
301
+ | `POST /api/v1/oauth/token` | `client.getTokens({ scope?, expiresIn? })`, `client.refreshTokens()`, `client.accessToken()` | `client_credentials` (signed request) and `refresh_token` grants return `{ accessToken, refreshToken, expiresAt, services, … }`; refresh tokens rotate and reuse revokes the session |
238
302
  | `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
239
303
 
240
304
  Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
package/dist/cli.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createDaywalker } from "./client.js";
5
- import { publicHostname } from "./config.js";
5
+ import { parseServiceSlugs, publicHostname } from "./config.js";
6
6
  import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets } from "./crypto/index.js";
7
7
  const HELP = `daywalker: connect a service to Daywalker Auth (RFC 9421 / Push Architecture)
8
8
 
9
9
  Usage:
10
- daywalker init --service <slug> [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
11
- daywalker rotate [--service <slug>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
10
+ daywalker init [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--skip-register] [--json|--export] [--print-key]
11
+ daywalker rotate [--service <slug[,slug…]>] [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--skip-register] [--json|--export] [--print-key]
12
12
  daywalker check
13
13
 
14
14
  init Generates an Ed25519 keypair, registers the public key directly with Daywalker Auth
@@ -180,12 +180,12 @@ async function init(opts) {
180
180
  const dir = process.cwd();
181
181
  const envFile = path.join(dir, ".env.local");
182
182
  const existing = readEnvFile(envFile);
183
- const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "")
184
- .trim()
185
- .toLowerCase();
186
- if (!slug || !/^[a-z0-9_-]+$/.test(slug)) {
187
- fail("--service must be this service's Daywalker slug (e.g. --service sonalyze)");
183
+ const slugs = parseServiceSlugs(String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? ""));
184
+ // Optional: requests never name a service. Only needed to verify access tokens offline (audience check).
185
+ if (!slugs.every((s) => /^[a-z0-9_-]+$/.test(s))) {
186
+ fail("--service must be Daywalker service slug(s) (e.g. --service sonalyze, or gigpulse,devpulse)");
188
187
  }
188
+ const slug = slugs.join(",");
189
189
  const rawDomain = opts.domain ? String(opts.domain) : existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "";
190
190
  let domain = null;
191
191
  if (rawDomain) {
@@ -214,7 +214,6 @@ async function init(opts) {
214
214
  const regResult = await registerPublicKey({
215
215
  authUrl,
216
216
  apiKey,
217
- serviceSlug: slug,
218
217
  domain,
219
218
  publicJwk: activeKey.publicJwk,
220
219
  });
@@ -233,7 +232,7 @@ async function init(opts) {
233
232
  }
234
233
  const signingKeyStr = key?.privateKeyPkcs8Base64 ?? existing.get("DAYWALKER_SIGNING_KEY") ?? "";
235
234
  const envValues = {
236
- DAYWALKER_SERVICE_SLUG: slug,
235
+ ...(slug ? { DAYWALKER_SERVICE_SLUG: slug } : {}),
237
236
  DAYWALKER_KEY_ID: kid,
238
237
  DAYWALKER_AUTH_URL: authUrl,
239
238
  ...(domain ? { DAYWALKER_DOMAIN: domain } : {}),
@@ -248,9 +247,9 @@ async function init(opts) {
248
247
  }
249
248
  writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
250
249
  ensureGitignored(dir, ".env.local");
251
- console.log(`✔ Daywalker configured for service "${slug}"${domain ? ` (${domain})` : ""}`);
250
+ console.log(`✔ Daywalker configured${slug ? ` (audience "${slug}")` : ""}${domain ? ` (${domain})` : ""}`);
252
251
  console.log(` • RFC 9421 / RFC 9530 HTTP Message Signing active`);
253
- console.log(` • .env.local: DAYWALKER_SERVICE_SLUG, DAYWALKER_KEY_ID, DAYWALKER_AUTH_URL${domain ? ", DAYWALKER_DOMAIN" : ""}${apiKey ? ", DAYWALKER_API_KEY" : ""}${signingKeyStr ? ", DAYWALKER_SIGNING_KEY" : ""}`);
252
+ console.log(` • .env.local: ${slug ? "DAYWALKER_SERVICE_SLUG, " : ""}DAYWALKER_KEY_ID, DAYWALKER_AUTH_URL${domain ? ", DAYWALKER_DOMAIN" : ""}${apiKey ? ", DAYWALKER_API_KEY" : ""}${signingKeyStr ? ", DAYWALKER_SIGNING_KEY" : ""}`);
254
253
  if (kid)
255
254
  console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
256
255
  if (registered)
@@ -268,7 +267,7 @@ async function rotate(opts) {
268
267
  const current = existing.get("DAYWALKER_SIGNING_KEY") ?? process.env.DAYWALKER_SIGNING_KEY;
269
268
  if (!current)
270
269
  fail("No DAYWALKER_SIGNING_KEY found; run `daywalker init` first");
271
- const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase();
270
+ const slug = parseServiceSlugs(String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "")).join(",");
272
271
  const apiKey = await resolveApiKey(opts, existing);
273
272
  const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
274
273
  const rotateDomain = publicHostname(String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? ""));
@@ -276,15 +275,14 @@ async function rotate(opts) {
276
275
  // The new key only becomes active once Daywalker Auth knows it, so registration must succeed
277
276
  // before anything is written (unless the caller explicitly registers it some other way).
278
277
  if (!skipRegister(opts)) {
279
- if (!apiKey || !slug) {
280
- fail("rotate needs DAYWALKER_API_KEY and DAYWALKER_SERVICE_SLUG to register the new key (or pass --skip-register to write it unregistered)");
278
+ if (!apiKey) {
279
+ fail("rotate needs DAYWALKER_API_KEY to register the new key (or pass --skip-register to write it unregistered)");
281
280
  }
282
281
  if (!isJson)
283
282
  console.log(`• Registering new public key (${key.publicJwk.kid}) with Daywalker Auth...`);
284
283
  const regResult = await registerPublicKey({
285
284
  authUrl,
286
285
  apiKey,
287
- serviceSlug: slug,
288
286
  domain: rotateDomain,
289
287
  publicJwk: key.publicJwk,
290
288
  });
@@ -317,7 +315,9 @@ async function check() {
317
315
  const result = await client.selfCheck();
318
316
  if (result.ok) {
319
317
  console.log(`✔ Daywalker configuration looks good`);
320
- if (result.serviceSlug)
318
+ if (result.serviceSlugs.length > 1)
319
+ console.log(` • Services: ${result.serviceSlugs.join(", ")} (default: ${result.serviceSlug})`);
320
+ else if (result.serviceSlug)
321
321
  console.log(` • Service slug: ${result.serviceSlug}`);
322
322
  if (result.domain)
323
323
  console.log(` • Caller domain: ${result.domain}`);
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
- import { type DaywalkerConfig, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
2
+ import { type DaywalkerConfig, type TokenSet, type GetTokensOptions, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
3
3
  /** Validation results kept in memory; the least recently used are evicted past this. */
4
4
  export declare const MAX_VALIDATION_CACHE_ENTRIES = 1000;
5
5
  /** Longest 429 `Retry-After` waited out by default (`retry.maxRetryAfterMs`); longer ones return the 429. */
@@ -28,6 +28,9 @@ export declare class Daywalker {
28
28
  get config(): ResolvedConfig;
29
29
  toJSON(): Record<string, unknown>;
30
30
  private getLoadedSigningKey;
31
+ private _dpopKey;
32
+ /** The client's DPoP key, generated on first use. Issued tokens are bound to it for this client's life. */
33
+ private getDpopKey;
31
34
  private getLoadedPreviousSigningKey;
32
35
  /**
33
36
  * Signs and executes a request to Daywalker Auth (same origin as `authUrl` only).
@@ -83,15 +86,27 @@ export declare class Daywalker {
83
86
  serviceSlug?: string;
84
87
  offlineOnly?: boolean;
85
88
  }): Promise<TokenPayload>;
89
+ private _tokens;
90
+ private _tokenFlight;
91
+ private _tokenRequest;
86
92
  /**
87
- * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
93
+ * Client credentials grant (RFC 6749 §4.4): the signed request (API key + registered Ed25519 key) is
94
+ * exchanged for a short-lived access token JWT listing every service the credentials cover, and a
95
+ * rotating refresh token. No service slug is sent. The tokens are remembered for `accessToken()`.
88
96
  */
89
- issueLease(ttlSeconds?: number): Promise<{
90
- lease: string;
91
- tokenType?: string;
92
- expiresAt?: string;
93
- jwksUri?: string;
94
- }>;
97
+ getTokens(options?: GetTokensOptions): Promise<TokenSet>;
98
+ /**
99
+ * Refresh token rotation: trades the refresh token for a NEW access token and a NEW refresh token; the
100
+ * old refresh token stops working. Presenting an old one again revokes the whole session, so refreshes
101
+ * are serialized per client and the new refresh token must replace the stored one.
102
+ */
103
+ refreshTokens(refreshToken?: string): Promise<TokenSet>;
104
+ /**
105
+ * A valid access token, refreshing (or re-authenticating with the client credentials once the session
106
+ * ended) when it is within 30 seconds of expiry. Concurrent callers share one request, so the single-use
107
+ * refresh token is never presented twice.
108
+ */
109
+ accessToken(): Promise<string>;
95
110
  /**
96
111
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
97
112
  * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { resolveConfig } from "./config.js";
2
- import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.js";
1
+ import { parseServiceSlugs, resolveConfig } from "./config.js";
2
+ import { loadSigningKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, generateDpopKey, createDpopProof, } from "./crypto/index.js";
3
3
  import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
4
4
  function parseRetryAfter(headerValue) {
5
5
  if (!headerValue)
@@ -159,6 +159,9 @@ function malformedValidation(json, payloadData) {
159
159
  if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
160
160
  return `${k} is not a string`;
161
161
  }
162
+ if (src.services !== undefined && src.services !== null && !(Array.isArray(src.services) && src.services.every((s) => typeof s === "string"))) {
163
+ return "services is not a list of strings";
164
+ }
162
165
  for (const k of ["rateLimit", "rateLimitHourly"]) {
163
166
  if (src[k] !== undefined && src[k] !== null && !isRateLimitWindow(src[k]))
164
167
  return `${k} is not a rate limit window`;
@@ -170,6 +173,7 @@ function malformedValidation(json, payloadData) {
170
173
  function cloneResult(result) {
171
174
  return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
172
175
  }
176
+ const DEFAULT_ISSUER = "https://auth.daywalker.dev";
173
177
  export class Daywalker {
174
178
  _config;
175
179
  _rawConfig;
@@ -210,6 +214,11 @@ export class Daywalker {
210
214
  }
211
215
  return this._signingKeyPromise;
212
216
  }
217
+ _dpopKey = null;
218
+ /** The client's DPoP key, generated on first use. Issued tokens are bound to it for this client's life. */
219
+ getDpopKey() {
220
+ return (this._dpopKey ??= generateDpopKey());
221
+ }
213
222
  async getLoadedPreviousSigningKey() {
214
223
  const raw = this._config.previousSigningKey;
215
224
  if (!raw)
@@ -280,6 +289,14 @@ export class Daywalker {
280
289
  if (idempotencyKey && !headers.has("idempotency-key")) {
281
290
  headers.set("idempotency-key", idempotencyKey);
282
291
  }
292
+ // DPoP (RFC 9449) is mandatory on the token endpoint and when presenting a DPoP access token.
293
+ const auth = headers.get("authorization") ?? "";
294
+ const isDpopAuth = /^dpop\s+/i.test(auth);
295
+ const isTokenEndpoint = url.pathname === "/api/v1/oauth/token";
296
+ if (!headers.has("dpop") && (isTokenEndpoint || isDpopAuth)) {
297
+ const accessToken = isDpopAuth ? auth.replace(/^dpop\s+/i, "") : undefined;
298
+ headers.set("dpop", await createDpopProof(await this.getDpopKey(), { method, url: url.toString(), accessToken }));
299
+ }
283
300
  if (signingKey) {
284
301
  // apiKey is left out so the signer can't replace an explicit Authorization header.
285
302
  const signedHeaders = await signRequest({
@@ -409,14 +426,14 @@ export class Daywalker {
409
426
  token,
410
427
  publicKey: this._config.daywalkerPublicKey,
411
428
  expectedServiceSlug: serviceSlug || undefined,
412
- expectedIssuer: this._config.leaseIssuer ?? undefined,
429
+ expectedIssuer: this._config.leaseIssuer ?? DEFAULT_ISSUER,
413
430
  });
414
431
  if (jwtResult.valid && jwtResult.payload) {
415
432
  const payload = jwtResult.payload;
416
433
  const result = {
417
434
  ok: true,
418
435
  data: {
419
- serviceSlug: serviceSlug || (payload.serviceSlug || payload.aud || ""),
436
+ serviceSlug: serviceSlug || (payload.serviceSlug || payload.service || ""),
420
437
  tenantId: (payload.tenantId || payload.sub || ""),
421
438
  rateLimit: payload.rateLimit ?? {
422
439
  limit: 1000,
@@ -430,6 +447,8 @@ export class Daywalker {
430
447
  stage: payload.stage,
431
448
  offlineVerified: true,
432
449
  ...payload,
450
+ // Every service the lease covers: its `services` claim, else its audience.
451
+ services: leaseServices(payload),
433
452
  },
434
453
  };
435
454
  if (this._config.cacheTtlMs > 0) {
@@ -482,10 +501,8 @@ export class Daywalker {
482
501
  message: redactSecrets(message),
483
502
  });
484
503
  try {
504
+ // No service slug is sent: the caller is identified by its credentials, not by naming a service
485
505
  const validateBody = { token };
486
- if (serviceSlug) {
487
- validateBody.serviceSlug = serviceSlug;
488
- }
489
506
  const res = await this.fetch("/api/v1/tokens/validate", {
490
507
  method: "POST",
491
508
  body: validateBody,
@@ -535,6 +552,7 @@ export class Daywalker {
535
552
  userId: (payloadData.userId || json.userId),
536
553
  stage: (payloadData.stage || json.stage),
537
554
  ...payloadData,
555
+ services: (payloadData.services ?? json.services) ?? [json.serviceSlug || serviceSlug],
538
556
  };
539
557
  const result = {
540
558
  ok: true,
@@ -602,19 +620,88 @@ export class Daywalker {
602
620
  }
603
621
  return result.data;
604
622
  }
623
+ _tokens = null;
624
+ _tokenFlight = null;
625
+ async _tokenRequest(body) {
626
+ const res = await this.fetch("/api/v1/oauth/token", { method: "POST", body });
627
+ const json = (await res.json().catch(() => ({})));
628
+ if (res.status !== 200) {
629
+ throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Token request failed with HTTP ${res.status}`)), res.status, json.code ?? json.error);
630
+ }
631
+ if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string") {
632
+ throw new DaywalkerAuthError("Daywalker Auth returned a malformed token response", res.status, "SERVICE_UNAVAILABLE");
633
+ }
634
+ const services = Array.isArray(json.services) ? json.services.filter((x) => typeof x === "string") : [];
635
+ return {
636
+ accessToken: json.access_token,
637
+ refreshToken: json.refresh_token,
638
+ tokenType: String(json.token_type ?? "DPoP"),
639
+ expiresAt: Number(json.expires_at) || Math.floor(Date.now() / 1000) + Number(json.expires_in ?? 0),
640
+ expiresIn: Number(json.expires_in ?? 0),
641
+ refreshExpiresIn: Number(json.refresh_expires_in ?? 0),
642
+ scope: String(json.scope ?? services.join(" ")),
643
+ services,
644
+ jwksUri: typeof json.jwks_uri === "string" ? json.jwks_uri : undefined,
645
+ };
646
+ }
605
647
  /**
606
- * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
648
+ * Client credentials grant (RFC 6749 §4.4): the signed request (API key + registered Ed25519 key) is
649
+ * exchanged for a short-lived access token JWT listing every service the credentials cover, and a
650
+ * rotating refresh token. No service slug is sent. The tokens are remembered for `accessToken()`.
607
651
  */
608
- async issueLease(ttlSeconds) {
609
- const res = await this.fetch("/api/v1/leases/issue", {
610
- method: "POST",
611
- body: ttlSeconds ? { ttlSeconds } : {},
652
+ async getTokens(options = {}) {
653
+ const scope = options.scope ? parseServiceSlugs(options.scope) : undefined;
654
+ if (options.scope && (!scope || scope.length === 0)) {
655
+ throw new DaywalkerAuthError("getTokens: scope must list at least one service slug", 400, "INVALID_REQUEST");
656
+ }
657
+ const tokens = await this._tokenRequest({
658
+ grant_type: "client_credentials",
659
+ ...(scope ? { scope: scope.join(" ") } : {}),
660
+ ...(options.expiresIn ? { expires_in: options.expiresIn } : {}),
612
661
  });
613
- const json = (await res.json().catch(() => ({})));
614
- if (res.status !== 201 && res.status !== 200) {
615
- throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Lease issue failed with HTTP ${res.status}`)), res.status, json.error);
662
+ this._tokens = tokens;
663
+ return tokens;
664
+ }
665
+ /**
666
+ * Refresh token rotation: trades the refresh token for a NEW access token and a NEW refresh token; the
667
+ * old refresh token stops working. Presenting an old one again revokes the whole session, so refreshes
668
+ * are serialized per client and the new refresh token must replace the stored one.
669
+ */
670
+ async refreshTokens(refreshToken) {
671
+ const token = refreshToken ?? this._tokens?.refreshToken;
672
+ if (!token)
673
+ throw new DaywalkerAuthError("refreshTokens: no refresh token (call getTokens() first)", 400, "INVALID_REQUEST");
674
+ const tokens = await this._tokenRequest({ grant_type: "refresh_token", refresh_token: token });
675
+ this._tokens = tokens;
676
+ return tokens;
677
+ }
678
+ /**
679
+ * A valid access token, refreshing (or re-authenticating with the client credentials once the session
680
+ * ended) when it is within 30 seconds of expiry. Concurrent callers share one request, so the single-use
681
+ * refresh token is never presented twice.
682
+ */
683
+ async accessToken() {
684
+ const current = this._tokens;
685
+ if (current && current.expiresAt - 30 > Math.floor(Date.now() / 1000))
686
+ return current.accessToken;
687
+ if (!this._tokenFlight) {
688
+ this._tokenFlight = (async () => {
689
+ if (current) {
690
+ try {
691
+ return await this.refreshTokens();
692
+ }
693
+ catch (err) {
694
+ if (!(err instanceof DaywalkerAuthError) || err.status !== 400)
695
+ throw err;
696
+ // invalid_grant: the session ended or was revoked; start a new one
697
+ }
698
+ }
699
+ return this.getTokens();
700
+ })().finally(() => {
701
+ this._tokenFlight = null;
702
+ });
616
703
  }
617
- return json;
704
+ return (await this._tokenFlight).accessToken;
618
705
  }
619
706
  /**
620
707
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
@@ -685,6 +772,7 @@ export class Daywalker {
685
772
  return {
686
773
  ok: problems.length === 0,
687
774
  serviceSlug: this._config.serviceSlug,
775
+ serviceSlugs: this._config.serviceSlugs,
688
776
  domain: this._config.domain,
689
777
  signing: Boolean(signingKey),
690
778
  keyId,
package/dist/config.d.ts CHANGED
@@ -5,7 +5,10 @@ export interface ResolvedConfig {
5
5
  /** Never signs; `keys()` publishes its public JWK alongside the current key during rotation. */
6
6
  previousSigningKey: string | SigningKey | null;
7
7
  keyId: string | null;
8
+ /** The slug used when a call doesn't name one: the first of `serviceSlugs`. */
8
9
  serviceSlug: string | null;
10
+ /** Every service this deployment is configured for (one API key can cover several). */
11
+ serviceSlugs: string[];
9
12
  domain: string | null;
10
13
  domainSource: string | null;
11
14
  daywalkerPublicKey: string | PublicJwk | PublicJwks | null;
@@ -31,4 +34,9 @@ export declare function publicHostname(value: string | undefined | null): string
31
34
  * so API keys, SDK keys and write keys never travel in cleartext.
32
35
  */
33
36
  export declare function assertSecureUrl(name: string, value: string): void;
37
+ /**
38
+ * Service slugs from a comma- or whitespace-separated string (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`)
39
+ * or an array: lower-cased, de-duplicated, in the order given (the first is the default service).
40
+ */
41
+ export declare function parseServiceSlugs(value: string | readonly string[] | null | undefined): string[];
34
42
  export declare function resolveConfig(options?: DaywalkerConfig): ResolvedConfig;
package/dist/config.js CHANGED
@@ -66,15 +66,22 @@ export function assertSecureUrl(name, value) {
66
66
  return;
67
67
  throw new Error(`${name} must use https:// (http:// is only allowed for localhost); got "${value}"`);
68
68
  }
69
+ /**
70
+ * Service slugs from a comma- or whitespace-separated string (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`)
71
+ * or an array: lower-cased, de-duplicated, in the order given (the first is the default service).
72
+ */
73
+ export function parseServiceSlugs(value) {
74
+ const parts = typeof value === "string" ? value.split(/[\s,]+/) : Array.isArray(value) ? value.flatMap((v) => String(v).split(/[\s,]+/)) : [];
75
+ return Array.from(new Set(parts.map((v) => v.trim().toLowerCase()).filter(Boolean)));
76
+ }
69
77
  export function resolveConfig(options = {}) {
70
78
  const env = options.env ?? (typeof process !== "undefined" && process.env ? process.env : {});
71
79
  const problems = [];
72
80
  const apiKey = (options.apiKey ?? env.DAYWALKER_API_KEY ?? "").trim() || null;
73
- const serviceSlug = (options.serviceSlug ?? env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase() || null;
81
+ const serviceSlugs = parseServiceSlugs(options.serviceSlugs ?? options.serviceSlug ?? env.DAYWALKER_SERVICE_SLUG);
82
+ const serviceSlug = serviceSlugs[0] ?? null;
74
83
  if (!apiKey)
75
84
  problems.push("DAYWALKER_API_KEY is not set");
76
- if (!serviceSlug)
77
- problems.push("DAYWALKER_SERVICE_SLUG is not set");
78
85
  const signingKey = options.signingKey ?? env.DAYWALKER_SIGNING_KEY ?? null;
79
86
  const previousSigningKey = options.previousSigningKey ?? env.DAYWALKER_SIGNING_KEY_PREVIOUS ?? null;
80
87
  const keyId = (options.keyId ?? env.DAYWALKER_KEY_ID ?? "").trim() || null;
@@ -123,6 +130,7 @@ export function resolveConfig(options = {}) {
123
130
  previousSigningKey,
124
131
  keyId,
125
132
  serviceSlug,
133
+ serviceSlugs,
126
134
  domain,
127
135
  domainSource,
128
136
  daywalkerPublicKey,
@@ -0,0 +1,37 @@
1
+ import type { AccessTokenUser } from "./types.js";
2
+ export interface TokenCookieOptions {
3
+ /** Default `Strict`. Use `Lax` only if users must arrive logged in from links on other sites. */
4
+ sameSite?: "Strict" | "Lax";
5
+ /** Default `/`. Scope a refresh token cookie to your refresh route, e.g. `/api/auth/refresh`. */
6
+ path?: string;
7
+ /** Lifetime in seconds. */
8
+ maxAge: number;
9
+ domain?: string;
10
+ /** Default true. Only disable for plain-http localhost development. */
11
+ secure?: boolean;
12
+ }
13
+ /**
14
+ * `Set-Cookie` header value for a JWT: always `HttpOnly` (scripts, so XSS, can't read it), `Secure`
15
+ * and `SameSite=Strict` (or `Lax`) so cross-site requests (CSRF) don't carry it. Throws on values that
16
+ * could inject attributes.
17
+ */
18
+ export declare function buildTokenCookie(name: string, value: string, options: TokenCookieOptions): string;
19
+ /** `Set-Cookie` value that deletes a cookie set with `buildTokenCookie` (use the same path/domain). */
20
+ export declare function clearTokenCookie(name: string, options?: Pick<TokenCookieOptions, "path" | "domain" | "sameSite" | "secure">): string;
21
+ /**
22
+ * The two cookies for a web app's session: the access token (`dw_at`, sent everywhere) and the refresh
23
+ * token (`dw_rt`, only sent to `refreshPath`). Both HttpOnly + Secure + SameSite.
24
+ */
25
+ export declare function daywalkerCookies(tokens: {
26
+ accessToken: string;
27
+ refreshToken: string;
28
+ expiresIn: number;
29
+ refreshExpiresIn: number;
30
+ }, options?: {
31
+ refreshPath?: string;
32
+ sameSite?: "Strict" | "Lax";
33
+ secure?: boolean;
34
+ domain?: string;
35
+ }): string[];
36
+ /** The `user` object (public UUID, name, email) of an access token payload that was already verified. */
37
+ export declare function accessTokenUser(payload: Record<string, unknown>): AccessTokenUser | null;
@@ -0,0 +1,58 @@
1
+ const COOKIE_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
2
+ const COOKIE_PATH = /^\/[\x21-\x3a\x3c-\x7e]*$/;
3
+ const COOKIE_DOMAIN = /^[A-Za-z0-9.-]+$/;
4
+ /**
5
+ * `Set-Cookie` header value for a JWT: always `HttpOnly` (scripts, so XSS, can't read it), `Secure`
6
+ * and `SameSite=Strict` (or `Lax`) so cross-site requests (CSRF) don't carry it. Throws on values that
7
+ * could inject attributes.
8
+ */
9
+ export function buildTokenCookie(name, value, options) {
10
+ if (!COOKIE_NAME.test(name))
11
+ throw new TypeError("Invalid cookie name");
12
+ if (!/^[A-Za-z0-9._~+/=-]+$/.test(value))
13
+ throw new TypeError("Invalid cookie value: expected a JWT");
14
+ const path = options.path ?? "/";
15
+ if (!COOKIE_PATH.test(path))
16
+ throw new TypeError("Invalid cookie path");
17
+ if (options.domain !== undefined && !COOKIE_DOMAIN.test(options.domain))
18
+ throw new TypeError("Invalid cookie domain");
19
+ if (!Number.isFinite(options.maxAge) || options.maxAge < 0)
20
+ throw new TypeError("maxAge must be a non-negative number of seconds");
21
+ const sameSite = options.sameSite ?? "Strict";
22
+ if (sameSite !== "Strict" && sameSite !== "Lax")
23
+ throw new TypeError("sameSite must be Strict or Lax");
24
+ return [
25
+ `${name}=${value}`,
26
+ `Path=${path}`,
27
+ `Max-Age=${Math.floor(options.maxAge)}`,
28
+ ...(options.domain ? [`Domain=${options.domain}`] : []),
29
+ "HttpOnly",
30
+ ...(options.secure === false ? [] : ["Secure"]),
31
+ `SameSite=${sameSite}`,
32
+ ].join("; ");
33
+ }
34
+ /** `Set-Cookie` value that deletes a cookie set with `buildTokenCookie` (use the same path/domain). */
35
+ export function clearTokenCookie(name, options = {}) {
36
+ return buildTokenCookie(name, "deleted", { ...options, maxAge: 0 }).replace("=deleted;", "=;");
37
+ }
38
+ /**
39
+ * The two cookies for a web app's session: the access token (`dw_at`, sent everywhere) and the refresh
40
+ * token (`dw_rt`, only sent to `refreshPath`). Both HttpOnly + Secure + SameSite.
41
+ */
42
+ export function daywalkerCookies(tokens, options = {}) {
43
+ const common = { sameSite: options.sameSite, secure: options.secure, domain: options.domain };
44
+ return [
45
+ buildTokenCookie("dw_at", tokens.accessToken, { ...common, path: "/", maxAge: tokens.expiresIn }),
46
+ buildTokenCookie("dw_rt", tokens.refreshToken, { ...common, path: options.refreshPath ?? "/api/auth/refresh", maxAge: tokens.refreshExpiresIn }),
47
+ ];
48
+ }
49
+ /** The `user` object (public UUID, name, email) of an access token payload that was already verified. */
50
+ export function accessTokenUser(payload) {
51
+ const u = payload.user;
52
+ if (!u || typeof u !== "object")
53
+ return null;
54
+ const { id, name, email } = u;
55
+ if (typeof id !== "string" || typeof email !== "string")
56
+ return null;
57
+ return { id, name: typeof name === "string" ? name : null, email };
58
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * DPoP (RFC 9449) proof-of-possession. Daywalker binds every access and refresh token to a client key and
3
+ * requires a fresh proof on each token request, refresh and validate call.
4
+ */
5
+ export interface DpopKey {
6
+ privateKey: CryptoKey;
7
+ publicJwk: {
8
+ kty: "OKP";
9
+ crv: "Ed25519";
10
+ x: string;
11
+ };
12
+ }
13
+ /** A new in-memory Ed25519 DPoP key. Tokens are bound to it, so keep it for the life of the session. */
14
+ export declare function generateDpopKey(): Promise<DpopKey>;
15
+ /**
16
+ * Builds a DPoP proof JWT for one request. `htu` is the URL without query or fragment. Pass the access token
17
+ * to add `ath` (its SHA-256, base64url) when presenting it with `Authorization: DPoP`.
18
+ */
19
+ export declare function createDpopProof(key: DpopKey, params: {
20
+ method: string;
21
+ url: string;
22
+ accessToken?: string;
23
+ }): Promise<string>;
@@ -0,0 +1,28 @@
1
+ import { bytesToBase64Url, stringToUtf8 } from "./encoding.js";
2
+ import { sha256Base64Url } from "./digest.js";
3
+ /** A new in-memory Ed25519 DPoP key. Tokens are bound to it, so keep it for the life of the session. */
4
+ export async function generateDpopKey() {
5
+ const pair = (await globalThis.crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]));
6
+ const jwk = (await globalThis.crypto.subtle.exportKey("jwk", pair.publicKey));
7
+ return { privateKey: pair.privateKey, publicJwk: { kty: "OKP", crv: "Ed25519", x: jwk.x } };
8
+ }
9
+ const b64u = (value) => bytesToBase64Url(stringToUtf8(JSON.stringify(value)));
10
+ /**
11
+ * Builds a DPoP proof JWT for one request. `htu` is the URL without query or fragment. Pass the access token
12
+ * to add `ath` (its SHA-256, base64url) when presenting it with `Authorization: DPoP`.
13
+ */
14
+ export async function createDpopProof(key, params) {
15
+ const u = new URL(params.url);
16
+ const header = { typ: "dpop+jwt", alg: "EdDSA", jwk: key.publicJwk };
17
+ const claims = {
18
+ jti: globalThis.crypto.randomUUID(),
19
+ htm: params.method.toUpperCase(),
20
+ htu: `${u.origin}${u.pathname}`,
21
+ iat: Math.floor(Date.now() / 1000),
22
+ };
23
+ if (params.accessToken)
24
+ claims.ath = await sha256Base64Url(params.accessToken);
25
+ const signingInput = `${b64u(header)}.${b64u(claims)}`;
26
+ const sig = new Uint8Array(await globalThis.crypto.subtle.sign({ name: "Ed25519" }, key.privateKey, stringToUtf8(signingInput)));
27
+ return `${signingInput}.${bytesToBase64Url(sig)}`;
28
+ }
@@ -1,5 +1,7 @@
1
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, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, 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, leaseServices, registerPublicKey, } from "./signatures.js";
5
5
  export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./signatures.js";
6
+ export { generateDpopKey, createDpopProof } from "./dpop.js";
7
+ export type { DpopKey } from "./dpop.js";
@@ -1,4 +1,5 @@
1
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, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, 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, leaseServices, registerPublicKey, } from "./signatures.js";
5
+ export { generateDpopKey, createDpopProof } from "./dpop.js";
@@ -88,8 +88,13 @@ export declare function signRequest(params: SignRequestParams): Promise<SignedHe
88
88
  * Replay protection uses `replayStore` (default: per-process memory; use a shared store across instances).
89
89
  */
90
90
  export declare function verifyRequestSignature(params: VerifySignatureParams): Promise<VerifySignatureResult>;
91
- /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
92
- export declare const JWT_CLOCK_SKEW_SECONDS = 60;
91
+ /**
92
+ * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
93
+ * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
94
+ */
95
+ export declare function leaseServices(payload: Record<string, unknown>): string[];
96
+ /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
97
+ export declare const JWT_CLOCK_SKEW_SECONDS = 30;
93
98
  /**
94
99
  * Zero-egress offline verification for asymmetric JWT token leases.
95
100
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
@@ -405,8 +405,18 @@ export async function verifyRequestSignature(params) {
405
405
  return { valid: false, error: `Verification error: ${err.message}` };
406
406
  }
407
407
  }
408
- /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
409
- export const JWT_CLOCK_SKEW_SECONDS = 60;
408
+ /**
409
+ * Every service slug a (verified) JWT lease covers: its `services` claim, else its audience, else the
410
+ * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
411
+ */
412
+ export function leaseServices(payload) {
413
+ const strings = (v) => (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]).filter((s) => typeof s === "string" && s !== "");
414
+ const listed = strings(payload.services);
415
+ const fallback = listed.length > 0 ? listed : strings(payload.aud).length > 0 ? strings(payload.aud) : strings(payload.serviceSlug ?? payload.service);
416
+ return Array.from(new Set(fallback.map((s) => s.toLowerCase())));
417
+ }
418
+ /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
419
+ export const JWT_CLOCK_SKEW_SECONDS = 30;
410
420
  /**
411
421
  * Zero-egress offline verification for asymmetric JWT token leases.
412
422
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
@@ -440,7 +450,8 @@ export async function verifyJwtLease(params) {
440
450
  if (header.alg !== "EdDSA" && header.alg !== "ed25519") {
441
451
  return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
442
452
  }
443
- if (header.typ !== undefined && String(header.typ).toUpperCase() !== "JWT") {
453
+ // "JWT" (older leases) or "at+jwt" (RFC 9068 access tokens); a refresh token ("rt+jwt") is never accepted here
454
+ if (header.typ !== undefined && !["JWT", "AT+JWT"].includes(String(header.typ).toUpperCase())) {
444
455
  return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
445
456
  }
446
457
  // RFC 7515 §4.1.11: extensions we don't understand must be rejected; we implement none.
@@ -457,7 +468,10 @@ export async function verifyJwtLease(params) {
457
468
  return { valid: false, error: "JWT signature verification failed" };
458
469
  }
459
470
  const now = params.currentTime ?? Math.floor(Date.now() / 1000);
460
- const skew = Math.max(0, Math.min(params.clockSkewSeconds ?? JWT_CLOCK_SKEW_SECONDS, 300));
471
+ const skew = Math.max(0, Math.min(params.clockSkewSeconds ?? JWT_CLOCK_SKEW_SECONDS, JWT_CLOCK_SKEW_SECONDS));
472
+ if (typeof payload.jti !== "string" || payload.jti === "") {
473
+ return { valid: false, error: "JWT has no jti claim" };
474
+ }
461
475
  if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
462
476
  return { valid: false, error: "JWT token lease has no numeric exp claim" };
463
477
  }
@@ -490,6 +504,16 @@ export async function verifyJwtLease(params) {
490
504
  error: `JWT audience mismatch: expected ${expected}, got ${audiences.join(", ").toLowerCase()}`,
491
505
  };
492
506
  }
507
+ // A lease that lists its services must list this one (belt and braces next to `aud`).
508
+ if (payload.services !== undefined) {
509
+ const listed = Array.isArray(payload.services) ? payload.services : null;
510
+ if (!listed || !listed.some((s) => typeof s === "string" && s.toLowerCase() === expected)) {
511
+ return {
512
+ valid: false,
513
+ error: `JWT services mismatch: expected ${expected}, got ${listed ? listed.map(String).join(", ").toLowerCase() : "(not a list)"}`,
514
+ };
515
+ }
516
+ }
493
517
  if (params.expectedIssuer && payload.iss !== params.expectedIssuer) {
494
518
  return { valid: false, error: `JWT issuer mismatch: expected ${params.expectedIssuer}, got ${payload.iss ?? "(none)"}` };
495
519
  }
@@ -527,6 +551,8 @@ export async function registerPublicKey(params) {
527
551
  keyId: params.publicJwk.kid,
528
552
  domain: params.domain,
529
553
  publicKey: params.publicJwk,
554
+ // Lets Daywalker refuse right away when the API key doesn't cover the service(s); a comma-separated list is fine
555
+ ...(params.serviceSlug ? { serviceSlug: params.serviceSlug } : {}),
530
556
  }),
531
557
  });
532
558
  if (res.ok) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } 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, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
5
- export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
4
+ export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, GetTokensOptions, AccessTokenUser, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
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, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } 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, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, leaseServices, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
8
8
  export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./crypto/index.js";
9
+ export { buildTokenCookie, clearTokenCookie, daywalkerCookies, accessTokenUser } from "./cookies.js";
10
+ export type { TokenCookieOptions } from "./cookies.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
2
2
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
3
- export { resolveConfig, 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, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
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";
5
+ export { buildTokenCookie, clearTokenCookie, daywalkerCookies, accessTokenUser } from "./cookies.js";
package/dist/types.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface TokenPayload {
12
12
  tokenName?: string;
13
13
  userId?: string;
14
14
  stage?: string;
15
+ /**
16
+ * Every service slug the token (or JWT lease) covers. One API key can cover several services, so this
17
+ * can hold more than `serviceSlug`, which is the service this call validated for.
18
+ */
19
+ services?: string[];
15
20
  offlineVerified?: boolean;
16
21
  [key: string]: unknown;
17
22
  }
@@ -76,7 +81,13 @@ export interface DaywalkerConfig {
76
81
  */
77
82
  previousSigningKey?: string | SigningKey;
78
83
  keyId?: string;
84
+ /**
85
+ * The service this deployment validates tokens for (env DAYWALKER_SERVICE_SLUG). One API key can cover
86
+ * several services: a comma-separated list ("gigpulse,devpulse") is accepted, and the first is the default.
87
+ */
79
88
  serviceSlug?: string;
89
+ /** Same as a comma-separated `serviceSlug`, as an array. Takes precedence over `serviceSlug`. */
90
+ serviceSlugs?: string[];
80
91
  domain?: string;
81
92
  /** A JWK, a JWKS (`{ keys: [...] }`, selected by the lease's `kid`), PEM, or Base64 SPKI / raw key. */
82
93
  daywalkerPublicKey?: string | PublicJwk | PublicJwks;
@@ -98,9 +109,40 @@ export interface DaywalkerConfig {
98
109
  fetch?: typeof fetch;
99
110
  env?: Record<string, string | undefined>;
100
111
  }
112
+ /** Tokens from the client credentials flow (POST /api/v1/oauth/token). */
113
+ export interface TokenSet {
114
+ /** Short-lived (5-15 min) EdDSA JWT. Verify it with Daywalker's public JWKS. */
115
+ accessToken: string;
116
+ /** Single use: every refresh returns a new one. Keep it server-side. */
117
+ refreshToken: string;
118
+ tokenType: string;
119
+ /** Unix seconds. */
120
+ expiresAt: number;
121
+ expiresIn: number;
122
+ /** Seconds until the session ends and the client credentials must be used again. */
123
+ refreshExpiresIn: number;
124
+ scope: string;
125
+ /** Every service slug the access token covers (also its `services` and `aud` claims). */
126
+ services: string[];
127
+ jwksUri?: string;
128
+ }
129
+ export interface GetTokensOptions {
130
+ /** Narrow the grant to these service slugs (least privilege); default: every service the credentials cover. */
131
+ scope?: string[];
132
+ /** Access token lifetime in seconds (Daywalker clamps it to 300-900). */
133
+ expiresIn?: number;
134
+ }
135
+ /** The `user` claim of an access token. */
136
+ export interface AccessTokenUser {
137
+ /** The user's public UUID v7. */
138
+ id: string;
139
+ name: string | null;
140
+ email: string;
141
+ }
101
142
  export interface SelfCheckResult {
102
143
  ok: boolean;
103
144
  serviceSlug: string | null;
145
+ serviceSlugs: string[];
104
146
  domain: string | null;
105
147
  signing: boolean;
106
148
  keyId: string | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.4.1",
4
- "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one zero-dependency package.",
3
+ "version": "0.6.0",
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",
7
7
  "types": "./dist/index.d.ts",
@@ -68,6 +68,7 @@
68
68
  "files": [
69
69
  "dist",
70
70
  "docs",
71
+ "!docs/maintenance",
71
72
  "README.md",
72
73
  "LICENSE"
73
74
  ],