@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
@@ -0,0 +1,20 @@
1
+ # Foundation defaults -- @o3co/auth-provider-foundation
2
+ #
3
+ # The section of the module that keeps a subject's MFA factors in the Store,
4
+ # `foundation-mfa-factor-store`: the Store's four MFA factor endpoints, each
5
+ # a POST of JSON (the package README, "The Store's MFA endpoints"). A
6
+ # composition root that installs a module reading the section layers this
7
+ # file between its own configuration and core's reference.conf.
8
+ #
9
+ # None has a default, and each is required: a module reading the section
10
+ # refuses the boot with any of them unset, naming each one missing. Each
11
+ # must be https, or http to a loopback host (localhost, 127.0.0.0/8, [::1]),
12
+ # and the endpoint that answers: no request follows a redirect. Each key has
13
+ # its variable, FOUNDATION_MFA_FACTOR_STORE_<KEY>.
14
+
15
+ foundation-mfa-factor-store {
16
+ listUrl = ${?FOUNDATION_MFA_FACTOR_STORE_LIST_URL}
17
+ createUrl = ${?FOUNDATION_MFA_FACTOR_STORE_CREATE_URL}
18
+ updateUrl = ${?FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL}
19
+ deleteUrl = ${?FOUNDATION_MFA_FACTOR_STORE_DELETE_URL}
20
+ }
@@ -1,41 +1,17 @@
1
1
  /**
2
- * Transport validation for the Store endpoints `HttpUserRepository` posts to.
3
- *
4
- * These endpoints receive **plaintext user credentials** — a password on
5
- * `authenticateUrl`, a bearer-ish token on `authenticateByTokenUrl`. An
6
- * `http://` URL therefore does not merely weaken the connection, it publishes
7
- * the credential to every hop on the path. A single mistyped environment
8
- * variable was enough to do that, and nothing anywhere refused it (#285).
9
- *
10
- * ## The loopback carve-out
11
- *
12
- * `http://` is accepted for **loopback hosts only** — `localhost`, anything in
13
- * `127.0.0.0/8`, and `[::1]`. Traffic to a loopback address never leaves the
14
- * machine, so there is no path to eavesdrop on, and requiring TLS there would
15
- * force every local development setup and every in-process test fixture to
16
- * provision a certificate for no security gain. Any other host — including a
17
- * private-range address such as `10.0.0.5` or a service name on a container
18
- * network — must use `https://`: those cross a network the deployment does not
19
- * control end to end, and "internal" is not a synonym for "encrypted".
20
- *
21
- * The rule matches `oauth.jwt.issuer`'s (`checkCanonicalIssuer` in
22
- * `@o3co/auth-provider-core`) so operators meet one policy, not two. It is kept
23
- * separate rather than reused because the constraints genuinely differ: an
24
- * issuer may not carry a query string or fragment (OIDC Discovery derives the
25
- * metadata URL from it), while a Store endpoint is an ordinary POST target for
26
- * which `?tenant=acme` is legitimate. The loopback *predicate* the carve-out
27
- * runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
28
- * `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
29
- * session redirect policy consumes, so the carve-outs cannot drift apart.
30
- *
31
- * ## What a message names of an endpoint
32
- *
33
- * Every failure the repository throws names the endpoint it was talking to,
34
- * and every caller logs what it throws. A message names the endpoint by its
35
- * origin and path alone ({@link endpointForMessage}): a query string is
36
- * accepted in a Store URL, and a deployment may carry a credential there
37
- * (`?api_key=…`) despite the README's advice, so the query never reaches a
38
- * message — nor does a fragment, which no request sends anyway.
2
+ * Transport validation for the Store endpoints the Store clients post to —
3
+ * `HttpUserRepository`'s and `HttpMfaFactorStore`'s: `https://`, or
4
+ * `http://` to a loopback host only (`localhost`, `127.0.0.0/8`, `[::1]`),
5
+ * because what they carry — credentials, verified identities, sealed factor
6
+ * records — must not cross a network in the clear. See README, Constructor
7
+ * validation.
8
+ *
9
+ * Kept separate from `oauth.jwt.issuer`'s rule (core's `checkCanonicalIssuer`)
10
+ * because an issuer may not carry a query string, while a Store endpoint may.
11
+ * The loopback predicate is core's `isLoopbackHostname`, the session redirect
12
+ * policy's too, so the carve-outs cannot drift apart. A message names an
13
+ * endpoint by origin and path alone ({@link endpointForMessage}): a query may
14
+ * carry a credential, and every caller logs what a client throws.
39
15
  */
40
16
  import { isLoopbackHostname } from "@o3co/auth-provider-core";
