@shelbyb/daywalker-sdk 0.1.2 → 0.1.4
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 +19 -4
- package/dist/cli.js +2 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +55 -4
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/crypto/signatures.js +4 -2
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,8 +34,8 @@ npm install @shelbyb/daywalker-sdk
|
|
|
34
34
|
# Generate Ed25519 keypair and write .env.local
|
|
35
35
|
npx daywalker init --service my-service-slug
|
|
36
36
|
|
|
37
|
-
# Or register the public key immediately with Daywalker Auth:
|
|
38
|
-
npx daywalker init --service my-service-slug --api-key srv_live_...
|
|
37
|
+
# Or register the public key immediately with Daywalker Auth (domain is required by the API):
|
|
38
|
+
npx daywalker init --service my-service-slug --domain app.example.com --api-key srv_live_...
|
|
39
39
|
|
|
40
40
|
# Pipe JSON secrets directly into Vercel, Fly.io, DigitalOcean, or AWS SSM:
|
|
41
41
|
npx daywalker init --service my-service-slug --api-key srv_live_... --json
|
|
@@ -43,7 +43,7 @@ npx daywalker init --service my-service-slug --api-key srv_live_... --json
|
|
|
43
43
|
|
|
44
44
|
`daywalker init`:
|
|
45
45
|
- Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
|
|
46
|
-
- Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register`
|
|
46
|
+
- 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`)
|
|
47
47
|
- Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local`
|
|
48
48
|
- No route injectors or public `/.well-known/` endpoints needed
|
|
49
49
|
|
|
@@ -193,14 +193,29 @@ const result = await client.validateToken(incomingJwt);
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## API Reference (per the [OpenAPI spec](https://auth.daywalker.dev/docs))
|
|
197
|
+
|
|
198
|
+
| Endpoint | SDK usage | Notes |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| `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 |
|
|
201
|
+
| `POST /api/v1/keys/register` | `npx daywalker init` / `rotate` | Bearer auth; body `{ keyId, domain, publicKey }` |
|
|
202
|
+
| `POST /api/v1/leases/issue` | `client.issueLease(ttlSeconds?)` | Signed request returns `{ lease, tokenType, expiresAt, jwksUri }` (EdDSA JWT) |
|
|
203
|
+
| `GET /.well-known/jwks.json` | `client.fetchJwks()` | Daywalker public JWKS for offline lease verification |
|
|
204
|
+
|
|
205
|
+
Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization: Bearer <JWT or API key>`.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
196
209
|
## Request Signing Specification (RFC 9421 & RFC 9530)
|
|
197
210
|
|
|
198
211
|
Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
|
|
199
212
|
- `Content-Digest: sha-256=:<base64-encoded-hash>:`
|
|
200
|
-
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519"`
|
|
213
|
+
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`
|
|
201
214
|
- `Signature: sig1=:<base64-encoded-signature>:`
|
|
202
215
|
- `Authorization: Bearer <DAYWALKER_API_KEY>`
|
|
203
216
|
|
|
217
|
+
A fresh random `nonce` is generated for every attempt (including retries), so identical requests in the same second still produce unique signatures and never trip replay protection. `validateToken()` also shares one in-flight request between concurrent callers for the same token, and briefly caches failures (`negativeCacheTtlMs`, default 3000ms; 429s and network errors are never cached).
|
|
218
|
+
|
|
204
219
|
The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
205
220
|
|
|
206
221
|
---
|
package/dist/cli.js
CHANGED
|
@@ -171,6 +171,7 @@ async function rotate(opts) {
|
|
|
171
171
|
const slug = String(opts.service ?? existing.get("DAYWALKER_SERVICE_SLUG") ?? process.env.DAYWALKER_SERVICE_SLUG ?? "").trim().toLowerCase();
|
|
172
172
|
const apiKey = String(opts["api-key"] ?? existing.get("DAYWALKER_API_KEY") ?? process.env.DAYWALKER_API_KEY ?? "").trim() || null;
|
|
173
173
|
const authUrl = String(opts["auth-url"] ?? existing.get("DAYWALKER_AUTH_URL") ?? process.env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
174
|
+
const rotateDomain = publicHostname(String(opts.domain ?? existing.get("DAYWALKER_DOMAIN") ?? process.env.DAYWALKER_DOMAIN ?? ""));
|
|
174
175
|
const key = await generateSigningKey();
|
|
175
176
|
if (apiKey && slug) {
|
|
176
177
|
if (!isJson)
|
|
@@ -179,6 +180,7 @@ async function rotate(opts) {
|
|
|
179
180
|
authUrl,
|
|
180
181
|
apiKey,
|
|
181
182
|
serviceSlug: slug,
|
|
183
|
+
domain: rotateDomain,
|
|
182
184
|
publicJwk: key.publicJwk,
|
|
183
185
|
});
|
|
184
186
|
if (regResult.ok) {
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class Daywalker {
|
|
|
9
9
|
private readonly _rawConfig;
|
|
10
10
|
private _signingKeyPromise?;
|
|
11
11
|
private readonly _cache;
|
|
12
|
+
private readonly _inflight;
|
|
12
13
|
constructor(config?: DaywalkerConfig);
|
|
13
14
|
get config(): ResolvedConfig;
|
|
14
15
|
toJSON(): Record<string, unknown>;
|
|
@@ -31,6 +32,7 @@ export declare class Daywalker {
|
|
|
31
32
|
forceRefresh?: boolean;
|
|
32
33
|
offlineOnly?: boolean;
|
|
33
34
|
}): Promise<ValidateResult>;
|
|
35
|
+
private _validateOnline;
|
|
34
36
|
/**
|
|
35
37
|
* Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
|
|
36
38
|
*/
|
|
@@ -42,6 +44,21 @@ export declare class Daywalker {
|
|
|
42
44
|
serviceSlug?: string;
|
|
43
45
|
offlineOnly?: boolean;
|
|
44
46
|
}): Promise<TokenPayload>;
|
|
47
|
+
/**
|
|
48
|
+
* Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
|
|
49
|
+
*/
|
|
50
|
+
issueLease(ttlSeconds?: number): Promise<{
|
|
51
|
+
lease: string;
|
|
52
|
+
tokenType?: string;
|
|
53
|
+
expiresAt?: string;
|
|
54
|
+
jwksUri?: string;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
|
|
58
|
+
*/
|
|
59
|
+
fetchJwks(): Promise<{
|
|
60
|
+
keys: PublicJwk[];
|
|
61
|
+
}>;
|
|
45
62
|
/**
|
|
46
63
|
* The public JWK(s) configured for this service.
|
|
47
64
|
*/
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveConfig } from "./config.js";
|
|
2
2
|
import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, } from "./crypto/index.js";
|
|
3
|
-
import { DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
|
|
3
|
+
import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
|
|
4
4
|
function parseRetryAfter(headerValue) {
|
|
5
5
|
if (!headerValue)
|
|
6
6
|
return undefined;
|
|
@@ -15,11 +15,20 @@ function parseRetryAfter(headerValue) {
|
|
|
15
15
|
}
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
|
+
function generateNonce() {
|
|
19
|
+
const bytes = new Uint8Array(16);
|
|
20
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
21
|
+
let bin = "";
|
|
22
|
+
for (const b of bytes)
|
|
23
|
+
bin += String.fromCharCode(b);
|
|
24
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
18
26
|
export class Daywalker {
|
|
19
27
|
_config;
|
|
20
28
|
_rawConfig;
|
|
21
29
|
_signingKeyPromise;
|
|
22
30
|
_cache = new Map();
|
|
31
|
+
_inflight = new Map();
|
|
23
32
|
constructor(config = {}) {
|
|
24
33
|
this._rawConfig = config;
|
|
25
34
|
this._config = resolveConfig(config);
|
|
@@ -104,6 +113,7 @@ export class Daywalker {
|
|
|
104
113
|
key: signingKey,
|
|
105
114
|
keyid: this._config.keyId ?? undefined,
|
|
106
115
|
apiKey: this._config.apiKey ?? undefined,
|
|
116
|
+
nonce: generateNonce(),
|
|
107
117
|
});
|
|
108
118
|
for (const [k, v] of Object.entries(signedHeaders)) {
|
|
109
119
|
if (v !== undefined) {
|
|
@@ -231,7 +241,25 @@ export class Daywalker {
|
|
|
231
241
|
message: "Offline verification requires DAYWALKER_PUBLIC_KEY and a valid JWT token lease",
|
|
232
242
|
};
|
|
233
243
|
}
|
|
234
|
-
// Online verification
|
|
244
|
+
// Online verification, deduplicated across concurrent callers
|
|
245
|
+
const inflightKey = `${cacheKey}`;
|
|
246
|
+
const pending = this._inflight.get(inflightKey);
|
|
247
|
+
if (pending)
|
|
248
|
+
return pending;
|
|
249
|
+
const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
|
|
250
|
+
this._inflight.delete(inflightKey);
|
|
251
|
+
});
|
|
252
|
+
this._inflight.set(inflightKey, run);
|
|
253
|
+
return run;
|
|
254
|
+
}
|
|
255
|
+
async _validateOnline(token, serviceSlug, cacheKey) {
|
|
256
|
+
const negTtl = this._config.negativeCacheTtlMs;
|
|
257
|
+
const fail = (result) => {
|
|
258
|
+
if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
|
|
259
|
+
this._cache.set(cacheKey, { result, expiresAt: Date.now() + negTtl });
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
};
|
|
235
263
|
try {
|
|
236
264
|
const validateBody = { token };
|
|
237
265
|
if (serviceSlug) {
|
|
@@ -282,12 +310,12 @@ export class Daywalker {
|
|
|
282
310
|
else if (json.error === "REVOKED" || /revoked/i.test(errMsg)) {
|
|
283
311
|
error = "REVOKED";
|
|
284
312
|
}
|
|
285
|
-
return {
|
|
313
|
+
return fail({
|
|
286
314
|
ok: false,
|
|
287
315
|
error,
|
|
288
316
|
status: res.status,
|
|
289
317
|
message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
|
|
290
|
-
};
|
|
318
|
+
});
|
|
291
319
|
}
|
|
292
320
|
catch (err) {
|
|
293
321
|
return {
|
|
@@ -311,6 +339,29 @@ export class Daywalker {
|
|
|
311
339
|
}
|
|
312
340
|
return result.data;
|
|
313
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Exchanges an RFC 9421 signed request for a short-lived EdDSA JWT lease (POST /api/v1/leases/issue).
|
|
344
|
+
*/
|
|
345
|
+
async issueLease(ttlSeconds) {
|
|
346
|
+
const res = await this.fetch("/api/v1/leases/issue", {
|
|
347
|
+
method: "POST",
|
|
348
|
+
body: ttlSeconds ? { ttlSeconds } : {},
|
|
349
|
+
});
|
|
350
|
+
const json = (await res.json().catch(() => ({})));
|
|
351
|
+
if (res.status !== 201 && res.status !== 200) {
|
|
352
|
+
throw new DaywalkerAuthError(redactSecrets(String(json.message || json.error || `Lease issue failed with HTTP ${res.status}`)), res.status, json.error);
|
|
353
|
+
}
|
|
354
|
+
return json;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
|
|
358
|
+
*/
|
|
359
|
+
async fetchJwks() {
|
|
360
|
+
const res = await globalThis.fetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, { headers: { Accept: "application/json" } });
|
|
361
|
+
if (!res.ok)
|
|
362
|
+
throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
|
|
363
|
+
return (await res.json());
|
|
364
|
+
}
|
|
314
365
|
/**
|
|
315
366
|
* The public JWK(s) configured for this service.
|
|
316
367
|
*/
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export function resolveConfig(options = {}) {
|
|
|
82
82
|
const daywalkerPublicKey = options.daywalkerPublicKey ?? (env.DAYWALKER_PUBLIC_KEY ? env.DAYWALKER_PUBLIC_KEY.trim() : null);
|
|
83
83
|
const authUrl = (options.authUrl ?? env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
84
84
|
const cacheTtlMs = options.cacheTtlMs ?? 60_000;
|
|
85
|
+
const negativeCacheTtlMs = options.negativeCacheTtlMs ?? 3_000;
|
|
85
86
|
const maxRetries = options.maxRetries ?? (options.retry?.maxRetries ?? 3);
|
|
86
87
|
return {
|
|
87
88
|
apiKey,
|
|
@@ -94,6 +95,7 @@ export function resolveConfig(options = {}) {
|
|
|
94
95
|
daywalkerPublicKey,
|
|
95
96
|
authUrl,
|
|
96
97
|
cacheTtlMs,
|
|
98
|
+
negativeCacheTtlMs,
|
|
97
99
|
maxRetries,
|
|
98
100
|
problems,
|
|
99
101
|
};
|
|
@@ -296,6 +296,9 @@ export async function registerPublicKey(params) {
|
|
|
296
296
|
const authUrl = (params.authUrl ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
297
297
|
const registerUrl = `${authUrl}/api/v1/keys/register`;
|
|
298
298
|
const doFetch = params.fetchFn ?? globalThis.fetch;
|
|
299
|
+
if (!params.domain) {
|
|
300
|
+
return { ok: false, status: 400, error: "domain is required to register a key (set --domain or DAYWALKER_DOMAIN)" };
|
|
301
|
+
}
|
|
299
302
|
try {
|
|
300
303
|
const res = await doFetch(registerUrl, {
|
|
301
304
|
method: "POST",
|
|
@@ -306,8 +309,7 @@ export async function registerPublicKey(params) {
|
|
|
306
309
|
},
|
|
307
310
|
body: JSON.stringify({
|
|
308
311
|
keyId: params.publicJwk.kid,
|
|
309
|
-
domain: params.domain
|
|
310
|
-
serviceSlug: params.serviceSlug ?? undefined,
|
|
312
|
+
domain: params.domain,
|
|
311
313
|
publicKey: params.publicJwk,
|
|
312
314
|
}),
|
|
313
315
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface DaywalkerConfig {
|
|
|
57
57
|
daywalkerPublicKey?: string | PublicJwk;
|
|
58
58
|
authUrl?: string;
|
|
59
59
|
cacheTtlMs?: number;
|
|
60
|
+
/** Short cache for failed validations (not 429/network errors). Default 3000ms; 0 disables. */
|
|
61
|
+
negativeCacheTtlMs?: number;
|
|
60
62
|
maxRetries?: number;
|
|
61
63
|
retry?: RetryOptions;
|
|
62
64
|
fetch?: typeof fetch;
|
package/package.json
CHANGED