@o3co/auth-provider-foundation 0.16.0 → 0.17.0-rc.2

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.
Files changed (39) hide show
  1. package/README.ja.md +154 -29
  2. package/README.md +482 -45
  3. package/config/reference.conf +20 -0
  4. package/dist/endpointUrl.d.mts +21 -47
  5. package/dist/endpointUrl.d.mts.map +1 -1
  6. package/dist/endpointUrl.mjs +23 -50
  7. package/dist/index.d.mts +5 -1
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +18 -35
  10. package/dist/mfa/HttpMfaFactorStore.d.mts +55 -0
  11. package/dist/mfa/HttpMfaFactorStore.d.mts.map +1 -0
  12. package/dist/mfa/HttpMfaFactorStore.mjs +183 -0
  13. package/dist/mfa/markEnrolled.d.mts +4 -0
  14. package/dist/mfa/markEnrolled.d.mts.map +1 -0
  15. package/dist/mfa/markEnrolled.mjs +31 -0
  16. package/dist/mfa/module.d.mts +27 -0
  17. package/dist/mfa/module.d.mts.map +1 -0
  18. package/dist/mfa/module.mjs +56 -0
  19. package/dist/mfa/section.d.mts +45 -0
  20. package/dist/mfa/section.d.mts.map +1 -0
  21. package/dist/mfa/section.mjs +99 -0
  22. package/dist/mfa/storeFailure.d.mts +52 -0
  23. package/dist/mfa/storeFailure.d.mts.map +1 -0
  24. package/dist/mfa/storeFailure.mjs +107 -0
  25. package/dist/repositories/HttpUserRepository.d.mts +54 -79
  26. package/dist/repositories/HttpUserRepository.d.mts.map +1 -1
  27. package/dist/repositories/HttpUserRepository.mjs +117 -413
  28. package/dist/repositories/storeErrors.d.mts +18 -27
  29. package/dist/repositories/storeErrors.d.mts.map +1 -1
  30. package/dist/repositories/storeErrors.mjs +30 -40
  31. package/dist/repositories/wwwAuthenticate.d.mts.map +1 -1
  32. package/dist/repositories/wwwAuthenticate.mjs +12 -16
  33. package/dist/storeTransport.d.mts +97 -0
  34. package/dist/storeTransport.d.mts.map +1 -0
  35. package/dist/storeTransport.mjs +290 -0
  36. package/dist/testing/index.d.mts +28 -0
  37. package/dist/testing/index.d.mts.map +1 -0
  38. package/dist/testing/index.mjs +58 -0
  39. package/package.json +18 -7
package/README.md CHANGED
@@ -1,21 +1,28 @@
1
1
  # @o3co/auth-provider-foundation
2
2
 
3
- Last updated: 2026-09-26
3
+ Last updated: 2026-10-06
4
4
 
5
5
  The HTTP client of "the Store" — the deployment's own user service — for
6
6
  auth.provider. `HttpUserRepository` implements core's `UserRepository` port over
7
- HTTPS: it authenticates users, links federated identities, and answers the
8
- identity lookup federation grants ask for. `registerBuiltinAdapters` registers
9
- it as the `"http"` user adapter.
7
+ HTTPS: it authenticates users, links federated identities, answers the
8
+ identity lookup federation grants ask for, and writes the MFA enrollment
9
+ witness. `registerBuiltinAdapters` registers
10
+ it as the `"http"` user adapter. The package also fixes the contract of the
11
+ Store's MFA endpoints — where a Store keeps a subject's second factors and the
12
+ enrollment witness — with the configuration section that names them and what
13
+ their failures throw; `HttpMfaFactorStore` implements core's `MfaFactorStore`
14
+ over the factor endpoints, and `foundationMfaFactorStoreModule` installs it.
10
15
 
11
16
  ## Responsibility
12
17
 
13
- **Role.** An adapter for one core port,
18
+ **Role.** An adapter for two core ports:
14
19
  [`UserRepository`](../core/src/repositories/UserRepository.mts) (see
15
- [`core/src/repositories`](../core/src/repositories/README.md)). Despite the
20
+ [`core/src/repositories`](../core/src/repositories/README.md)), and, where
21
+ the Store keeps a deployment's MFA factors,
22
+ [`MfaFactorStore`](../core/src/mfa/factorStore.mts). Despite the
16
23
  package name it is not a base layer: no other package imports it at runtime
17
24
  (`federation-grants` uses it in a test). A composition root selects it —
18
- `repositories.user.type = "http"` in the standalone template.
25
+ `adapters.userRepository = "http"` in the standalone template.
19
26
 
20
27
  **Owns:**
21
28
 
@@ -28,9 +35,20 @@ package name it is not a base layer: no other package imports it at runtime
28
35
  credential is held to, and how the Store's refusal of it reads;
29
36
  - that nothing a transport reports — which may quote the request — is thrown;
30
37
  - the request deadline and the response-size cap;
31
- - the coverage declaration the identity lookup is judged by at boot.
32
-
33
- **Does not own:** the port and the `User` shape (core); when a user is
38
+ - the coverage declaration the identity lookup is judged by at boot;
39
+ - the contract of the Store's MFA endpoints: what each is sent, what each
40
+ answer means, and that nothing the Store sends reaches a client when a call
41
+ fails ([below](#the-stores-mfa-endpoints)); the `foundation-mfa-factor-store`
42
+ section that names the factor endpoints; and `MfaStoreError`, what their
43
+ failures throw;
44
+ - `HttpMfaFactorStore`, the Store-backed `MfaFactorStore`, and the
45
+ `foundation-mfa-factor-store` module that installs it
46
+ ([below](#the-store-backed-factor-store)).
47
+
48
+ **Does not own:** the port and the `User` shape (core); the MFA ports and the
49
+ JSON bodies of the MFA endpoints (core's
50
+ [`mfa/storeWire.mts`](../core/src/mfa/storeWire.mts), which a Store's own
51
+ implementation and the test kit's fake Store read too); when a user is
34
52
  authenticated, an identity linked or an owner looked up — the session routes
35
53
  ([`@o3co/auth-provider-session`](../session/README.md)), `oauth`'s jwt-bearer
36
54
  grant ([`@o3co/auth-provider-oauth`](../oauth/README.md)) and federation grants
@@ -43,7 +61,8 @@ Redis authorization-code store).
43
61
  a file; a production deployment keeps its users in a service of its own, and
44
62
  this is the client for one. It is the replaceable piece — a deployment with its
45
63
  own `UserRepository` implementation does not install it — and it depends on
46
- nothing but core (the global `fetch`, no other dependency).
64
+ core alone among the packages, with the global `fetch` and `zod` for its
65
+ section's schema.
47
66
 
48
67
  ## Install
49
68
 
@@ -51,8 +70,8 @@ nothing but core (the global `fetch`, no other dependency).
51
70
  npm install @o3co/auth-provider-foundation @o3co/auth-provider-core
52
71
  ```
53
72
 
54
- Peer dependency: `@o3co/auth-provider-core`. The package has no dependencies
55
- of its own.
73
+ Peer dependency: `@o3co/auth-provider-core`. Its one dependency of its own is
74
+ `zod`, for the schema of its configuration section.
56
75
 
57
76
  ## Usage
58
77
 
@@ -84,19 +103,21 @@ required, since the 5000 ms default is the builder's.
84
103
 
85
104
  ### Configuration
86
105
 
87
- Under `repositories.user` (`type = "http"`, `CLIENT_USER_TYPE`), in its `http`
88
- block; defaults are in [`reference.conf`](../core/config/reference.conf):
106
+ Under `repositories.user.http`, the standalone template's `repositories`
107
+ section, read when `adapters.userRepository = "http"` (`ADAPTERS_USER_REPOSITORY`);
108
+ defaults are in [the template's `reference.conf`](../../templates/standalone/config/reference.conf):
89
109
 
90
110
  | Key | Env | |
91
111
  | --- | --- | --- |
92
- | `authenticateUrl` | `CLIENT_USER_AUTHENTICATE_URL` | Required. Password login. |
93
- | `authenticateByTokenUrl` | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | Required. Resolves an opaque handle: federated login, the jwt-bearer grant. |
94
- | `linkFederatedIdentityUrl` | `CLIENT_USER_LINK_FEDERATED_IDENTITY_URL` | Optional. Enables account linking. |
95
- | `findSubjectByFederatedIdentityUrl` | `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | Optional. The identity lookup. |
112
+ | `authenticateUrl` | `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` | Required. Password login. |
113
+ | `authenticateByTokenUrl` | `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` | Required. Resolves an opaque handle: federated login, the jwt-bearer grant. |
114
+ | `linkFederatedIdentityUrl` | `REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL` | Optional. Enables account linking. |
115
+ | `findSubjectByFederatedIdentityUrl` | `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | Optional. The identity lookup. |
96
116
  | `federatedIdentityLookupCoverage` | — (a list; HOCON only) | What the lookup covers. Default `[]`. |
97
- | `bearerToken` | `CLIENT_USER_BEARER_TOKEN` | Optional. Sent on every request as `Authorization: Bearer <token>`; at least 32 bytes of key material. Unset, no `Authorization` header is sent. See [who may call the Store](#what-the-store-must-enforce-itself). |
98
- | `timeout` | `CLIENT_USER_TIMEOUT` | Milliseconds. Default 5000. |
99
- | `maxResponseBytes` | `CLIENT_USER_MAX_RESPONSE_BYTES` | Default 1048576. |
117
+ | `markMfaEnrolledUrl` | `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` | Optional. Writes the MFA enrollment witness ([below](#the-witness-written-through-the-user-repository)). No default. |
118
+ | `bearerToken` | `REPOSITORIES_USER_HTTP_BEARER_TOKEN` | Optional. Sent on every request as `Authorization: Bearer <token>`; at least 32 bytes of key material. Unset, no `Authorization` header is sent. See [who may call the Store](#what-the-store-must-enforce-itself). |
119
+ | `timeout` | `REPOSITORIES_USER_HTTP_TIMEOUT` | Milliseconds. Default 5000. |
120
+ | `maxResponseBytes` | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | Default 1048576. |
100
121
 
101
122
  ## The wire contract
102
123
 
@@ -110,13 +131,31 @@ username arrives as `email`); **`authenticateByToken`** posts `{ token }` to
110
131
  to a user — `<provider>:<sub>` from the federation callback, the verified
111
132
  assertion's subject handle from `oauth`'s jwt-bearer grant. For both:
112
133
 
113
- - a `2xx` whose body is a JSON `User` (`{ id: string, username: string, … }`) is
114
- the user;
134
+ - a `2xx` whose body is a JSON `User` (`{ id: string, username: string, … }`,
135
+ both non-empty) is the user;
115
136
  - `401` or `403` is `null` — no such user, or wrong credentials;
116
137
  - a `2xx` with a body that is not a `User` throws — an upstream failure, not a
117
- "user not found";
138
+ "user not found". An empty `id` or `username` is not a `User`: an empty `id`
139
+ names nobody, and OpenID Connect Core §2 requires the `sub` it becomes to be
140
+ a locally unique identifier. For a user without a username, the Store sends
141
+ a stable label as `username`, such as the e-mail address. The session routes
142
+ and the jwt-bearer grant answer the throw `503 temporarily_unavailable`, as
143
+ they answer any Store failure (the table under
144
+ [What the Store must enforce itself](#what-the-store-must-enforce-itself)
145
+ names each line they log);
118
146
  - any other status throws.
119
147
 
148
+ **The MFA enrollment witness on both reads.** A Store that keeps the witness
149
+ (the MFA ADR's D12) MUST answer `User.mfaEnrolled` on `authenticate` and on
150
+ `authenticateByToken` alike: a boolean, left out until the subject is first
151
+ marked. A federated login records the witness from `authenticateByToken`'s
152
+ `User`, so a Store that answers it on `authenticate` alone leaves federated
153
+ sessions without the defence the witness gives. The adapter hands
154
+ `mfaEnrolled` through as the Store answered it, and core reads it
155
+ (`readMfaEnrollmentWitness`): any value other than `true`, `false` or absent
156
+ is malformed, which the MFA package answers `503` and never a first binding.
157
+ The test kit's witness suite holds a Store to both reads.
158
+
120
159
  The body of a non-`2xx` answer is discarded unread, for these and for linking.
121
160
  No request follows a redirect, including the identity lookup: a `3xx` is one
122
161
  more status that throws, and its `Location` is never contacted.
@@ -124,7 +163,7 @@ more status that throws, and its `Location` is never contacted.
124
163
  **A `401` or `403` with a `Bearer` challenge is a refused credential.** When
125
164
  `bearerToken` is configured, a `401` or `403` carrying
126
165
  `WWW-Authenticate: Bearer …` (RFC 6750 §3 — `invalid_token`,
127
- `insufficient_scope`) from any of the four endpoints throws a
166
+ `insufficient_scope`) from any endpoint it posts to throws a
128
167
  `StoreCredentialRefusedError`, `HttpUserRepository: the Store at <url> refused
129
168
  this deployment's credential (HTTP <status> with a Bearer challenge) — …`,
130
169
  instead of the reading this section otherwise gives that status: not "no such
@@ -167,7 +206,10 @@ among them. A request that outlives the deadline is a `TimeoutError` instead
167
206
  **`linkFederatedIdentity`** posts `{ userId, provider, sub, token, claims }` to
168
207
  `linkFederatedIdentityUrl`: a `2xx` `User` is `{ ok: true, user }`; `401` / `403`
169
208
  is `{ ok: false, reason: "refused" }`; `409` is
170
- `{ ok: false, reason: "conflict" }`; anything else throws. Because the body of a
209
+ `{ ok: false, reason: "conflict" }`; anything else throws. A `2xx` whose body
210
+ is not a `User` throws too, although the Store has already made the link: the
211
+ caller answers `503` and audits no link, so the Store reconciles that link on
212
+ its side. Because the body of a
171
213
  refusal is not read, a refusal carries no description from the Store. The
172
214
  method is absent when `linkFederatedIdentityUrl` is not configured, which is
173
215
  how the federation
@@ -239,18 +281,350 @@ exactly (no trimming, no case folding, no trailing-slash tolerance), and
239
281
  registration and `sub` alone. `supportsFederatedIdentityLookup(registration,
240
282
  identityClaims)` is `true` when an entry equals the registration and every
241
283
  `requiredClaims` name is among `identityClaims`. When federation grants are enabled and have at
242
- least one connection and `federationGrants.identityLookup` is `"required"` (the
284
+ least one connection and `federation-grants.identityLookup` is `"required"` (the
243
285
  default), the grants module refuses at boot every connection it is `false` for;
244
286
  under `"unsupported"`, or with no connection, it asks nothing. A registration nobody declared is
245
287
  answered `registration_not_covered` locally, and a declared one arriving
246
288
  without a required claim `identity_not_resolvable`, with no request either
247
289
  way. The declaration is snapshotted at construction; a duplicate registration,
248
290
  a malformed entry, or coverage without the URL is a construction error. It has
249
- no environment-variable form (a list is HOCON's), as `federationGrants.connections`
291
+ no environment-variable form (a list is HOCON's), as `federation-grants.connections`
250
292
  has none. `supportsFederatedIdentityLookup` and `findSubjectByFederatedIdentity`
251
293
  are absent when `findSubjectByFederatedIdentityUrl` is not configured, and a
252
294
  deployment that then has a connection under `"required"` is refused at boot.
253
295
 
296
+ ## The Store's MFA endpoints
297
+
298
+ A Store can keep a subject's second factors (the MFA ADR's D7) and the
299
+ enrollment witness (D12). The provider decides and the Store persists; the
300
+ Store never decodes, logs or derives anything from a factor's `data`, which
301
+ the provider seals first. This package holds the contract, the section that
302
+ names the factor endpoints and what their failures throw; `HttpUserRepository`
303
+ sends the witness's request when `markMfaEnrolledUrl` is configured
304
+ ([below](#the-witness-written-through-the-user-repository)), and
305
+ `HttpMfaFactorStore` the four factor endpoints'
306
+ ([below](#the-store-backed-factor-store)). The JSON bodies are core's
307
+ [`mfa/storeWire.mts`](../core/src/mfa/storeWire.mts) (`MfaStoreFactor`,
308
+ `MfaStoreUpdateRequest`, …), with the codec both sides use.
309
+
310
+ **Every request** is a `POST` of JSON to the configured URL as it is — no path
311
+ segment or query is added, so no subject or factor id appears in a request
312
+ line or a Store's access log — under the user repository's rules: the same
313
+ `bearerToken`, deadline and response cap, `https` or loopback `http`, no
314
+ redirect followed, and a `401` or `403` with a `Bearer` challenge a refused
315
+ credential ([the wire contract](#the-wire-contract)).
316
+
317
+ **A factor record** is `{ id, subject, kind, label?, binding?, createdAtMs,
318
+ lastUsedAtMs?, version, data }`. Times are epoch milliseconds, as numbers,
319
+ named `…Ms`. An optional field with no value is left out: `null` is never
320
+ "unset", and a record carrying it for any field is one the provider cannot
321
+ read. `id` is 22 base64url characters (16 random bytes); `kind` a hint token
322
+ (`^[a-z][a-z0-9_-]{0,63}$`); `label` 1 to 64 characters, well formed, none of
323
+ them one that breaks or reorders a line; `binding` is `password`,
324
+ `email_proof`, `federated` or `mfa`; `version` a safe non-negative integer; `data` a
325
+ string kept byte for byte. A record outside that shape is one the provider
326
+ cannot read (core's `isMfaFactorId`, `isMfaFactorKind`, `isMfaFactorLabel`).
327
+
328
+ | Endpoint | Request | Answers |
329
+ | --- | --- | --- |
330
+ | list (`listUrl`) | `{ subject }` | `200 { factors: [record…], generation }`: every record held for the subject and the set's generation, from one snapshot; `{ factors: [], generation: null }` for an absent set. Any other status throws. |
331
+ | create (`createUrl`), conditional | `{ factor: record, expectedGeneration, deadlineMs }` | `200 { outcome: "created", generation }`: created, the set at its new generation. `409 { outcome: "conflict" }`: nothing written. Any other status throws, a `404` included. |
332
+ | update (`updateUrl`) | `{ subject, id, expectedVersion, changes: { data, label?, lastUsedAtMs? } }` | `200 { factor: record }`, the record as written at `expectedVersion + 1`. `409`: the version moved; `404`: the record is gone — each the port's `null`. Any other status throws. |
333
+ | delete (`deleteUrl`), conditional | `{ subject, id, expectedGeneration, deadlineMs }` | `200 { outcome: "removed", generation }`: removed, the set at its new generation. `404 { outcome: "missing" }` and `409 { outcome: "conflict" }`: nothing written. Any other status throws. |
334
+ | delete, the set's reset | `{ subject, all: true }` | `2xx` (`204`), or `404` when nothing was held: both done. Any other status throws. |
335
+ | markMfaEnrolled (`markMfaEnrolledUrl`) | `{ subject, enrolled }` | `204`, the value held already included. `404`: the Store holds no such subject — an error, since the subject has just authenticated. Any other status throws. |
336
+
337
+ - **An update writes its changes and nothing else.** It names the record by
338
+ `subject` and `id` and carries the expected version and, as its changes,
339
+ only `data`, `label` and `lastUsedAtMs`; one left out is cleared. `id`, `subject`, `kind`, `binding` and `createdAtMs` are
340
+ never sent as changes, and the Store must not change them; it refuses
341
+ changes carrying any other field (`400`). The Store compares and sets
342
+ atomically on `expectedVersion` and writes `version` one higher: two
343
+ updates at one version never both succeed.
344
+ - **Every record, always.** A list answers every record the Store holds for
345
+ the subject, one it or the provider cannot read included, and only that
346
+ subject's. The provider holds a record it cannot read — a field missing, of
347
+ the wrong type, out of range or out of shape, or `null` — a record naming
348
+ another subject, and a second record with an id already listed, as present
349
+ and unusable: the subject's list is refused whole (core's
350
+ `readMfaStoreListAnswer`), never read as fewer records, so such a record
351
+ never lets the subject count as having none and never opens a first
352
+ binding. A transport failure is an outage, never an empty list.
353
+ - **A version other than `expectedVersion + 1`** in an update's answer is an
354
+ outage, never a success, and one error line naming the subject and the
355
+ factor id.
356
+ - **The witness** is answered back on `authenticate` and on
357
+ `authenticateByToken` as `User.mfaEnrolled` ([both reads](#the-wire-contract)):
358
+ a boolean, left out until the subject is first marked, read only through
359
+ core's `readMfaEnrollmentWitness`.
360
+ - **The factors' integrity and freshness are the Store's.** A record's
361
+ version never goes back, and a write the Store acknowledged is never lost —
362
+ across a restore or a failover included. A list answers the latest write,
363
+ every record the subject has and no record removed. The provider cannot
364
+ check any of it ([what the Store must enforce itself](#what-the-store-must-enforce-itself)).
365
+
366
+ **The factor set's generation.** A subject's records are one set, and the
367
+ Store keeps one generation for its membership, under core's
368
+ conditional-write convention for a set
369
+ ([`docs/adapter-surface.md`, "Conditional writes"](../../docs/adapter-surface.md#conditional-writes)),
370
+ whose HTTP wire the table follows. Core's codec carries it on both sides
371
+ (`readMfaStoreVersionedListAnswer`, `toMfaStoreCreateIfRequest`,
372
+ `readMfaStoreCreateIfAnswer`, `toMfaStoreRemoveIfRequest`,
373
+ `readMfaStoreRemoveIfAnswer`).
374
+
375
+ - **A generation** is 1 to 128 visible ASCII characters, none of them `"`
376
+ (core's `isStoreGeneration`), compared only whole. The Store mints it at
377
+ random, a v4 UUID for one, and never issues it again for the subject: not
378
+ after the set is emptied and written again, a byte-identical rewrite, a
379
+ tombstone's expiry, or a restore or a failover that loses writes. A digest,
380
+ a timestamp or a counter is not one.
381
+ - **Every membership write mints a fresh one**: a create, a removal that
382
+ removed, and a reset. An update keeps it.
383
+ - **A conditional write is one atomic step**, its check and its write: in
384
+ SQL, one transaction that locks the set's row first, then its records'. An
385
+ in-process lock does not count. A reset is one atomic step too,
386
+ serialised with the conditional writes.
387
+ - **A create and a removal of one record always carry `expectedGeneration`.**
388
+ The provider never sends either without it, and a Store may refuse one
389
+ that comes without it with `400`, as the convention does. `null`, on a
390
+ create only, means "only while the set is absent". A value that is no
391
+ generation, and `null` on a removal, are `400`.
392
+ - **`conflict` and `missing` write nothing.** A create answers `conflict`
393
+ when the set is at another generation, absent while `expectedGeneration`
394
+ names one, present while it is `null`, or holding the `(subject, id)`
395
+ already; never `missing`. A removal answers `missing` for an absent set;
396
+ for a present one it checks the generation first, `conflict` at another,
397
+ then the record, `missing` when it is not held.
398
+ - **An emptied set stays, as its tombstone.** A removal of the last record,
399
+ or a reset, leaves the set present and empty at a new generation, and a
400
+ reset creates it for a subject never written. A tombstone reads absent only
401
+ once `BUNDLED_STORE_WRITE_LIFETIME_MS` (24 hours) has passed since the
402
+ set's last membership write, and each emptying write, a reset of an empty
403
+ set included, starts that again. A set holding a record never expires.
404
+ Account deletion is the same reset.
405
+ - **A set held without a generation**, written before the Store kept one,
406
+ is given a fresh one, atomically, by its first list, its records kept. A
407
+ conditional write against it answers `conflict` and mints nothing.
408
+ - **No list is served from a cache.** A list answers the records and the
409
+ generation from one snapshot that reflects every write the Store
410
+ acknowledged before the list began: never a cached answer, from the Store,
411
+ an HTTP cache in front of it or a lagging replica. A stale list answers a
412
+ factor already removed as present, and a generation already moved.
413
+ - **A rollback must not bring a generation back.** A restore or a failover
414
+ that returns a set to a state from before a write the Store acknowledged
415
+ would answer that state's generation again, which a writer may still hold.
416
+ The Store mints a fresh generation for every set it restores before it
417
+ answers one, or runs so that acknowledged writes are never rolled back, and
418
+ states which.
419
+ - **A conditional write states its deadline.** A conditional create or delete carries `deadlineMs`: the instant, in epoch milliseconds on the provider's clock, after which it must not be applied. The adapter sets it to the moment it sends plus its request timeout, and gives up at that moment itself. The Store checks `deadlineMs` against its own clock in the same atomic step as the conditional write; at or after it, the write is not applied and the answer is `408`.
420
+ The adapter's write lifetime W is its request timeout plus the clock skew assumed between the provider and the Store.
421
+ The provider's and the Store's clocks agree within that skew.
422
+ This adapter takes `deadlineMs` just before it sends the request, and its
423
+ timer starts after that, so the deadline is at or before the moment it gives
424
+ up: a write past it is never applied after the adapter stopped waiting.
425
+ A `deadlineMs` absent or not a whole instant above 0 within the `Date`
426
+ range is `400`.
427
+ - **No retry.** The adapter never retries a conditional write; it gives up at
428
+ its deadline, and a timeout or an unexpected status leaves the outcome
429
+ unknown. The Store, and anything in front of it, answers `421` only for a
430
+ request it did not apply, because the HTTP client may send it again: Node's
431
+ `fetch` sends a `POST` once more on a `421`.
432
+
433
+ **When a call fails, nothing the Store sent reaches a client**: no status, no
434
+ error text, no header, no record, and not whether a record was unreadable.
435
+ Every failure of these endpoints is answered with the provider's answer for an
436
+ outage, `503 temporarily_unavailable`. What went wrong reaches the operator's
437
+ log line and audit event only, through what the adapter throws: a
438
+ `MfaStoreError` ([`src/mfa/storeFailure.mts`](src/mfa/storeFailure.mts)) built
439
+ from the operation, the endpoint by origin and path, the status as a number
440
+ and — for a skipped version — the subject and the factor id, leading the
441
+ message so a log line's cut keeps both, each through core's `auditErrorText`
442
+ and at most 64 characters; never from a body, a status text or a header, with
443
+ the body released unread; and with no `status`, `statusCode` or `cause` an
444
+ HTTP layer would answer with or print. A transport failure, the deadline and
445
+ a refused credential throw the user repository's errors.
446
+
447
+ A readable record's `id`, `kind` and `label` do reach a client, by design: the
448
+ MFA ADR's pages show them (the account page's list of factors, F4; a first
449
+ binding's enrollable kinds, F3). They reach it only in the shape the reader
450
+ holds them to, so nothing else the Store writes in them does.
451
+
452
+ | `reason` | When |
453
+ | --- | --- |
454
+ | `unexpected_status` | A status the table does not give the endpoint; `storeStatus` holds it |
455
+ | `unknown_subject` | `markMfaEnrolledUrl` answered `404` |
456
+ | `malformed_answer` | A `2xx` whose body is not the table's, or a conditional write's `404` or `409` without its outcome body; from `listVersioned`, a list holding a record the provider cannot read too |
457
+ | `unreadable_record` | From `list`, a list holding a record the provider cannot read. One fault, two reasons: the same list makes `listVersioned` throw `malformed_answer`, since core's codec reads it as an answer outside the contract |
458
+ | `version_skipped` | An update answered a version other than `expectedVersion + 1` |
459
+
460
+ ### Where the factor endpoints are configured
461
+
462
+ The four factor endpoints are the section of the module that keeps a subject's
463
+ factors in the Store, `foundation-mfa-factor-store`, named after it
464
+ ([`src/mfa/section.mts`](src/mfa/section.mts)); the package's
465
+ [`config/reference.conf`](config/reference.conf), exported as
466
+ `@o3co/auth-provider-foundation/reference.conf`, binds each key to the
467
+ variable named after its path, with no default:
468
+
469
+ | Key | Env |
470
+ | --- | --- |
471
+ | `listUrl` | `FOUNDATION_MFA_FACTOR_STORE_LIST_URL` |
472
+ | `createUrl` | `FOUNDATION_MFA_FACTOR_STORE_CREATE_URL` |
473
+ | `updateUrl` | `FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL` |
474
+ | `deleteUrl` | `FOUNDATION_MFA_FACTOR_STORE_DELETE_URL` |
475
+
476
+ A key the section does not know, and a URL that is not `https` or loopback
477
+ `http`, refuse the boot at config validation, naming the key in printable
478
+ characters and quoting no value. Each URL is required: the module reading the
479
+ section, `foundationMfaFactorStoreModule`, provides its store eagerly
480
+ (`foundationMfaFactorStoreLifecycle`) and calls
481
+ `readFoundationMfaFactorStoreUrls` first, so a composition that installs it
482
+ with any of the four unset refuses the boot, naming each missing key and its
483
+ variable, whether or not anything requires the store.
484
+
485
+ `markMfaEnrolledUrl` is not in this section. The witness is written through
486
+ the user repository whatever keeps the factors — Redis factors with the
487
+ Store's witness among them — so its URL is a key of the user repository's
488
+ settings: `repositories.user.http.markMfaEnrolledUrl`
489
+ (`REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL`), with no default. With the
490
+ URL, the user repository writes the witness (`markMfaEnrolled`); without it,
491
+ the repository has no `markMfaEnrolled` — the capability is absent, never a
492
+ refused boot, since the witness is optional (the MFA ADR's D12) — and a
493
+ composition with MFA on whose user repository cannot write the witness is
494
+ warned at boot (`mfa_enrollment_witness_unwritable`), whatever keeps the
495
+ factors: the witness is what keeps a lost factor store from opening a first
496
+ binding. A value set that is not a string, a URL that is not `https` or
497
+ loopback `http`, and one carrying `user:password@` refuse the boot when the
498
+ repository is built, naming `markMfaEnrolledUrl` and quoting no value.
499
+
500
+ ### The Store-backed factor store
501
+
502
+ [`HttpMfaFactorStore`](src/mfa/HttpMfaFactorStore.mts) is `MfaFactorStore`
503
+ (`kind` `"store"`) over the four factor endpoints, and
504
+ [`foundationMfaFactorStoreModule`](src/mfa/module.mts) installs it:
505
+
506
+ ```typescript
507
+ import { foundationMfaFactorStoreModule } from "@o3co/auth-provider-foundation";
508
+
509
+ const modules = [
510
+ // The Store transport settings: the user repository's HTTP settings, handed
511
+ // over as the composition root hands them to the user repository's builder.
512
+ foundationMfaFactorStoreModule({ storeTransport: config.repositories.user.http }),
513
+ // ...the MFA package's modules, a transaction store
514
+ ];
515
+ ```
516
+
517
+ - The module provides `mfaFactorStore`, built at boot whether or not
518
+ anything requires it, from the section's four URLs, read first.
519
+ - It requires no slot and reads no configuration beyond its own section.
520
+ - `storeTransport` is required: the Store's credential, deadline and response
521
+ cap are the user repository's, so the composition root hands it the user
522
+ repository's HTTP settings (`repositories.user.http`). Their `bearerToken`,
523
+ `timeout` and `maxResponseBytes` are read as the `"http"` builder reads
524
+ them, text as numbers, so one token goes to every Store endpoint. `{}`
525
+ states that there are none: no credential is sent, and the deadline and the
526
+ cap are 5000 ms and 1 MiB. Settings left out, or that are not a section of
527
+ keys, refuse the boot rather than send no credential. A setting the
528
+ transport refuses — the rules of
529
+ [constructor validation](#constructor-validation) — refuses the boot too
530
+ (`provides-factory-failed`), naming `HttpMfaFactorStore`.
531
+ - It declares no replica-unsafe state: the factors are the Store's.
532
+
533
+ It writes the factor set's membership through `createIf`, `removeIf` and
534
+ the reset. It answers the port as the
535
+ [contract](#the-stores-mfa-endpoints) gives each answer, and throws on
536
+ anything else:
537
+
538
+ | Operation | The Store answers | The port gets |
539
+ | --- | --- | --- |
540
+ | `list` | `200 { factors }` | Every record, read whole. A record it cannot read, one of another subject or a second record with one id throws `unreadable_record`. |
541
+ | `list` | Any other status, a redirect included | Throws `unexpected_status`: never "no factors". |
542
+ | `update` | `200 { factor }` | The record, when it is the one named, holding the changes sent, at `expectedVersion + 1`; otherwise throws `malformed_answer` or `version_skipped`. Its `kind`, `binding` and `createdAtMs` are the Store's word: the port hands the adapter no earlier record to compare them with. `kind` is bound into the seal, so data answered under another kind does not open. |
543
+ | `update` | `409` or `404` / any other | `null` / throws `unexpected_status`. |
544
+ | `removeAllForSubject` | `2xx` or `404` / any other | Done / throws `unexpected_status`. |
545
+ | `listVersioned` | `200 { factors, generation }` | The records and the set's generation, read whole by `readMfaStoreVersionedListAnswer`; `{ items: [], generation: null }` for an absent set. An answer the codec refuses — no `generation`, as a Store from before it answers, a generation out of shape, a record it cannot read, of another subject or twice — throws `malformed_answer`. |
546
+ | `listVersioned` | Any other status | Throws `unexpected_status`. |
547
+ | `createIf` | `200` / `409`, each with its outcome body | `created` with the set's new generation / `conflict`. Without its body, or with another status's outcome, throws `malformed_answer`. |
548
+ | `createIf` | Any other status, a `404`, a late write's `408` and a `204` from a Store that ignores `expectedGeneration` included | Throws `unexpected_status`. |
549
+ | `removeIf` | `200` / `404` / `409`, each with its outcome body | `removed` with the set's new generation / `missing` / `conflict`. Without its body, or with another status's outcome, throws `malformed_answer`. |
550
+ | `removeIf` | Any other status, a late write's `408` included | Throws `unexpected_status`. |
551
+
552
+ The set's members read the status first: one the operation is not given
553
+ throws `unexpected_status`, its body released unread. The body of a status
554
+ it is given is parsed once and read by core's codec alone, and what the codec
555
+ refuses throws `malformed_answer`, carrying none of the codec's words. A
556
+ conditional write that rejects after it was sent — its deadline, a lost
557
+ connection, a `408`, a refused answer — is unknown, never `missing` or
558
+ `conflict`: it may have committed. A Store that ignores `expectedGeneration` may have
559
+ written unfenced before its answer is refused; only the contract suite, run
560
+ in the Store's own CI, shows that it does not.
561
+
562
+ Before any request, a record or an update the wire codec would not read back
563
+ — an id that is not 22 base64url characters, a label out of shape, an update
564
+ at `Number.MAX_SAFE_INTEGER`, an expected generation that is no generation —
565
+ is a `RangeError`, and nothing is sent. The
566
+ `data` it is handed is sent as it is: the MFA package sealed it, and nothing
567
+ here opens it.
568
+
569
+ **The Store is trusted with the factors.** The adapter reads each answer
570
+ strictly, but it cannot tell an old record from a current one, a list with a
571
+ record left out from a complete one, or a factor removed and answered again
572
+ from one never removed: the Store is responsible for the factors' integrity
573
+ and freshness ([what the Store must enforce itself](#what-the-store-must-enforce-itself)).
574
+
575
+ ### The witness written through the user repository
576
+
577
+ With `markMfaEnrolledUrl`, `HttpUserRepository.markMfaEnrolled(subject,
578
+ enrolled)` posts `{ subject, enrolled }` to it
579
+ ([`src/mfa/markEnrolled.mts`](src/mfa/markEnrolled.mts)), with the user
580
+ repository's credential and deadline; it reads no body, so the response cap
581
+ does not apply. `204` is done; `404` throws an
582
+ `MfaStoreError` `unknown_subject`, and any other status — a `200` and a
583
+ redirect included — `unexpected_status`, the body released unread. A
584
+ transport failure, the deadline and a refused credential throw the user
585
+ repository's errors. A subject that is not a non-empty string, or a value
586
+ that is not a boolean, is a `RangeError`, and nothing is sent.
587
+
588
+ The MFA package marks a subject `true` once its first counting factor is
589
+ written — a first binding at a login or in a signed-in session — and again
590
+ whenever a counting factor is verified where the login's `User` does not say
591
+ the subject enrolled, a step-up included, so a mark that failed is written at
592
+ the subject's next such verification; a mark that fails is one warning
593
+ (`mfa_enrollment_witness_unwritten`) and the login completes. A Store that
594
+ takes the marks but never answers `mfaEnrolled` back is therefore marked once
595
+ per counting verification.
596
+ The witness then stops a lost factor store from opening a first binding: a
597
+ login whose `User` says the subject enrolled, beside no factor that counts,
598
+ is `503` (`mfa.enrollment_state_inconsistent`), never a password-only login
599
+ or a first binding.
600
+
601
+ Without the URL nothing is written, and the provider reads back whatever
602
+ `mfaEnrolled` the Store answers. Then nothing but the Store keeps a
603
+ subject's factor list whole: a Store that drops a subject's records lets a
604
+ password-only login through under `mfa.mode = "optional"`, and opens a first
605
+ binding to whoever holds the password under `required`.
606
+
607
+ **Turning it on.** A subject that enrolled before
608
+ `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` was set has no witness until
609
+ its next counting verification, and until then a lost factor store reads it
610
+ as never enrolled. When you set the URL, backfill `mfaEnrolled = true` in the
611
+ Store for every subject that holds a counting factor.
612
+
613
+ ### Testing against the contract
614
+
615
+ [`@o3co/auth-provider-test-kit`](../test-kit/README.md) holds the enrollment
616
+ witness's contract suite, `MfaFactorStore`'s (`mfaFactorStoreContract`), the
617
+ factor set's (`mfaFactorStoreConditionalContract`), and a fake Store that
618
+ answers these endpoints as the table says, the set's generation and its
619
+ tombstone included — and, told to, as a broken Store would. This package's
620
+ tests run both factor store suites against `HttpMfaFactorStore` over the fake
621
+ Store, a second adapter on the same Store racing the first, and the
622
+ witness's suite
623
+ against `HttpUserRepository` with `markMfaEnrolledUrl` over it, reading the
624
+ witness back through `authenticate` and `authenticateByToken`. The
625
+ section's test builder, `foundationMfaFactorStoreConfig`, is on
626
+ `@o3co/auth-provider-foundation/testing`.
627
+
254
628
  ## What the Store must enforce itself
255
629
 
256
630
  - **Who may call it.** What `authenticateByToken` and linking carry is not a
@@ -258,12 +632,17 @@ deployment that then has a connection under `"required"` is refused at boot.
258
632
  Store that answers any caller lets anyone who can reach
259
633
  `authenticateByTokenUrl` resolve a known identity to its user, and anyone who
260
634
  can reach an open `linkFederatedIdentityUrl` bind any identity to any
261
- `userId`. Configure `bearerToken` (`CLIENT_USER_BEARER_TOKEN`, from
262
- `openssl rand -hex 32`) and have the Store refuse every request on all four
263
- endpoints whose `Authorization` is not exactly `Bearer <that token>`,
264
- compared in constant time, and never log the header. One token goes to all
265
- four URLs, so they must be one trust domain: whoever runs any of those
266
- endpoints holds a credential the others accept. Refuse with `401` and
635
+ `userId`. The MFA endpoints are no less: anyone who can reach an open
636
+ `deleteUrl` or `markMfaEnrolledUrl` can remove a subject's factors and clear
637
+ its witness, and the subject's next password login then opens a first
638
+ binding to whoever holds the password. Configure `bearerToken`
639
+ (`REPOSITORIES_USER_HTTP_BEARER_TOKEN`, from `openssl rand -hex 32`) and have the Store
640
+ refuse every request on every endpoint it serves auth.provider — the user
641
+ repository's four and the MFA endpoints alike — whose `Authorization` is not
642
+ exactly `Bearer <that token>`, compared in constant time, and never log the
643
+ header. One token goes to every one of those URLs, so they must be one trust
644
+ domain: whoever runs any of those endpoints holds a credential the others
645
+ accept. Refuse with `401` and
267
646
  `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3), or `403` and
268
647
  `error="insufficient_scope"` for a token that is valid but not enough. With
269
648
  that challenge, a token the Store does not accept — a typo, a half-finished
@@ -293,12 +672,29 @@ deployment that then has a connection under `"required"` is refused at boot.
293
672
  password, a token, a link request or an identity goes only to the configured
294
673
  URL — the one the `https` rule below checks — and no answer from anywhere
295
674
  else is taken as the user, the link or the lookup's answer. A `3xx` from any
296
- of the four endpoints throws like any other unexpected status (the session
675
+ endpoint throws like any other unexpected status (the session
297
676
  routes and the jwt-bearer grant answer `503 temporarily_unavailable`, the
298
677
  grants callback `temporarily_unavailable`), so a Store behind a URL that
299
678
  redirects — a host alias redirecting to the canonical host, an added
300
679
  trailing slash, a moved path — fails every call. Configure each URL as the
301
680
  endpoint that answers, not one that redirects.
681
+ - **Keep the MFA factors whole and fresh.** A Store that keeps MFA factors
682
+ is responsible for their integrity and freshness: it never rolls a factor
683
+ back, hides one from a list, answers one it acknowledged removing, or lets
684
+ two updates at one version both succeed. A version never goes back, and an
685
+ acknowledged write is never lost across a restore or a failover; a list
686
+ answers the latest write, never a cache's or a lagging replica's. The provider cannot tell when this breaks: an
687
+ older record's sealed data opens as it did then — the seal binds the
688
+ subject, the factor id and the kind, not the version — so a TOTP step
689
+ already used is accepted again within its window, a spent recovery code
690
+ works again, and a removed factor is back. A Store that drops a subject's
691
+ records lets a password-only login through under `mfa.mode = "optional"`,
692
+ and opens a first binding to whoever holds the password under `required`;
693
+ the enrollment witness, kept outside the factor store, is what stops that
694
+ (the MFA ADR's D12). The MFA ADR's O6 recommends the factors in Redis and the witness in the Store; keeping
695
+ both in one Store loses that protection, since what drops or rolls back
696
+ the one can do the same to the other. The runbook, "Keeping MFA factors in
697
+ the Store", has the failover and restore procedures.
302
698
 
303
699
  **What a refused token looks like, per caller.** Each caller answers a
304
700
  `StoreCredentialRefusedError` as it answers any Store failure — a
@@ -313,7 +709,7 @@ deployment that then has a connection under `"required"` is refused at boot.
313
709
 
314
710
  Where `err` is logged, a `StoreCredentialRefusedError`'s message names the
315
711
  Store endpoint (its origin and path), the status and
316
- `CLIENT_USER_BEARER_TOKEN` — never the token; a `StoreTransportError`'s names
712
+ `REPOSITORIES_USER_HTTP_BEARER_TOKEN` — never the token; a `StoreTransportError`'s names
317
713
  the endpoint the same way, what failed and at most a transport code. No
318
714
  message this adapter throws quotes a URL's query string or fragment.
319
715
 
@@ -322,7 +718,7 @@ message this adapter throws quotes a URL's query string or fragment.
322
718
  Every option is validated in the **constructor**, so a misconfigured deployment
323
719
  fails at boot rather than at the first login attempt.
324
720
 
325
- **Every URL must use `https://`** (the link and lookup endpoints included). They carry plaintext user credentials — a
721
+ **Every URL must use `https://`** (the link, lookup and witness endpoints included). They carry plaintext user credentials — a
326
722
  password on `authenticateUrl`, a token on `authenticateByTokenUrl`, a verified
327
723
  upstream identity on `findSubjectByFederatedIdentityUrl` — so an
328
724
  `http://` URL does not merely weaken the connection, it publishes the credential
@@ -346,6 +742,10 @@ strings allowed (an issuer may not carry one; a POST endpoint legitimately may).
346
742
  A query is sent, and never quoted: every error names the endpoint by origin
347
743
  and path.
348
744
 
745
+ `HttpMfaFactorStore` holds its four URLs, `timeout`, `maxResponseBytes` and
746
+ `bearerToken` to the same rules below, each refusal naming
747
+ `HttpMfaFactorStore`.
748
+
349
749
  **`timeout` must be a positive integer** no greater than `2147483647`
350
750
  milliseconds. `0`, a negative number, and `NaN` all clamp to "fire immediately"
351
751
  in `setTimeout` — which would abort every request — and a value above Node's
@@ -356,8 +756,8 @@ against it rather than relying on the abort signal, because aborting a request
356
756
  does not reliably interrupt a read already in flight. That is the slow-loris
357
757
  shape — headers arrive promptly, then the body dribbles or stops — and without
358
758
  the race it hangs forever. A request that outlives the deadline rejects with a
359
- `timed out after <n>ms` error naming the endpoint, named `TimeoutError` on all
360
- four requests, so a reporter that classifies by name reads it as a timeout.
759
+ `timed out after <n>ms` error naming the endpoint, named `TimeoutError` on
760
+ every request, so a reporter that classifies by name reads it as a timeout.
361
761
 
362
762
  **`maxResponseBytes` must be a positive integer**, defaulting to
363
763
  `DEFAULT_MAX_RESPONSE_BYTES` (1 MiB). The cap is enforced against
@@ -370,7 +770,7 @@ refused unless it is a string, not blank (a blank environment override is a
370
770
  boot failure, not "no token"), made only of letters, digits, `-._~+/` and
371
771
  trailing `=` padding — no whitespace, no line break, and no `Bearer ` prefix,
372
772
  which the adapter adds — and it clears core's shared-secret floor
373
- (`MIN_SECRET_ENTROPY_BYTES`, the one `SESSION_SECRET` and `OAUTH_JWT_SECRET`
773
+ (`MIN_SECRET_ENTROPY_BYTES`, the one `SESSION_STORE_SECRET` and `KEY_STORE_LOCAL_SECRET`
374
774
  clear), measured on the decoded length of a hex or base64 value, so
375
775
  `openssl rand -hex 16` is 16 bytes however long it looks. Whoever holds the
376
776
  token speaks to the Store as auth.provider. The shape is checked here because
@@ -379,7 +779,7 @@ refusal quotes the value, no error from a request carries it — a transport
379
779
  failure is thrown without the transport's error (see the wire contract) — and
380
780
  it is held in an ECMAScript private field, so it is absent from `inspect()`
381
781
  and `JSON.stringify` of the repository. These checks run when the repository
382
- is built, which a deployment does under `repositories.user.type = "http"`
782
+ is built, which a deployment does under `adapters.userRepository = "http"`
383
783
  (the standalone template's default); under core's `reference.conf` default,
384
784
  `yaml`, the `http` block — and a token in it — is not read at all.
385
785
 
@@ -400,23 +800,60 @@ Exported from [`src/index.mts`](src/index.mts):
400
800
  ([`src/repositories/storeErrors.mts`](src/repositories/storeErrors.mts)).
401
801
  - `DEFAULT_MAX_RESPONSE_BYTES` — the default response cap.
402
802
  - `FederatedIdentityLookupCoverage` — the type of one coverage entry.
803
+ - `HttpMfaFactorStore`, `HttpMfaFactorStoreOptions` — the Store-backed
804
+ `MfaFactorStore` ([`src/mfa/HttpMfaFactorStore.mts`](src/mfa/HttpMfaFactorStore.mts)).
805
+ - `foundationMfaFactorStoreModule`, `FoundationMfaFactorStoreModuleOptions` —
806
+ its module ([`src/mfa/module.mts`](src/mfa/module.mts)).
807
+ - `MfaStoreError`, `MfaStoreFailure`, `MfaStoreOperation` — what the MFA
808
+ endpoints' failures throw; its `name`, `reason`, `operation` and
809
+ `storeStatus` are part of the contract, and it has no `status`
810
+ ([`src/mfa/storeFailure.mts`](src/mfa/storeFailure.mts)). The builders the
811
+ adapter throws through, and the `foundation-mfa-factor-store` section's
812
+ schema and reader ([`src/mfa/section.mts`](src/mfa/section.mts)), are the
813
+ package's own.
814
+
815
+ Exported from [`src/testing/index.mts`](src/testing/index.mts) as
816
+ `@o3co/auth-provider-foundation/testing`, for test code only:
817
+ `foundationMfaFactorStoreConfig(urls, extra?)`, the
818
+ `foundation-mfa-factor-store` section as a configuration fragment to lay over
819
+ a test's configuration, holding the four URLs `urls` holds — a fake Store's
820
+ `urls` included, its other endpoints left behind — and `extra` as given; and
821
+ `foundationUserRepositoryHttpConfig(urls, extra?)`, the user repository's
822
+ `http` block, holding the Store URLs the `"http"` builder reads that `urls`
823
+ holds — its MFA factor endpoints left behind — and `extra` as given, which a
824
+ test hands to the `"http"` builder, or to `foundationMfaFactorStoreModule` as
825
+ its `storeTransport`.
403
826
 
404
827
  ## Tests
405
828
 
406
829
  | Test file | Pins |
407
830
  | --- | --- |
408
- | [`HttpUserRepository.test.mts`](src/repositories/__tests__/HttpUserRepository.test.mts) | authentication and its answers, the `User` shape check, the https rule, the timeout and the response cap, linking, and the identity lookup's presence, probe and wire |
831
+ | [`HttpUserRepository.test.mts`](src/repositories/__tests__/HttpUserRepository.test.mts) | authentication and its answers, `mfaEnrolled` handed through as the Store answered it, the `User` shape check, the https rule, the timeout and the response cap, linking, and the identity lookup's presence, probe and wire |
409
832
  | [`HttpUserRepository.transport.test.mts`](src/repositories/__tests__/HttpUserRepository.transport.test.mts) | against real HTTP servers: the identity lookup releasing a refused answer's connection, and a redirect refused on each of the four requests — to another origin, to the same origin, or with no `Location` — with nothing sent to a redirect target |
410
833
  | [`HttpUserRepository.credential.test.mts`](src/repositories/__tests__/HttpUserRepository.credential.test.mts) | against real HTTP servers: `Authorization: Bearer <token>` on each of the four requests when `bearerToken` is set and no `Authorization` header when it is not, by hand and through the `"http"` builder; a weak, malformed, blank or non-string token refused at construction; the token in no failure and no inspection of the repository; a transport failure a `StoreTransportError` that says what failed — a refused connection and an https URL on a plain-HTTP port (not reached), a peer that reflects the token into a status line or header, a head over the size limit (malformed response), a close after a `1xx`, mid-head, before any byte, or of a pooled keep-alive connection between two requests (connection closed), a chunked body (not read), a TLS 1.2 server that refuses the handshake (not reached, `ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE`) — with its code and no cause; a timeout a `TimeoutError` whether the headers or the body stall; with a token sent, a `401` or `403` with a `Bearer` challenge a `StoreCredentialRefusedError` (`storeStatus`, no `status`) on each of the four, and one without — or with no token sent — read as before |
411
834
  | [`storeErrors.test.mts`](src/repositories/__tests__/storeErrors.test.mts) | which transport codes are kept, and the two named errors' shape — no `status`, no cause |
412
835
  | [`wwwAuthenticate.test.mts`](src/repositories/__tests__/wwwAuthenticate.test.mts) | which `WWW-Authenticate` values carry a `Bearer` challenge, and a hostile 64 KiB value read in one pass |
413
- | [`registerBuiltinAdapters.test.mts`](src/repositories/__tests__/registerBuiltinAdapters.test.mts) | the `"http"` builder, its defaults and string coercion, and configuration refused at build time |
836
+ | [`registerBuiltinAdapters.test.mts`](src/repositories/__tests__/registerBuiltinAdapters.test.mts) | the `"http"` builder, its defaults and string coercion, the optional URLs it forwards, and configuration refused at build time |
414
837
  | [`endpointUrl.test.mts`](src/__tests__/endpointUrl.test.mts) | the https-or-loopback rule |
838
+ | [`storeFailure.test.mts`](src/mfa/__tests__/storeFailure.test.mts) | what the MFA endpoints' failures throw: nothing the Store wrote in any form the error leaves in, the body released unread, no status to answer with; a skipped version's log line naming the subject and the factor id, each sanitised and bounded, however long they are |
839
+ | [`section.test.mts`](src/mfa/__tests__/section.test.mts) | the `foundation-mfa-factor-store` section: its schema, its reader, and through `createApp` with the package's module the boot refused for a missing, malformed or unknown key, the module installed alone included |
840
+ | [`module.test.mts`](src/mfa/__tests__/module.test.mts) | `foundationMfaFactorStoreModule` through `createApp`: the Store-backed store provided over the section's URLs with nothing else installed; the Store transport settings required, the user repository's bearer token, deadline and cap read from them (text read as numbers), the boot refused for settings absent or not a section of keys and for a value the user repository refuses too |
841
+ | [`HttpMfaFactorStore.contract.test.mts`](src/mfa/__tests__/HttpMfaFactorStore.contract.test.mts) | the test kit's `MfaFactorStore` suite and the factor set's conditional-write binding against `HttpMfaFactorStore` over the fake Store, with a second adapter on the same Store, one whose Store has closed, and the Store's tombstone clock moved past the write-lifetime bound |
842
+ | [`HttpMfaFactorStore.test.mts`](src/mfa/__tests__/HttpMfaFactorStore.test.mts) | what it sends — each URL as configured, the bearer token, the sealed data byte for byte and nothing it was sealed from, nothing the codec refuses; each operation's answers, and a Store that breaks the contract: `404`, `5xx`, a redirect, a malformed answer, an unreadable record, a foreign subject, a repeated id, a skipped version, an answer that did not write the changes; the set's members: what they send, each status with its outcome body, a `404` or `409` without one malformed, any other status unexpected, a list without its generation malformed, a conditional write given up at its deadline, its `deadlineMs` the send time plus the timeout, a late one refused `408` and not applied, one within its deadline applied; nothing the Store sent in anything thrown; the credential refused (naming this store), a deadline over the head or the body, the cap, an unreachable Store; construction, no unconditional `create` or `remove`, and neither the token nor the endpoints shown when the store is inspected |
843
+ | [`foundationMfaFactorStoreConfig.test.mts`](src/testing/__tests__/foundationMfaFactorStoreConfig.test.mts) | the testing entry's section builder |
844
+ | [`foundationUserRepositoryHttpConfig.test.mts`](src/testing/__tests__/foundationUserRepositoryHttpConfig.test.mts) | the testing entry's builder of the user repository's `http` block, which the `"http"` builder takes |
845
+ | [`storeRequestMessages.test.mts`](src/mfa/__tests__/storeRequestMessages.test.mts) | one wording for a transport failure at the MFA endpoints, whichever client sends to them |
846
+ | [`referenceConf.test.mts`](src/mfa/__tests__/referenceConf.test.mts) | the package's `reference.conf`: only that section, each URL bound to the variable named after its path, no default |
847
+ | [`enrollmentWitness.contract.test.mts`](src/mfa/__tests__/enrollmentWitness.contract.test.mts) | the test kit's witness suite against `HttpUserRepository` over its fake Store, written through `markMfaEnrolled` and read back through `authenticate` and `authenticateByToken`; the capability present with `markMfaEnrolledUrl` alone |
848
+ | [`markEnrolled.test.mts`](src/mfa/__tests__/markEnrolled.test.mts) | `markMfaEnrolled`: what it sends — the URL as configured, `{ subject, enrolled }`, the bearer token, nothing for a subject or a value out of shape; `204` done, `404` `unknown_subject`, any other status `unexpected_status` with no `Location` contacted; a refused credential, a deadline and an unreachable Store; nothing the Store sent, nor the query or the subject, in anything thrown; the URL held to the https rule |
415
849
 
416
850
  ## See also
417
851
 
418
852
  - [`@o3co/auth-provider-core`](../core/README.md) — the `UserRepository` port,
419
- `createRepositoryFactories` and the development user adapters
853
+ `createRepositoryFactories` and the development user adapters, the MFA
854
+ ports and the MFA endpoints' wire format
855
+ - [`@o3co/auth-provider-test-kit`](../test-kit/README.md) — the enrollment
856
+ witness's and the factor store's contract suites, and the fake Store
420
857
  - [`@o3co/auth-provider-session`](../session/README.md) — the routes that call
421
858
  `authenticate`, `authenticateByToken` and `linkFederatedIdentity`
422
859
  - [`@o3co/auth-provider-federation-grants`](../federation-grants/README.md) — the