@shelbyb/daywalker-sdk 0.5.0 → 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
@@ -61,7 +61,7 @@ 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 |
64
+ | `DAYWALKER_API_KEY` | ✅ | The service's Daywalker API token (`srv_live_…`) or access token |
65
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 |
@@ -69,7 +69,7 @@ The private key is never printed unless you pass `--print-key`.
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
 
@@ -211,26 +211,51 @@ DAYWALKER_API_KEY=srv_live_… npx daywalker init --service gigpulse --domain gi
211
211
  DAYWALKER_API_KEY=srv_live_… npx daywalker init --service devpulse --domain devpulse.daywalker.dev
212
212
  ```
213
213
 
214
- `init` and `rotate` fail right away with `service_not_covered` if the API key doesn't cover the slug you name. An app that serves several services lists them all, `--service gigpulse,devpulse` (`DAYWALKER_SERVICE_SLUG="gigpulse,devpulse"`); the first is the default for `validateToken()`, and `client.config.serviceSlugs` holds the list.
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
215
 
216
- **The JWT lists every service.** Once your backend has authenticated with its signed request, ask for a lease. It lists all the service slugs the token covers (and may use right now):
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
217
 
218
218
  ```ts
219
- const lease = await client.issueLease();
220
- lease.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
219
+ const tokens = await client.getTokens();
220
+ tokens.services; // ["gigpulse", "devpulse"]; also the JWT's `services` and `aud` claims
221
221
 
222
- // Least privilege: only what this process needs. A service the key doesn't cover is refused (403 SERVICE_NOT_GRANTED).
223
- const narrow = await client.issueLease({ services: ["devpulse"], ttlSeconds: 3600 });
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.
224
249
  ```
225
250
 
226
- 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 `lease.services` rather than assuming.
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.
227
252
 
228
- Whoever receives the lease verifies it for **their own** service. It only passes when that slug is in the lease's `services` and `aud`:
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`:
229
254
 
230
255
  ```ts
231
256
  const verifier = new Daywalker({ serviceSlug: "devpulse", daywalkerPublicKey: process.env.DAYWALKER_PUBLIC_KEY });
232
257
  const result = await verifier.validateToken(incomingJwt);
233
- if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this lease covers
258
+ if (result.ok) result.data.services; // ["gigpulse", "devpulse"]: everything this token covers
234
259
  ```
235
260
 