41
17
  export { isLoopbackHostname };
@@ -58,22 +34,20 @@ export declare function checkSecureEndpoint(value: unknown): EndpointRejection |
58
34
  export declare function describeEndpointRejection(reason: EndpointRejection): string;
59
35
  /**
60
36
  * Returns `value` when it is a usable Store endpoint, otherwise throws naming
61
- * `field` and the reason.
37
+ * `owner`, `field` and the reason.
62
38
  *
63
39
  * The rejected value is deliberately **not** echoed into the message: it is
64
40
  * operator-supplied configuration that may embed a secret, and a boot error
65
41
  * lands in logs.
66
42
  */
67
- export declare function assertSecureEndpoint(value: unknown, field: string): string;
43
+ export declare function assertSecureEndpoint(value: unknown, field: string, owner?: string): string;
68
44
  /**
69
- * `url` as a message names it: its origin and path — `https://store.example
70
- * /authenticate`, as the WHATWG URL parser normalises them (a default port
71
- * dropped, scheme and host lower-cased, an IPv6 literal in brackets) — and
72
- * never its query or fragment, which may carry a credential. Anything that is
73
- * not an http or https URL is named by what it is not: its origin would be
74
- * `"null"` and its "path" the rest of it (`data:…`, `mailto:…`), and the
75
- * constructor refuses such a Store URL anyway — but a caller can hand
76
- * `StoreCredentialRefusedError` anything.
45
+ * `url` as a message names it: its origin and path, as the WHATWG URL parser
46
+ * normalises them (`https://store.example/authenticate`), and never its query
47
+ * or fragment, which may carry a credential. Anything that is not an http or
48
+ * https URL is named by what it is not, since its origin would be `"null"` and
49
+ * its path the rest of it (`data:…`): the constructor refuses such a Store URL,
50
+ * but a caller can hand `StoreCredentialRefusedError` anything.
77
51
  */
78
52
  export declare function endpointForMessage(url: string): string;
79
53
  //# sourceMappingURL=endpointUrl.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"endpointUrl.d.mts","sourceRoot":"","sources":["../src/endpointUrl.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAC1B,cAAc,GACd,OAAO,GACP,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,iBAAiB,CAAC;AAKrB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAyB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAS1E;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD"}
1
+ {"version":3,"file":"endpointUrl.d.mts","sourceRoot":"","sources":["../src/endpointUrl.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAC1B,cAAc,GACd,OAAO,GACP,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,iBAAiB,CAAC;AAKrB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAyB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CACnC,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,MAAM,EACb,KAAK,SAAuB,GAC1B,MAAM,CAQR;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD"}
@@ -14,48 +14,24 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  /**
17
- * Transport validation for the Store endpoints `HttpUserRepository` posts to.
17
+ * Transport validation for the Store endpoints the Store clients post to —
18
+ * `HttpUserRepository`'s and `HttpMfaFactorStore`'s: `https://`, or
19
+ * `http://` to a loopback host only (`localhost`, `127.0.0.0/8`, `[::1]`),
20
+ * because what they carry — credentials, verified identities, sealed factor
21
+ * records — must not cross a network in the clear. See README, Constructor
22
+ * validation.
18
23
  *
19
- * These endpoints receive **plaintext user credentials** — a password on
20
- * `authenticateUrl`, a bearer-ish token on `authenticateByTokenUrl`. An
21
- * `http://` URL therefore does not merely weaken the connection, it publishes
22
- * the credential to every hop on the path. A single mistyped environment
23
- * variable was enough to do that, and nothing anywhere refused it (#285).
24
- *
25
- * ## The loopback carve-out
26
- *
27
- * `http://` is accepted for **loopback hosts only** — `localhost`, anything in
28
- * `127.0.0.0/8`, and `[::1]`. Traffic to a loopback address never leaves the
29
- * machine, so there is no path to eavesdrop on, and requiring TLS there would
30
- * force every local development setup and every in-process test fixture to
31
- * provision a certificate for no security gain. Any other host — including a
32
- * private-range address such as `10.0.0.5` or a service name on a container
33
- * network — must use `https://`: those cross a network the deployment does not
34
- * control end to end, and "internal" is not a synonym for "encrypted".
35
- *
36
- * The rule matches `oauth.jwt.issuer`'s (`checkCanonicalIssuer` in
37
- * `@o3co/auth-provider-core`) so operators meet one policy, not two. It is kept
38
- * separate rather than reused because the constraints genuinely differ: an
39
- * issuer may not carry a query string or fragment (OIDC Discovery derives the
40
- * metadata URL from it), while a Store endpoint is an ordinary POST target for
41
- * which `?tenant=acme` is legitimate. The loopback *predicate* the carve-out
42
- * runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
43
- * `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
44
- * session redirect policy consumes, so the carve-outs cannot drift apart.
45
- *
46
- * ## What a message names of an endpoint
47
- *
48
- * Every failure the repository throws names the endpoint it was talking to,
49
- * and every caller logs what it throws. A message names the endpoint by its
50
- * origin and path alone ({@link endpointForMessage}): a query string is
51
- * accepted in a Store URL, and a deployment may carry a credential there
52
- * (`?api_key=…`) despite the README's advice, so the query never reaches a
53
- * message — nor does a fragment, which no request sends anyway.
24
+ * Kept separate from `oauth.jwt.issuer`'s rule (core's `checkCanonicalIssuer`)
25
+ * because an issuer may not carry a query string, while a Store endpoint may.
26
+ * The loopback predicate is core's `isLoopbackHostname`, the session redirect
27
+ * policy's too, so the carve-outs cannot drift apart. A message names an
28
+ * endpoint by origin and path alone ({@link endpointForMessage}): a query may
29
+ * carry a credential, and every caller logs what a client throws.
54
30
  */
55
31
  import { isLoopbackHostname } from "@o3co/auth-provider-core";
56
32
  // Re-exported unchanged: this module's callers (and its tests) read the
57
33
  // predicate as part of the endpoint-validation surface. The definition lives
58
- // in core (#364) — re-exporting is the sanctioned way to surface it.
34
+ // in core.
59
35
  export { isLoopbackHostname };
60
36
  /** Matches `scheme://` — the shape that distinguishes an absolute URL from a bare host. */
61
37
  const ABSOLUTE_URL_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
@@ -114,7 +90,7 @@ export function describeEndpointRejection(reason) {
114
90
  return ("must use https, or http for a loopback host (localhost, 127.0.0.0/8, [::1]) — " +
115
91
  "no other scheme is accepted");
116
92
  case "insecure-scheme":
117
- return ("must use https — it carries plaintext user credentials; http is accepted only for " +
93
+ return ("must use https — what it carries must not cross a network in the clear; http is accepted only for " +
118
94
  "a loopback host (localhost, 127.0.0.0/8, [::1])");
119
95
  case "has-credentials":
120
96
  return "must not embed credentials in the URL";
@@ -122,29 +98,26 @@ export function describeEndpointRejection(reason) {
122
98
  }
123
99
  /**
124
100
  * Returns `value` when it is a usable Store endpoint, otherwise throws naming
125
- * `field` and the reason.
101
+ * `owner`, `field` and the reason.
126
102
  *
127
103
  * The rejected value is deliberately **not** echoed into the message: it is
128
104
  * operator-supplied configuration that may embed a secret, and a boot error
129
105
  * lands in logs.
130
106
  */
131
- export function assertSecureEndpoint(value, field) {
107
+ export function assertSecureEndpoint(value, field, owner = "HttpUserRepository") {
132
108
  const rejection = checkSecureEndpoint(value);
133
109
  if (rejection !== null) {
134
- throw new Error(`HttpUserRepository: "${field}" ${describeEndpointRejection(rejection)} ` +
135
- `(reason: ${rejection})`);
110
+ throw new Error(`${owner}: "${field}" ${describeEndpointRejection(rejection)} (reason: ${rejection})`);
136
111
  }
137
112
  return value;
138
113
  }
139
114
  /**
140
- * `url` as a message names it: its origin and path — `https://store.example
141
- * /authenticate`, as the WHATWG URL parser normalises them (a default port
142
- * dropped, scheme and host lower-cased, an IPv6 literal in brackets) — and
143
- * never its query or fragment, which may carry a credential. Anything that is
144
- * not an http or https URL is named by what it is not: its origin would be
145
- * `"null"` and its "path" the rest of it (`data:…`, `mailto:…`), and the
146
- * constructor refuses such a Store URL anyway — but a caller can hand
147
- * `StoreCredentialRefusedError` anything.
115
+ * `url` as a message names it: its origin and path, as the WHATWG URL parser
116
+ * normalises them (`https://store.example/authenticate`), and never its query
117
+ * or fragment, which may carry a credential. Anything that is not an http or
118
+ * https URL is named by what it is not, since its origin would be `"null"` and
119
+ * its path the rest of it (`data:…`): the constructor refuses such a Store URL,
120
+ * but a caller can hand `StoreCredentialRefusedError` anything.
148
121
  */
149
122
  export function endpointForMessage(url) {
150
123
  try {
package/dist/index.d.mts CHANGED
@@ -2,6 +2,10 @@ import type { AdapterFactory, UserRepository } from "@o3co/auth-provider-core";
2
2
  export declare const registerBuiltinAdapters: (factories: {
3
3
  userFactory: AdapterFactory<UserRepository>;
4
4
  }) => void;
5
- export { DEFAULT_MAX_RESPONSE_BYTES, type FederatedIdentityLookupCoverage, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
5
+ export { HttpMfaFactorStore, type HttpMfaFactorStoreOptions, } from "./mfa/HttpMfaFactorStore.mjs";
6
+ export { type FoundationMfaFactorStoreModuleOptions, foundationMfaFactorStoreModule, } from "./mfa/module.mjs";
7
+ export { MfaStoreError, type MfaStoreFailure, type MfaStoreOperation, } from "./mfa/storeFailure.mjs";
8
+ export { type FederatedIdentityLookupCoverage, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
6
9
  export { StoreCredentialRefusedError, StoreTransportError, type StoreTransportFailure, } from "./repositories/storeErrors.mjs";
10
+ export { DEFAULT_MAX_RESPONSE_BYTES } from "./storeTransport.mjs";
7
11
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA8B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IA0CH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,KAAK,+BAA+B,EACpC,kBAAkB,GAClB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACN,2BAA2B,EAC3B,mBAAmB,EACnB,KAAK,qBAAqB,GAC1B,MAAM,gCAAgC,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAO/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IA4CH,CAAC;AAEF,OAAO,EACN,kBAAkB,EAClB,KAAK,yBAAyB,GAC9B,MAAM,8BAA8B,CAAC;AACtC,OAAO,EACN,KAAK,qCAAqC,EAC1C,8BAA8B,GAC9B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACN,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACN,KAAK,+BAA+B,EACpC,kBAAkB,GAClB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACN,2BAA2B,EAC3B,mBAAmB,EACnB,KAAK,qBAAqB,GAC1B,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/index.mjs CHANGED
@@ -13,31 +13,8 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
17
- /** Default request deadline, in milliseconds, when the config names none. */
18
- const DEFAULT_TIMEOUT_MS = 5000;
19
- /**
20
- * Coerces a numeric config value that may arrive as a string.
21
- *
22
- * HOCON environment substitution yields strings, so `CLIENT_USER_TIMEOUT=1234`
23
- * reaches the builder as `"1234"`. An *absent* key takes `fallback`; anything
24
- * present but unreadable becomes `NaN` and is rejected by the constructor,
25
- * which owns the range rules and names the field in its message.
26
- *
27
- * The distinction matters because HOCON substitutes a **blank** environment
28
- * variable as `""` — a very ordinary shape in a `.env` file or an empty
29
- * ConfigMap key — and `Number("")` is `0`. This used to fall back to the
30
- * default, hiding the misconfiguration; it is now a boot failure (#285).
31
- */
32
- const toNumber = (value, fallback) => {
33
- if (value === undefined || value === null)
34
- return fallback;
35
- if (typeof value === "number")
36
- return value;
37
- if (typeof value === "string")
38
- return Number(value.trim());
39
- return Number.NaN;
40
- };
16
+ import { HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
17
+ import { readStoreTransportConfig } from "./storeTransport.mjs";
41
18
  export const registerBuiltinAdapters = (factories) => {
42
19
  factories.userFactory.register("http", (config) => {
43
20
  if (typeof config.authenticateUrl !== "string") {
@@ -52,11 +29,11 @@ export const registerBuiltinAdapters = (factories) => {
52
29
  return new HttpUserRepository({
53
30
  authenticateUrl: config.authenticateUrl,
54
31
  authenticateByTokenUrl: config.authenticateByTokenUrl,
55
- // #482: optional. Present → the repository can link a federated identity.
32
+ // Optional. Present → the repository can link a federated identity.
56
33
  ...(typeof config.linkFederatedIdentityUrl === "string"
57
34
  ? { linkFederatedIdentityUrl: config.linkFederatedIdentityUrl }
58
35
  : {}),
59
- // #613: optional. Forwarded whenever SET, not only when well-typed —
36
+ // Optional. Forwarded whenever SET, not only when well-typed —
60
37
  // the link URL above vanishes when misspelt, and for the lookup a value
61
38
  // that vanishes is a deployment that believes itself covered and is
62
39
  // not. The constructor refuses what is not a URL, or not a list.
@@ -65,20 +42,26 @@ export const registerBuiltinAdapters = (factories) => {
65
42
  findSubjectByFederatedIdentityUrl: config.findSubjectByFederatedIdentityUrl,
66
43
  }
67
44
  : {}),
45
+ // Optional, forwarded whenever SET for the same reason: a witness URL
46
+ // that vanished leaves the MFA enrollment witness unwritten.
47
+ ...(config.markMfaEnrolledUrl !== undefined
48
+ ? { markMfaEnrolledUrl: config.markMfaEnrolledUrl }
49
+ : {}),
68
50
  ...(config.federatedIdentityLookupCoverage !== undefined
69
51
  ? {
70
52
  federatedIdentityLookupCoverage: config.federatedIdentityLookupCoverage,
71
53
  }
72
54
  : {}),
73
- // Optional, and forwarded whenever SET for the lookup URL's reason: a
74
- // token that vanished would be a deployment that believes its Store
75
- // calls authenticated and sends them bare. The constructor refuses
76
- // what is not a string, and what is blank, malformed or too weak.
77
- ...(config.bearerToken !== undefined ? { bearerToken: config.bearerToken } : {}),
78
- timeout: toNumber(config.timeout, DEFAULT_TIMEOUT_MS),
79
- maxResponseBytes: toNumber(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
55
+ // The bearer token, the deadline and the response cap. The constructor
56
+ // refuses a token that is not a string, or is blank, malformed or too
57
+ // weak, and a deadline or cap that is not a positive integer.
58
+ ...readStoreTransportConfig(config),
80
59
  });
81
60
  });
82
61
  };
83
- export { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
62
+ export { HttpMfaFactorStore, } from "./mfa/HttpMfaFactorStore.mjs";
63
+ export { foundationMfaFactorStoreModule, } from "./mfa/module.mjs";
64
+ export { MfaStoreError, } from "./mfa/storeFailure.mjs";
65
+ export { HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
84
66
  export { StoreCredentialRefusedError, StoreTransportError, } from "./repositories/storeErrors.mjs";
67
+ export { DEFAULT_MAX_RESPONSE_BYTES } from "./storeTransport.mjs";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `MfaFactorStore` kept by the Store, over its four MFA factor endpoints
3
+ * (README, "The Store's MFA endpoints"), on the user repository's transport.
4
+ *
5
+ * Guarantees: every answer the contract does not give an operation throws —
6
+ * a list is never answered as fewer records, an update never as written; a
7
+ * list is read whole or refused (a record the provider cannot read, one of
8
+ * another subject, an id twice); an update's answer must be the record named,
9
+ * with the changes sent, at the expected version plus one; what is thrown is
10
+ * built from an allowlist (`storeFailure.mts`, `storeErrors.mts`) and carries
11
+ * nothing the Store sent; the sealed `data` it is handed is sent as it is,
12
+ * and nothing is sent that the wire codec would not read back.
13
+ *
14
+ * A record is created or removed only conditionally: every create and every
15
+ * single-record removal it sends carries `expectedGeneration`, and only the
16
+ * whole set's reset (`removeAllForSubject`) is sent without one.
17
+ *
18
+ * The factor set's members (`listVersioned`, `createIf`, `removeIf`) read
19
+ * every answer through core's codec alone, once: a status the operation does
20
+ * not give is `unexpected_status`, read before any body, and whatever the
21
+ * codec refuses — a `404` or `409` without its outcome body among them — is
22
+ * `malformed_answer`. A conditional write states its deadline on the wire
23
+ * (`deadlineMs`, the request timeout from just before it is sent), at or
24
+ * before the moment the transport gives up, whose timer starts after it: a
25
+ * write past it is never applied after the adapter stopped waiting. A Store
26
+ * that reads it as passed answers `408`, which is `unexpected_status`. A conditional write that is sent and then fails,
27
+ * its deadline included, is unknown: it may have committed.
28
+ */
29
+ import { type ConditionalCreateAnswer, type ConditionalSetRemoveAnswer, type MfaFactorRecord, type MfaFactorRecordUpdate, type MfaFactorStore, type StoreGeneration, type VersionedSet } from "@o3co/auth-provider-core";
30
+ export interface HttpMfaFactorStoreOptions {
31
+ /** The Store's four MFA factor endpoints, each https or loopback http. */
32
+ readonly listUrl: string;
33
+ readonly createUrl: string;
34
+ readonly updateUrl: string;
35
+ readonly deleteUrl: string;
36
+ /** The user repository's credential: sent as `Authorization: Bearer <token>`. */
37
+ readonly bearerToken?: string;
38
+ /** The whole exchange's deadline, in milliseconds. */
39
+ readonly timeout: number;
40
+ /** The most bytes of an answer read. Default `DEFAULT_MAX_RESPONSE_BYTES`. */
41
+ readonly maxResponseBytes?: number;
42
+ }
43
+ export declare class HttpMfaFactorStore implements MfaFactorStore {
44
+ #private;
45
+ readonly kind = "store";
46
+ constructor({ listUrl, createUrl, updateUrl, deleteUrl, bearerToken, timeout, maxResponseBytes, }: HttpMfaFactorStoreOptions);
47
+ list(subject: string): Promise<readonly MfaFactorRecord[]>;
48
+ listVersioned(subject: string): Promise<VersionedSet<MfaFactorRecord>>;
49
+ createIf(record: MfaFactorRecord, expected: StoreGeneration | null): Promise<ConditionalCreateAnswer>;
50
+ removeIf(subject: string, id: string, expected: StoreGeneration): Promise<ConditionalSetRemoveAnswer>;
51
+ update(subject: string, id: string, expectedVersion: number, next: MfaFactorRecordUpdate): Promise<MfaFactorRecord | null>;
52
+ /** The subject's whole set reset: done on a `2xx` or a `404`. */
53
+ removeAllForSubject(subject: string): Promise<void>;
54
+ }
55
+ //# sourceMappingURL=HttpMfaFactorStore.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HttpMfaFactorStore.d.mts","sourceRoot":"","sources":["../../src/mfa/HttpMfaFactorStore.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EACN,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAE/B,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EAUnB,KAAK,eAAe,EAIpB,KAAK,YAAY,EACjB,MAAM,0BAA0B,CAAC;AA6BlC,MAAM,WAAW,yBAAyB;IACzC,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8EAA8E;IAC9E,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACnC;AAqBD,qBAAa,kBAAmB,YAAW,cAAc;;IACxD,QAAQ,CAAC,IAAI,WAAW;gBAKZ,EACX,OAAO,EACP,SAAS,EACT,SAAS,EACT,SAAS,EACT,WAAW,EACX,OAAO,EACP,gBAA6C,GAC7C,EAAE,yBAAyB;IActB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,eAAe,EAAE,CAAC;IAc1D,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IAOtE,QAAQ,CACb,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,eAAe,GAAG,IAAI,GAC9B,OAAO,CAAC,uBAAuB,CAAC;IAU7B,QAAQ,CACb,OAAO,EAAE,MAAM,EACf,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,eAAe,GACvB,OAAO,CAAC,0BAA0B,CAAC;IAUhC,MAAM,CACX,OAAO,EAAE,MAAM,EACf,EAAE,EAAE,MAAM,EACV,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE,qBAAqB,GACzB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAyBlC,iEAAiE;IAC3D,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAsDzD"}
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ /**
17
+ * `MfaFactorStore` kept by the Store, over its four MFA factor endpoints
18
+ * (README, "The Store's MFA endpoints"), on the user repository's transport.
19
+ *
20
+ * Guarantees: every answer the contract does not give an operation throws —
21
+ * a list is never answered as fewer records, an update never as written; a
22
+ * list is read whole or refused (a record the provider cannot read, one of
23
+ * another subject, an id twice); an update's answer must be the record named,
24
+ * with the changes sent, at the expected version plus one; what is thrown is
25
+ * built from an allowlist (`storeFailure.mts`, `storeErrors.mts`) and carries
26
+ * nothing the Store sent; the sealed `data` it is handed is sent as it is,
27
+ * and nothing is sent that the wire codec would not read back.
28
+ *
29
+ * A record is created or removed only conditionally: every create and every
30
+ * single-record removal it sends carries `expectedGeneration`, and only the
31
+ * whole set's reset (`removeAllForSubject`) is sent without one.
32
+ *
33
+ * The factor set's members (`listVersioned`, `createIf`, `removeIf`) read
34
+ * every answer through core's codec alone, once: a status the operation does
35
+ * not give is `unexpected_status`, read before any body, and whatever the
36
+ * codec refuses — a `404` or `409` without its outcome body among them — is
37
+ * `malformed_answer`. A conditional write states its deadline on the wire
38
+ * (`deadlineMs`, the request timeout from just before it is sent), at or
39
+ * before the moment the transport gives up, whose timer starts after it: a
40
+ * write past it is never applied after the adapter stopped waiting. A Store
41
+ * that reads it as passed answers `408`, which is `unexpected_status`. A conditional write that is sent and then fails,
42
+ * its deadline included, is unknown: it may have committed.
43
+ */
44
+ import { fromMfaStoreFactor, readMfaStoreCreateIfAnswer, readMfaStoreFactor, readMfaStoreListAnswer, readMfaStoreRemoveIfAnswer, readMfaStoreVersionedListAnswer, toMfaStoreCreateIfRequest, toMfaStoreRemoveIfRequest, toMfaStoreUpdateRequest, } from "@o3co/auth-provider-core";
45
+ import { assertSecureEndpoint } from "../endpointUrl.mjs";
46
+ import { bearerAuthorization, checkStoreResponseCap, checkStoreTimeout, DEFAULT_MAX_RESPONSE_BYTES, postToStore, } from "../storeTransport.mjs";
47
+ import { mfaStoreMalformedAnswer, mfaStoreRequestMessages, mfaStoreStatusError, mfaStoreUnreadableRecord, mfaStoreVersionSkipped, } from "./storeFailure.mjs";
48
+ /** What this adapter's messages lead with. */
49
+ const OWNER = "HttpMfaFactorStore";
50
+ /** The statuses a conditional create is answered with, each with its outcome body. */
51
+ const CREATE_IF_STATUSES = new Set([200, 409]);
52
+ /** The statuses a conditional removal is answered with, each with its outcome body. */
53
+ const REMOVE_IF_STATUSES = new Set([200, 404, 409]);
54
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
55
+ /** What JSON text parses to, or `NOT_JSON`. */
56
+ const NOT_JSON = Symbol("not JSON");
57
+ function parsed(text) {
58
+ try {
59
+ return JSON.parse(text);
60
+ }
61
+ catch {
62
+ return NOT_JSON;
63
+ }
64
+ }
65
+ /** Whether `factor` holds exactly the changes an update sent. */
66
+ const wrote = (factor, changes) => factor.data === changes.data &&
67
+ factor.label === changes.label &&
68
+ factor.lastUsedAtMs === changes.lastUsedAtMs;
69
+ export class HttpMfaFactorStore {
70
+ kind = "store";
71
+ /** Private fields: `inspect()` and `JSON.stringify` of the store show neither the credential nor the endpoints. */
72
+ #urls;
73
+ #settings;
74
+ constructor({ listUrl, createUrl, updateUrl, deleteUrl, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
75
+ this.#urls = Object.freeze({
76
+ list: assertSecureEndpoint(listUrl, "listUrl", OWNER),
77
+ create: assertSecureEndpoint(createUrl, "createUrl", OWNER),
78
+ update: assertSecureEndpoint(updateUrl, "updateUrl", OWNER),
79
+ delete: assertSecureEndpoint(deleteUrl, "deleteUrl", OWNER),
80
+ });
81
+ this.#settings = Object.freeze({
82
+ authorization: bearerAuthorization(bearerToken, OWNER),
83
+ timeout: checkStoreTimeout(timeout, OWNER),
84
+ maxResponseBytes: checkStoreResponseCap(maxResponseBytes, OWNER),
85
+ });
86
+ }
87
+ async list(subject) {
88
+ const url = this.#urls.list;
89
+ const body = { subject };
90
+ const { response, text } = await this.#post("list", body, (status) => status === 200);
91
+ if (text === undefined)
92
+ throw mfaStoreStatusError("list", url, response);
93
+ const reading = readMfaStoreListAnswer(parsed(text), subject);
94
+ if (!reading.ok) {
95
+ throw reading.reason === "malformed"
96
+ ? mfaStoreMalformedAnswer("list", url)
97
+ : mfaStoreUnreadableRecord(url);
98
+ }
99
+ return reading.factors.map(fromMfaStoreFactor);
100
+ }
101
+ async listVersioned(subject) {
102
+ const body = { subject };
103
+ const { response, text } = await this.#post("list", body, (status) => status === 200);
104
+ if (text === undefined)
105
+ throw mfaStoreStatusError("list", this.#urls.list, response);
106
+ return this.#read("list", () => readMfaStoreVersionedListAnswer(parsed(text), subject));
107
+ }
108
+ async createIf(record, expected) {
109
+ const { response, text } = await this.#postConditional("create", (deadlineMs) => toMfaStoreCreateIfRequest(record, expected, deadlineMs), (status) => CREATE_IF_STATUSES.has(status));
110
+ if (text === undefined)
111
+ throw mfaStoreStatusError("create", this.#urls.create, response);
112
+ return this.#read("create", () => readMfaStoreCreateIfAnswer(response.status, parsed(text)));
113
+ }
114
+ async removeIf(subject, id, expected) {
115
+ const { response, text } = await this.#postConditional("delete", (deadlineMs) => toMfaStoreRemoveIfRequest(subject, id, expected, deadlineMs), (status) => REMOVE_IF_STATUSES.has(status));
116
+ if (text === undefined)
117
+ throw mfaStoreStatusError("delete", this.#urls.delete, response);
118
+ return this.#read("delete", () => readMfaStoreRemoveIfAnswer(response.status, parsed(text)));
119
+ }
120
+ async update(subject, id, expectedVersion, next) {
121
+ const url = this.#urls.update;
122
+ const body = toMfaStoreUpdateRequest(subject, id, expectedVersion, next);
123
+ const { response, text } = await this.#post("update", body, (status) => status === 200);
124
+ if (response.status === 409 || response.status === 404)
125
+ return null;
126
+ if (text === undefined)
127
+ throw mfaStoreStatusError("update", url, response);
128
+ const answer = parsed(text);
129
+ const factor = isRecord(answer) && Object.hasOwn(answer, "factor")
130
+ ? readMfaStoreFactor(answer.factor)
131
+ : undefined;
132
+ if (factor === undefined ||
133
+ factor.subject !== subject ||
134
+ factor.id !== id ||
135
+ !wrote(factor, body.changes)) {
136
+ throw mfaStoreMalformedAnswer("update", url);
137
+ }
138
+ if (factor.version !== expectedVersion + 1) {
139
+ throw mfaStoreVersionSkipped(url, { subject, id, expectedVersion });
140
+ }
141
+ return fromMfaStoreFactor(factor);
142
+ }
143
+ /** The subject's whole set reset: done on a `2xx` or a `404`. */
144
+ async removeAllForSubject(subject) {
145
+ const body = { subject, all: true };
146
+ const { response } = await this.#post("delete", body, () => false);
147
+ if (response.ok || response.status === 404)
148
+ return;
149
+ throw mfaStoreStatusError("delete", this.#urls.delete, response);
150
+ }
151
+ /**
152
+ * A conditional write to `operation`'s endpoint, its body built by
153
+ * `build` with its `deadlineMs`: the request timeout from now, taken just
154
+ * before the request is sent. Nothing yields between this and the
155
+ * transport's timer, which starts after it, so the deadline is at or
156
+ * before the moment the adapter gives up.
157
+ */
158
+ #postConditional(operation, build, readsBody) {
159
+ const deadlineMs = Date.now() + this.#settings.timeout;
160
+ return this.#post(operation, build(deadlineMs), readsBody);
161
+ }
162
+ /**
163
+ * What `read`, a reader of core's codec, makes of an answer of
164
+ * `operation`; the `TypeError` it throws for an answer outside the
165
+ * contract is `malformed_answer`, carrying none of the reader's words.
166
+ */
167
+ #read(operation, read) {
168
+ try {
169
+ return read();
170
+ }
171
+ catch (error) {
172
+ if (error instanceof TypeError) {
173
+ throw mfaStoreMalformedAnswer(operation, this.#urls[operation]);
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+ /** `body` to `operation`'s endpoint, its answer read when `readsBody` says so. */
179
+ #post(operation, body, readsBody) {
180
+ const url = this.#urls[operation];
181
+ return postToStore(url, body, this.#settings, mfaStoreRequestMessages(OWNER, operation, url), readsBody);
182
+ }
183
+ }
@@ -0,0 +1,4 @@
1
+ import { type StoreRequestSettings } from "../storeTransport.mjs";
2
+ /** Posts `{ subject, enrolled }` to `url`; resolves on a `204` and throws on anything else. */
3
+ export declare function markMfaEnrolledAtStore(url: string, settings: StoreRequestSettings, owner: string, subject: string, enrolled: boolean): Promise<void>;
4
+ //# sourceMappingURL=markEnrolled.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markEnrolled.d.mts","sourceRoot":"","sources":["../../src/mfa/markEnrolled.mts"],"names":[],"mappings":"AA8BA,OAAO,EAAe,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAG/E,+FAA+F;AAC/F,wBAAsB,sBAAsB,CAC3C,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,oBAAoB,EAC9B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,OAAO,GACf,OAAO,CAAC,IAAI,CAAC,CAiBf"}
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { postToStore } from "../storeTransport.mjs";
17
+ import { mfaStoreRequestMessages, mfaStoreStatusError } from "./storeFailure.mjs";
18
+ /** Posts `{ subject, enrolled }` to `url`; resolves on a `204` and throws on anything else. */
19
+ export async function markMfaEnrolledAtStore(url, settings, owner, subject, enrolled) {
20
+ if (typeof subject !== "string" || subject.length === 0) {
21
+ throw new RangeError(`${owner}: markMfaEnrolled takes a non-empty subject`);
22
+ }
23
+ if (typeof enrolled !== "boolean") {
24
+ throw new RangeError(`${owner}: markMfaEnrolled takes enrolled as a boolean`);
25
+ }
26
+ const body = { subject, enrolled };
27
+ const { response } = await postToStore(url, body, settings, mfaStoreRequestMessages(owner, "markMfaEnrolled", url), () => false);
28
+ if (response.status === 204)
29
+ return;
30
+ throw mfaStoreStatusError("markMfaEnrolled", url, response);
31
+ }