@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
@@ -1,27 +1,20 @@
1
1
  /**
2
2
  * The Store refused the credential this deployment presented: a `401` or a
3
- * `403` carrying a `Bearer` challenge (RFC 6750 §3 — `invalid_token`,
4
- * `insufficient_scope`) to a request that sent `bearerToken`. An outage, not
5
- * an answer about the user: thrown, so every caller answers it as it answers
6
- * any Store failure.
3
+ * `403` carrying a `Bearer` challenge (RFC 6750 §3) to a request that sent
4
+ * `bearerToken`. An outage, not an answer about a user or a factor. See
5
+ * README, The wire contract.
7
6
  *
8
- * `name` is part of the contract. A caller that does not depend on this
9
- * package recognises the refusal by it — an operator reading the
10
- * federation-grants callback's `federation_grant_callback_unavailable` line
11
- * finds it as the projected error's `name`. The message names the endpoint —
12
- * its origin and path, never a query or fragment, however it is handed one —
13
- * the status and the option to check; never the token, and nothing the Store
14
- * wrote.
15
- *
16
- * The Store's status is `storeStatus`, never `status` or `statusCode`: those
17
- * are what Express's finalhandler, http-errors and the standalone's terminal
18
- * handler read as the status to ANSWER with, and a 4xx there is taken for a
19
- * client error — the Store's 401 would reach the browser as its own, and go
20
- * unlogged.
7
+ * `name` and `storeStatus` are part of the contract. The message leads with
8
+ * the client that was refused, and names the endpoint's origin and path, the
9
+ * status and the option to check; never the
10
+ * token, and nothing the Store wrote. Not `status` or `statusCode`: Express's
11
+ * finalhandler, http-errors and the standalone's terminal handler read those
12
+ * as the status to ANSWER with, so the Store's 401 would reach the browser as
13
+ * its own, unlogged.
21
14
  */
22
15
  export declare class StoreCredentialRefusedError extends Error {
23
16
  readonly storeStatus: 401 | 403;
24
- constructor(url: string, status: 401 | 403);
17
+ constructor(url: string, status: 401 | 403, owner?: string);
25
18
  }
26
19
  /** What went wrong with the exchange, when it was not the Store's answer. */
27
20
  export type StoreTransportFailure =
@@ -47,17 +40,15 @@ export type StoreTransportFailure =
47
40
  /** An HTTP answer arrived, and its body broke before it was read. */
48
41
  | "unreadable";
49
42
  /**
50
- * The Store could not be reached, or what it answered could not be read — a
51
- * transport failure rather than an answer. Thrown so every caller answers it
52
- * as it answers any Store failure; `name` is part of the contract (the
53
- * federation-grants callback's outage line carries it as the projected
54
- * error's `name`), as are `reason` and `code`.
43
+ * The Store could not be reached, or what it answered could not be read: a
44
+ * transport failure rather than an answer, thrown so every caller answers it
45
+ * as it answers any Store failure. `name`, `reason` and `code` are part of the
46
+ * contract.
55
47
  *
56
48
  * Built only from what an operator can act on: a fixed message naming the
57
- * endpoint and the failure, and `code` — a transport code from the allowlist
58
- * below, when there is one. Never a `cause`: the transport's own error may
59
- * quote what was sent or received. And no `status` (see
60
- * {@link StoreCredentialRefusedError}).
49
+ * endpoint and the failure, and `code`, an allowlisted transport code when
50
+ * there is one. Never a `cause`: the transport's own error may quote what was
51
+ * sent or received. And no `status` (see {@link StoreCredentialRefusedError}).
61
52
  */
