@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 +73 -9
- package/dist/cli.js +17 -17
- package/dist/client.d.ts +23 -8
- package/dist/client.js +104 -16
- package/dist/config.d.ts +8 -0
- package/dist/config.js +11 -3
- package/dist/cookies.d.ts +37 -0
- package/dist/cookies.js +58 -0
- package/dist/crypto/dpop.d.ts +23 -0
- package/dist/crypto/dpop.js +28 -0
- package/dist/crypto/index.d.ts +3 -1
- package/dist/crypto/index.js +2 -1
- package/dist/crypto/signatures.d.ts +7 -2
- package/dist/crypto/signatures.js +30 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/types.d.ts +42 -0
- package/package.json +3 -2
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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 ?? "")
|
|
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
|
|
280
|
-
fail("rotate needs DAYWALKER_API_KEY
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 ??
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/cookies.js
ADDED
|
@@ -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
|
+
}
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/crypto/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
92
|
-
|
|
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
|
-
/**
|
|
409
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
"description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one
|
|
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
|
],
|