@o3co/auth-provider-foundation 0.17.0-rc.4 → 0.18.0-rc.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 +2 -867
- package/audit/README.md +38 -0
- package/audit/config.example +3 -0
- package/audit/src/contract.d.ts +37 -0
- package/audit/src/contract.js +29 -0
- package/audit/src/module.d.ts +2 -0
- package/audit/src/module.js +151 -0
- package/audit/src/section.d.ts +5 -0
- package/audit/src/section.js +19 -0
- package/audit/src/testing.d.ts +16 -0
- package/audit/src/testing.js +19 -0
- package/client-certificate/README.md +27 -0
- package/client-certificate/src/certificate.d.ts +9 -0
- package/client-certificate/src/certificate.js +40 -0
- package/client-certificate/src/contract.d.ts +23 -0
- package/client-certificate/src/contract.js +5 -0
- package/client-certificate/src/pem.d.ts +4 -0
- package/client-certificate/src/pem.js +19 -0
- package/client-certificate/src/settings.d.ts +13 -0
- package/client-certificate/src/settings.js +22 -0
- package/client-certificate/src/warnings.d.ts +7 -0
- package/client-certificate/src/warnings.js +17 -0
- package/client-certificate/src/xfcc.d.ts +5 -0
- package/client-certificate/src/xfcc.js +72 -0
- package/cookies/README.md +34 -0
- package/cookies/src/contract.d.ts +15 -0
- package/cookies/src/contract.js +3 -0
- package/cookies/src/cookies.d.ts +3 -0
- package/cookies/src/cookies.js +39 -0
- package/cookies/src/module.d.ts +2 -0
- package/cookies/src/module.js +7 -0
- package/external-jwt/README.md +38 -0
- package/external-jwt/src/contract.d.ts +58 -0
- package/external-jwt/src/contract.js +2 -0
- package/external-jwt/src/jwks.d.ts +20 -0
- package/external-jwt/src/jwks.js +61 -0
- package/external-jwt/src/keys.d.ts +16 -0
- package/external-jwt/src/keys.js +26 -0
- package/external-jwt/src/module.d.ts +6 -0
- package/external-jwt/src/module.js +21 -0
- package/external-jwt/src/testing.d.ts +38 -0
- package/external-jwt/src/testing.js +43 -0
- package/external-jwt/src/verifier.d.ts +10 -0
- package/external-jwt/src/verifier.js +147 -0
- package/http/README.md +39 -0
- package/http/config.example +5 -0
- package/http/src/bodies/README.md +31 -0
- package/http/src/bodies/body.d.ts +6 -0
- package/http/src/bodies/body.js +69 -0
- package/http/src/bodies/json.d.ts +2 -0
- package/http/src/bodies/json.js +35 -0
- package/http/src/bodies/values.d.ts +20 -0
- package/http/src/bodies/values.js +45 -0
- package/http/src/contract.d.ts +113 -0
- package/http/src/contract.js +23 -0
- package/http/src/cors/README.md +24 -0
- package/http/src/cors/cors.d.ts +5 -0
- package/http/src/cors/cors.js +35 -0
- package/http/src/ending/README.md +23 -0
- package/http/src/ending/ending.d.ts +5 -0
- package/http/src/ending/ending.js +27 -0
- package/http/src/guards/README.md +20 -0
- package/http/src/guards/guards.d.ts +19 -0
- package/http/src/guards/guards.js +31 -0
- package/http/src/module.d.ts +2 -0
- package/http/src/module.js +46 -0
- package/http/src/security-headers/README.md +23 -0
- package/http/src/security-headers/security-headers.d.ts +3 -0
- package/http/src/security-headers/security-headers.js +58 -0
- package/http/src/serving/README.md +33 -0
- package/http/src/serving/server.d.ts +17 -0
- package/http/src/serving/server.js +174 -0
- package/http/src/settings/README.md +16 -0
- package/http/src/settings/section.d.ts +21 -0
- package/http/src/settings/section.js +37 -0
- package/http/src/testing.d.ts +37 -0
- package/http/src/testing.js +24 -0
- package/http-ops/README.md +22 -0
- package/http-ops/src/liveness.d.ts +3 -0
- package/http-ops/src/liveness.js +10 -0
- package/http-ops/src/module.d.ts +2 -0
- package/http-ops/src/module.js +24 -0
- package/http-ops/src/readiness.d.ts +10 -0
- package/http-ops/src/readiness.js +54 -0
- package/http-ops/src/section.d.ts +4 -0
- package/http-ops/src/section.js +10 -0
- package/http-ops/src/timing.d.ts +9 -0
- package/http-ops/src/timing.js +30 -0
- package/kernel/README.md +39 -0
- package/kernel/config.example +4 -0
- package/kernel/src/assemble.d.ts +27 -0
- package/kernel/src/assemble.js +429 -0
- package/kernel/src/config.d.ts +13 -0
- package/kernel/src/config.js +32 -0
- package/kernel/src/contract.d.ts +11 -0
- package/kernel/src/contract.js +10 -0
- package/kernel/src/every.d.ts +7 -0
- package/kernel/src/every.js +36 -0
- package/kernel/src/freeze.d.ts +5 -0
- package/kernel/src/freeze.js +17 -0
- package/kernel/src/handles.d.ts +19 -0
- package/kernel/src/handles.js +6 -0
- package/kernel/src/log.d.ts +12 -0
- package/kernel/src/log.js +55 -0
- package/kernel/src/module.d.ts +67 -0
- package/kernel/src/module.js +18 -0
- package/kernel/src/readiness.d.ts +24 -0
- package/kernel/src/readiness.js +72 -0
- package/kernel/src/refusal.d.ts +19 -0
- package/kernel/src/refusal.js +28 -0
- package/kernel/src/runtime.d.ts +35 -0
- package/kernel/src/runtime.js +5 -0
- package/kernel/src/secret.d.ts +2 -0
- package/kernel/src/secret.js +7 -0
- package/kernel/src/settings.d.ts +20 -0
- package/kernel/src/settings.js +23 -0
- package/kernel/src/testing.d.ts +31 -0
- package/kernel/src/testing.js +99 -0
- package/keys/README.md +39 -0
- package/keys/config.example +7 -0
- package/keys/src/contract.d.ts +21 -0
- package/keys/src/contract.js +5 -0
- package/keys/src/jwks.d.ts +3 -0
- package/keys/src/jwks.js +26 -0
- package/keys/src/jws.d.ts +7 -0
- package/keys/src/jws.js +11 -0
- package/keys/src/module.d.ts +2 -0
- package/keys/src/module.js +31 -0
- package/keys/src/public.d.ts +21 -0
- package/keys/src/public.js +100 -0
- package/keys/src/ring.d.ts +19 -0
- package/keys/src/ring.js +60 -0
- package/keys/src/secret.d.ts +17 -0
- package/keys/src/secret.js +33 -0
- package/keys/src/section.d.ts +43 -0
- package/keys/src/section.js +55 -0
- package/keys/src/signer.d.ts +3 -0
- package/keys/src/signer.js +61 -0
- package/keys/src/testing.d.ts +28 -0
- package/keys/src/testing.js +12 -0
- package/mail/README.md +45 -0
- package/mail/config.example +3 -0
- package/mail/src/address.d.ts +7 -0
- package/mail/src/address.js +45 -0
- package/mail/src/contract.d.ts +38 -0
- package/mail/src/contract.js +15 -0
- package/mail/src/lines.d.ts +4 -0
- package/mail/src/lines.js +4 -0
- package/mail/src/module.d.ts +3 -0
- package/mail/src/module.js +27 -0
- package/mail/src/plain-text.d.ts +6 -0
- package/mail/src/plain-text.js +23 -0
- package/mail/src/prepare.d.ts +14 -0
- package/mail/src/prepare.js +69 -0
- package/mail/src/section.d.ts +4 -0
- package/mail/src/section.js +7 -0
- package/mail/src/testing.d.ts +26 -0
- package/mail/src/testing.js +140 -0
- package/metrics/README.md +45 -0
- package/metrics/src/build.d.ts +6 -0
- package/metrics/src/build.js +36 -0
- package/metrics/src/contract.d.ts +46 -0
- package/metrics/src/contract.js +8 -0
- package/metrics/src/gauge.d.ts +9 -0
- package/metrics/src/gauge.js +67 -0
- package/metrics/src/instrument.d.ts +50 -0
- package/metrics/src/instrument.js +58 -0
- package/metrics/src/module.d.ts +2 -0
- package/metrics/src/module.js +6 -0
- package/metrics/src/registry.d.ts +13 -0
- package/metrics/src/registry.js +95 -0
- package/metrics/src/testing.d.ts +16 -0
- package/metrics/src/testing.js +104 -0
- package/outbound/README.md +39 -0
- package/outbound/config.example +5 -0
- package/outbound/src/contract.d.ts +76 -0
- package/outbound/src/contract.js +36 -0
- package/outbound/src/module.d.ts +28 -0
- package/outbound/src/module.js +188 -0
- package/outbound/src/policy.d.ts +54 -0
- package/outbound/src/policy.js +187 -0
- package/outbound/src/transport.d.ts +21 -0
- package/outbound/src/transport.js +176 -0
- package/package.json +186 -44
- package/rate-limit/README.md +39 -0
- package/rate-limit/src/audit-events.d.ts +8 -0
- package/rate-limit/src/audit-events.js +8 -0
- package/rate-limit/src/contract.d.ts +125 -0
- package/rate-limit/src/contract.js +27 -0
- package/rate-limit/src/lockout.d.ts +13 -0
- package/rate-limit/src/lockout.js +195 -0
- package/rate-limit/src/module.d.ts +2 -0
- package/rate-limit/src/module.js +50 -0
- package/rate-limit/src/partition.d.ts +4 -0
- package/rate-limit/src/partition.js +4 -0
- package/rate-limit/src/per-source.d.ts +11 -0
- package/rate-limit/src/per-source.js +52 -0
- package/rate-limit/src/source.d.ts +2 -0
- package/rate-limit/src/source.js +12 -0
- package/rate-limit/src/testing.d.ts +2 -0
- package/rate-limit/src/testing.js +68 -0
- package/records/README.md +39 -0
- package/records/src/records.d.ts +100 -0
- package/records/src/records.js +293 -0
- package/records/src/shards.d.ts +10 -0
- package/records/src/shards.js +2 -0
- package/records/src/testing.d.ts +39 -0
- package/records/src/testing.js +137 -0
- package/request-source/README.md +38 -0
- package/request-source/src/contract.d.ts +18 -0
- package/request-source/src/contract.js +7 -0
- package/request-source/src/settings.d.ts +17 -0
- package/request-source/src/settings.js +46 -0
- package/request-source/src/source.d.ts +14 -0
- package/request-source/src/source.js +55 -0
- package/request-source/src/warnings.d.ts +4 -0
- package/request-source/src/warnings.js +15 -0
- package/seal/README.md +30 -0
- package/seal/config.example +8 -0
- package/seal/src/contract.d.ts +13 -0
- package/seal/src/contract.js +2 -0
- package/seal/src/module.d.ts +2 -0
- package/seal/src/module.js +57 -0
- package/seal/src/section.d.ts +9 -0
- package/seal/src/section.js +15 -0
- package/seal/src/testing.d.ts +11 -0
- package/seal/src/testing.js +5 -0
- package/secret-hash/README.md +33 -0
- package/secret-hash/src/checker.d.ts +9 -0
- package/secret-hash/src/checker.js +53 -0
- package/secret-hash/src/contract.d.ts +20 -0
- package/secret-hash/src/contract.js +10 -0
- package/secret-hash/src/hashes.d.ts +23 -0
- package/secret-hash/src/hashes.js +73 -0
- package/secret-hash/src/module.d.ts +2 -0
- package/secret-hash/src/module.js +17 -0
- package/secret-hash/src/testing.d.ts +13 -0
- package/secret-hash/src/testing.js +31 -0
- package/store/README.md +39 -0
- package/store/config.example +3 -0
- package/store/src/contract.d.ts +227 -0
- package/store/src/contract.js +261 -0
- package/store/src/memory.d.ts +11 -0
- package/store/src/memory.js +241 -0
- package/store/src/module.d.ts +2 -0
- package/store/src/module.js +14 -0
- package/store/src/section.d.ts +4 -0
- package/store/src/section.js +7 -0
- package/store/src/simulated.d.ts +41 -0
- package/store/src/simulated.js +216 -0
- package/store/src/testing.d.ts +33 -0
- package/store/src/testing.js +955 -0
- package/url-setting/README.md +29 -0
- package/url-setting/src/contract.d.ts +18 -0
- package/url-setting/src/contract.js +99 -0
- package/README.ja.md +0 -309
- package/config/reference.conf +0 -20
- package/dist/endpointUrl.d.mts +0 -53
- package/dist/endpointUrl.d.mts.map +0 -1
- package/dist/endpointUrl.mjs +0 -132
- package/dist/index.d.mts +0 -11
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -67
- package/dist/mfa/HttpMfaFactorStore.d.mts +0 -60
- package/dist/mfa/HttpMfaFactorStore.d.mts.map +0 -1
- package/dist/mfa/HttpMfaFactorStore.mjs +0 -205
- package/dist/mfa/markEnrolled.d.mts +0 -4
- package/dist/mfa/markEnrolled.d.mts.map +0 -1
- package/dist/mfa/markEnrolled.mjs +0 -31
- package/dist/mfa/module.d.mts +0 -27
- package/dist/mfa/module.d.mts.map +0 -1
- package/dist/mfa/module.mjs +0 -56
- package/dist/mfa/section.d.mts +0 -45
- package/dist/mfa/section.d.mts.map +0 -1
- package/dist/mfa/section.mjs +0 -99
- package/dist/mfa/storeFailure.d.mts +0 -52
- package/dist/mfa/storeFailure.d.mts.map +0 -1
- package/dist/mfa/storeFailure.mjs +0 -107
- package/dist/repositories/HttpUserRepository.d.mts +0 -130
- package/dist/repositories/HttpUserRepository.d.mts.map +0 -1
- package/dist/repositories/HttpUserRepository.mjs +0 -376
- package/dist/repositories/storeErrors.d.mts +0 -72
- package/dist/repositories/storeErrors.d.mts.map +0 -1
- package/dist/repositories/storeErrors.mjs +0 -183
- package/dist/repositories/wwwAuthenticate.d.mts +0 -10
- package/dist/repositories/wwwAuthenticate.d.mts.map +0 -1
- package/dist/repositories/wwwAuthenticate.mjs +0 -54
- package/dist/storeTransport.d.mts +0 -97
- package/dist/storeTransport.d.mts.map +0 -1
- package/dist/storeTransport.mjs +0 -290
- package/dist/testing/index.d.mts +0 -28
- package/dist/testing/index.d.mts.map +0 -1
- package/dist/testing/index.mjs +0 -58
package/README.md
CHANGED
|
@@ -1,870 +1,5 @@
|
|
|
1
1
|
# @o3co/auth-provider-foundation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of auth-provider 0.18.0-rc.1, built from `modules/foundation` of https://github.com/o3co/auth.provider. Its modules, settings and documentation are there.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
auth.provider. `HttpUserRepository` implements core's `UserRepository` port over
|
|
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.
|
|
15
|
-
|
|
16
|
-
## Responsibility
|
|
17
|
-
|
|
18
|
-
**Role.** An adapter for two core ports:
|
|
19
|
-
[`UserRepository`](../core/src/repositories/UserRepository.mts) (see
|
|
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
|
|
23
|
-
package name it is not a base layer: no other package imports it at runtime
|
|
24
|
-
(`federation-grants` uses it in a test). A composition root selects it —
|
|
25
|
-
`adapters.userRepository = "http"` in the standalone template.
|
|
26
|
-
|
|
27
|
-
**Owns:**
|
|
28
|
-
|
|
29
|
-
- the wire contract a Store implements: the requests `HttpUserRepository` sends
|
|
30
|
-
and what each answer means (below);
|
|
31
|
-
- the transport rules on the Store's URLs — `https`, or `http` to a loopback
|
|
32
|
-
host only ([`src/endpointUrl.mts`](src/endpointUrl.mts)) — and that no
|
|
33
|
-
request follows a redirect away from them;
|
|
34
|
-
- the credential it presents to the Store (`bearerToken`), the floor that
|
|
35
|
-
credential is held to, and how the Store's refusal of it reads;
|
|
36
|
-
- that nothing a transport reports — which may quote the request — is thrown;
|
|
37
|
-
- the request deadline and the response-size cap;
|
|
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
|
|
52
|
-
authenticated, an identity linked or an owner looked up — the session routes
|
|
53
|
-
([`@o3co/auth-provider-session`](../session/README.md)), `oauth`'s jwt-bearer
|
|
54
|
-
grant ([`@o3co/auth-provider-oauth`](../oauth/README.md)) and federation grants
|
|
55
|
-
([`@o3co/auth-provider-federation-grants`](../federation-grants/README.md)); the
|
|
56
|
-
Store itself; core's development user adapters (`yaml` / `static`); Redis-backed
|
|
57
|
-
stores ([`@o3co/auth-provider-redis`](../redis/README.md), which also holds the
|
|
58
|
-
Redis authorization-code store).
|
|
59
|
-
|
|
60
|
-
**Why a separate package.** Core ships only development user adapters, read from
|
|
61
|
-
a file; a production deployment keeps its users in a service of its own, and
|
|
62
|
-
this is the client for one. It is the replaceable piece — a deployment with its
|
|
63
|
-
own `UserRepository` implementation does not install it — and it depends on
|
|
64
|
-
core alone among the packages, with the global `fetch` and `zod` for its
|
|
65
|
-
section's schema.
|
|
66
|
-
|
|
67
|
-
## Install
|
|
68
|
-
|
|
69
|
-
```sh
|
|
70
|
-
npm install @o3co/auth-provider-foundation @o3co/auth-provider-core
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Peer dependency: `@o3co/auth-provider-core`. Its one dependency of its own is
|
|
74
|
-
`zod`, for the schema of its configuration section.
|
|
75
|
-
|
|
76
|
-
## Usage
|
|
77
|
-
|
|
78
|
-
```typescript
|
|
79
|
-
import { createRepositoryFactories } from "@o3co/auth-provider-core";
|
|
80
|
-
import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
81
|
-
|
|
82
|
-
const { userFactory } = createRepositoryFactories();
|
|
83
|
-
|
|
84
|
-
registerBuiltinAdapters({ userFactory });
|
|
85
|
-
|
|
86
|
-
// Create an HTTP user repository via the factory
|
|
87
|
-
const userRepo = await userFactory.create({
|
|
88
|
-
type: "http",
|
|
89
|
-
authenticateUrl: "https://users.example.com/authenticate",
|
|
90
|
-
authenticateByTokenUrl: "https://users.example.com/authenticate-by-token",
|
|
91
|
-
timeout: 5000,
|
|
92
|
-
});
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
`registerBuiltinAdapters` ([`src/index.mts`](src/index.mts)) registers the
|
|
96
|
-
`"http"` type on a `UserRepository` factory; the builder reads the keys below,
|
|
97
|
-
defaults `timeout` to 5000 ms and `maxResponseBytes` to
|
|
98
|
-
`DEFAULT_MAX_RESPONSE_BYTES` (1 MiB) when absent, and accepts numbers given as
|
|
99
|
-
strings (an environment override). `HttpUserRepository`
|
|
100
|
-
([`src/repositories/HttpUserRepository.mts`](src/repositories/HttpUserRepository.mts))
|
|
101
|
-
can also be constructed directly with the same options; there `timeout` is
|
|
102
|
-
required, since the 5000 ms default is the builder's.
|
|
103
|
-
|
|
104
|
-
### Configuration
|
|
105
|
-
|
|
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):
|
|
109
|
-
|
|
110
|
-
| Key | Env | |
|
|
111
|
-
| --- | --- | --- |
|
|
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. |
|
|
116
|
-
| `federatedIdentityLookupCoverage` | — (a list; HOCON only) | What the lookup covers. Default `[]`. |
|
|
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. |
|
|
121
|
-
|
|
122
|
-
## The wire contract
|
|
123
|
-
|
|
124
|
-
Every request is a `POST` with a JSON body, and — when `bearerToken` is
|
|
125
|
-
configured — an `Authorization: Bearer <token>` header. The user a Store
|
|
126
|
-
answers with is core's [`User`](../core/src/repositories/types.mts).
|
|
127
|
-
|
|
128
|
-
**`authenticate`** posts `{ email, password }` to `authenticateUrl` (the
|
|
129
|
-
username arrives as `email`); **`authenticateByToken`** posts `{ token }` to
|
|
130
|
-
`authenticateByTokenUrl`, where `token` is an opaque handle the Store resolves
|
|
131
|
-
to a user — `<provider>:<sub>` from the federation callback, the verified
|
|
132
|
-
assertion's subject handle from `oauth`'s jwt-bearer grant. For both:
|
|
133
|
-
|
|
134
|
-
- a `2xx` whose body is a JSON `User` (`{ id: string, username: string, … }`,
|
|
135
|
-
both non-empty) is the user;
|
|
136
|
-
- `401` or `403` is `null` — no such user, or wrong credentials;
|
|
137
|
-
- a `2xx` with a body that is not a `User` throws — an upstream failure, not a
|
|
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);
|
|
146
|
-
- any other status throws.
|
|
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
|
-
|
|
159
|
-
The body of a non-`2xx` answer is discarded unread, for these and for linking.
|
|
160
|
-
No request follows a redirect, including the identity lookup: a `3xx` is one
|
|
161
|
-
more status that throws, and its `Location` is never contacted.
|
|
162
|
-
|
|
163
|
-
**A `401` or `403` with a `Bearer` challenge is a refused credential.** When
|
|
164
|
-
`bearerToken` is configured, a `401` or `403` carrying
|
|
165
|
-
`WWW-Authenticate: Bearer …` (RFC 6750 §3 — `invalid_token`,
|
|
166
|
-
`insufficient_scope`) from any endpoint it posts to throws a
|
|
167
|
-
`StoreCredentialRefusedError`, `HttpUserRepository: the Store at <url> refused
|
|
168
|
-
this deployment's credential (HTTP <status> with a Bearer challenge) — …`,
|
|
169
|
-
instead of the reading this section otherwise gives that status: not "no such
|
|
170
|
-
user", not a refused link, not the lookup's `answered HTTP <status>`. What each
|
|
171
|
-
caller then answers and logs is in
|
|
172
|
-
[What the Store must enforce itself](#what-the-store-must-enforce-itself). The
|
|
173
|
-
challenge is found in any case, among other challenges or on a header line of
|
|
174
|
-
its own, and never inside a quoted string; a quoted string left unterminated
|
|
175
|
-
runs to the end of the value and hides whatever follows it, which then reads
|
|
176
|
-
as no challenge. Nothing the Store wrote after the scheme is repeated. A `401`
|
|
177
|
-
or `403` without a `Bearer` challenge, and any `401` or `403` when no token is
|
|
178
|
-
configured, keeps the meaning this section gives it, so a Store that does not
|
|
179
|
-
check the token is unaffected.
|
|
180
|
-
|
|
181
|
-
**A transport failure carries nothing of the exchange.** When a request cannot
|
|
182
|
-
be made or its answer cannot be read, the error is a `StoreTransportError`
|
|
183
|
-
whose `reason` and message say which:
|
|
184
|
-
|
|
185
|
-
| `reason` | When | Message (the lookup: `identity lookup at <url> …`) |
|
|
186
|
-
| --- | --- | --- |
|
|
187
|
-
| `unreachable` | no connection to exchange on: a refused connection, DNS, TLS, a route or host the network cannot reach — the network path, or TLS | `HttpUserRepository: request to <url> could not be reached` |
|
|
188
|
-
| `connection_closed` | a connection closed or reset before a complete response arrived: before any byte, after an interim `1xx`, mid-head, or a pooled keep-alive connection the Store, a proxy or an idle timeout closed between two requests — the transport cannot tell which | `HttpUserRepository: the connection to <url> closed before a complete response arrived` (the lookup: `identity lookup at <url>: the connection closed …`) |
|
|
189
|
-
| `malformed_response` | the Store sent a response head the transport cannot take: the parser refused the status line or a header, or the head outgrew the size limit — the Store's answer, or a proxy's | `HttpUserRepository: the Store at <url> answered with a malformed HTTP response` |
|
|
190
|
-
| `unreadable` | an HTTP answer whose body broke mid-read | `HttpUserRepository: response from <url> could not be read` |
|
|
191
|
-
|
|
192
|
-
Each carries at most a transport code an operator can act on, in the message
|
|
193
|
-
and as `code`: `ECONNREFUSED`, `ENOTFOUND`, `ECONNRESET`, `EPROTO`, an
|
|
194
|
-
`UND_ERR_*` (`UND_ERR_HEADERS_OVERFLOW`, `UND_ERR_SOCKET`, …), an `HPE_*` where
|
|
195
|
-
the runtime sets one (Node 26's undici sets no code on its parser errors), an
|
|
196
|
-
`ERR_SSL_*` (`ERR_SSL_WRONG_VERSION_NUMBER` is an https URL on a port that
|
|
197
|
-
speaks plain HTTP; `ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE` is a TLS 1.2
|
|
198
|
-
handshake the Store refused — mutual TLS it expects a client certificate for,
|
|
199
|
-
or no shared cipher), a certificate code. It never
|
|
200
|
-
carries the transport's own error or a `cause`: undici's parser errors quote
|
|
201
|
-
the bytes they rejected, and a peer that reflects the request — a broken
|
|
202
|
-
proxy, a debugging echo — puts the `Authorization` header, or a password,
|
|
203
|
-
among them. A request that outlives the deadline is a `TimeoutError` instead
|
|
204
|
-
(see Constructor validation).
|
|
205
|
-
|
|
206
|
-
**`linkFederatedIdentity`** posts `{ userId, provider, sub, token, claims }` to
|
|
207
|
-
`linkFederatedIdentityUrl`: a `2xx` `User` is `{ ok: true, user }`; `401` / `403`
|
|
208
|
-
is `{ ok: false, reason: "refused" }`; `409` is
|
|
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
|
|
213
|
-
refusal is not read, a refusal carries no description from the Store. The
|
|
214
|
-
method is absent when `linkFederatedIdentityUrl` is not configured, which is
|
|
215
|
-
how the federation
|
|
216
|
-
routes know to refuse `?link=1` up front. What a Store checks before answering
|
|
217
|
-
`2xx` — never on an unverified or relay address, never by e-mail alone — is in
|
|
218
|
-
the
|
|
219
|
-
[session package README](../session/README.md#account-linking-across-federations-482).
|
|
220
|
-
|
|
221
|
-
#### The identity lookup (#613)
|
|
222
|
-
|
|
223
|
-
What a federation-grant deployment (`@o3co/auth-provider-federation-grants`,
|
|
224
|
-
the connect callback's check 5 in the ADR's
|
|
225
|
-
[D7](../core/docs/adr/2026-09-17-federation-grants-offline-delegation.md#d7--the-connect-flow-binds-its-callback-and-refuses-on-any-mismatch))
|
|
226
|
-
asks of the Store: who holds an upstream identity, so that a
|
|
227
|
-
delegation is refused when the account belongs to another local user. The
|
|
228
|
-
Store answers about ownership across **every** registration of the IdP, not
|
|
229
|
-
only the one the identity came through — a login links under the federation
|
|
230
|
-
the user signed in through, and an IdP whose `sub` is pairwise per app
|
|
231
|
-
registration (Entra's is) gives the same person another `sub` under each. For
|
|
232
|
-
a separate registration for grants (the ADR's
|
|
233
|
-
[D19](../core/docs/adr/2026-09-17-federation-grants-offline-delegation.md#d19--entra-on-behalf-of-is-not-implemented-and-consent-accumulates)),
|
|
234
|
-
that means: to answer `unlinked`
|
|
235
|
-
honestly, your Store must resolve the verified `(tid, oid)` against an
|
|
236
|
-
authoritative directory complete for the relevant tenant and all local
|
|
237
|
-
ownership links across registrations; no match in a partial directory or an
|
|
238
|
-
unfamiliar identity is `identity_not_resolvable`, and multiple distinct local
|
|
239
|
-
owners are a server error. Declaring coverage attests to that strategy; boot
|
|
240
|
-
neither discovers nor proves the remote directory's completeness.
|
|
241
|
-
|
|
242
|
-
The request is `POST findSubjectByFederatedIdentityUrl` with
|
|
243
|
-
|
|
244
|
-
```json
|
|
245
|
-
{ "provider": "entra-files", "issuer": "https://login.microsoftonline.com/<tenant>/v2.0",
|
|
246
|
-
"clientId": "<the grants app registration>", "sub": "<verified sub>",
|
|
247
|
-
"claims": { "tid": "<verified>", "oid": "<verified>" } }
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
— the registration exactly as the connection is configured, the verified
|
|
251
|
-
`sub`, and every claim the connection's `identityClaims` named (verified,
|
|
252
|
-
from the id_token; `{}` when it named none). The Store must not log, persist
|
|
253
|
-
or echo those claims — core's
|
|
254
|
-
[`FederatedIdentityLookup`](../core/src/repositories/UserRepository.mts) marks
|
|
255
|
-
them transient. The lookup must change nothing:
|
|
256
|
-
no login stamped, no link made, no user provisioned. The answer is a `2xx`
|
|
257
|
-
JSON body, one of
|
|
258
|
-
|
|
259
|
-
| Body | Meaning |
|
|
260
|
-
|---|---|
|
|
261
|
-
| `{ "kind": "linked", "subject": "<local User.id>" }` | exactly one local user holds it. `subject` is that user's `id` **byte for byte** — the callback compares it with the signed-in user's `sub` exactly, so a padded or otherwise normalised id reads as another user's |
|
|
262
|
-
| `{ "kind": "unlinked" }` | a **complete** resolution found nobody |
|
|
263
|
-
| `{ "kind": "indeterminate", "reason": "registration_not_covered" }` | no strategy for this registration |
|
|
264
|
-
| `{ "kind": "indeterminate", "reason": "identity_not_resolvable" }` | a strategy, and this identity is not in it |
|
|
265
|
-
|
|
266
|
-
Fields beyond those are ignored. **Everything else is an outage, never
|
|
267
|
-
"nobody"**: any status but a `2xx` — `404`, `401`, `403`, `409`, `5xx` — a
|
|
268
|
-
`2xx` whose body is empty or not one of the four, a redirect (never followed:
|
|
269
|
-
the body carries a verified identity), a timeout, or a body over the cap all
|
|
270
|
-
throw, and the callback answers `temporarily_unavailable`. More than one
|
|
271
|
-
distinct local owner is a `500` from the Store. What is thrown names the
|
|
272
|
-
endpoint — and, for an answer with a status, the status — never the body,
|
|
273
|
-
the identity, a status text or an underlying cause.
|
|
274
|
-
|
|
275
|
-
**Coverage.** The probe the grants module asks at boot is synchronous and
|
|
276
|
-
cannot reach the Store, so `federatedIdentityLookupCoverage` relays what the
|
|
277
|
-
Store's lookup covers: one entry per registration,
|
|
278
|
-
`{ provider, issuer, clientId, requiredClaims }`, all four fields, compared
|
|
279
|
-
exactly (no trimming, no case folding, no trailing-slash tolerance), and
|
|
280
|
-
`requiredClaims` the claim names the strategy needs — `[]` for one on the
|
|
281
|
-
registration and `sub` alone. `supportsFederatedIdentityLookup(registration,
|
|
282
|
-
identityClaims)` is `true` when an entry equals the registration and every
|
|
283
|
-
`requiredClaims` name is among `identityClaims`. When federation grants are enabled and have at
|
|
284
|
-
least one connection and `federation-grants.identityLookup` is `"required"` (the
|
|
285
|
-
default), the grants module refuses at boot every connection it is `false` for;
|
|
286
|
-
under `"unsupported"`, or with no connection, it asks nothing. A registration nobody declared is
|
|
287
|
-
answered `registration_not_covered` locally, and a declared one arriving
|
|
288
|
-
without a required claim `identity_not_resolvable`, with no request either
|
|
289
|
-
way. The declaration is snapshotted at construction; a duplicate registration,
|
|
290
|
-
a malformed entry, or coverage without the URL is a construction error. It has
|
|
291
|
-
no environment-variable form (a list is HOCON's), as `federation-grants.connections`
|
|
292
|
-
has none. `supportsFederatedIdentityLookup` and `findSubjectByFederatedIdentity`
|
|
293
|
-
are absent when `findSubjectByFederatedIdentityUrl` is not configured, and a
|
|
294
|
-
deployment that then has a connection under `"required"` is refused at boot.
|
|
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`; so does a
|
|
531
|
-
`timeout` above the factor store's own bound, 85 500 000 ms, which the user
|
|
532
|
-
repository alone would take.
|
|
533
|
-
- It declares no replica-unsafe state: the factors are the Store's.
|
|
534
|
-
|
|
535
|
-
It writes the factor set's membership through `createIf`, `removeIf` and
|
|
536
|
-
the reset. It answers the port as the
|
|
537
|
-
[contract](#the-stores-mfa-endpoints) gives each answer, and throws on
|
|
538
|
-
anything else:
|
|
539
|
-
|
|
540
|
-
| Operation | The Store answers | The port gets |
|
|
541
|
-
| --- | --- | --- |
|
|
542
|
-
| `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`. |
|
|
543
|
-
| `list` | Any other status, a redirect included | Throws `unexpected_status`: never "no factors". |
|
|
544
|
-
| `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. |
|
|
545
|
-
| `update` | `409` or `404` / any other | `null` / throws `unexpected_status`. |
|
|
546
|
-
| `removeAllForSubject` | `2xx` or `404` / any other | Done / throws `unexpected_status`. |
|
|
547
|
-
| `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`. |
|
|
548
|
-
| `listVersioned` | Any other status | Throws `unexpected_status`. |
|
|
549
|
-
| `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`. |
|
|
550
|
-
| `createIf` | Any other status, a `404`, a late write's `408` and a `204` from a Store that ignores `expectedGeneration` included | Throws `unexpected_status`. |
|
|
551
|
-
| `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`. |
|
|
552
|
-
| `removeIf` | Any other status, a late write's `408` included | Throws `unexpected_status`. |
|
|
553
|
-
|
|
554
|
-
The set's members read the status first: one the operation is not given
|
|
555
|
-
throws `unexpected_status`, its body released unread. The body of a status
|
|
556
|
-
it is given is parsed once and read by core's codec alone, and what the codec
|
|
557
|
-
refuses throws `malformed_answer`, carrying none of the codec's words. A
|
|
558
|
-
conditional write that rejects after it was sent — its deadline, a lost
|
|
559
|
-
connection, a `408`, a refused answer — is unknown, never `missing` or
|
|
560
|
-
`conflict`: it may have committed. A Store that ignores `expectedGeneration` may have
|
|
561
|
-
written unfenced before its answer is refused; only the contract suite, run
|
|
562
|
-
in the Store's own CI, shows that it does not.
|
|
563
|
-
|
|
564
|
-
Before any request, a record or an update the wire codec would not read back
|
|
565
|
-
— an id that is not 22 base64url characters, a label out of shape, an update
|
|
566
|
-
at `Number.MAX_SAFE_INTEGER`, an expected generation that is no generation —
|
|
567
|
-
is a `RangeError`, and nothing is sent. The
|
|
568
|
-
`data` it is handed is sent as it is: the MFA package sealed it, and nothing
|
|
569
|
-
here opens it.
|
|
570
|
-
|
|
571
|
-
**The Store is trusted with the factors.** The adapter reads each answer
|
|
572
|
-
strictly, but it cannot tell an old record from a current one, a list with a
|
|
573
|
-
record left out from a complete one, or a factor removed and answered again
|
|
574
|
-
from one never removed: the Store is responsible for the factors' integrity
|
|
575
|
-
and freshness ([what the Store must enforce itself](#what-the-store-must-enforce-itself)).
|
|
576
|
-
|
|
577
|
-
### The witness written through the user repository
|
|
578
|
-
|
|
579
|
-
With `markMfaEnrolledUrl`, `HttpUserRepository.markMfaEnrolled(subject,
|
|
580
|
-
enrolled)` posts `{ subject, enrolled }` to it
|
|
581
|
-
([`src/mfa/markEnrolled.mts`](src/mfa/markEnrolled.mts)), with the user
|
|
582
|
-
repository's credential and deadline; it reads no body, so the response cap
|
|
583
|
-
does not apply. `204` is done; `404` throws an
|
|
584
|
-
`MfaStoreError` `unknown_subject`, and any other status — a `200` and a
|
|
585
|
-
redirect included — `unexpected_status`, the body released unread. A
|
|
586
|
-
transport failure, the deadline and a refused credential throw the user
|
|
587
|
-
repository's errors. A subject that is not a non-empty string, or a value
|
|
588
|
-
that is not a boolean, is a `RangeError`, and nothing is sent.
|
|
589
|
-
|
|
590
|
-
The MFA package marks a subject `true` once its first counting factor is
|
|
591
|
-
written — a first binding at a login or in a signed-in session — and again
|
|
592
|
-
whenever a counting factor is verified where the login's `User` does not say
|
|
593
|
-
the subject enrolled, a step-up included, so a mark that failed is written at
|
|
594
|
-
the subject's next such verification; a mark that fails is one warning
|
|
595
|
-
(`mfa_enrollment_witness_unwritten`) and the login completes. A Store that
|
|
596
|
-
takes the marks but never answers `mfaEnrolled` back is therefore marked once
|
|
597
|
-
per counting verification.
|
|
598
|
-
The witness then stops a lost factor store from opening a first binding: a
|
|
599
|
-
login whose `User` says the subject enrolled, beside no factor that counts,
|
|
600
|
-
is `503` (`mfa.enrollment_state_inconsistent`), never a password-only login
|
|
601
|
-
or a first binding.
|
|
602
|
-
|
|
603
|
-
Without the URL nothing is written, and the provider reads back whatever
|
|
604
|
-
`mfaEnrolled` the Store answers. Then nothing but the Store keeps a
|
|
605
|
-
subject's factor list whole: a Store that drops a subject's records lets a
|
|
606
|
-
password-only login through under `mfa.mode = "optional"`, and opens a first
|
|
607
|
-
binding to whoever holds the password under `required`.
|
|
608
|
-
|
|
609
|
-
**Turning it on.** A subject that enrolled before
|
|
610
|
-
`REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` was set has no witness until
|
|
611
|
-
its next counting verification, and until then a lost factor store reads it
|
|
612
|
-
as never enrolled. When you set the URL, backfill `mfaEnrolled = true` in the
|
|
613
|
-
Store for every subject that holds a counting factor.
|
|
614
|
-
|
|
615
|
-
### Testing against the contract
|
|
616
|
-
|
|
617
|
-
[`@o3co/auth-provider-test-kit`](../test-kit/README.md) holds the enrollment
|
|
618
|
-
witness's contract suite, `MfaFactorStore`'s (`mfaFactorStoreContract`), the
|
|
619
|
-
factor set's (`mfaFactorStoreConditionalContract`), and a fake Store that
|
|
620
|
-
answers these endpoints as the table says, the set's generation and its
|
|
621
|
-
tombstone included — and, told to, as a broken Store would. This package's
|
|
622
|
-
tests run both factor store suites against `HttpMfaFactorStore` over the fake
|
|
623
|
-
Store, a second adapter on the same Store racing the first, and the
|
|
624
|
-
witness's suite
|
|
625
|
-
against `HttpUserRepository` with `markMfaEnrolledUrl` over it, reading the
|
|
626
|
-
witness back through `authenticate` and `authenticateByToken`. The
|
|
627
|
-
section's test builder, `foundationMfaFactorStoreConfig`, is on
|
|
628
|
-
`@o3co/auth-provider-foundation/testing`.
|
|
629
|
-
|
|
630
|
-
## What the Store must enforce itself
|
|
631
|
-
|
|
632
|
-
- **Who may call it.** What `authenticateByToken` and linking carry is not a
|
|
633
|
-
secret — the federation callback's `<provider>:<sub>` is an identifier — so a
|
|
634
|
-
Store that answers any caller lets anyone who can reach
|
|
635
|
-
`authenticateByTokenUrl` resolve a known identity to its user, and anyone who
|
|
636
|
-
can reach an open `linkFederatedIdentityUrl` bind any identity to any
|
|
637
|
-
`userId`. The MFA endpoints are no less: anyone who can reach an open
|
|
638
|
-
`deleteUrl` or `markMfaEnrolledUrl` can remove a subject's factors and clear
|
|
639
|
-
its witness, and the subject's next password login then opens a first
|
|
640
|
-
binding to whoever holds the password. Configure `bearerToken`
|
|
641
|
-
(`REPOSITORIES_USER_HTTP_BEARER_TOKEN`, from `openssl rand -hex 32`) and have the Store
|
|
642
|
-
refuse every request on every endpoint it serves auth.provider — the user
|
|
643
|
-
repository's four and the MFA endpoints alike — whose `Authorization` is not
|
|
644
|
-
exactly `Bearer <that token>`, compared in constant time, and never log the
|
|
645
|
-
header. One token goes to every one of those URLs, so they must be one trust
|
|
646
|
-
domain: whoever runs any of those endpoints holds a credential the others
|
|
647
|
-
accept. Refuse with `401` and
|
|
648
|
-
`WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3), or `403` and
|
|
649
|
-
`error="insufficient_scope"` for a token that is valid but not enough. With
|
|
650
|
-
that challenge, a token the Store does not accept — a typo, a half-finished
|
|
651
|
-
rotation — is an outage on every call, which each caller reports as the
|
|
652
|
-
table below shows. Without the challenge the `401` or `403` keeps its wire
|
|
653
|
-
meaning, "no such user" or a refused link, and a mismatch shows only as
|
|
654
|
-
every login failing. For the same reason, never send a `Bearer` challenge
|
|
655
|
-
with a user's wrong password, an unknown identity or a link your policy
|
|
656
|
-
refuses: that answer would read as the Store refusing auth.provider, and the
|
|
657
|
-
user's failed login or link as an outage. To rotate, have the Store accept the old token and the new, move
|
|
658
|
-
auth.provider to the new, then retire the old.
|
|
659
|
-
Without `bearerToken` no request carries an `Authorization` header, and the
|
|
660
|
-
Store must admit only auth.provider some other way — a network policy or a
|
|
661
|
-
private network, or mutual TLS provided by the platform in front of the
|
|
662
|
-
Store (a sidecar on a loopback address, which the `http` carve-out admits).
|
|
663
|
-
This adapter offers no client certificate of its own: Node's `fetch` takes
|
|
664
|
-
one only through an `undici` dispatcher, a dependency this package does not
|
|
665
|
-
carry. A URL carrying `user:password@` is refused.
|
|
666
|
-
- **No secret in the URL.** A query-string token would not stay secret: it
|
|
667
|
-
travels in every request line, to the Store's own access logs and to any
|
|
668
|
-
proxy in between. The errors this adapter throws — which the session routes
|
|
669
|
-
log — name an endpoint by its origin and path only, never its query or
|
|
670
|
-
fragment, so the query at least does not reach this deployment's log. The
|
|
671
|
-
caller's credential belongs in `bearerToken`, which nothing this adapter
|
|
672
|
-
throws carries.
|
|
673
|
-
- **Answer without redirecting.** No request follows a redirect, so a
|
|
674
|
-
password, a token, a link request or an identity goes only to the configured
|
|
675
|
-
URL — the one the `https` rule below checks — and no answer from anywhere
|
|
676
|
-
else is taken as the user, the link or the lookup's answer. A `3xx` from any
|
|
677
|
-
endpoint throws like any other unexpected status (the session
|
|
678
|
-
routes and the jwt-bearer grant answer `503 temporarily_unavailable`, the
|
|
679
|
-
grants callback `temporarily_unavailable`), so a Store behind a URL that
|
|
680
|
-
redirects — a host alias redirecting to the canonical host, an added
|
|
681
|
-
trailing slash, a moved path — fails every call. Configure each URL as the
|
|
682
|
-
endpoint that answers, not one that redirects.
|
|
683
|
-
- **Keep the MFA factors whole and fresh.** A Store that keeps MFA factors
|
|
684
|
-
is responsible for their integrity and freshness: it never rolls a factor
|
|
685
|
-
back, hides one from a list, answers one it acknowledged removing, or lets
|
|
686
|
-
two updates at one version both succeed. A version never goes back, and an
|
|
687
|
-
acknowledged write is never lost across a restore or a failover; a list
|
|
688
|
-
answers the latest write, never a cache's or a lagging replica's. The provider cannot tell when this breaks: an
|
|
689
|
-
older record's sealed data opens as it did then — the seal binds the
|
|
690
|
-
subject, the factor id and the kind, not the version — so a TOTP step
|
|
691
|
-
already used is accepted again within its window, a spent recovery code
|
|
692
|
-
works again, and a removed factor is back. A Store that drops a subject's
|
|
693
|
-
records lets a password-only login through under `mfa.mode = "optional"`,
|
|
694
|
-
and opens a first binding to whoever holds the password under `required`;
|
|
695
|
-
the enrollment witness, kept outside the factor store, is what stops that
|
|
696
|
-
(the MFA ADR's D12). The MFA ADR's O6 recommends the factors in Redis and the witness in the Store; keeping
|
|
697
|
-
both in one Store loses that protection, since what drops or rolls back
|
|
698
|
-
the one can do the same to the other. The runbook, "Keeping MFA factors in
|
|
699
|
-
the Store", has the failover and restore procedures.
|
|
700
|
-
|
|
701
|
-
**What a refused token looks like, per caller.** Each caller answers a
|
|
702
|
-
`StoreCredentialRefusedError` as it answers any Store failure — a
|
|
703
|
-
`StoreTransportError` or a `TimeoutError` too; what differs is what it logs:
|
|
704
|
-
|
|
705
|
-
| Caller | Answers | Logs |
|
|
706
|
-
| --- | --- | --- |
|
|
707
|
-
| Password login, `POST /session/login` ([`@o3co/auth-provider-session`](../session/README.md)) | `503 temporarily_unavailable` | `login_store_unavailable` (error, `store: "user_repository"`), with `err` |
|
|
708
|
-
| Federation login and `?link=1` callbacks (session) | `503 temporarily_unavailable` | `federation_callback_store_unavailable` or `federation_link_store_unavailable` (error, `store: "user_repository"`), with `err` |
|
|
709
|
-
| The jwt-bearer grant ([`@o3co/auth-provider-oauth`](../oauth/README.md)) | `503 temporarily_unavailable` | `jwt_bearer_user_repository_unavailable` (error), with `err` |
|
|
710
|
-
| The federation-grants connect callback — the identity lookup ([`@o3co/auth-provider-federation-grants`](../federation-grants/README.md)) | redirect with `error=temporarily_unavailable` | `federation_grant_callback_unavailable` (error), `store: "user_directory"`, with `err` |
|
|
711
|
-
|
|
712
|
-
Where `err` is logged, a `StoreCredentialRefusedError`'s message names the
|
|
713
|
-
Store endpoint (its origin and path), the status and
|
|
714
|
-
`REPOSITORIES_USER_HTTP_BEARER_TOKEN` — never the token; a `StoreTransportError`'s names
|
|
715
|
-
the endpoint the same way, what failed and at most a transport code. No
|
|
716
|
-
message this adapter throws quotes a URL's query string or fragment.
|
|
717
|
-
|
|
718
|
-
## Constructor validation
|
|
719
|
-
|
|
720
|
-
Every option is validated in the **constructor**, so a misconfigured deployment
|
|
721
|
-
fails at boot rather than at the first login attempt.
|
|
722
|
-
|
|
723
|
-
**Every URL must use `https://`** (the link, lookup and witness endpoints included). They carry plaintext user credentials — a
|
|
724
|
-
password on `authenticateUrl`, a token on `authenticateByTokenUrl`, a verified
|
|
725
|
-
upstream identity on `findSubjectByFederatedIdentityUrl` — so an
|
|
726
|
-
`http://` URL does not merely weaken the connection, it publishes the credential
|
|
727
|
-
to every hop on the path. The URL checked here is the only place a request goes
|
|
728
|
-
and the only one whose answer is taken: no request follows a redirect, so a
|
|
729
|
-
`307` or `308` cannot re-send the body to, and no `301`, `302` or `303` can fetch an answer from, a `Location` this rule never saw.
|
|
730
|
-
|
|
731
|
-
**The one carve-out is loopback:** `http://` is accepted when the host is
|
|
732
|
-
`localhost`, an address in `127.0.0.0/8`, or `[::1]`. That traffic never leaves
|
|
733
|
-
the machine, so local development and in-process test fixtures need no
|
|
734
|
-
certificate. Every other host must use `https://`, **including private-range
|
|
735
|
-
addresses and container-network service names** (`http://10.0.0.5/…`,
|
|
736
|
-
`http://user-service/…` are refused): those cross a network the deployment does
|
|
737
|
-
not control end to end, and "internal" is not a synonym for "encrypted". URLs
|
|
738
|
-
embedding credentials (`https://user:pass@…`) are refused as well.
|
|
739
|
-
|
|
740
|
-
This is the same rule `oauth.jwt.issuer` applies in
|
|
741
|
-
[`@o3co/auth-provider-core`](../core/README.md), with the carve-out widened from
|
|
742
|
-
the single address `127.0.0.1` to the whole `127.0.0.0/8` block, and query
|
|
743
|
-
strings allowed (an issuer may not carry one; a POST endpoint legitimately may).
|
|
744
|
-
A query is sent, and never quoted: every error names the endpoint by origin
|
|
745
|
-
and path.
|
|
746
|
-
|
|
747
|
-
`HttpMfaFactorStore` holds its four URLs, `timeout`, `maxResponseBytes` and
|
|
748
|
-
`bearerToken` to the same rules below, each refusal naming
|
|
749
|
-
`HttpMfaFactorStore`.
|
|
750
|
-
|
|
751
|
-
**`timeout` must be a positive integer** no greater than `2147483647`
|
|
752
|
-
milliseconds. `0`, a negative number, and `NaN` all clamp to "fire immediately"
|
|
753
|
-
in `setTimeout` — which would abort every request — and a value above Node's
|
|
754
|
-
timer range clamps to 1ms, turning "be patient" into the most impatient setting
|
|
755
|
-
available. A blank environment override is a boot failure, not the default. The
|
|
756
|
-
deadline covers the whole exchange, **body included**: the body read is raced
|
|
757
|
-
against it rather than relying on the abort signal, because aborting a request
|
|
758
|
-
does not reliably interrupt a read already in flight. That is the slow-loris
|
|
759
|
-
shape — headers arrive promptly, then the body dribbles or stops — and without
|
|
760
|
-
the race it hangs forever. A request that outlives the deadline rejects with a
|
|
761
|
-
`timed out after <n>ms` error naming the endpoint, named `TimeoutError` on
|
|
762
|
-
every request, so a reporter that classifies by name reads it as a timeout.
|
|
763
|
-
|
|
764
|
-
**`HttpMfaFactorStore` holds `timeout` to 85 500 000 milliseconds** (23.75
|
|
765
|
-
hours) as well; above it, construction throws a `RangeError` naming `timeout`
|
|
766
|
-
and the bound. Its write lifetime W is the timeout plus `DEFAULT_CLOCK_SKEW_MS`,
|
|
767
|
-
and the factor-set writer issues a conditional write up to
|
|
768
|
-
`MFA_SUBJECT_LEASE_MAX_MS` after its read, so the timeout plus those two must
|
|
769
|
-
be within `BUNDLED_STORE_WRITE_LIFETIME_MS` (all core's exports).
|
|
770
|
-
|
|
771
|
-
**`maxResponseBytes` must be a positive integer**, defaulting to
|
|
772
|
-
`DEFAULT_MAX_RESPONSE_BYTES` (1 MiB). The cap is enforced against
|
|
773
|
-
`Content-Length` *and* while streaming, so a Store that omits the header — or
|
|
774
|
-
lies in it — is still cut off rather than allowed to exhaust memory.
|
|
775
|
-
|
|
776
|
-
**`bearerToken`, when set, must be a bare RFC 6750 token of at least 32 bytes
|
|
777
|
-
of key material.** Unset (absent) sends no `Authorization` header. Set, it is
|
|
778
|
-
refused unless it is a string, not blank (a blank environment override is a
|
|
779
|
-
boot failure, not "no token"), made only of letters, digits, `-._~+/` and
|
|
780
|
-
trailing `=` padding — no whitespace, no line break, and no `Bearer ` prefix,
|
|
781
|
-
which the adapter adds — and it clears core's shared-secret floor
|
|
782
|
-
(`MIN_SECRET_ENTROPY_BYTES`, the one `SESSION_STORE_SECRET` and `KEY_STORE_LOCAL_SECRET`
|
|
783
|
-
clear), measured on the decoded length of a hex or base64 value, so
|
|
784
|
-
`openssl rand -hex 16` is 16 bytes however long it looks. Whoever holds the
|
|
785
|
-
token speaks to the Store as auth.provider. The shape is checked here because
|
|
786
|
-
a header value `fetch` refuses is one it quotes in the error it throws. No
|
|
787
|
-
refusal quotes the value, no error from a request carries it — a transport
|
|
788
|
-
failure is thrown without the transport's error (see the wire contract) — and
|
|
789
|
-
it is held in an ECMAScript private field, so it is absent from `inspect()`
|
|
790
|
-
and `JSON.stringify` of the repository. These checks run when the repository
|
|
791
|
-
is built, which a deployment does under `adapters.userRepository = "http"`
|
|
792
|
-
(the standalone template's default); under core's `reference.conf` default,
|
|
793
|
-
`yaml`, the `http` block — and a token in it — is not read at all.
|
|
794
|
-
|
|
795
|
-
## Public API
|
|
796
|
-
|
|
797
|
-
Exported from [`src/index.mts`](src/index.mts):
|
|
798
|
-
|
|
799
|
-
- `registerBuiltinAdapters({ userFactory })` — registers the `"http"` type.
|
|
800
|
-
- `HttpUserRepository` — the repository
|
|
801
|
-
([`src/repositories/HttpUserRepository.mts`](src/repositories/HttpUserRepository.mts)).
|
|
802
|
-
- `StoreCredentialRefusedError` — what a refused credential throws; its
|
|
803
|
-
`name` and `storeStatus` (`401` or `403`) are part of the contract. Not
|
|
804
|
-
`status`: Express, http-errors and the standalone's terminal handler read
|
|
805
|
-
that as the status to answer with.
|
|
806
|
-
- `StoreTransportError`, `StoreTransportFailure` — what a transport failure
|
|
807
|
-
throws; its `name`, `reason` and `code` are part of the contract, and it has
|
|
808
|
-
no `status` either
|
|
809
|
-
([`src/repositories/storeErrors.mts`](src/repositories/storeErrors.mts)).
|
|
810
|
-
- `DEFAULT_MAX_RESPONSE_BYTES` — the default response cap.
|
|
811
|
-
- `FederatedIdentityLookupCoverage` — the type of one coverage entry.
|
|
812
|
-
- `HttpMfaFactorStore`, `HttpMfaFactorStoreOptions` — the Store-backed
|
|
813
|
-
`MfaFactorStore` ([`src/mfa/HttpMfaFactorStore.mts`](src/mfa/HttpMfaFactorStore.mts)).
|
|
814
|
-
- `foundationMfaFactorStoreModule`, `FoundationMfaFactorStoreModuleOptions` —
|
|
815
|
-
its module ([`src/mfa/module.mts`](src/mfa/module.mts)).
|
|
816
|
-
- `MfaStoreError`, `MfaStoreFailure`, `MfaStoreOperation` — what the MFA
|
|
817
|
-
endpoints' failures throw; its `name`, `reason`, `operation` and
|
|
818
|
-
`storeStatus` are part of the contract, and it has no `status`
|
|
819
|
-
([`src/mfa/storeFailure.mts`](src/mfa/storeFailure.mts)). The builders the
|
|
820
|
-
adapter throws through, and the `foundation-mfa-factor-store` section's
|
|
821
|
-
schema and reader ([`src/mfa/section.mts`](src/mfa/section.mts)), are the
|
|
822
|
-
package's own.
|
|
823
|
-
|
|
824
|
-
Exported from [`src/testing/index.mts`](src/testing/index.mts) as
|
|
825
|
-
`@o3co/auth-provider-foundation/testing`, for test code only:
|
|
826
|
-
`foundationMfaFactorStoreConfig(urls, extra?)`, the
|
|
827
|
-
`foundation-mfa-factor-store` section as a configuration fragment to lay over
|
|
828
|
-
a test's configuration, holding the four URLs `urls` holds — a fake Store's
|
|
829
|
-
`urls` included, its other endpoints left behind — and `extra` as given; and
|
|
830
|
-
`foundationUserRepositoryHttpConfig(urls, extra?)`, the user repository's
|
|
831
|
-
`http` block, holding the Store URLs the `"http"` builder reads that `urls`
|
|
832
|
-
holds — its MFA factor endpoints left behind — and `extra` as given, which a
|
|
833
|
-
test hands to the `"http"` builder, or to `foundationMfaFactorStoreModule` as
|
|
834
|
-
its `storeTransport`.
|
|
835
|
-
|
|
836
|
-
## Tests
|
|
837
|
-
|
|
838
|
-
| Test file | Pins |
|
|
839
|
-
| --- | --- |
|
|
840
|
-
| [`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 |
|
|
841
|
-
| [`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 |
|
|
842
|
-
| [`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 |
|
|
843
|
-
| [`storeErrors.test.mts`](src/repositories/__tests__/storeErrors.test.mts) | which transport codes are kept, and the two named errors' shape — no `status`, no cause |
|
|
844
|
-
| [`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 |
|
|
845
|
-
| [`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 |
|
|
846
|
-
| [`endpointUrl.test.mts`](src/__tests__/endpointUrl.test.mts) | the https-or-loopback rule |
|
|
847
|
-
| [`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 |
|
|
848
|
-
| [`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 |
|
|
849
|
-
| [`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, or a deadline past the factor store's write lifetime |
|
|
850
|
-
| [`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 |
|
|
851
|
-
| [`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, a timeout past the write lifetime a `RangeError` the user repository does not throw, no unconditional `create` or `remove`, and neither the token nor the endpoints shown when the store is inspected |
|
|
852
|
-
| [`foundationMfaFactorStoreConfig.test.mts`](src/testing/__tests__/foundationMfaFactorStoreConfig.test.mts) | the testing entry's section builder |
|
|
853
|
-
| [`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 |
|
|
854
|
-
| [`storeRequestMessages.test.mts`](src/mfa/__tests__/storeRequestMessages.test.mts) | one wording for a transport failure at the MFA endpoints, whichever client sends to them |
|
|
855
|
-
| [`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 |
|
|
856
|
-
| [`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 |
|
|
857
|
-
| [`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 |
|
|
858
|
-
|
|
859
|
-
## See also
|
|
860
|
-
|
|
861
|
-
- [`@o3co/auth-provider-core`](../core/README.md) — the `UserRepository` port,
|
|
862
|
-
`createRepositoryFactories` and the development user adapters, the MFA
|
|
863
|
-
ports and the MFA endpoints' wire format
|
|
864
|
-
- [`@o3co/auth-provider-test-kit`](../test-kit/README.md) — the enrollment
|
|
865
|
-
witness's and the factor store's contract suites, and the fake Store
|
|
866
|
-
- [`@o3co/auth-provider-session`](../session/README.md) — the routes that call
|
|
867
|
-
`authenticate`, `authenticateByToken` and `linkFederatedIdentity`
|
|
868
|
-
- [`@o3co/auth-provider-federation-grants`](../federation-grants/README.md) — the
|
|
869
|
-
caller of the identity lookup
|
|
870
|
-
- [auth.provider](../../README.md) — top-level repository documentation
|
|
5
|
+
Copyright 2026 1o1 Co. Ltd. Licensed under the Apache License, Version 2.0.
|