@metalabel/dfos-client 0.30.0 → 0.31.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 CHANGED
@@ -6,8 +6,6 @@ If verification logic appears in this package, that is the bug: every proof come
6
6
 
7
7
  ## Install
8
8
 
9
- > **Not yet published — pre-release.** This package is `private` until it ships with a stamped release; until then it is consumable only inside this workspace.
10
-
11
9
  ```bash
12
10
  npm install @metalabel/dfos-client @metalabel/dfos-protocol @metalabel/dfos-web-relay
13
11
  ```
@@ -78,10 +76,30 @@ import { indexedDbStore, memoryStore } from '@metalabel/dfos-client/store';
78
76
  ### `@metalabel/dfos-client/siwd`
79
77
 
80
78
  ```typescript
81
- import { createSiwdChallenge, siwdSigningInput, verifySiwd } from '@metalabel/dfos-client/siwd';
79
+ import {
80
+ createSiwdLoginRequest,
81
+ readSiwdCallback,
82
+ siwdSigningInput,
83
+ verifySiwd,
84
+ } from '@metalabel/dfos-client/siwd';
85
+ ```
86
+
87
+ 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.
88
+
89
+ In production, spend the nonce instead of comparing it:
90
+
91
+ ```typescript
92
+ await verifySiwd(client, jws, {
93
+ domain,
94
+ consumeNonce: async (nonce) => (await store.getdel(nonce)) !== null,
95
+ });
82
96
  ```
83
97
 
84
- Sign In With DFOS. `siwdSigningInput(challenge)` is the pure byte contract both the signer and the verifier share (see [SIWD.md](../../specs/SIWD.md)); `verifySiwd` is a no-throw verifier that accepts only a current `authKeys` entry of a non-deleted identity.
98
+ `consumeNonce` replaces `expect.nonce` (supply exactly one) and 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. It is called 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.
99
+
100
+ `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)).
101
+
102
+ `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.
85
103
 
86
104
  ## License
87
105
 