62
53
  export declare class StoreTransportError extends Error {
63
54
  readonly reason: StoreTransportFailure;
@@ -1 +1 @@
1
- {"version":3,"file":"storeErrors.d.mts","sourceRoot":"","sources":["../../src/repositories/storeErrors.mts"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACrD,QAAQ,CAAC,WAAW,EAAE,GAAG,GAAG,GAAG,CAAC;gBAEpB,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG;CAS1C;AAED,6EAA6E;AAC7E,MAAM,MAAM,qBAAqB;AAChC;;;GAGG;AACD,aAAa;AACf;;;;;;GAMG;GACD,mBAAmB;AACrB;;;;GAIG;GACD,oBAAoB;AACtB,qEAAqE;GACnE,YAAY,CAAC;AAEhB;;;;;;;;;;;;GAYG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBAEtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,IAAI,CAAC,EAAE,MAAM;CAMzE;AA4DD,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAW9D;AAwCD;;;;GAIG;AACH,wBAAgB,cAAc,CAC7B,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE;IACT,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B,GACC,mBAAmB,CAUrB;AAED,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,mBAAmB,CAG9E"}
1
+ {"version":3,"file":"storeErrors.d.mts","sourceRoot":"","sources":["../../src/repositories/storeErrors.mts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;GAaG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACrD,QAAQ,CAAC,WAAW,EAAE,GAAG,GAAG,GAAG,CAAC;gBAEpB,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,EAAE,KAAK,SAAuB;CASxE;AAED,6EAA6E;AAC7E,MAAM,MAAM,qBAAqB;AAChC;;;GAGG;AACD,aAAa;AACf;;;;;;GAMG;GACD,mBAAmB;AACrB;;;;GAIG;GACD,oBAAoB;AACtB,qEAAqE;GACnE,YAAY,CAAC;AAEhB;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBAEtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,IAAI,CAAC,EAAE,MAAM;CAMzE;AA4DD,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAW9D;AAsCD;;;;GAIG;AACH,wBAAgB,cAAc,CAC7B,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE;IACT,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B,GACC,mBAAmB,CAUrB;AAED,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,mBAAmB,CAG9E"}
@@ -14,8 +14,9 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  /**
17
- * The failures `HttpUserRepository` throws about the exchange with the Store
18
- * rather than about a user: the Store refusing this deployment's credential,
17
+ * The failures the Store clients — `HttpUserRepository` and
18
+ * `HttpMfaFactorStore` — throw about the exchange with the Store rather than
19
+ * about what they asked: the Store refusing this deployment's credential,
19
20
  * and a transport that failed. Each is built from an allowlist — the
20
21
  * endpoint (origin and path), a status, a transport code — and never from
21
22
  * what the Store or the transport said, which may quote the request.
@@ -23,47 +24,38 @@
23
24
  import { endpointForMessage } from "../endpointUrl.mjs";
24
25
  /**
25
26
  * The Store refused the credential this deployment presented: a `401` or a
26
- * `403` carrying a `Bearer` challenge (RFC 6750 §3 — `invalid_token`,
27
- * `insufficient_scope`) to a request that sent `bearerToken`. An outage, not
28
- * an answer about the user: thrown, so every caller answers it as it answers
29
- * any Store failure.
27
+ * `403` carrying a `Bearer` challenge (RFC 6750 §3) to a request that sent
28
+ * `bearerToken`. An outage, not an answer about a user or a factor. See
29
+ * README, The wire contract.
30
30
  *
31
- * `name` is part of the contract. A caller that does not depend on this
32
- * package recognises the refusal by it — an operator reading the
33
- * federation-grants callback's `federation_grant_callback_unavailable` line
34
- * finds it as the projected error's `name`. The message names the endpoint —
35
- * its origin and path, never a query or fragment, however it is handed one —
36
- * the status and the option to check; never the token, and nothing the Store
37
- * wrote.
38
- *
39
- * The Store's status is `storeStatus`, never `status` or `statusCode`: those
40
- * are what Express's finalhandler, http-errors and the standalone's terminal
41
- * handler read as the status to ANSWER with, and a 4xx there is taken for a
42
- * client error — the Store's 401 would reach the browser as its own, and go
43
- * unlogged.
31
+ * `name` and `storeStatus` are part of the contract. The message leads with
32
+ * the client that was refused, and names the endpoint's origin and path, the
33
+ * status and the option to check; never the
34
+ * token, and nothing the Store wrote. Not `status` or `statusCode`: Express's
35
+ * finalhandler, http-errors and the standalone's terminal handler read those
36
+ * as the status to ANSWER with, so the Store's 401 would reach the browser as
37
+ * its own, unlogged.
44
38
  */
45
39
  export class StoreCredentialRefusedError extends Error {
46
40
  storeStatus;
47
- constructor(url, status) {
48
- super(`HttpUserRepository: the Store at ${endpointForMessage(url)} refused this deployment's credential ` +
49
- `(HTTP ${status} with a Bearer challenge) — bearerToken (CLIENT_USER_BEARER_TOKEN) is ` +
41
+ constructor(url, status, owner = "HttpUserRepository") {
42
+ super(`${owner}: the Store at ${endpointForMessage(url)} refused this deployment's credential ` +
43
+ `(HTTP ${status} with a Bearer challenge) — bearerToken (REPOSITORIES_USER_HTTP_BEARER_TOKEN) is ` +
50
44
  "not a token the Store accepts");
51
45
  this.name = "StoreCredentialRefusedError";
52
46
  this.storeStatus = status;
53
47
  }
54
48
  }
55
49
  /**
56
- * The Store could not be reached, or what it answered could not be read — a
57
- * transport failure rather than an answer. Thrown so every caller answers it
58
- * as it answers any Store failure; `name` is part of the contract (the
59
- * federation-grants callback's outage line carries it as the projected
60
- * error's `name`), as are `reason` and `code`.
50
+ * The Store could not be reached, or what it answered could not be read: a
51
+ * transport failure rather than an answer, thrown so every caller answers it
52
+ * as it answers any Store failure. `name`, `reason` and `code` are part of the
53
+ * contract.
61
54
  *
62
55
  * Built only from what an operator can act on: a fixed message naming the
63
- * endpoint and the failure, and `code` — a transport code from the allowlist
64
- * below, when there is one. Never a `cause`: the transport's own error may
65
- * quote what was sent or received. And no `status` (see
66
- * {@link StoreCredentialRefusedError}).
56
+ * endpoint and the failure, and `code`, an allowlisted transport code when
57
+ * there is one. Never a `cause`: the transport's own error may quote what was
58
+ * sent or received. And no `status` (see {@link StoreCredentialRefusedError}).
67
59
  */
68
60
  export class StoreTransportError extends Error {
69
61
  reason;
@@ -144,15 +136,13 @@ const CLOSED_CODES = new Set([
144
136
  "ECONNABORTED",
145
137
  ]);
146
138
  /**
147
- * Which failure a rejected request is, read by the error's name and code
148
- * only. A head the transport cannot take — an `HTTPParserError` (an `HPE_*`
149
- * code where the runtime sets one), `UND_ERR_HEADERS_OVERFLOW` — is
150
- * `malformed_response`. A connection closed or reset under the request —
151
- * undici's `UND_ERR_SOCKET` ("other side closed"), `ECONNRESET`, `EPIPE` — is
152
- * `connection_closed`, whatever the socket had read: undici counts bytes over
153
- * the socket's life, and fetch reuses keep-alive connections, so a pooled
154
- * connection closed between two requests has read an entire earlier answer.
155
- * Anything else is `unreachable`.
139
+ * Which failure a rejected request is, read by the error's name and code only.
140
+ * A head the transport cannot take (`HTTPParserError`, an `HPE_*` code,
141
+ * `UND_ERR_HEADERS_OVERFLOW`) is `malformed_response`. A connection closed or
142
+ * reset under the request (`CLOSED_CODES`) is `connection_closed`, whatever the
143
+ * socket had read: undici counts bytes over the socket's life, and a pooled
144
+ * keep-alive connection closed between two requests has read an entire
145
+ * earlier answer. Anything else is `unreachable`.
156
146
  */
157
147
  function requestFailureReason(err) {
158
148
  let closed = false;
@@ -1 +1 @@
1
- {"version":3,"file":"wwwAuthenticate.d.mts","sourceRoot":"","sources":["../../src/repositories/wwwAuthenticate.mts"],"names":[],"mappings":"AAmDA;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAEhE"}
1
+ {"version":3,"file":"wwwAuthenticate.d.mts","sourceRoot":"","sources":["../../src/repositories/wwwAuthenticate.mts"],"names":[],"mappings":"AA+CA;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAEhE"}
@@ -23,26 +23,22 @@
23
23
  * reach an error this adapter throws.
24
24
  */
25
25
  /**
26
- * A quoted-string (RFC 9110 §5.6.4), escapes included — and one left
27
- * unterminated runs to the end of the value, so what follows its opening
28
- * quote is its content, never a challenge. Each character is consumed once
29
- * (a backslash with the character after it, when there is one), so a value
30
- * of escaped quotes with no closing one costs a scan: matching up to a
31
- * closing quote that never comes retried from every quote, which a 64 KiB
32
- * value turned into seconds per answer.
26
+ * A quoted-string (RFC 9110 §5.6.4), escapes included; one left unterminated
27
+ * runs to the end of the value, so what follows its opening quote is its
28
+ * content, never a challenge. Each character is consumed once (a backslash
29
+ * with the character after it, when there is one), so a value of escaped
30
+ * quotes with no closing one costs one scan rather than a retry from every
31
+ * quote, which on a 64 KiB value takes seconds per answer.
33
32
  */
34
33
  const QUOTED_STRING = /"(?:[^"\\]|\\[\s\S]?)*(?:"|$)/g;
35
34
  /**
36
35
  * An auth-scheme `Bearer`, case-insensitive (RFC 9110 §11.1), where a
37
- * challenge begins — the value's start or after a comma — and followed by the
38
- * value's end, a comma, or whitespace that does not lead to `=` (which would
39
- * make `bearer` a parameter's name, not a scheme: `bearer = 1` is one).
40
- *
41
- * The whitespace after the scheme is taken whole, once: the lookahead
42
- * captures the entire run and the backreference consumes exactly it, and a
43
- * lookahead is never re-entered, so a long run followed by `=` is not
44
- * retried from every shorter run. `(?<=[ \t])` then requires that the run
45
- * was not empty, so `bearerish` is not the scheme.
36
+ * challenge begins (the value's start or after a comma), followed by the
37
+ * value's end, a comma, or whitespace that does not lead to `=` (then `bearer`
38
+ * is a parameter's name, as in `bearer = 1`). The lookahead captures the
39
+ * whole whitespace run and the backreference consumes exactly it, so a long
40
+ * run followed by `=` is not retried from every shorter run; `(?<=[ \t])`
41
+ * requires the run was not empty, so `bearerish` is not the scheme.
46
42
  */
47
43
  const BEARER_CHALLENGE = /(?:^|,)[ \t]*bearer(?=([ \t]*))\1(?:$|,|(?<=[ \t])(?!=))/i;
48
44
  /**
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Default ceiling on an upstream response body, in bytes.
3
+ *
4
+ * A `User` record is a few hundred bytes; 1 MiB is generous for one carrying
5
+ * custom claims and small enough that a hostile or broken Store cannot walk the
6
+ * process out of memory one login at a time.
7
+ */
8
+ export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
9
+ /**
10
+ * The credential presented to the Store, checked when `owner` is built (so
11
+ * an unusable one fails at boot) and turned into the `Authorization` value;
12
+ * `undefined` when none is configured, which sends no `Authorization` header.
13
+ *
14
+ * The shape is refused here, not left to `fetch`, which QUOTES a header value
15
+ * it refuses in the `TypeError` it throws, where the session routes log it.
16
+ * The strength is core's shared-secret floor (`MIN_SECRET_ENTROPY_BYTES`, on
17
+ * the decoded length): whoever holds this token speaks to the Store as
18
+ * auth.provider. No message quotes the value.
19
+ */
20
+ export declare function bearerAuthorization(value: unknown, owner: string): string | undefined;
21
+ /** `timeout`, the whole exchange's deadline in milliseconds, or an error naming it for `owner`. */
22
+ export declare function checkStoreTimeout(timeout: unknown, owner: string): number;
23
+ /** `maxResponseBytes`, the response cap, or an error naming it for `owner`. */
24
+ export declare function checkStoreResponseCap(maxResponseBytes: unknown, owner: string): number;
25
+ /**
26
+ * The transport settings of a Store client's configuration block — the user
27
+ * repository's `http` block — as a client is built from them: `timeout` and
28
+ * `maxResponseBytes` read from text too and defaulted only when absent, and
29
+ * `bearerToken` forwarded whenever set, whatever it holds. The client's
30
+ * constructor refuses what the transport cannot honour.
31
+ */
32
+ export declare function readStoreTransportConfig(block: Readonly<Record<string, unknown>>): {
33
+ readonly bearerToken?: string;
34
+ readonly timeout: number;
35
+ readonly maxResponseBytes: number;
36
+ };
37
+ /**
38
+ * Releases a response body we are not going to read. Left unconsumed, undici
39
+ * holds the socket until the response is garbage collected instead of
40
+ * returning it to the keep-alive pool: a slow leak on the failure path.
41
+ *
42
+ * Deliberately not awaited: that would hand a hostile Store a second way to
43
+ * stall the caller, the one the request deadline exists to close, and some
44
+ * interceptors never settle it at all.
45
+ */
46
+ export declare function discardBody(res: Response): void;
47
+ /**
48
+ * Reads at most `limit` bytes of `res` as text, throwing once it is passed.
49
+ * `Content-Length` refuses an honest oversized response before a byte is read,
50
+ * but the streaming count is the load-bearing half: a hostile Store omits the
51
+ * header or lies, and chunked encoding has none. Everything it throws is the
52
+ * adapter's own (the cap, the deadline's rejection, what `unreadable` makes of
53
+ * a transport error mid-read), never the transport's error as it is.
54
+ */
55
+ export declare function readBodyCapped(res: Response, limit: number, endpoint: string, deadline: Promise<never>, unreadable: (err: unknown) => Error, owner: string): Promise<string>;
56
+ /**
57
+ * Whether `err` is the abort our own deadline raised on the `fetch` itself,
58
+ * where the response headers never arrive. Deliberately shallow: an aborted
59
+ * `fetch` rejects with the `AbortError` directly. A runtime that wrapped one
60
+ * would still fail the request, as a `StoreTransportError` rather than a
61
+ * `TimeoutError`: misnamed, not missed. A stalled *body* is the deadline race
62
+ * in `readBodyCapped`.
63
+ */
64
+ export declare function isAbortError(err: unknown): boolean;
65
+ /** What a request to the Store is sent with. */
66
+ export interface StoreRequestSettings {
67
+ /** `Bearer <token>`, or `undefined` to send no `Authorization` header. */
68
+ readonly authorization: string | undefined;
69
+ /** The whole exchange's deadline, in milliseconds. */
70
+ readonly timeout: number;
71
+ /** The most bytes of a body read. */
72
+ readonly maxResponseBytes: number;
73
+ }
74
+ /** What a failure of the exchange says, each naming the endpoint. */
75
+ export interface StoreRequestMessages {
76
+ /** Who is asking, leading every message. */
77
+ readonly owner: string;
78
+ readonly unreachable: string;
79
+ readonly closed: string;
80
+ readonly malformed: string;
81
+ readonly unreadable: string;
82
+ }
83
+ /** An answer: the response, whose body is released, and the body as text when it was read. */
84
+ export interface StoreAnswer {
85
+ readonly response: Response;
86
+ readonly text: string | undefined;
87
+ }
88
+ /**
89
+ * `POST`s `body` as JSON to `url` and answers what came back, the body read
90
+ * (up to the cap, within the deadline) only when `readsBody` says so for the
91
+ * status, and released otherwise. A `401` or `403` with a `Bearer` challenge
92
+ * to a request that carried the credential throws
93
+ * `StoreCredentialRefusedError`; a deadline passed throws an error named
94
+ * `TimeoutError`; a transport failure throws a `StoreTransportError`.
95
+ */
96
+ export declare function postToStore(url: string, body: unknown, settings: StoreRequestSettings, messages: StoreRequestMessages, readsBody: (status: number) => boolean): Promise<StoreAnswer>;
97
+ //# sourceMappingURL=storeTransport.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storeTransport.d.mts","sourceRoot":"","sources":["../src/storeTransport.mts"],"names":[],"mappings":"AA4CA;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAqBtD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA2BrF;AAOD,mGAAmG;AACnG,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzE;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAKtF;AAkBD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG;IACnF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CAClC,CASA;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,IAAI,CAI/C;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CACnC,GAAG,EAAE,QAAQ,EACb,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,OAAO,CAAC,KAAK,CAAC,EACxB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,EACnC,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CA2CjB;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAMlD;AAED,gDAAgD;AAChD,MAAM,WAAW,oBAAoB;IACpC,0EAA0E;IAC1E,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,sDAAsD;IACtD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,qCAAqC;IACrC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CAClC;AAED,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACpC,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,8FAA8F;AAC9F,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAChC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,OAAO,EACb,QAAQ,EAAE,oBAAoB,EAC9B,QAAQ,EAAE,oBAAoB,EAC9B,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GACpC,OAAO,CAAC,WAAW,CAAC,CAqEtB"}
@@ -0,0 +1,290 @@
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
+ * The transport every request to the Store rides on: the credential it
18
+ * presents, the deadline, the response cap, and a `POST` of JSON that
19
+ * follows no redirect.
20
+ *
21
+ * Guarantees: a setting the transport cannot honour is refused when the
22
+ * client is built, naming the setting and quoting no value; one deadline
23
+ * covers a whole exchange, the body read included; a body is read up to the
24
+ * cap and no further, and a body not read is released unawaited; what is
25
+ * thrown is the adapter's own — never a transport's error, which may quote
26
+ * the request — and names an endpoint by origin and path alone.
27
+ */
28
+ import { describeWeakSecret, MIN_SECRET_ENTROPY_BYTES, measureSecretEntropyBytes, wholeNumberFromEnv, } from "@o3co/auth-provider-core";
29
+ import { z } from "zod";
30
+ import { endpointForMessage } from "./endpointUrl.mjs";
31
+ import { readFailure, requestFailure, StoreCredentialRefusedError, } from "./repositories/storeErrors.mjs";
32
+ import { hasBearerChallenge } from "./repositories/wwwAuthenticate.mjs";
33
+ /**
34
+ * Default ceiling on an upstream response body, in bytes.
35
+ *
36
+ * A `User` record is a few hundred bytes; 1 MiB is generous for one carrying
37
+ * custom claims and small enough that a hostile or broken Store cannot walk the
38
+ * process out of memory one login at a time.
39
+ */
40
+ export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
41
+ /** Default request deadline, in milliseconds, when the configuration names none. */
42
+ const DEFAULT_TIMEOUT_MS = 5000;
43
+ /**
44
+ * Largest delay Node's timer subsystem represents. Anything above it is
45
+ * silently clamped to 1ms — so an operator writing a very large number meaning
46
+ * "be patient" would otherwise get the most impatient timeout possible.
47
+ */
48
+ const MAX_TIMEOUT_MS = 2_147_483_647;
49
+ /**
50
+ * RFC 6750 §2.1 `b64token` — the characters a bearer credential may carry.
51
+ * Nothing in it is whitespace or a control character, so a token that
52
+ * matches cannot break the header it rides in.
53
+ */
54
+ const B64TOKEN = /^[A-Za-z0-9\-._~+/]+=*$/;
55
+ const BEARER_TOKEN_FIELD = "bearerToken";
56
+ /**
57
+ * The credential presented to the Store, checked when `owner` is built (so
58
+ * an unusable one fails at boot) and turned into the `Authorization` value;
59
+ * `undefined` when none is configured, which sends no `Authorization` header.
60
+ *
61
+ * The shape is refused here, not left to `fetch`, which QUOTES a header value
62
+ * it refuses in the `TypeError` it throws, where the session routes log it.
63
+ * The strength is core's shared-secret floor (`MIN_SECRET_ENTROPY_BYTES`, on
64
+ * the decoded length): whoever holds this token speaks to the Store as
65
+ * auth.provider. No message quotes the value.
66
+ */
67
+ export function bearerAuthorization(value, owner) {
68
+ if (value === undefined)
69
+ return undefined;
70
+ const refuse = (problem) => new Error(`${owner}: "${BEARER_TOKEN_FIELD}" ${problem}`);
71
+ if (typeof value !== "string")
72
+ throw refuse("must be a string");
73
+ if (value === "") {
74
+ throw refuse('must not be empty — HOCON substitutes an exported-but-empty variable as ""; ' +
75
+ "leave it unset to send no Authorization header");
76
+ }
77
+ if (!B64TOKEN.test(value)) {
78
+ throw refuse("must be a bare RFC 6750 token: letters, digits and - . _ ~ + /, then optional = padding — " +
79
+ 'no whitespace, no line break, and no "Bearer " prefix (the scheme is added)');
80
+ }
81
+ const actualBytes = measureSecretEntropyBytes(value);
82
+ if (actualBytes < MIN_SECRET_ENTROPY_BYTES) {
83
+ throw new Error(`${owner}: ${describeWeakSecret(actualBytes, {
84
+ configKey: "repositories.user.http.bearerToken",
85
+ envVar: "REPOSITORIES_USER_HTTP_BEARER_TOKEN",
86
+ })}`);
87
+ }
88
+ return `Bearer ${value}`;
89
+ }
90
+ /** Whether `value` is a positive integer that fits `bound`. */
91
+ function isPositiveIntegerWithin(value, bound) {
92
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
93
+ }
94
+ /** `timeout`, the whole exchange's deadline in milliseconds, or an error naming it for `owner`. */
95
+ export function checkStoreTimeout(timeout, owner) {
96
+ if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
97
+ throw new Error(`${owner}: "timeout" must be a positive integer no greater than ` +
98
+ `${MAX_TIMEOUT_MS} milliseconds`);
99
+ }
100
+ return timeout;
101
+ }
102
+ /** `maxResponseBytes`, the response cap, or an error naming it for `owner`. */
103
+ export function checkStoreResponseCap(maxResponseBytes, owner) {
104
+ if (!isPositiveIntegerWithin(maxResponseBytes, Number.MAX_SAFE_INTEGER)) {
105
+ throw new Error(`${owner}: "maxResponseBytes" must be a positive integer`);
106
+ }
107
+ return maxResponseBytes;
108
+ }
109
+ /** A number, or a string of decimal digits as an environment variable carries one. */
110
+ const numberOrDecimalDigits = wholeNumberFromEnv(z.number());
111
+ /**
112
+ * A numeric config value that may arrive as a string (HOCON environment
113
+ * substitution yields strings), read as core reads one: a number, or decimal
114
+ * digits. Only an *absent* key takes `fallback`. Anything else present (a
115
+ * blank variable, `"0x10"`, `"1e3"`, `"5.0"`, `"+5"`, a boolean) becomes `NaN`
116
+ * for the constructor to reject: a boot failure, not a silent default.
117
+ */
118
+ const toNumber = (value, fallback) => {
119
+ if (value === undefined || value === null)
120
+ return fallback;
121
+ const read = numberOrDecimalDigits.safeParse(value);
122
+ return read.success ? read.data : Number.NaN;
123
+ };
124
+ /**
125
+ * The transport settings of a Store client's configuration block — the user
126
+ * repository's `http` block — as a client is built from them: `timeout` and
127
+ * `maxResponseBytes` read from text too and defaulted only when absent, and
128
+ * `bearerToken` forwarded whenever set, whatever it holds. The client's
129
+ * constructor refuses what the transport cannot honour.
130
+ */
131
+ export function readStoreTransportConfig(block) {
132
+ return {
133
+ // Forwarded whenever SET, not only when well-typed: a token that vanished
134
+ // would be a deployment that believes its Store calls authenticated and
135
+ // sends them bare.
136
+ ...(block.bearerToken !== undefined ? { bearerToken: block.bearerToken } : {}),
137
+ timeout: toNumber(block.timeout, DEFAULT_TIMEOUT_MS),
138
+ maxResponseBytes: toNumber(block.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
139
+ };
140
+ }
141
+ /**
142
+ * Releases a response body we are not going to read. Left unconsumed, undici
143
+ * holds the socket until the response is garbage collected instead of
144
+ * returning it to the keep-alive pool: a slow leak on the failure path.
145
+ *
146
+ * Deliberately not awaited: that would hand a hostile Store a second way to
147
+ * stall the caller, the one the request deadline exists to close, and some
148
+ * interceptors never settle it at all.
149
+ */
150
+ export function discardBody(res) {
151
+ res.body?.cancel().catch(() => {
152
+ // Already consumed, already errored, or aborted — nothing to release.
153
+ });
154
+ }
155
+ /**
156
+ * Reads at most `limit` bytes of `res` as text, throwing once it is passed.
157
+ * `Content-Length` refuses an honest oversized response before a byte is read,
158
+ * but the streaming count is the load-bearing half: a hostile Store omits the
159
+ * header or lies, and chunked encoding has none. Everything it throws is the
160
+ * adapter's own (the cap, the deadline's rejection, what `unreadable` makes of
161
+ * a transport error mid-read), never the transport's error as it is.
162
+ */
163
+ export async function readBodyCapped(res, limit, endpoint, deadline, unreadable, owner) {
164
+ const declared = Number(res.headers.get("content-length"));
165
+ if (Number.isFinite(declared) && declared > limit) {
166
+ discardBody(res);
167
+ throw new Error(`${owner}: upstream ${endpoint} response exceeds the ${limit}-byte cap ` +
168
+ `(Content-Length: ${declared})`);
169
+ }
170
+ if (res.body === null)
171
+ return "";
172
+ const reader = res.body.getReader();
173
+ const decoder = new TextDecoder();
174
+ let text = "";
175
+ let read = 0;
176
+ try {
177
+ for (;;) {
178
+ // Raced against the deadline rather than relying on `signal` alone:
179
+ // aborting a request does not reliably interrupt a `read()` already
180
+ // in flight, which is exactly the slow-loris shape — headers arrive
181
+ // promptly, then the body dribbles or stops. One absolute deadline
182
+ // for the whole exchange, not a fresh one per chunk.
183
+ const { done, value } = await Promise.race([
184
+ reader.read().catch((err) => {
185
+ throw unreadable(err);
186
+ }),
187
+ deadline,
188
+ ]);
189
+ if (done)
190
+ break;
191
+ read += value.byteLength;
192
+ if (read > limit) {
193
+ throw new Error(`${owner}: upstream ${endpoint} response exceeds the ${limit}-byte cap`);
194
+ }
195
+ text += decoder.decode(value, { stream: true });
196
+ }
197
+ }
198
+ finally {
199
+ // Tears down the connection when we bail out early; a no-op once the
200
+ // stream has completed on its own. Not awaited, for the reason given on
201
+ // `discardBody`.
202
+ reader.cancel().catch(() => { });
203
+ }
204
+ return text + decoder.decode();
205
+ }
206
+ /**
207
+ * Whether `err` is the abort our own deadline raised on the `fetch` itself,
208
+ * where the response headers never arrive. Deliberately shallow: an aborted
209
+ * `fetch` rejects with the `AbortError` directly. A runtime that wrapped one
210
+ * would still fail the request, as a `StoreTransportError` rather than a
211
+ * `TimeoutError`: misnamed, not missed. A stalled *body* is the deadline race
212
+ * in `readBodyCapped`.
213
+ */
214
+ export function isAbortError(err) {
215
+ // Optional chaining rather than a `typeof` guard: it covers `null`,
216
+ // `undefined` and a thrown primitive in the same expression, with no
217
+ // branch that only a contrived throw could reach.
218
+ const name = err?.name;
219
+ return name === "AbortError" || name === "TimeoutError";
220
+ }
221
+ /**
222
+ * `POST`s `body` as JSON to `url` and answers what came back, the body read
223
+ * (up to the cap, within the deadline) only when `readsBody` says so for the
224
+ * status, and released otherwise. A `401` or `403` with a `Bearer` challenge
225
+ * to a request that carried the credential throws
226
+ * `StoreCredentialRefusedError`; a deadline passed throws an error named
227
+ * `TimeoutError`; a transport failure throws a `StoreTransportError`.
228
+ */
229
+ export async function postToStore(url, body, settings, messages, readsBody) {
230
+ const endpoint = endpointForMessage(url);
231
+ const controller = new AbortController();
232
+ let timedOut = false;
233
+ const timeoutError = () => {
234
+ const error = new Error(`${messages.owner}: request to ${endpoint} timed out after ${settings.timeout}ms`);
235
+ error.name = "TimeoutError";
236
+ return error;
237
+ };
238
+ let fireDeadline = () => { };
239
+ const deadline = new Promise((_resolve, reject) => {
240
+ fireDeadline = () => reject(timeoutError());
241
+ });
242
+ deadline.catch(() => { });
243
+ const timer = setTimeout(() => {
244
+ timedOut = true;
245
+ controller.abort();
246
+ fireDeadline();
247
+ }, settings.timeout);
248
+ try {
249
+ let response;
250
+ try {
251
+ response = await fetch(url, {
252
+ method: "POST",
253
+ headers: settings.authorization === undefined
254
+ ? { "Content-Type": "application/json" }
255
+ : { "Content-Type": "application/json", Authorization: settings.authorization },
256
+ body: JSON.stringify(body),
257
+ signal: controller.signal,
258
+ // Never followed: a redirect would re-send the body to a `Location`
259
+ // no endpoint check has seen, and take its answer as the Store's.
260
+ redirect: "manual",
261
+ });
262
+ }
263
+ catch (err) {
264
+ if (timedOut && isAbortError(err))
265
+ throw timeoutError();
266
+ throw requestFailure(err, messages);
267
+ }
268
+ if (!readsBody(response.status)) {
269
+ discardBody(response);
270
+ if (settings.authorization !== undefined &&
271
+ (response.status === 401 || response.status === 403) &&
272
+ hasBearerChallenge(response.headers.get("www-authenticate"))) {
273
+ throw new StoreCredentialRefusedError(url, response.status, messages.owner);
274
+ }
275
+ return { response, text: undefined };
276
+ }
277
+ try {
278
+ const text = await readBodyCapped(response, settings.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, messages.unreadable), messages.owner);
279
+ return { response, text };
280
+ }
281
+ catch (err) {
282
+ if (timedOut)
283
+ throw timeoutError();
284
+ throw err;
285
+ }
286
+ }
287
+ finally {
288
+ clearTimeout(timer);
289
+ }
290
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@o3co/auth-provider-foundation/testing`: what a test uses to configure
3
+ * this package's modules. Test code imports it; production code never does.
4
+ */
5
+ import { FOUNDATION_MFA_FACTOR_STORE_SECTION, type FoundationMfaFactorStoreUrls } from "../mfa/section.mjs";
6
+ /** The Store URLs the `"http"` user adapter's builder reads (`registerBuiltinAdapters`). */
7
+ declare const USER_REPOSITORY_URL_KEYS: readonly ["authenticateUrl", "authenticateByTokenUrl", "linkFederatedIdentityUrl", "findSubjectByFederatedIdentityUrl", "markMfaEnrolledUrl"];
8
+ /** The user repository's Store URLs, as the `"http"` builder names them. */
9
+ export type FoundationUserRepositoryUrls = Readonly<Record<(typeof USER_REPOSITORY_URL_KEYS)[number], unknown>>;
10
+ /**
11
+ * The `foundation-mfa-factor-store` section as a configuration fragment to
12
+ * lay over a test's configuration: the four URLs `urls` holds — a fake
13
+ * Store's `urls` included, its other endpoints left behind — and `extra`
14
+ * as given.
15
+ */
16
+ export declare function foundationMfaFactorStoreConfig(urls: Partial<Readonly<Record<keyof FoundationMfaFactorStoreUrls, unknown>>>, extra?: Readonly<Record<string, unknown>>): {
17
+ readonly [FOUNDATION_MFA_FACTOR_STORE_SECTION]: Readonly<Record<string, unknown>>;
18
+ };
19
+ /**
20
+ * The user repository's `http` block, as the `"http"` builder reads it: the
21
+ * Store URLs `urls` holds — a fake Store's `urls` included, its MFA factor
22
+ * endpoints left behind — and `extra` as given. A test hands it to the
23
+ * `"http"` builder, or to `foundationMfaFactorStoreModule` as its
24
+ * `storeTransport`.
25
+ */
26
+ export declare function foundationUserRepositoryHttpConfig(urls: Partial<FoundationUserRepositoryUrls>, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>>;
27
+ export {};
28
+ //# sourceMappingURL=index.d.mts.map