@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 +22 -4
- package/dist/siwd.d.ts +41 -7
- package/dist/siwd.js +15 -2
- package/package.json +5 -5
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 {
|
|
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
|
-
|
|
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' | '
|
|
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
|
-
*
|
|
142
|
-
*
|
|
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
|
-
/**
|
|
218
|
-
|
|
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 && !
|
|
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.
|
|
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.
|
|
47
|
-
"@metalabel/dfos-web-relay": "^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.
|
|
54
|
-
"@metalabel/dfos-web-relay": "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",
|