236
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).
@@ -253,7 +278,7 @@ const client = new Daywalker({
253
278
  const result = await client.validateToken(incomingJwt);
254
279
  ```
255
280
 
256
- Leases must have a numeric `exp` 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`.
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`.
257
282
 
258
283
  ---
259
284
 
@@ -273,7 +298,7 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) containing your
273
298
  |---|---|---|
274
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 |
275
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` |
276
- | `POST /api/v1/leases/issue` | `client.issueLease(ttlSeconds? \| { ttlSeconds?, services? })` | Signed request returns `{ lease, tokenType, expiresAt, jwksUri, services }` (EdDSA JWT listing every service the key covers; `services` narrows it) |
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 |
277
302
  | `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
278
303
 
279
304
  Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { generateSigningKey, loadSigningKey, registerPublicKey, redactSecrets }
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[,slug…]> [--api-key-stdin] [--domain <host>] [--auth-url <url>] [--force] [--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
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
 
@@ -181,8 +181,9 @@ async function init(opts) {
181
181
  const envFile = path.join(dir, ".env.local");
182
182
  const existing = readEnvFile(envFile);
183
183
  const slugs = parseServiceSlugs(String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? ""));
184
- if (slugs.length === 0 || !slugs.every((s) => /^[a-z0-9_-]+$/.test(s))) {
185
- fail("--service must be this service's Daywalker slug (e.g. --service sonalyze), or a comma-separated list when one API key covers several (--service gigpulse,devpulse)");
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)");
186
187
  }
187
188
  const slug = slugs.join(",");
188
189
  const rawDomain = opts.domain ? String(opts.domain) : existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? "";
@@ -213,7 +214,6 @@ async function init(opts) {
213
214
  const regResult = await registerPublicKey({
214
215
  authUrl,
215
216
  apiKey,
216
- serviceSlug: slug,
217
217
  domain,
218
218
  publicJwk: activeKey.publicJwk,
219
219
  });
@@ -232,7 +232,7 @@ async function init(opts) {
232
232
  }
233
233
  const signingKeyStr = key?.privateKeyPkcs8Base64 ?? existing.get("DAYWALKER_SIGNING_KEY") ?? "";
234
234
  const envValues = {
235
- DAYWALKER_SERVICE_SLUG: slug,
235
+ ...(slug ? { DAYWALKER_SERVICE_SLUG: slug } : {}),
236
236
  DAYWALKER_KEY_ID: kid,
237
237
  DAYWALKER_AUTH_URL: authUrl,
238
238
  ...(domain ? { DAYWALKER_DOMAIN: domain } : {}),
@@ -247,9 +247,9 @@ async function init(opts) {
247
247
  }
248
248
  writeEnvFile(envFile, envValues, { DAYWALKER_SIGNING_KEY_PREVIOUS: PREVIOUS_KEY_COMMENT });
249
249
  ensureGitignored(dir, ".env.local");
250
- console.log(`✔ Daywalker configured for service "${slug}"${domain ? ` (${domain})` : ""}`);
250
+ console.log(`✔ Daywalker configured${slug ? ` (audience "${slug}")` : ""}${domain ? ` (${domain})` : ""}`);
251
251
  console.log(` • RFC 9421 / RFC 9530 HTTP Message Signing active`);
252
- 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" : ""}`);
253
253
  if (kid)
254
254
  console.log(` • Key ID (RFC 7638 thumbprint): ${kid}`);
255
255
  if (registered)
@@ -275,15 +275,14 @@ async function rotate(opts) {
275
275
  // The new key only becomes active once Daywalker Auth knows it, so registration must succeed
276
276
  // before anything is written (unless the caller explicitly registers it some other way).
277
277
  if (!skipRegister(opts)) {
278
- if (!apiKey || !slug) {
279
- 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)");
280
280
  }
281
281
  if (!isJson)
282
282
  console.log(`• Registering new public key (${key.publicJwk.kid}) with Daywalker Auth...`);
283
283
  const regResult = await registerPublicKey({
284
284
  authUrl,
285
285
  apiKey,
286
- serviceSlug: slug,
287
286
  domain: rotateDomain,
288
287
  publicJwk: key.publicJwk,
289
288
  });
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
- import { type DaywalkerConfig, type IssueLeaseOptions, type LeaseResponse, 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,12 +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).
88
- * The lease lists every service slug the API key covers (`services`, also the JWT's `services` and `aud`
89
- * claims), so one set of credentials serves all of them. Pass `{ services: [...] }` to narrow it.
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()`.
90
96
  */
91
- issueLease(options?: number | IssueLeaseOptions): Promise<LeaseResponse>;
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>;
92
110
  /**
93
111
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
94
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
1
  import { parseServiceSlugs, resolveConfig } from "./config.js";
2
- import { loadSigningKey, signRequest, verifyJwtLease, leaseServices, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.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)
@@ -173,6 +173,7 @@ function malformedValidation(json, payloadData) {
173
173
  function cloneResult(result) {
174
174
  return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
175
175
  }
176
+ const DEFAULT_ISSUER = "https://auth.daywalker.dev";
176
177
  export class Daywalker {
177
178
  _config;
178
179
  _rawConfig;
@@ -213,6 +214,11 @@ export class Daywalker {
213
214
  }
214
215
  return this._signingKeyPromise;
215
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
+ }
216
222
  async getLoadedPreviousSigningKey() {
217
223
  const raw = this._config.previousSigningKey;
218
224
  if (!raw)
@@ -283,6 +289,14 @@ export class Daywalker {
283
289
  if (idempotencyKey && !headers.has("idempotency-key")) {
284
290
  headers.set("idempotency-key", idempotencyKey);
285
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
+ }
286
300
  if (signingKey) {
287
301
  // apiKey is left out so the signer can't replace an explicit Authorization header.
288
302
  const signedHeaders = await signRequest({
@@ -412,7 +426,7 @@ export class Daywalker {
412
426
  token,
413
427
  publicKey: this._config.daywalkerPublicKey,
414
428
  expectedServiceSlug: serviceSlug || undefined,
415
- expectedIssuer: this._config.leaseIssuer ?? undefined,
429
+ expectedIssuer: this._config.leaseIssuer ?? DEFAULT_ISSUER,
416
430
  });
417
431
  if (jwtResult.valid && jwtResult.payload) {
418
432
  const payload = jwtResult.payload;
@@ -487,10 +501,8 @@ export class Daywalker {
487
501
  message: redactSecrets(message),
488
502
  });
489
503
  try {
504
+ // No service slug is sent: the caller is identified by its credentials, not by naming a service
490
505
  const validateBody = { token };
491
- if (serviceSlug) {
492
- validateBody.serviceSlug = serviceSlug;
493
- }
494
506
  const res = await this.fetch("/api/v1/tokens/validate", {
495
507
  method: "POST",
496
508
  body: validateBody,
@@ -608,31 +620,88 @@ export class Daywalker {
608
620
  }
609
621
  return result.data;
610
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
+ }
611
647
  /**
612
- * Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
613
- * The lease lists every service slug the API key covers (`services`, also the JWT's `services` and `aud`
614
- * claims), so one set of credentials serves all of them. Pass `{ services: [...] }` to narrow it.
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()`.
615
651
  */
616
- async issueLease(options) {
617
- const opts = typeof options === "number" ? { ttlSeconds: options } : (options ?? {});
618
- const services = opts.services ? parseServiceSlugs(opts.services) : undefined;
619
- if (opts.services && (!services || services.length === 0)) {
620
- throw new DaywalkerAuthError("issueLease: services must list at least one service slug", 400, "INVALID_REQUEST");
621
- }
622
- const res = await this.fetch("/api/v1/leases/issue", {
623
- method: "POST",
624
- body: { ...(opts.ttlSeconds ? { ttlSeconds: opts.ttlSeconds } : {}), ...(services ? { services } : {}) },
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 } : {}),
625
661
  });
626
- const json = (await res.json().catch(() => ({})));
627
- if (res.status !== 201 && res.status !== 200) {
628
- throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Lease issue failed with HTTP ${res.status}`)), res.status, json.error);
629
- }
630
- if (typeof json.lease !== "string") {
631
- throw new DaywalkerAuthError("Daywalker Auth returned a malformed lease response (no lease)", res.status, "SERVICE_UNAVAILABLE");
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
+ });
632
703
  }
633
- // Servers from before multi-service tokens don't list services; their lease covers the one configured slug.
634
- const listed = Array.isArray(json.services) ? json.services.filter((s) => typeof s === "string") : [];
635
- return { ...json, lease: json.lease, services: listed.length > 0 ? listed : this._config.serviceSlug ? [this._config.serviceSlug] : [] };
704
+ return (await this._tokenFlight).accessToken;
636
705
  }
637
706
  /**
638
707
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
package/dist/config.js CHANGED
@@ -82,8 +82,6 @@ export function resolveConfig(options = {}) {
82
82
  const serviceSlug = serviceSlugs[0] ?? null;
83
83
  if (!apiKey)
84
84
  problems.push("DAYWALKER_API_KEY is not set");
85
- if (!serviceSlug)
86
- problems.push("DAYWALKER_SERVICE_SLUG is not set");
87
85
  const signingKey = options.signingKey ?? env.DAYWALKER_SIGNING_KEY ?? null;
88
86
  const previousSigningKey = options.previousSigningKey ?? env.DAYWALKER_SIGNING_KEY_PREVIOUS ?? null;
89
87
  const keyId = (options.keyId ?? env.DAYWALKER_KEY_ID ?? "").trim() || null;
@@ -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
+ }
@@ -3,3 +3,5 @@ export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256
3
3
  export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
4
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";
@@ -2,3 +2,4 @@ export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64
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
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";
@@ -93,8 +93,8 @@ export declare function verifyRequestSignature(params: VerifySignatureParams): P
93
93
  * single `serviceSlug` / `service` claim of leases issued before multi-service tokens.
94
94
  */
95
95
  export declare function leaseServices(payload: Record<string, unknown>): string[];
96
- /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
97
- export declare const JWT_CLOCK_SKEW_SECONDS = 60;
96
+ /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
97
+ export declare const JWT_CLOCK_SKEW_SECONDS = 30;
98
98
  /**
99
99
  * Zero-egress offline verification for asymmetric JWT token leases.
100
100
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
@@ -415,8 +415,8 @@ export function leaseServices(payload) {
415
415
  const fallback = listed.length > 0 ? listed : strings(payload.aud).length > 0 ? strings(payload.aud) : strings(payload.serviceSlug ?? payload.service);
416
416
  return Array.from(new Set(fallback.map((s) => s.toLowerCase())));
417
417
  }
418
- /** Clock skew tolerated on JWT lease `exp` / `nbf`. */
419
- export const JWT_CLOCK_SKEW_SECONDS = 60;
418
+ /** Clock skew tolerated on JWT `exp` / `nbf`: 30 seconds, and never more (see `clockSkewSeconds`). */
419
+ export const JWT_CLOCK_SKEW_SECONDS = 30;
420
420
  /**
421
421
  * Zero-egress offline verification for asymmetric JWT token leases.
422
422
  * Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
@@ -450,7 +450,8 @@ export async function verifyJwtLease(params) {
450
450
  if (header.alg !== "EdDSA" && header.alg !== "ed25519") {
451
451
  return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
452
452
  }
453
- 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())) {
454
455
  return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
455
456
  }
456
457
  // RFC 7515 §4.1.11: extensions we don't understand must be rejected; we implement none.
@@ -467,7 +468,10 @@ export async function verifyJwtLease(params) {
467
468
  return { valid: false, error: "JWT signature verification failed" };
468
469
  }
469
470
  const now = params.currentTime ?? Math.floor(Date.now() / 1000);
470
- 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
+ }
471
475
  if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
472
476
  return { valid: false, error: "JWT token lease has no numeric exp claim" };
473
477
  }
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, LeaseResponse, IssueLeaseOptions, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
4
+ export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, TokenSet, GetTokensOptions, AccessTokenUser, 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
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
@@ -2,3 +2,4 @@ export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATI
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
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
@@ -109,25 +109,35 @@ export interface DaywalkerConfig {
109
109
  fetch?: typeof fetch;
110
110
  env?: Record<string, string | undefined>;
111
111
  }
112
- /** Response of `client.issueLease()` (POST /api/v1/leases/issue). */
113
- export interface LeaseResponse {
114
- /** The signed EdDSA JWT. Verify it with Daywalker's public JWKS. */
115
- lease: string;
116
- tokenType?: string;
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;
117
119
  /** Unix seconds. */
118
- expiresAt?: number | string;
119
- jwksUri?: string;
120
- /** Every service slug the lease covers (also its `services` and `aud` claims). */
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). */
121
126
  services: string[];
127
+ jwksUri?: string;
122
128
  }
123
- export interface IssueLeaseOptions {
124
- /** Lease lifetime in seconds (Daywalker clamps it to 1h-24h). */
125
- ttlSeconds?: number;
126
- /**
127
- * Narrow the lease to these service slugs (least privilege). Each must be one the API key covers;
128
- * otherwise Daywalker answers 403 `SERVICE_NOT_GRANTED`. Default: every service the key covers.
129
- */
130
- services?: string[];
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;
131
141
  }
132
142
  export interface SelfCheckResult {
133
143
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",