@metalabel/dfos-client 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 {
@@ -172,7 +172,54 @@ await verifySiwd(client, jws, {
172
172
 
173
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.
174
174
 
175
- `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 (a local port holds no `client_did` for a credential to be issued to — see [SIWD.md](../../specs/SIWD.md)).
175
+ `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
+
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`.
180
+
181
+ ```typescript
182
+ import {
183
+ createSiwdLoopbackLoginRequest,
184
+ mintSiwdClientIdentity,
185
+ restoreSiwdClientIdentity,
186
+ } from '@metalabel/dfos-client/siwd';
187
+
188
+ // The one place this kit generates a key. Persist `privateKey` + `chain` and
189
+ // restore them next run: a client that re-mints is a NEW did, so it re-consents
190
+ // every time and orphans the credentials the last run earned.
191
+ const app = saved
192
+ ? await restoreSiwdClientIdentity(saved) // { privateKey, chain }
193
+ : await mintSiwdClientIdentity();
194
+
195
+ const request = await createSiwdLoopbackLoginRequest({
196
+ authorizeUrl: 'https://app.example.com/authorize',
197
+ redirectUri: 'http://127.0.0.1:8976/callback',
198
+ scope: 'read:profile',
199
+ client: app, // did + kid + signer, and the chain to carry
200
+ });
201
+ // open request.url, hold request.expect.nonce in memory, and read back the FULL
202
+ // callback URL (see above — location.search alone would miss the credential).
203
+ const result = readSiwdCallback(callbackUrl);
204
+ if (result.kind === 'success') {
205
+ // Consumed verification, store of size one: a credential-returning scope
206
+ // requires it, and one outstanding nonce in memory IS the store. `client` is
207
+ // the resolver from the setup section, not the identity above.
208
+ let outstanding: string | undefined = request.expect.nonce;
209
+ const session = await verifySiwd(client, result.jws, {
210
+ domain: request.expect.domain,
211
+ consumeNonce: (nonce) => {
212
+ if (outstanding === undefined || nonce !== outstanding) return false;
213
+ outstanding = undefined;
214
+ return true;
215
+ },
216
+ });
217
+ if (session.ok) {
218
+ // only now is result.credential a credential you earned — store it, and
219
+ // send a per-request proof signed by app.signer when you spend it
220
+ }
221
+ }
222
+ ```
176
223
 
177
224
  `siwdSigningInput(challenge)` is the pure byte contract both the signer and the verifier share (see [SIWD.md](../../specs/SIWD.md)); `createSiwdChallenge` mints a challenge on its own for a caller building its own redirect; `verifySiwd` is a no-throw verifier that accepts only a current `authKeys` entry of a non-deleted identity.
178
225
 
package/dist/siwd.d.ts CHANGED
@@ -9,6 +9,19 @@ import '@metalabel/dfos-web-relay/peer-client';
9
9
  * routing dispatchers tell a SIWD proof apart from credentials and chain ops.
10
10
  */
11
11
  declare const SIWD_JWS_TYP = "did:dfos:siwd";
12
+ /**
13
+ * The normative JWS header `typ` for a client ASK PROOF — the artifact a
14
+ * loopback client signs to prove key control at ask-time, registered alongside
15
+ * `SIWD_JWS_TYP` by SIWD.md §The ask proof.
16
+ *
17
+ * The two artifacts cover the SAME canonical challenge bytes, so the distinct
18
+ * `typ` is the only thing keeping them from being fungible: without it, an ask
19
+ * proof a client signed to open a consent screen would present as a subject's
20
+ * sign-in for that same challenge, and a subject's sign-in would present as a
21
+ * client's ask. The typ gate on each side is what scopes a signature to the
22
+ * purpose it was produced for.
23
+ */
24
+ declare const SIWD_ASK_JWS_TYP = "did:dfos:siwd-ask";
12
25
  interface SiwdChallenge {
13
26
  /** Origin domain of the requesting application. */
14
27
  domain: string;
@@ -74,7 +87,13 @@ interface SiwdLoginRequestInput {
74
87
  domain: string;
75
88
  /** Exact redirect target; must match the RP's registered or served allowlist. */
76
89
  redirectUri: string;
77
- /** 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
+ */
78
97
  scope: string;
79
98
  /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
80
99
  statement?: string;
@@ -86,7 +105,11 @@ interface SiwdLoginRequestInput {
86
105
  * only the signature would accept it.
87
106
  */
88
107
  did?: string;
89
- /** The RP's own DID. Omitted from the URL automatically for loopback redirects. */
108
+ /**
109
+ * The RP's own DID. Rides through on a loopback redirect too, but a host
110
+ * honors it there only under the loopback credential tier — see
111
+ * `createSiwdLoopbackLoginRequest`, which adds the proof that backs it.
112
+ */
90
113
  clientDid?: string;
91
114
  /** Supply a nonce minted elsewhere (e.g. by your backend); default: minted here. */
92
115
  nonce?: string;
@@ -132,32 +155,219 @@ interface SiwdLoginRequest {
132
155
  * OUTBOUND half of profile A. `readSiwdCallback` is the inbound half, and
133
156
  * `verifySiwd` is what both compose around: mint → redirect, read → verify.
134
157
  *
135
- * The rule this function exists to own is the LOOPBACK OMISSION. Nothing can
136
- * prove a client DID for an app on a local port there is no domain serving a
137
- * well-known and no registration to checkso a host REFUSES a `client_did`
138
- * on a loopback redirect rather than displaying an identity it cannot stand
139
- * behind. A CLI that passed its own DID would have the whole request rejected,
140
- * not downgraded, so the param is dropped here instead of being forwarded into
141
- * a guaranteed refusal.
158
+ * The rule this function owns is the LOOPBACK RULE, and what it turns on is
159
+ * whether the request names a client identity. A bare `client_did` from an app
160
+ * on a local port used to be refused outright there is no domain serving a
161
+ * well-known and no registration to check, so nothing backed the DID and a host
162
+ * would not display an identity it could not stand behind. The LOOPBACK
163
+ * CREDENTIAL TIER (specs/SIWD.md §Loopback Clients) replaces "nothing backs it"
164
+ * with the one thing local software can prove: control of that identity's
165
+ * current keys. So the param now rides through on a loopback redirect instead
166
+ * of being dropped — but it is honored only when the request ALSO carries an
167
+ * ask proof and, unless the DID is already resident on the host, the client's
168
+ * identity chain. `createSiwdLoopbackLoginRequest` composes all three; a
169
+ * `clientDid` passed to this function alone is an unbacked assertion the host
170
+ * will refuse.
142
171
  *
143
172
  * The same judgment BOUNDS THE SCOPE. Every scope past `identity` returns a
144
- * credential issued to a `client_did` the one param a loopback request cannot
145
- * carry so specs/SIWD.md admits a loopback target for `scope=identity` only.
146
- * Asking for more from a local port is not a downgrade the way `client_did` is;
147
- * there is nothing to drop, so it throws.
173
+ * credential issued to a `client_did`, so a loopback request with no client
174
+ * identity at all still has nothing to issue to and specs/SIWD.md admits it for
175
+ * `scope=identity` only there is nothing to downgrade, so it throws. With a
176
+ * client identity the tier is open and every scope is available.
148
177
  *
149
178
  * It also owns the WIRE PARAM NAMES (`challenge`, `redirect_uri`, `scope`,
150
- * `client_did`) as their single source in this package. They are snake_case on
151
- * the wire and camelCase everywhere else, which is exactly the kind of seam
179
+ * `client_did`, and via `createSiwdLoopbackLoginRequest` `client_proof` and
180
+ * `client_chain`) as their single source in this package. They are snake_case
181
+ * on the wire and camelCase everywhere else, which is exactly the kind of seam
152
182
  * every hand-rolled RP re-implements and eventually gets wrong.
153
183
  *
154
184
  * PURE: no DOM, no storage, no navigation, no fetch — identical in a browser
155
185
  * and in Node. Throws on an unparseable `authorizeUrl` or `redirectUri`, and on
156
- * a non-`identity` scope over a loopback redirect, because those are mistakes
157
- * in the RP's own configuration rather than runtime conditions a result type
158
- * would help a caller recover from.
186
+ * a non-`identity` scope over a loopback redirect with no client identity,
187
+ * because those are mistakes in the RP's own configuration rather than runtime
188
+ * conditions a result type would help a caller recover from.
159
189
  */
160
190
  declare const createSiwdLoginRequest: (input: SiwdLoginRequestInput) => SiwdLoginRequest;
191
+ /**
192
+ * Sign the ask proof for a loopback authorize request: a JWS over the exact
193
+ * canonical bytes of the request's own challenge, under `SIWD_ASK_JWS_TYP`,
194
+ * signed by a CURRENT auth key of the client identity's chain (SIWD.md §The ask
195
+ * proof). It is what makes a `client_did` on a loopback request mean something
196
+ * — the host verifies it against the chain's current state before rendering any
197
+ * consent, so key control is established at ask-time, not just at spend-time.
198
+ *
199
+ * BYTE-PRECISE, and deliberately not built with `createJws`. The host compares
200
+ * the proof's payload SEGMENT by string equality against its own re-derivation
201
+ * of `base64url(siwdSigningInput(challenge))`; a construction that round-tripped
202
+ * the challenge through a JSON object would re-serialize it, and any spelling
203
+ * that differed from the canonical one by a single byte would fail that
204
+ * comparison while looking correct here. So this signs the `siwdSigningInput`
205
+ * output directly.
206
+ *
207
+ * `kid` is the DID URL of the signing key. The host ignores it for key
208
+ * SELECTION — it tries the chain's current auth keys — but it is set honestly
209
+ * because a proof that names a key it was not signed with is a lie the wire
210
+ * format has no reason to carry.
211
+ *
212
+ * The `client_proof` param that carries this is the REFERENCE wire surface, not
213
+ * a normative name: SIWD.md defers how the ask proof travels to the hosted
214
+ * endpoint's reference implementation, exactly as it does the endpoint itself.
215
+ * What is normative is that it arrives WITH the ask and verifies BEFORE any
216
+ * consent is rendered.
217
+ */
218
+ declare const signSiwdAskProof: (input: {
219
+ challenge: SiwdChallenge;
220
+ /** DID URL of the signing key: `<did>#<keyId>`. Must be a CURRENT auth key. */
221
+ kid: string;
222
+ signer: Signer;
223
+ }) => Promise<string>;
224
+ /** SIWD.md carriage cap — an identity that has outgrown it has outgrown carriage. */
225
+ declare const MAX_SIWD_CLIENT_CHAIN_OPS = 100;
226
+ /**
227
+ * Encode a client identity chain for carriage on the authorize request: the
228
+ * FULL ordered operation log, genesis first, as base64url of its JSON array —
229
+ * the same grammar as the `challenge` param, so one decoder shape serves both.
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
+ *
239
+ * The DID derived from the genesis operation MUST equal the `client_did` the
240
+ * request names; a request where the two disagree makes no claim at all and the
241
+ * host refuses it WHOLE rather than ingesting the chain and ignoring the
242
+ * mismatch (SIWD.md §Chain residence). Carriage is only needed when the DID is
243
+ * not already resident on the verifying host.
244
+ *
245
+ * The 100-operation cap is spec-normative and enforced here. Hosts MAY
246
+ * additionally bound the ENCODED size for URL-transport reasons — the reference
247
+ * host refuses carriages over 8KiB — which is transport policy rather than
248
+ * protocol, so it is not a client-side throw; the practical reading is that a
249
+ * chain anywhere near the op cap belongs on relays, not in a URL.
250
+ *
251
+ * As with the ask proof, the `client_chain` param is the REFERENCE wire surface
252
+ * rather than a normative name — SIWD.md leaves the carriage encoding to the
253
+ * hosted endpoint's reference implementation and pins only that the chain
254
+ * arrives with the ask and verifies before any consent is rendered.
255
+ */
256
+ declare const encodeSiwdClientChain: (log: string[]) => string;
257
+ interface SiwdClientIdentity {
258
+ did: string;
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
+ */
265
+ chain: string[];
266
+ /** DID URL of the current auth key — feed to signSiwdAskProof. */
267
+ kid: string;
268
+ signer: Signer;
269
+ /** Raw Ed25519 private key — persist it (with `chain`) to keep this DID across runs. */
270
+ privateKey: Uint8Array;
271
+ }
272
+ /**
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.
286
+ *
287
+ * KEY CUSTODY IS THE CALLER'S, and the identity is only as durable as the
288
+ * custody: persist `privateKey` and `chain` — an OS keychain, a file the caller
289
+ * protects — and hand them back to `restoreSiwdClientIdentity` on the next run.
290
+ * That is the other half of this function, and it is not optional in practice.
291
+ * A client that re-mints instead of restoring arrives as a DIFFERENT DID every
292
+ * time, so every run asks the user to consent again and every credential the
293
+ * last run earned belongs to an identity nothing will ever present again.
294
+ * Nothing here touches storage; where the key lives is the caller's to decide.
295
+ */
296
+ declare const mintSiwdClientIdentity: () => Promise<SiwdClientIdentity>;
297
+ /**
298
+ * Rebuild a client identity from what a previous run persisted — the other half
299
+ * of `mintSiwdClientIdentity`, and what makes the DID (and the consent it
300
+ * earned) survive across runs.
301
+ *
302
+ * It re-derives the public key from `privateKey` and finds the CURRENT auth key
303
+ * of the verified chain that key belongs to, which is the same currency rule the
304
+ * host applies to the ask proof. A key that has been ROTATED OUT of the chain
305
+ * therefore refuses to restore rather than producing an identity whose proofs
306
+ * would be rejected at the far end: failing here, with the reason in hand, is
307
+ * strictly better than failing after a redirect. The `kid` comes from the chain
308
+ * rather than from storage for the same reason — the chain is the authority on
309
+ * which key is current, and a persisted `kid` is a guess that ages.
310
+ *
311
+ * `chain` is the caller's stored log, and it is verified here, so a corrupted or
312
+ * truncated log surfaces as a throw rather than as a silently wrong DID.
313
+ */
314
+ declare const restoreSiwdClientIdentity: (input: {
315
+ privateKey: Uint8Array;
316
+ /** The verbatim log `mintSiwdClientIdentity` returned, genesis first. */
317
+ chain: string[];
318
+ }) => Promise<SiwdClientIdentity>;
319
+ interface SiwdLoopbackLoginRequestInput {
320
+ /** The host's authorize endpoint, e.g. `https://app.example.com/authorize`. */
321
+ authorizeUrl: string;
322
+ /** Loopback redirect target — `http://` on localhost / 127.0.0.1 / [::1], any port/path. */
323
+ redirectUri: string;
324
+ /**
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.
330
+ */
331
+ scope: string;
332
+ /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
333
+ statement?: string;
334
+ /** Bind the challenge to ONE subject DID (sign in as this DID or not at all). */
335
+ did?: string;
336
+ /** The client identity asking under the loopback credential tier. */
337
+ client: Pick<SiwdClientIdentity, 'did' | 'kid' | 'signer'> & {
338
+ /** Verbatim chain to carry; omit when the DID is already resident on the host. */
339
+ chain?: string[];
340
+ };
341
+ /** Supply a nonce minted elsewhere; default: minted here. Verify against it. */
342
+ nonce?: string;
343
+ }
344
+ /**
345
+ * Build a loopback authorize URL under the LOOPBACK CREDENTIAL TIER — the
346
+ * outbound half of what `createSiwdLoginRequest` alone cannot produce. It is
347
+ * that function plus the two things that back the `client_did` it now carries:
348
+ * an ask proof (SIWD.md §The ask proof) and, unless the DID is already resident
349
+ * on the host, the client's identity chain (SIWD.md §Chain residence).
350
+ *
351
+ * `domain` is DERIVED, not accepted. SIWD.md pins a loopback challenge's domain
352
+ * to the BARE loopback host — the port is not part of the binding, because a
353
+ * local application cannot reserve one — and the host compares that value
354
+ * literally against the redirect's host. Taking a `domain` input here would be
355
+ * an invitation to a mismatch that fails only after the redirect.
356
+ *
357
+ * WHAT THIS PROVES IS KEY CONTROL, NOT PROVENANCE. The chain says which keys
358
+ * the asking party holds; nothing about a loopback client's origin or authorship
359
+ * is checkable, and the host's consent screen says exactly that rather than
360
+ * displaying a domain it cannot stand behind. The residue is bounded on the
361
+ * other side: a credential minted to a loopback client carries a hard expiry
362
+ * ceiling — RECOMMENDED 14 days — so a proven-but-unvouched-for client's grant
363
+ * ages out on its own, with revocation remaining the user's real disconnect.
364
+ *
365
+ * Async (it signs) and throwing on configuration errors — a `redirectUri` that
366
+ * is not an `http://` loopback target, a `kid` that does not belong to the
367
+ * client's own DID, a chain past the carriage cap — consistent with the
368
+ * constructor half of this module.
369
+ */
370
+ declare const createSiwdLoopbackLoginRequest: (input: SiwdLoopbackLoginRequestInput) => Promise<SiwdLoginRequest>;
161
371
  /**
162
372
  * What came back on the redirect. `none` means this was a plain page load, not
163
373
  * a callback at all — the common case on an RP's own landing page.
@@ -166,6 +376,8 @@ type SiwdCallbackResult = {
166
376
  kind: 'success';
167
377
  jws: string;
168
378
  did: string;
379
+ /** Present only when the host minted one and the input carried a fragment. */
380
+ credential?: string;
169
381
  } | {
170
382
  kind: 'denied';
171
383
  error: string;
@@ -182,17 +394,39 @@ type SiwdCallbackResult = {
182
394
  * Takes an absolute URL string, a `URL`, or a bare `?…` query string (so
183
395
  * `readSiwdCallback(location.search)` works, including when it is empty).
184
396
  *
185
- * SCRUB THE URL YOURSELF, IMMEDIATELY. A signed JWS is sitting in the query
186
- * string, which means it is in the address bar, in `history`, in the referrer
187
- * of anything the page loads next, and in any analytics that samples the
188
- * location. This function cannot do the scrubbing for you `history` is
189
- * environment-owned and this package stays free of the DOM so a browser RP
190
- * should follow the read with a `history.replaceState` back to the bare path.
397
+ * A CREDENTIAL COMES BACK IN THE FRAGMENT, and that is a deliberate asymmetry
398
+ * with `jws`/`did`, which ride the query. A fragment is never sent to a server:
399
+ * it lands in no access log, no proxy log, and no `Referer` header, so the one
400
+ * artifact that is redeemable outside this channel is the one that stays on the
401
+ * client. Reading it therefore requires the WHOLE URL`location.href`, not
402
+ * `location.search`.
403
+ *
404
+ * THAT SAME PROPERTY IS A PROBLEM FOR A CLI, and this tier's primary consumer is
405
+ * a CLI. A browser does not send the fragment to the loopback listener either,
406
+ * so the request line a local HTTP server sees carries the query and nothing
407
+ * else. The standard resolution (SIWD.md §4's loopback note) is for the listener
408
+ * to answer with a small page whose script reads `location.href` and posts the
409
+ * whole URL back to the local server; feed THAT to this function. A browser RP
410
+ * passes `location.href` directly and needs no relay.
411
+ *
412
+ * SCRUB THE URL YOURSELF, IMMEDIATELY. The signed JWS in the query string is in
413
+ * the address bar, in `history`, in the referrer of anything the page loads
414
+ * next, and in any analytics that samples the location; a credential in the
415
+ * fragment is spared the referrer and the server-side logs but is in the address
416
+ * bar and `history` just the same. This function cannot do the scrubbing for you
417
+ * — `history` is environment-owned and this package stays free of the DOM — so a
418
+ * browser RP should follow the read with a `history.replaceState` back to the
419
+ * bare path, which clears both halves.
191
420
  *
192
421
  * A HALF-CALLBACK IS A FAILURE, NOT A NON-EVENT: `jws` without `did` (or the
193
422
  * reverse) resolves to `denied` carrying a synthetic reason rather than `none`.
194
423
  * Silently treating it as a plain page load would strand the user on a
195
424
  * sign-in button with no explanation of why the last attempt vanished.
425
+ *
426
+ * THE QUERY HALF'S VERDICT WINS. A credential in the fragment is lifted only
427
+ * onto a `success`, never on its own: a `denied` or `none` stays exactly what
428
+ * it was. There is no half-callback state a stray fragment could promote —
429
+ * a fragment on a non-callback page load is noise, not a session.
196
430
  */
197
431
  declare const readSiwdCallback: (url: string | URL) => SiwdCallbackResult;
198
432
  interface BuildSiwdSignRequestInput {
@@ -273,4 +507,4 @@ interface SiwdExpectations {
273
507
  */
274
508
  declare const verifySiwd: (client: Client, jws: string, expect: SiwdExpectations) => Promise<VerifyResult<SiwdSession>>;
275
509
 
276
- export { type BuildSiwdSignRequestInput, type CreateChallengeInput, SIWD_JWS_TYP, type SiwdCallbackResult, type SiwdChallenge, type SiwdExpectations, type SiwdLoginRequest, type SiwdLoginRequestInput, type SiwdSession, type ValidateSiwdSignRequestOptions, type ValidatedSiwdSignRequest, buildSiwdSignRequest, createSiwdChallenge, createSiwdLoginRequest, decodeSiwdChallenge, parseSiwdChallenge, readSiwdCallback, siwdSigningInput, validateSiwdSignRequest, verifySiwd };
510
+ export { type BuildSiwdSignRequestInput, type CreateChallengeInput, MAX_SIWD_CLIENT_CHAIN_OPS, SIWD_ASK_JWS_TYP, SIWD_JWS_TYP, type SiwdCallbackResult, type SiwdChallenge, type SiwdClientIdentity, type SiwdExpectations, type SiwdLoginRequest, type SiwdLoginRequestInput, type SiwdLoopbackLoginRequestInput, type SiwdSession, type ValidateSiwdSignRequestOptions, type ValidatedSiwdSignRequest, buildSiwdSignRequest, createSiwdChallenge, createSiwdLoginRequest, createSiwdLoopbackLoginRequest, decodeSiwdChallenge, encodeSiwdClientChain, mintSiwdClientIdentity, parseSiwdChallenge, readSiwdCallback, restoreSiwdClientIdentity, signSiwdAskProof, siwdSigningInput, validateSiwdSignRequest, verifySiwd };
package/dist/siwd.js CHANGED
@@ -2,18 +2,26 @@
2
2
  import {
3
3
  buildSignRequest,
4
4
  decodeMultikey,
5
+ encodeEd25519Multikey,
6
+ signIdentityOperation,
5
7
  SignRequestVerifyError,
8
+ verifyIdentityChain,
6
9
  verifySignRequest
7
10
  } from "@metalabel/dfos-protocol/chain";
8
11
  import {
9
12
  assertJwsProfile,
10
13
  base64urlDecode,
11
14
  base64urlEncode,
15
+ createNewEd25519Keypair,
12
16
  decodeJwsUnsafe,
17
+ generateId,
13
18
  generateIdNoPrefix,
19
+ importEd25519Keypair,
20
+ signPayloadEd25519,
14
21
  verifyJws
15
22
  } from "@metalabel/dfos-protocol/crypto";
16
23
  var SIWD_JWS_TYP = "did:dfos:siwd";
24
+ var SIWD_ASK_JWS_TYP = "did:dfos:siwd-ask";
17
25
  var MAX_CLOCK_SKEW_SECONDS = 60;
18
26
  var SIWD_CHALLENGE_FIELDS = /* @__PURE__ */ new Set(["domain", "nonce", "timestamp", "statement", "did"]);
19
27
  var WHOLE_SECOND_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/;
@@ -125,8 +133,10 @@ var createSiwdLoginRequest = (input) => {
125
133
  const authorizeUrl = parseUrlOrThrow(input.authorizeUrl, "authorizeUrl");
126
134
  const redirect = parseUrlOrThrow(input.redirectUri, "redirectUri");
127
135
  const isLoopback = SIWD_LOOPBACK_HOSTS.has(bareHostname(redirect));
128
- if (isLoopback && input.scope !== "identity") {
129
- throw new Error("invalid SIWD login request: loopback redirects support scope=identity only");
136
+ if (isLoopback && input.scope !== "identity" && input.clientDid === void 0) {
137
+ throw new Error(
138
+ "invalid SIWD login request: loopback redirects support scope=identity only without a client identity \u2014 a credential scope needs a client_did proven under the loopback credential tier (specs/SIWD.md \xA7Loopback Clients)"
139
+ );
130
140
  }
131
141
  const { challenge, encoded, nonce } = createSiwdChallenge({
132
142
  domain: input.domain,
@@ -138,7 +148,7 @@ var createSiwdLoginRequest = (input) => {
138
148
  url.searchParams.set("challenge", encoded);
139
149
  url.searchParams.set("redirect_uri", input.redirectUri);
140
150
  url.searchParams.set("scope", input.scope);
141
- if (input.clientDid !== void 0 && !isLoopback) {
151
+ if (input.clientDid !== void 0) {
142
152
  url.searchParams.set("client_did", input.clientDid);
143
153
  }
144
154
  return {
@@ -152,21 +162,152 @@ var createSiwdLoginRequest = (input) => {
152
162
  timestamp: challenge.timestamp
153
163
  };
154
164
  };
165
+ var signSiwdAskProof = async (input) => {
166
+ const hashIdx = input.kid.indexOf("#");
167
+ if (hashIdx <= 0 || hashIdx === input.kid.length - 1) {
168
+ throw new Error(
169
+ "invalid SIWD ask proof: kid must be a DID URL with both halves non-empty (<did>#<keyId>)"
170
+ );
171
+ }
172
+ const headerB64 = base64urlEncode(
173
+ JSON.stringify({ alg: "EdDSA", typ: SIWD_ASK_JWS_TYP, kid: input.kid })
174
+ );
175
+ const payloadB64 = base64urlEncode(siwdSigningInput(input.challenge));
176
+ const signingInput = `${headerB64}.${payloadB64}`;
177
+ const signature = await input.signer(new TextEncoder().encode(signingInput));
178
+ return `${signingInput}.${base64urlEncode(signature)}`;
179
+ };
180
+ var MAX_SIWD_CLIENT_CHAIN_OPS = 100;
181
+ var encodeSiwdClientChain = (log) => {
182
+ if (!Array.isArray(log)) {
183
+ throw new Error("invalid SIWD client chain: expected an array of identity-op JWS strings");
184
+ }
185
+ if (log.length === 0) {
186
+ throw new Error("invalid SIWD client chain: log is empty \u2014 carriage needs the full log");
187
+ }
188
+ if (log.length > MAX_SIWD_CLIENT_CHAIN_OPS) {
189
+ throw new Error(
190
+ `invalid SIWD client chain: ${log.length} operations exceeds the ${MAX_SIWD_CLIENT_CHAIN_OPS}-operation carriage cap`
191
+ );
192
+ }
193
+ if (log.some((operation) => typeof operation !== "string" || operation.length === 0)) {
194
+ throw new Error(
195
+ "invalid SIWD client chain: every member must be a non-empty identity-op JWS string"
196
+ );
197
+ }
198
+ return base64urlEncode(JSON.stringify(log));
199
+ };
200
+ var mintSiwdClientIdentity = async () => {
201
+ const keypair = createNewEd25519Keypair();
202
+ const keyId = generateId("key");
203
+ const key = {
204
+ id: keyId,
205
+ type: "Multikey",
206
+ publicKeyMultibase: encodeEd25519Multikey(keypair.publicKey)
207
+ };
208
+ const signer = async (message) => signPayloadEd25519(message, keypair.privateKey);
209
+ const genesis = {
210
+ version: 1,
211
+ type: "create",
212
+ authKeys: [key],
213
+ assertKeys: [key],
214
+ controllerKeys: [key],
215
+ createdAt: new Date(Math.floor(Date.now() / 1e3) * 1e3).toISOString()
216
+ };
217
+ const { jwsToken } = await signIdentityOperation({ operation: genesis, signer, keyId });
218
+ const { did } = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
219
+ return {
220
+ did,
221
+ chain: [jwsToken],
222
+ kid: `${did}#${keyId}`,
223
+ signer,
224
+ privateKey: keypair.privateKey
225
+ };
226
+ };
227
+ var restoreSiwdClientIdentity = async (input) => {
228
+ const keypair = importEd25519Keypair(input.privateKey);
229
+ const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: input.chain });
230
+ const authKey = identity.authKeys.find((key) => {
231
+ const keyBytes = decodeMultikey(key.publicKeyMultibase).keyBytes;
232
+ return keyBytes.length === keypair.publicKey.length && keyBytes.every((byte, index) => byte === keypair.publicKey[index]);
233
+ });
234
+ if (!authKey) {
235
+ throw new Error(
236
+ "invalid SIWD client identity: the private key is not a CURRENT authentication key of the supplied chain \u2014 a rotated-out key cannot restore, because the host would refuse the ask proof it signs"
237
+ );
238
+ }
239
+ return {
240
+ did: identity.did,
241
+ chain: input.chain,
242
+ kid: `${identity.did}#${authKey.id}`,
243
+ signer: async (message) => signPayloadEd25519(message, keypair.privateKey),
244
+ privateKey: input.privateKey
245
+ };
246
+ };
247
+ var createSiwdLoopbackLoginRequest = async (input) => {
248
+ const redirect = parseUrlOrThrow(input.redirectUri, "redirectUri");
249
+ const domain = bareHostname(redirect);
250
+ if (!SIWD_LOOPBACK_HOSTS.has(domain) || redirect.protocol !== "http:") {
251
+ throw new Error(
252
+ "invalid SIWD login request: redirectUri must be an http:// loopback target (localhost, 127.0.0.1, [::1])"
253
+ );
254
+ }
255
+ if (!input.client.kid.startsWith(`${input.client.did}#`)) {
256
+ throw new Error(
257
+ "invalid SIWD login request: client.kid must be a DID URL of client.did (<did>#<keyId>)"
258
+ );
259
+ }
260
+ const request = createSiwdLoginRequest({
261
+ authorizeUrl: input.authorizeUrl,
262
+ domain,
263
+ redirectUri: input.redirectUri,
264
+ scope: input.scope,
265
+ clientDid: input.client.did,
266
+ ...input.statement !== void 0 ? { statement: input.statement } : {},
267
+ ...input.did !== void 0 ? { did: input.did } : {},
268
+ ...input.nonce !== void 0 ? { nonce: input.nonce } : {}
269
+ });
270
+ const proof = await signSiwdAskProof({
271
+ challenge: decodeSiwdChallenge(request.challenge),
272
+ kid: input.client.kid,
273
+ signer: input.client.signer
274
+ });
275
+ const url = new URL(request.url);
276
+ url.searchParams.set("client_proof", proof);
277
+ if (input.client.chain !== void 0) {
278
+ url.searchParams.set("client_chain", encodeSiwdClientChain(input.client.chain));
279
+ }
280
+ return { ...request, url: url.toString() };
281
+ };
155
282
  var callbackParam = (params, key) => {
156
283
  const value = params.get(key);
157
284
  return value === null || value === "" ? void 0 : value;
158
285
  };
159
- var callbackParams = (url) => {
160
- if (typeof url !== "string") return url.searchParams;
161
- if (url === "" || url.startsWith("?")) return new URLSearchParams(url);
162
- return parseUrlOrThrow(url, "url").searchParams;
286
+ var callbackParts = (url) => {
287
+ if (typeof url === "string" && (url === "" || url.startsWith("?"))) {
288
+ const hashIdx = url.indexOf("#");
289
+ const hash2 = hashIdx < 0 ? "" : url.slice(hashIdx + 1);
290
+ return {
291
+ query: new URLSearchParams(hashIdx < 0 ? url : url.slice(0, hashIdx)),
292
+ ...hash2 !== "" ? { fragment: new URLSearchParams(hash2) } : {}
293
+ };
294
+ }
295
+ const parsed = typeof url === "string" ? parseUrlOrThrow(url, "url") : url;
296
+ const hash = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
297
+ return {
298
+ query: parsed.searchParams,
299
+ ...hash !== "" ? { fragment: new URLSearchParams(hash) } : {}
300
+ };
163
301
  };
164
302
  var readSiwdCallback = (url) => {
165
- const params = callbackParams(url);
166
- const jws = callbackParam(params, "jws");
167
- const did = callbackParam(params, "did");
168
- const error = callbackParam(params, "error");
169
- if (jws !== void 0 && did !== void 0) return { kind: "success", jws, did };
303
+ const { query, fragment } = callbackParts(url);
304
+ const jws = callbackParam(query, "jws");
305
+ const did = callbackParam(query, "did");
306
+ const error = callbackParam(query, "error");
307
+ if (jws !== void 0 && did !== void 0) {
308
+ const credential = fragment ? callbackParam(fragment, "credential") : void 0;
309
+ return { kind: "success", jws, did, ...credential !== void 0 ? { credential } : {} };
310
+ }
170
311
  if (error !== void 0) return { kind: "denied", error };
171
312
  if (jws !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing did" };
172
313
  if (did !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing jws" };
@@ -321,13 +462,20 @@ var verifySiwd = async (client, jws, expect) => {
321
462
  }
322
463
  };
323
464
  export {
465
+ MAX_SIWD_CLIENT_CHAIN_OPS,
466
+ SIWD_ASK_JWS_TYP,
324
467
  SIWD_JWS_TYP,
325
468
  buildSiwdSignRequest,
326
469
  createSiwdChallenge,
327
470
  createSiwdLoginRequest,
471
+ createSiwdLoopbackLoginRequest,
328
472
  decodeSiwdChallenge,
473
+ encodeSiwdClientChain,
474
+ mintSiwdClientIdentity,
329
475
  parseSiwdChallenge,
330
476
  readSiwdCallback,
477
+ restoreSiwdClientIdentity,
478
+ signSiwdAskProof,
331
479
  siwdSigningInput,
332
480
  validateSiwdSignRequest,
333
481
  verifySiwd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
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.35.0",
51
- "@metalabel/dfos-web-relay": "^0.35.0"
50
+ "@metalabel/dfos-protocol": "^0.36.1",
51
+ "@metalabel/dfos-web-relay": "^0.36.1"
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-protocol": "0.35.0",
58
- "@metalabel/dfos-web-relay": "0.35.0"
57
+ "@metalabel/dfos-protocol": "0.36.1",
58
+ "@metalabel/dfos-web-relay": "0.36.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup",