@metalabel/dfos-client 0.36.0 → 0.37.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.
Files changed (3) hide show
  1. package/README.md +7 -11
  2. package/dist/siwd.d.ts +39 -10
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -77,7 +77,7 @@ import { indexedDbStore, memoryStore } from '@metalabel/dfos-client/store';
77
77
 
78
78
  ### `@metalabel/dfos-client/api-auth`
79
79
 
80
- API Authentication request proofs. A proof is a short-lived JWS, signed by the key a DFOS credential was issued to, that binds one exact HTTP request — method, host, path, body — to that credential. The credential says what its holder may do; the proof says the holder is the one doing it, and doing exactly this. See the [API-AUTH specification](https://protocol.dfos.com/api-auth).
80
+ API Authentication request proofs the per-request proof-of-possession envelope defined by the [API-AUTH specification](https://protocol.dfos.com/api-auth), which owns the semantics; this subpath signs and verifies it.
81
81
 
82
82
  **Spending a credential: a signing `fetch`.** Hand it to any API client with a fetch seam, and every request that client composes goes out credential-gated.
83
83
 
@@ -100,7 +100,7 @@ Three consequences worth knowing before you wire it up:
100
100
  - **It does not follow redirects** (`redirect: 'manual'`): a 3xx comes back to you as-is, because following it would re-issue the request at coordinates the proof does not cover and carry `X-Credential` to whatever authority the `Location` names.
101
101
  - **It buffers the request body before sending.** The proof covers the whole body, so there is nothing to sign until the last octet is in hand — size-bounded requests only. An unbounded or live stream cannot be proof-signed, in any implementation.
102
102
 
103
- **A backend that must not proxy uses the decomposed form.** A signing backend fronting a browser must authorize the coordinates it is about to sign against its own session, not sign whatever `{method, path, body}` the browser hands it a backend that signs blindly is an oracle for every credential it holds ([Security Considerations](https://protocol.dfos.com/api-auth#security-considerations)). Such a backend describes the one request it is willing to make, so there is no `Request` for the adapter above to cover:
103
+ **A backend that must not proxy uses the decomposed form.** A signing backend fronting a browser describes the one request it is willing to make never signing coordinates the browser supplies ([API-AUTH § Security Considerations](https://protocol.dfos.com/api-auth#security-considerations)) so there is no `Request` for the adapter above to cover:
104
104
 
105
105
  ```typescript
106
106
  import { buildApiAuthHeaders, signApiRequest } from '@metalabel/dfos-client/api-auth';
@@ -136,7 +136,7 @@ It throws `ApiRequestVerifyError`, carrying `reason` (`invalid` / `unverifiable`
136
136
 
137
137
  ### `@metalabel/dfos-client/siwd`
138
138
 
139
- The end-to-end integration recipe (mint an app identity, serve the app description, verify the callback) is at <https://docs.dfos.com/docs/developers/sign-in-with-dfos/setup>.
139
+ There are two end-to-end integration recipes, and which one you want depends on where your application runs. An app with a domain (mint an app identity, serve the app description, verify the callback) follows <https://docs.dfos.com/docs/developers/sign-in-with-dfos/setup>. A CLI or agent running on the user's own machine, redirecting to a loopback port, follows <https://docs.dfos.com/docs/developers/sign-in-with-dfos/local-apps>.
140
140
 
141
141
  ```typescript
142
142
  import {
@@ -149,7 +149,7 @@ import {
149
149
 
150
150
  Sign In With DFOS. The three verbs above are the relying-party login kit, in the order a login uses them: `createSiwdLoginRequest` mints the challenge and builds the `/authorize` URL to redirect to, `readSiwdCallback` parses what comes back, and `verifySiwd` verifies it — mint → redirect, read → verify. The `expect` object `createSiwdLoginRequest` returns (nonce, domain, and the DID when the challenge is bound to one) is what `verifySiwd` checks against, so the relying party MUST persist it across the redirect: a verifier that takes its expectation from the callback has implemented the check and none of the protection. See [`examples/siwd-demo`](../../examples/siwd-demo) for the reference consumer.
151
151
 
152
- The `nonce`/`consumeNonce` pair on the expectation (supply exactly one) is the spec's [two replay disciplines](../../specs/SIWD.md#replay-prevention), one field each:
152
+ The `nonce`/`consumeNonce` pair on the expectation (supply exactly one) maps one field each to the spec's two replay disciplines — which discipline a given scope obliges, and why, is [SIWD § Replay prevention](https://protocol.dfos.com/siwd#replay-prevention)'s argument to make:
153
153
 
154
154
  **`expect.nonce` — flow-bound login.** For a backend granting only a browser session (`scope=identity`), source the expected nonce from state you bound to that browser at mint time — a server-side session, or the nonce sealed under your own key in an `httpOnly` cookie — and compare:
155
155
 
@@ -159,9 +159,7 @@ The `nonce`/`consumeNonce` pair on the expectation (supply exactly one) is the s
159
159
  await verifySiwd(client, jws, { domain, nonce });
160
160
  ```
161
161
 
162
- The seal (or the session) is what makes this a defense at all: a _bare_ cookie value is presenter-supplied, and an attacker replaying a captured JWS can read the nonce out of the artifact and send it as the cookie. Never compare against anything the presenter could have authored.
163
-
164
- **`consumeNonce` — spend the nonce.** Required the moment success grants anything beyond a session with the presenting browser (a credential-returning scope, a portable token, a profile-B mailbox flow):
162
+ **`consumeNonce` spend the nonce.** Required the moment success grants anything beyond a session with the presenting browser (a credential-returning scope, a portable token, a profile-B mailbox flow). Return true iff **this** verifier minted the nonce and it was unspent, deleting it in the same atomic operation (a Redis `GETDEL`, a `DELETE RETURNING` — never get-then-delete):
165
163
 
166
164
  ```typescript
167
165
  await verifySiwd(client, jws, {
@@ -170,13 +168,11 @@ await verifySiwd(client, jws, {
170
168
  });
171
169
  ```
172
170
 
173
- `consumeNonce` returns true iff **this** verifier minted the nonce and it was unspent — membership in verifier-minted state is what satisfies the spec's rule that the verifier MUST have minted the nonce it checks, and deleting it in the same operation is what makes it single-use. The atomicity is the caller's: a get-then-delete lets two concurrent replays both win, where a Redis `GETDEL` or a `DELETE … RETURNING` does not. Under either discipline the nonce check runs at most once, and only after every other check has passed, so an invalid presentation can never burn a nonce the user is still holding.
171
+ Under either discipline `verifySiwd` checks the nonce at most once, and only after every other check has passed, so an invalid presentation can never burn a nonce the user is still holding.
174
172
 
175
173
  `createSiwdLoginRequest` throws rather than returning an error on the two things that are RP misconfiguration: an `authorizeUrl` or `redirectUri` that is not an absolute URL, and any scope other than `identity` over a loopback redirect that names no client identity.
176
174
 
177
- **Loopback redirects** — `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, on any port — come in two shapes. The **anonymous** one is unchanged: no `client_did`, `scope=identity` only, and the consent screen shows the local delivery target and nothing else. The **key-proven** one is the [loopback credential tier](../../specs/SIWD.md#loopback-clients): the request carries a `client_did`, an ask proof over its own challenge bytes, and unless the DID is already resident on the host the client's identity chain. That is what lets local software receive a credential. It cannot prove where it came from, but it can prove it controls the keys, and the host's consent screen says exactly that. Credentials minted this way come back in the URL **fragment** and carry a hard expiry ceiling the host enforces; 14 days is the spec's recommendation.
178
-
179
- The fragment is what keeps the credential off every server, and it is also why a CLI needs one extra step: a browser does not send the fragment to your loopback listener either, so the request line your local server sees carries the query and nothing else. Answer it with a small page whose script reads `location.href` and posts the whole URL back to your server, then feed _that_ to `readSiwdCallback`. A browser relying party just passes `location.href`.
175
+ **Loopback redirects** — `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, on any port — come in two shapes: the **anonymous** one (no `client_did`, `scope=identity` only) and the **key-proven** one, the [loopback credential tier](https://protocol.dfos.com/siwd#loopback-clients), which is what lets local software receive a credential — the spec defines both. The one integration consequence to know: a credential comes back in the URL **fragment**, which a browser sends to no server your loopback listener's request line included so a CLI answers the callback with a small page whose script reads `location.href` and posts the whole URL back, then feeds _that_ to `readSiwdCallback`. A browser relying party just passes `location.href`.
180
176
 
181
177
  ```typescript
182
178
  import {
package/dist/siwd.d.ts CHANGED
@@ -87,7 +87,13 @@ interface SiwdLoginRequestInput {
87
87
  domain: string;
88
88
  /** Exact redirect target; must match the RP's registered or served allowlist. */
89
89
  redirectUri: string;
90
- /** Requested scope. `identity` is the only scope implemented today. */
90
+ /**
91
+ * Requested scope: a space-separated SET of scope tokens (the OAuth `scope`
92
+ * convention), each of which must be one specs/SIWD.md §Scopes and Credentials
93
+ * registers. A request naming an unregistered token is refused WHOLE rather
94
+ * than partially honored — a consent screen that silently dropped a token
95
+ * would describe something other than what was asked for.
96
+ */
91
97
  scope: string;
92
98
  /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
93
99
  statement?: string;
@@ -222,6 +228,14 @@ declare const MAX_SIWD_CLIENT_CHAIN_OPS = 100;
222
228
  * FULL ordered operation log, genesis first, as base64url of its JSON array —
223
229
  * the same grammar as the `challenge` param, so one decoder shape serves both.
224
230
  *
231
+ * THIS IS THE LOOPBACK CARRIAGE FORM, and only that. An application that holds a
232
+ * domain encodes nothing: it publishes the very same log as the raw JSON array
233
+ * of the `identity_chain` member of its `/.well-known/dfos-app.json` app
234
+ * description (SIWD.md §`identity_chain` — chain carriage), where the origin
235
+ * serving the file is what associates the domain with the DID. Same chain, same
236
+ * carriage rules — a URL is simply the carrier available to software that holds
237
+ * no origin to publish from.
238
+ *
225
239
  * The DID derived from the genesis operation MUST equal the `client_did` the
226
240
  * request names; a request where the two disagree makes no claim at all and the
227
241
  * host refuses it WHOLE rather than ingesting the chain and ignoring the
@@ -242,7 +256,12 @@ declare const MAX_SIWD_CLIENT_CHAIN_OPS = 100;
242
256
  declare const encodeSiwdClientChain: (log: string[]) => string;
243
257
  interface SiwdClientIdentity {
244
258
  did: string;
245
- /** Verbatim identity-op JWS log, genesis first — feed to encodeSiwdClientChain. */
259
+ /**
260
+ * Verbatim identity-op JWS log, genesis first. A loopback client feeds it to
261
+ * `encodeSiwdClientChain` to carry on the authorize URL; an application that
262
+ * holds a domain serves this same array verbatim as the `identity_chain`
263
+ * member of its app description document.
264
+ */
246
265
  chain: string[];
247
266
  /** DID URL of the current auth key — feed to signSiwdAskProof. */
248
267
  kid: string;
@@ -251,11 +270,19 @@ interface SiwdClientIdentity {
251
270
  privateKey: Uint8Array;
252
271
  }
253
272
  /**
254
- * Mint a fresh client identity for the loopback credential tier: one Ed25519
255
- * keypair and a single genesis `create` operation naming it as the auth,
256
- * assertion, and controller key. That is the whole identity a CLI needs to ask
257
- * under this tier a DID it can prove control of, and a one-operation chain
258
- * small enough to carry on the request itself.
273
+ * Mint a fresh client identity: one Ed25519 keypair and a single genesis
274
+ * `create` operation naming it as the auth, assertion, and controller key. That
275
+ * is an entire application identity a DID whose key control is provable, and a
276
+ * one-operation chain small enough to travel anywhere a chain has to travel.
277
+ *
278
+ * TIER-AGNOSTIC. What comes back is an ordinary DFOS identity, not a
279
+ * loopback-only artifact; the tier is a property of how the identity is
280
+ * PRESENTED, not of the identity itself. An application that holds a domain
281
+ * serves `chain` verbatim as the `identity_chain` member of its
282
+ * `/.well-known/dfos-app.json` app description and names `did` as its
283
+ * `client_did` there; a loopback client carries the same chain on the authorize
284
+ * request instead (`createSiwdLoopbackLoginRequest`), because it has no origin
285
+ * to publish from. Both are minted here.
259
286
  *
260
287
  * KEY CUSTODY IS THE CALLER'S, and the identity is only as durable as the
261
288
  * custody: persist `privateKey` and `chain` — an OS keychain, a file the caller
@@ -295,9 +322,11 @@ interface SiwdLoopbackLoginRequestInput {
295
322
  /** Loopback redirect target — `http://` on localhost / 127.0.0.1 / [::1], any port/path. */
296
323
  redirectUri: string;
297
324
  /**
298
- * Requested scope. Naming a client identity opens the tier, so every scope the
299
- * HOST offers is available here the `identity`-only bound belongs to the
300
- * anonymous loopback shape, which has no `client_did` to issue a credential to.
325
+ * Requested scope, in the same space-separated set form as
326
+ * `SiwdLoginRequestInput.scope`. Naming a client identity opens the tier, so
327
+ * every scope the HOST offers is available here the `identity`-only bound
328
+ * belongs to the anonymous loopback shape, which has no `client_did` to issue
329
+ * a credential to.
301
330
  */
302
331
  scope: string;
303
332
  /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Client — the client-side kit for participating in the protocol: resolve, verify, prove. Fetch, resolve, verify-orchestration and cache over untrusted relays, plus the SIWD and API-AUTH proof surfaces. Holds no keys; all crypto truth comes from @metalabel/dfos-protocol",
6
6
  "license": "MIT",
@@ -47,15 +47,15 @@
47
47
  "README.md"
48
48
  ],
49
49
  "peerDependencies": {
50
- "@metalabel/dfos-protocol": "^0.36.0",
51
- "@metalabel/dfos-web-relay": "^0.36.0"
50
+ "@metalabel/dfos-protocol": "^0.37.0",
51
+ "@metalabel/dfos-web-relay": "^0.37.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^24.10.4",
55
55
  "tsup": "^8.5.1",
56
56
  "vitest": "^4.1.8",
57
- "@metalabel/dfos-web-relay": "0.36.0",
58
- "@metalabel/dfos-protocol": "0.36.0"
57
+ "@metalabel/dfos-protocol": "0.37.0",
58
+ "@metalabel/dfos-web-relay": "0.37.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup",