@metalabel/dfos-client 0.29.1 → 0.30.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/dist/siwd.d.ts CHANGED
@@ -67,6 +67,118 @@ 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.
107
+ *
108
+ * WHOEVER VERIFIES MUST HAVE MINTED. A verifier that accepts an expectation
109
+ * supplied by the party presenting the JWS is comparing a value against
110
+ * itself and has verified nothing — the replay guard binds only when the
111
+ * expectation comes from the verifier's own prior state (this object, held
112
+ * server-side or in the session that began the sign-in) or from an
113
+ * independent validation of it.
114
+ */
115
+ expect: Pick<SiwdExpectations, 'domain' | 'nonce' | 'did'>;
116
+ /** base64url canonical challenge bytes, exactly as embedded in `url`. */
117
+ challenge: string;
118
+ /** ISO whole-second mint timestamp, exactly as embedded in the signed bytes. */
119
+ timestamp: string;
120
+ }
121
+ /**
122
+ * Mint a challenge and build the `/authorize` URL to send the browser to — the
123
+ * OUTBOUND half of profile A. `readSiwdCallback` is the inbound half, and
124
+ * `verifySiwd` is what both compose around: mint → redirect, read → verify.
125
+ *
126
+ * The rule this function exists to own is the LOOPBACK OMISSION. Nothing can
127
+ * prove a client DID for an app on a local port — there is no domain serving a
128
+ * well-known and no registration to check — so a host REFUSES a `client_did`
129
+ * on a loopback redirect rather than displaying an identity it cannot stand
130
+ * behind. A CLI that passed its own DID would have the whole request rejected,
131
+ * not downgraded, so the param is dropped here instead of being forwarded into
132
+ * a guaranteed refusal.
133
+ *
134
+ * It also owns the WIRE PARAM NAMES (`challenge`, `redirect_uri`, `scope`,
135
+ * `client_did`) as their single source in this package. They are snake_case on
136
+ * the wire and camelCase everywhere else, which is exactly the kind of seam
137
+ * every hand-rolled RP re-implements and eventually gets wrong.
138
+ *
139
+ * 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.
143
+ */
144
+ declare const createSiwdLoginRequest: (input: SiwdLoginRequestInput) => SiwdLoginRequest;
145
+ /**
146
+ * What came back on the redirect. `none` means this was a plain page load, not
147
+ * a callback at all — the common case on an RP's own landing page.
148
+ */
149
+ type SiwdCallbackResult = {
150
+ kind: 'success';
151
+ jws: string;
152
+ did: string;
153
+ } | {
154
+ kind: 'denied';
155
+ error: string;
156
+ } | {
157
+ kind: 'none';
158
+ };
159
+ /**
160
+ * Read a profile-A callback — the INBOUND half of the pair. Pure parse: it
161
+ * decides only what kind of return this is, and hands `jws` to the caller for
162
+ * `verifySiwd`. NOTHING here is trusted; the `did` param is unauthenticated
163
+ * courier convenience, and the DID a caller should act on is the one
164
+ * `verifySiwd` returns from the signature.
165
+ *
166
+ * Takes an absolute URL string, a `URL`, or a bare `?…` query string (so
167
+ * `readSiwdCallback(location.search)` works, including when it is empty).
168
+ *
169
+ * SCRUB THE URL YOURSELF, IMMEDIATELY. A signed JWS is sitting in the query
170
+ * string, which means it is in the address bar, in `history`, in the referrer
171
+ * of anything the page loads next, and in any analytics that samples the
172
+ * location. This function cannot do the scrubbing for you — `history` is
173
+ * environment-owned and this package stays free of the DOM — so a browser RP
174
+ * should follow the read with a `history.replaceState` back to the bare path.
175
+ *
176
+ * A HALF-CALLBACK IS A FAILURE, NOT A NON-EVENT: `jws` without `did` (or the
177
+ * reverse) resolves to `denied` carrying a synthetic reason rather than `none`.
178
+ * Silently treating it as a plain page load would strand the user on a
179
+ * sign-in button with no explanation of why the last attempt vanished.
180
+ */
181
+ declare const readSiwdCallback: (url: string | URL) => SiwdCallbackResult;
70
182
  interface BuildSiwdSignRequestInput {
71
183
  /** Requester DID that signs the courier envelope. */
72
184
  did: string;
@@ -127,4 +239,4 @@ interface SiwdExpectations {
127
239
  */
128
240
  declare const verifySiwd: (client: Client, jws: string, expect: SiwdExpectations) => Promise<VerifyResult<SiwdSession>>;
129
241
 
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 };
242
+ 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,65 @@ 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 { challenge, encoded, nonce } = createSiwdChallenge({
128
+ domain: input.domain,
129
+ ...input.statement !== void 0 ? { statement: input.statement } : {},
130
+ ...input.did !== void 0 ? { did: input.did } : {},
131
+ ...input.nonce !== void 0 ? { nonce: input.nonce } : {}
132
+ });
133
+ const url = new URL(authorizeUrl);
134
+ url.searchParams.set("challenge", encoded);
135
+ url.searchParams.set("redirect_uri", input.redirectUri);
136
+ url.searchParams.set("scope", input.scope);
137
+ if (input.clientDid !== void 0 && !SIWD_LOOPBACK_HOSTS.has(bareHostname(redirect))) {
138
+ url.searchParams.set("client_did", input.clientDid);
139
+ }
140
+ return {
141
+ url: url.toString(),
142
+ expect: {
143
+ domain: input.domain,
144
+ nonce,
145
+ ...input.did !== void 0 ? { did: input.did } : {}
146
+ },
147
+ challenge: encoded,
148
+ timestamp: challenge.timestamp
149
+ };
150
+ };
151
+ var callbackParam = (params, key) => {
152
+ const value = params.get(key);
153
+ return value === null || value === "" ? void 0 : value;
154
+ };
155
+ var callbackParams = (url) => {
156
+ if (typeof url !== "string") return url.searchParams;
157
+ if (url === "" || url.startsWith("?")) return new URLSearchParams(url);
158
+ return parseUrlOrThrow(url, "url").searchParams;
159
+ };
160
+ var readSiwdCallback = (url) => {
161
+ const params = callbackParams(url);
162
+ const jws = callbackParam(params, "jws");
163
+ const did = callbackParam(params, "did");
164
+ const error = callbackParam(params, "error");
165
+ if (jws !== void 0 && did !== void 0) return { kind: "success", jws, did };
166
+ if (error !== void 0) return { kind: "denied", error };
167
+ if (jws !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing did" };
168
+ if (did !== void 0) return { kind: "denied", error: "malformed SIWD callback: missing jws" };
169
+ return { kind: "none" };
170
+ };
112
171
  var assertSiwdAcceptanceWindow = (seconds) => {
113
172
  if (!Number.isSafeInteger(seconds) || seconds <= 0) {
114
173
  throw new Error("SIWD acceptanceWindowSeconds must be a positive integer");
@@ -252,8 +311,10 @@ export {
252
311
  SIWD_JWS_TYP,
253
312
  buildSiwdSignRequest,
254
313
  createSiwdChallenge,
314
+ createSiwdLoginRequest,
255
315
  decodeSiwdChallenge,
256
316
  parseSiwdChallenge,
317
+ readSiwdCallback,
257
318
  siwdSigningInput,
258
319
  validateSiwdSignRequest,
259
320
  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.30.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.30.0",
47
+ "@metalabel/dfos-web-relay": "^0.30.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.30.0",
54
+ "@metalabel/dfos-web-relay": "0.30.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",