@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 +38 -13
- package/dist/cli.js +9 -10
- package/dist/client.d.ts +23 -5
- package/dist/client.js +95 -26
- package/dist/config.js +0 -2
- 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 +2 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/signatures.d.ts +2 -2
- package/dist/crypto/signatures.js +8 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +26 -16
- package/package.json +1 -1
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
|
|
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
|
|
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`
|
|
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
|
-
**
|
|
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
|
|
220
|
-
|
|
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
|
|
223
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|
|
279
|
-
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)");
|
|
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
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
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
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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;
|
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
|
@@ -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";
|
package/dist/crypto/index.js
CHANGED
|
@@ -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
|
|
97
|
-
export declare const JWT_CLOCK_SKEW_SECONDS =
|
|
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
|
|
419
|
-
export const JWT_CLOCK_SKEW_SECONDS =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
113
|
-
export interface
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
/**
|
|
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
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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",
|