@metalabel/dfos-client 0.29.1 → 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
@@ -67,6 +67,134 @@ declare const createSiwdChallenge: (input: CreateChallengeInput) => {
67
67
  };
68
68
  /** Decode a base64url `challenge` query-param body back into a challenge object. */
69
69
  declare const decodeSiwdChallenge: (encoded: string) => SiwdChallenge;
70
+ interface SiwdLoginRequestInput {
71
+ /** The host's authorize endpoint, e.g. `https://app.example.com/authorize`. */
72
+ authorizeUrl: string;
73
+ /** The RP's own domain — bound into the signed challenge as a bare hostname. */
74
+ domain: string;
75
+ /** Exact redirect target; must match the RP's registered or served allowlist. */
76
+ redirectUri: string;
77
+ /** Requested scope. `identity` is the only scope implemented today. */
78
+ scope: string;
79
+ /** Consent-screen prose. A host MAY decline to render it; see specs/SIWD.md. */
80
+ statement?: string;
81
+ /**
82
+ * Bind the challenge to ONE identity — "sign in as this DID, or not at all".
83
+ * Threaded into the signed bytes AND into `expect`, because binding a
84
+ * challenge without verifying the binding proves nothing: a host that ignored
85
+ * it would return a signature from whoever was logged in, and an RP checking
86
+ * only the signature would accept it.
87
+ */
88
+ did?: string;
89
+ /** The RP's own DID. Omitted from the URL automatically for loopback redirects. */
90
+ clientDid?: string;
91
+ /** Supply a nonce minted elsewhere (e.g. by your backend); default: minted here. */
92
+ nonce?: string;
93
+ }
94
+ interface SiwdLoginRequest {
95
+ /** Navigate the browser here, or hand it to the user. */
96
+ url: string;
97
+ /**
98
+ * THE THING TO PERSIST ACROSS THE REDIRECT. One JSON-serializable object that
99
+ * satisfies `SiwdExpectations`, so the whole round trip is: store this before
100
+ * navigating, rehydrate it when the callback lands, and hand it straight to
101
+ * `verifySiwd(client, jws, saved)`. Nothing else has to survive the hop, and
102
+ * nothing has to be threaded to both ends by hand — which is the point, since
103
+ * a `domain` or `did` that drifts between mint and verify is a check that
104
+ * silently stops checking.
105
+ *
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.
110
+ *
111
+ * WHOEVER VERIFIES MUST HAVE MINTED. A verifier that accepts an expectation
112
+ * supplied by the party presenting the JWS is comparing a value against
113
+ * itself and has verified nothing — the replay guard binds only when the
114
+ * expectation comes from the verifier's own prior state (this object, held
115
+ * server-side or in the session that began the sign-in) or from an
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.
121
+ */
122
+ expect: Pick<SiwdExpectations, 'domain' | 'did'> & {
123
+ nonce: string;
124
+ };
125
+ /** base64url canonical challenge bytes, exactly as embedded in `url`. */
126
+ challenge: string;
127
+ /** ISO whole-second mint timestamp, exactly as embedded in the signed bytes. */
128
+ timestamp: string;
129
+ }
130
+ /**
131
+ * Mint a challenge and build the `/authorize` URL to send the browser to — the
132
+ * OUTBOUND half of profile A. `readSiwdCallback` is the inbound half, and
133
+ * `verifySiwd` is what both compose around: mint → redirect, read → verify.
134
+ *
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 check — so 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.
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
+ *
149
+ * 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
152
+ * every hand-rolled RP re-implements and eventually gets wrong.
153
+ *
154
+ * PURE: no DOM, no storage, no navigation, no fetch — identical in a browser
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.
159
+ */
160
+ declare const createSiwdLoginRequest: (input: SiwdLoginRequestInput) => SiwdLoginRequest;
161
+ /**
162
+ * What came back on the redirect. `none` means this was a plain page load, not
163
+ * a callback at all — the common case on an RP's own landing page.
164
+ */
165
+ type SiwdCallbackResult = {
166
+ kind: 'success';
167
+ jws: string;
168
+ did: string;
169
+ } | {
170
+ kind: 'denied';
171
+ error: string;
172
+ } | {
173
+ kind: 'none';
174
+ };
175
+ /**
176
+ * Read a profile-A callback — the INBOUND half of the pair. Pure parse: it
177
+ * decides only what kind of return this is, and hands `jws` to the caller for
178
+ * `verifySiwd`. NOTHING here is trusted; the `did` param is unauthenticated
179
+ * courier convenience, and the DID a caller should act on is the one
180
+ * `verifySiwd` returns from the signature.
181
+ *
182
+ * Takes an absolute URL string, a `URL`, or a bare `?…` query string (so
183
+ * `readSiwdCallback(location.search)` works, including when it is empty).
184
+ *
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.
191
+ *
192
+ * A HALF-CALLBACK IS A FAILURE, NOT A NON-EVENT: `jws` without `did` (or the
193
+ * reverse) resolves to `denied` carrying a synthetic reason rather than `none`.
194
+ * Silently treating it as a plain page load would strand the user on a
195
+ * sign-in button with no explanation of why the last attempt vanished.
196
+ */
197
+ declare const readSiwdCallback: (url: string | URL) => SiwdCallbackResult;
70
198
  interface BuildSiwdSignRequestInput {
71
199
  /** Requester DID that signs the courier envelope. */
72
200
  did: string;
@@ -102,8 +230,26 @@ declare const validateSiwdSignRequest: (jwsToken: string, options: ValidateSiwdS
102
230
  interface SiwdExpectations {
103
231
  /** The verifier's own origin — MUST match the challenge domain. */
104
232
  domain: string;
105
- /** The nonce this verifier issued for the session. */
106
- 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>;
107
253
  /** If set, the challenge (and identity) MUST bind to this DID. */
108
254
  did?: string;
109
255
  /** If set, the signed challenge's timestamp MUST equal this exact value. */
@@ -127,4 +273,4 @@ interface SiwdExpectations {
127
273
  */
128
274
  declare const verifySiwd: (client: Client, jws: string, expect: SiwdExpectations) => Promise<VerifyResult<SiwdSession>>;
129
275
 
130
- export { type BuildSiwdSignRequestInput, type CreateChallengeInput, SIWD_JWS_TYP, type SiwdChallenge, type SiwdExpectations, type SiwdSession, type ValidateSiwdSignRequestOptions, type ValidatedSiwdSignRequest, buildSiwdSignRequest, createSiwdChallenge, decodeSiwdChallenge, parseSiwdChallenge, siwdSigningInput, validateSiwdSignRequest, verifySiwd };
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 };
package/dist/siwd.js CHANGED
@@ -109,6 +109,69 @@ var createSiwdChallenge = (input) => {
109
109
  return { challenge, encoded, nonce };
110
110
  };
111
111
  var decodeSiwdChallenge = (encoded) => parseSiwdChallenge(base64urlDecode(encoded));
112
+ var SIWD_LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
113
+ var bareHostname = (url) => {
114
+ const host = url.hostname.toLowerCase();
115
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
116
+ };
117
+ var parseUrlOrThrow = (value, field) => {
118
+ try {
119
+ return new URL(value);
120
+ } catch {
121
+ throw new Error(`invalid SIWD login request: ${field} must be an absolute URL`);
122
+ }
123
+ };
124
+ var createSiwdLoginRequest = (input) => {
125
+ const authorizeUrl = parseUrlOrThrow(input.authorizeUrl, "authorizeUrl");
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
+ }
131
+ const { challenge, encoded, nonce } = createSiwdChallenge({
132
+ domain: input.domain,
133
+ ...input.statement !== void 0 ? { statement: input.statement } : {},
134
+ ...input.did !== void 0 ? { did: input.did } : {},
135
+ ...input.nonce !== void 0 ? { nonce: input.nonce } : {}
136
+ });
137
+ const url = new URL(authorizeUrl);
138
+ url.searchParams.set("challenge", encoded);
139
+ url.searchParams.set("redirect_uri", input.redirectUri);
140
+ url.searchParams.set("scope", input.scope);
141
+ if (input.clientDid !== void 0 && !isLoopback) {
142
+ url.searchParams.set("client_did", input.clientDid);
143
+ }
144
+ return {
145
+ url: url.toString(),
146
+ expect: {
147
+ domain: input.domain,
148
+ nonce,
149
+ ...input.did !== void 0 ? { did: input.did } : {}
150
+ },
151
+ challenge: encoded,
152
+ timestamp: challenge.timestamp
153
+ };
154
+ };
155
+ var callbackParam = (params, key) => {
156
+ const value = params.get(key);
157
+ return value === null || value === "" ? void 0 : value;
158
+ };
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;
163
+ };
164
+ 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 };
170
+ if (error !== void 0) return { kind: "denied", error };
171
+ if (jws !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing did" };
172
+ if (did !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing jws" };
173
+ return { kind: "none" };
174
+ };
112
175
  var assertSiwdAcceptanceWindow = (seconds) => {
113
176
  if (!Number.isSafeInteger(seconds) || seconds <= 0) {
114
177
  throw new Error("SIWD acceptanceWindowSeconds must be a positive integer");
@@ -174,6 +237,9 @@ var validateSiwdSignRequest = async (jwsToken, options) => {
174
237
  var fail = (error) => ({ ok: false, error });
175
238
  var verifySiwd = async (client, jws, expect) => {
176
239
  try {
240
+ if (expect.nonce === void 0 === (expect.consumeNonce === void 0)) {
241
+ return fail("provide exactly one of nonce or consumeNonce");
242
+ }
177
243
  const decoded = decodeJwsUnsafe(jws);
178
244
  if (!decoded) return fail("failed to decode JWS");
179
245
  const rawHeader = decoded.header;
@@ -216,7 +282,6 @@ var verifySiwd = async (client, jws, expect) => {
216
282
  } catch (err) {
217
283
  return fail(err instanceof Error ? err.message : "invalid signature");
218
284
  }
219
- if (payload.nonce !== expect.nonce) return fail("nonce mismatch");
220
285
  if (payload.domain !== expect.domain) return fail("domain mismatch");
221
286
  if (expect.timestamp !== void 0 && payload.timestamp !== expect.timestamp) {
222
287
  return fail("timestamp does not match expected challenge timestamp");
@@ -229,6 +294,13 @@ var verifySiwd = async (client, jws, expect) => {
229
294
  if (issuedMs - nowMs > MAX_CLOCK_SKEW_SECONDS * 1e3) {
230
295
  return fail("challenge timestamp is in the future");
231
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
+ }
232
304
  const session = {
233
305
  did,
234
306
  domain: payload.domain,
@@ -252,8 +324,10 @@ export {
252
324
  SIWD_JWS_TYP,
253
325
  buildSiwdSignRequest,
254
326
  createSiwdChallenge,
327
+ createSiwdLoginRequest,
255
328
  decodeSiwdChallenge,
256
329
  parseSiwdChallenge,
330
+ readSiwdCallback,
257
331
  siwdSigningInput,
258
332
  validateSiwdSignRequest,
259
333
  verifySiwd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.29.1",
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.29.1",
47
- "@metalabel/dfos-web-relay": "^0.29.1"
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.29.1",
54
- "@metalabel/dfos-web-relay": "0.29.1"
53
+ "@metalabel/dfos-protocol": "0.31.0",
54
+ "@metalabel/dfos-web-relay": "0.31.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",