package/dist/siwd.d.ts CHANGED
@@ -103,7 +103,10 @@ interface SiwdLoginRequest {
103
103
  * a `domain` or `did` that drifts between mint and verify is a check that
104
104
  * silently stops checking.
105
105
  *
106
- * SINGLE USE: consume the nonce on the way back, pass or fail.
106
+ * SINGLE USE: consume the nonce on the way back, pass or fail. A backend
107
+ * holding its minted nonces in shared state should hand `verifySiwd` a
108
+ * `consumeNonce` instead of this object's `nonce`, so the consumption is
109
+ * atomic and is the last thing that happens before a session is granted.
107
110
  *
108
111
  * WHOEVER VERIFIES MUST HAVE MINTED. A verifier that accepts an expectation
109
112
  * supplied by the party presenting the JWS is comparing a value against
@@ -111,8 +114,14 @@ interface SiwdLoginRequest {
111
114
  * expectation comes from the verifier's own prior state (this object, held
112
115
  * server-side or in the session that began the sign-in) or from an
113
116
  * independent validation of it.
117
+ *
118
+ * `nonce` is REQUIRED here even though `SiwdExpectations` leaves it optional
119
+ * for the `consumeNonce` form: what this function mints into the challenge is
120
+ * always a string, and an RP persisting this object must get it back.
114
121
  */
115
- expect: Pick<SiwdExpectations, 'domain' | 'nonce' | 'did'>;
122
+ expect: Pick<SiwdExpectations, 'domain' | 'did'> & {
123
+ nonce: string;
124
+ };
116
125
  /** base64url canonical challenge bytes, exactly as embedded in `url`. */
117
126
  challenge: string;
118
127
  /** ISO whole-second mint timestamp, exactly as embedded in the signed bytes. */
@@ -131,15 +140,22 @@ interface SiwdLoginRequest {
131
140
  * not downgraded, so the param is dropped here instead of being forwarded into
132
141
  * a guaranteed refusal.
133
142
  *
143
+ * 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.
148
+ *
134
149
  * It also owns the WIRE PARAM NAMES (`challenge`, `redirect_uri`, `scope`,
135
150
  * `client_did`) as their single source in this package. They are snake_case on
136
151
  * the wire and camelCase everywhere else, which is exactly the kind of seam
137
152
  * every hand-rolled RP re-implements and eventually gets wrong.
138
153
  *
139
154
  * PURE: no DOM, no storage, no navigation, no fetch — identical in a browser
140
- * and in Node. Throws on an unparseable `authorizeUrl` or `redirectUri`,
141
- * because those are mistakes in the RP's own configuration rather than runtime
142
- * conditions a result type would help a caller recover from.
155
+ * 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.
143
159
  */
144
160
  declare const createSiwdLoginRequest: (input: SiwdLoginRequestInput) => SiwdLoginRequest;
145
161
  /**
@@ -214,8 +230,26 @@ declare const validateSiwdSignRequest: (jwsToken: string, options: ValidateSiwdS
214
230
  interface SiwdExpectations {
215
231
  /** The verifier's own origin — MUST match the challenge domain. */
216
232
  domain: string;
217
- /** The nonce this verifier issued for the session. */
218
- nonce: string;
233
+ /**
234
+ * The nonce this verifier issued for the session. Supply EXACTLY ONE of
235
+ * `nonce` and `consumeNonce`.
236
+ */
237
+ nonce?: string;
238
+ /**
239
+ * Atomically consume the presented nonce against verifier-minted state — a
240
+ * Redis `GETDEL`, a `DELETE … RETURNING` row — returning true iff THIS
241
+ * verifier minted it and it was unspent. Membership in the verifier's own
242
+ * minted state is what proves the verifier minted it, which is the whole
243
+ * check; an equality test against a `nonce` fed in from the presented
244
+ * artifact is a value compared against itself. The ATOMICITY is yours: a
245
+ * get-then-delete lets two concurrent replays both win.
246
+ *
247
+ * Called AT MOST ONCE, and only after every other check has passed —
248
+ * signature, current-key resolution, did binding, domain, timestamp — so an
249
+ * otherwise-invalid presentation can never burn a live nonce, and the
250
+ * consumption is the last gate before the caller grants anything.
251
+ */
252
+ consumeNonce?: (nonce: string) => boolean | Promise<boolean>;
219
253
  /** If set, the challenge (and identity) MUST bind to this DID. */
220
254
  did?: string;
221
255
  /** If set, the signed challenge's timestamp MUST equal this exact value. */
package/dist/siwd.js CHANGED
@@ -124,6 +124,10 @@ var parseUrlOrThrow = (value, field) => {
124
124
  var createSiwdLoginRequest = (input) => {
125
125
  const authorizeUrl = parseUrlOrThrow(input.authorizeUrl, "authorizeUrl");
126
126
  const redirect = parseUrlOrThrow(input.redirectUri, "redirectUri");
127
+ 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");
130
+ }
127
131
  const { challenge, encoded, nonce } = createSiwdChallenge({
128
132
  domain: input.domain,
129
133
  ...input.statement !== void 0 ? { statement: input.statement } : {},
@@ -134,7 +138,7 @@ var createSiwdLoginRequest = (input) => {
134
138
  url.searchParams.set("challenge", encoded);
135
139
  url.searchParams.set("redirect_uri", input.redirectUri);
136
140
  url.searchParams.set("scope", input.scope);
137
- if (input.clientDid !== void 0 && !SIWD_LOOPBACK_HOSTS.has(bareHostname(redirect))) {
141
+ if (input.clientDid !== void 0 && !isLoopback) {
138
142
  url.searchParams.set("client_did", input.clientDid);
139
143
  }
140
144
  return {
@@ -233,6 +237,9 @@ var validateSiwdSignRequest = async (jwsToken, options) => {
233
237
  var fail = (error) => ({ ok: false, error });
234
238
  var verifySiwd = async (client, jws, expect) => {
235
239
  try {
240
+ if (expect.nonce === void 0 === (expect.consumeNonce === void 0)) {
241
+ return fail("provide exactly one of nonce or consumeNonce");
242
+ }
236
243
  const decoded = decodeJwsUnsafe(jws);
237
244
  if (!decoded) return fail("failed to decode JWS");
238
245
  const rawHeader = decoded.header;
@@ -275,7 +282,6 @@ var verifySiwd = async (client, jws, expect) => {
275
282
  } catch (err) {
276
283
  return fail(err instanceof Error ? err.message : "invalid signature");
277
284
  }
278
- if (payload.nonce !== expect.nonce) return fail("nonce mismatch");
279
285
  if (payload.domain !== expect.domain) return fail("domain mismatch");
280
286
  if (expect.timestamp !== void 0 && payload.timestamp !== expect.timestamp) {
281
287
  return fail("timestamp does not match expected challenge timestamp");
@@ -288,6 +294,13 @@ var verifySiwd = async (client, jws, expect) => {
288
294
  if (issuedMs - nowMs > MAX_CLOCK_SKEW_SECONDS * 1e3) {
289
295
  return fail("challenge timestamp is in the future");
290
296
  }
297
+ if (expect.consumeNonce !== void 0) {
298
+ if (!await expect.consumeNonce(payload.nonce)) {
299
+ return fail("nonce already used or not recognized");
300
+ }
301
+ } else if (payload.nonce !== expect.nonce) {
302
+ return fail("nonce mismatch");
303
+ }
291
304
  const session = {
292
305
  did,
293
306
  domain: payload.domain,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Client — read-only resolve + verify orchestration over untrusted relays. Fetch, resolve, verify-orchestration, cache; all crypto truth comes from @metalabel/dfos-protocol",
6
6
  "license": "MIT",
@@ -43,15 +43,15 @@
43
43
  "README.md"
44
44
  ],
45
45
  "peerDependencies": {
46
- "@metalabel/dfos-protocol": "^0.30.0",
47
- "@metalabel/dfos-web-relay": "^0.30.0"
46
+ "@metalabel/dfos-protocol": "^0.31.0",
47
+ "@metalabel/dfos-web-relay": "^0.31.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^24.10.4",
51
51
  "tsup": "^8.5.1",
52
52
  "vitest": "^4.1.8",
53
- "@metalabel/dfos-protocol": "0.30.0",
54
- "@metalabel/dfos-web-relay": "0.30.0"
53
+ "@metalabel/dfos-protocol": "0.31.0",
54
+ "@metalabel/dfos-web-relay": "0.31.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",