@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 +49 -2
- package/dist/siwd.d.ts +259 -25
- package/dist/siwd.js +160 -12
- package/package.json +5 -5
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
151
|
-
*
|
|
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
|
|
157
|
-
* in the RP's own configuration rather than runtime
|
|
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
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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(
|
|
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
|
|
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
|
|
160
|
-
if (typeof url
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
166
|
-
const jws = callbackParam(
|
|
167
|
-
const did = callbackParam(
|
|
168
|
-
const error = callbackParam(
|
|
169
|
-
if (jws !== void 0 && did !== void 0)
|
|
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.
|
|
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.
|
|
51
|
-
"@metalabel/dfos-web-relay": "^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.
|
|
58
|
-
"@metalabel/dfos-web-relay": "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",
|