@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
@@ -13,46 +13,31 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { describeWeakSecret, MIN_SECRET_ENTROPY_BYTES, measureSecretEntropyBytes, } from "@o3co/auth-provider-core";
17
16
  import { assertSecureEndpoint, endpointForMessage } from "../endpointUrl.mjs";
18
- import { readFailure, requestFailure, StoreCredentialRefusedError } from "./storeErrors.mjs";
19
- import { hasBearerChallenge } from "./wwwAuthenticate.mjs";
20
- /** The Store answered 409 to a link request: the identity is already someone else's (#482). */
17
+ import { markMfaEnrolledAtStore } from "../mfa/markEnrolled.mjs";
18
+ import { bearerAuthorization, checkStoreResponseCap, checkStoreTimeout, DEFAULT_MAX_RESPONSE_BYTES, postToStore, } from "../storeTransport.mjs";
19
+ export { DEFAULT_MAX_RESPONSE_BYTES };
20
+ /** What this repository's messages lead with. */
21
+ const OWNER = "HttpUserRepository";
22
+ /** Whether a status is a `2xx`, the answers whose body is read. */
23
+ const isSuccess = (status) => status >= 200 && status < 300;
24
+ /** The Store answered 409 to a link request: the identity is already someone else's. */
21
25
  const CONFLICT = Symbol("conflict");
22
26
  /**
23
- * Default ceiling on an upstream response body, in bytes.
24
- *
25
- * A `User` record is a few hundred bytes; 1 MiB is generous for one carrying
26
- * custom claims and small enough that a hostile or broken Store cannot walk the
27
- * process out of memory one login at a time (#285).
28
- */
29
- export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
30
- /**
31
- * Largest delay Node's timer subsystem represents. Anything above it is
32
- * silently clamped to 1ms — so an operator writing a very large number meaning
33
- * "be patient" would otherwise get the most impatient timeout possible.
34
- */
35
- const MAX_TIMEOUT_MS = 2_147_483_647;
36
- /**
37
- * Runtime guard for the upstream user-service response. The previous
38
- * `(await res.json()) as User` was a compile-time cast only — a malformed
39
- * upstream payload (`{ status: "ok" }`, schema migration, tampered
40
- * response) silently produced a `User` with `undefined` required fields,
41
- * leaking `sub: undefined` into the authentication flow.
42
- *
43
- * The guard accepts any object with string `id` and `username`,
44
- * preserving the index-signature `[key: string]: unknown` extras that
45
- * `User` allows. Empty strings pass — bcrypt compare and downstream
46
- * gates prevent empty-credential authentication in practice; tightening
47
- * to `.min(1)` is a Phase F follow-up if needed.
48
- *
49
- * Per TS-2 (Wave 5g).
27
+ * Runtime guard for the Store's user response, so a malformed payload cannot
28
+ * become a `User` whose `id` is missing or empty: an empty `id` names nobody,
29
+ * and OpenID Connect Core §2 requires the `sub` it becomes to be a locally
30
+ * unique identifier. Accepts any object with a non-empty string `id` and
31
+ * `username`, keeping the extras `User`'s index signature allows.
50
32
  */
51
33
  function isUser(v) {
52
34
  if (typeof v !== "object" || v === null)
53
35
  return false;
54
36
  const o = v;
55
- return typeof o.id === "string" && typeof o.username === "string";
37
+ return (typeof o.id === "string" &&
38
+ o.id.length > 0 &&
39
+ typeof o.username === "string" &&
40
+ o.username.length > 0);
56
41
  }
57
42
  const COVERAGE_FIELD = "federatedIdentityLookupCoverage";
58
43
  const COVERAGE_KEYS = new Set([
@@ -159,184 +144,19 @@ function lookupAnswer(value) {
159
144
  }
160
145
  }
161
146
  /**
162
- * RFC 6750 §2.1 `b64token` — the characters a bearer credential may carry.
163
- * Nothing in it is whitespace or a control character, so a token that
164
- * matches cannot break the header it rides in.
165
- */
166
- const B64TOKEN = /^[A-Za-z0-9\-._~+/]+=*$/;
167
- const BEARER_TOKEN_FIELD = "bearerToken";
168
- /**
169
- * The credential presented to the Store, checked (#285's rule: at
170
- * construction, so a deployment that would send an unusable one fails at
171
- * boot) and turned into the `Authorization` value; `undefined` when none is
172
- * configured, which sends no `Authorization` header at all.
173
- *
174
- * The shape is refused here and not left to `fetch`: a header value `fetch`
175
- * refuses is one it QUOTES in the `TypeError` it throws, on the request path,
176
- * where the session routes log what is thrown. The strength is core's
177
- * shared-secret floor (`MIN_SECRET_ENTROPY_BYTES`, measured on the decoded
178
- * length as `SESSION_SECRET` is): whoever holds this token speaks to the Store
179
- * as auth.provider — resolves an identity to its user, links one to any user.
180
- * No message quotes the value.
181
- */
182
- function bearerAuthorization(value) {
183
- if (value === undefined)
184
- return undefined;
185
- const refuse = (problem) => new Error(`HttpUserRepository: "${BEARER_TOKEN_FIELD}" ${problem}`);
186
- if (typeof value !== "string")
187
- throw refuse("must be a string");
188
- if (value === "") {
189
- throw refuse('must not be empty — HOCON substitutes an exported-but-empty variable as ""; ' +
190
- "leave it unset to send no Authorization header");
191
- }
192
- if (!B64TOKEN.test(value)) {
193
- throw refuse("must be a bare RFC 6750 token: letters, digits and - . _ ~ + /, then optional = padding — " +
194
- 'no whitespace, no line break, and no "Bearer " prefix (the scheme is added)');
195
- }
196
- const actualBytes = measureSecretEntropyBytes(value);
197
- if (actualBytes < MIN_SECRET_ENTROPY_BYTES) {
198
- throw new Error(`HttpUserRepository: ${describeWeakSecret(actualBytes, {
199
- configKey: "repositories.user.http.bearerToken",
200
- envVar: "CLIENT_USER_BEARER_TOKEN",
201
- })}`);
202
- }
203
- return `Bearer ${value}`;
204
- }
205
- /** Whether `value` is a positive integer that fits `bound`. */
206
- function isPositiveIntegerWithin(value, bound) {
207
- return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
208
- }
209
- /**
210
- * Releases a response body we are not going to read.
211
- *
212
- * Without this an error or 401 leaves the body unconsumed, and undici holds the
213
- * socket until the response is garbage collected rather than returning it to
214
- * the keep-alive pool — a slow leak on the failure path, which is exactly the
215
- * path a struggling deployment spends its time on.
216
- *
217
- * Deliberately not awaited: cancelling is a signal to the transport, and how
218
- * long the peer takes to act on it is the peer's business. Awaiting would hand
219
- * a hostile Store a second way to stall the caller — the one the request
220
- * deadline exists to close — and some interceptors never settle it at all.
221
- */
222
- function discardBody(res) {
223
- res.body?.cancel().catch(() => {
224
- // Already consumed, already errored, or aborted — nothing to release.
225
- });
226
- }
227
- /**
228
- * Reads at most `limit` bytes of `res` and returns them as text, throwing once
229
- * the limit is passed.
230
- *
231
- * `Content-Length` is checked first so an honest oversized response is refused
232
- * before a byte of it is read, but the streaming count is the load-bearing
233
- * half: a hostile Store simply omits the header (or lies), and chunked transfer
234
- * encoding has none to omit.
235
- *
236
- * Everything it throws is the adapter's own: the cap, the deadline's
237
- * rejection, or — for a stream that broke mid-read — what `unreadable` makes
238
- * of the transport's error, which is never passed on as it is. `endpoint` is
239
- * how its messages name the Store: origin and path (`endpointForMessage`).
240
- */
241
- async function readBodyCapped(res, limit, endpoint, deadline, unreadable) {
242
- const declared = Number(res.headers.get("content-length"));
243
- if (Number.isFinite(declared) && declared > limit) {
244
- discardBody(res);
245
- throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap ` +
246
- `(Content-Length: ${declared})`);
247
- }
248
- if (res.body === null)
249
- return "";
250
- const reader = res.body.getReader();
251
- const decoder = new TextDecoder();
252
- let text = "";
253
- let read = 0;
254
- try {
255
- for (;;) {
256
- // Raced against the deadline rather than relying on `signal` alone:
257
- // aborting a request does not reliably interrupt a `read()` already
258
- // in flight, which is exactly the slow-loris shape — headers arrive
259
- // promptly, then the body dribbles or stops. One absolute deadline
260
- // for the whole exchange, not a fresh one per chunk.
261
- const { done, value } = await Promise.race([
262
- reader.read().catch((err) => {
263
- throw unreadable(err);
264
- }),
265
- deadline,
266
- ]);
267
- if (done)
268
- break;
269
- read += value.byteLength;
270
- if (read > limit) {
271
- throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap`);
272
- }
273
- text += decoder.decode(value, { stream: true });
274
- }
275
- }
276
- finally {
277
- // Tears down the connection when we bail out early; a no-op once the
278
- // stream has completed on its own. Not awaited, for the reason given on
279
- // `discardBody`.
280
- reader.cancel().catch(() => { });
281
- }
282
- return text + decoder.decode();
283
- }
284
- /**
285
- * Whether `err` is the abort our own deadline raised on the `fetch` itself —
286
- * the case where the response headers never arrive.
287
- *
288
- * A deliberately shallow check. An aborted `fetch` rejects with the
289
- * `AbortError` directly; the wrapping that `fetch` does apply is for network
290
- * failures, which are not aborts. If some runtime did wrap one, the request
291
- * still fails — as a `StoreTransportError` ("could not be reached") instead of
292
- * a `TimeoutError`, a misnamed failure rather than a missed one, and not worth
293
- * an untestable `cause` walk.
294
- * A stalled *body* is not covered here at all: that is the deadline race in
295
- * `readBodyCapped`, which does not depend on abort semantics.
296
- */
297
- function isAbortError(err) {
298
- // Optional chaining rather than a `typeof` guard: it covers `null`,
299
- // `undefined` and a thrown primitive in the same expression, with no
300
- // branch that only a contrived throw could reach.
301
- const name = err?.name;
302
- return name === "AbortError" || name === "TimeoutError";
303
- }
304
- /**
305
- * `UserRepository` backed by "the Store" — the upstream user service defined
147
+ * `UserRepository` backed by "the Store", the upstream user service defined
306
148
  * on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
149
+ * See README, The wire contract and Constructor validation.
307
150
  *
308
151
  * Every endpoint receives a plaintext credential or a verified identity, so
309
- * each is validated at **construction**: a deployment configured with an
310
- * `http://` Store URL fails at boot rather than leaking the first user's
311
- * password (#285). `http://` is accepted for loopback hosts only — see
312
- * `src/endpointUrl.mts` for the carve-out and its rationale.
313
- *
314
- * No request follows a redirect, so a URL that passed that check is the only
315
- * place its body is ever sent and the only one whose answer is taken: a `3xx`
316
- * from the Store is an upstream failure, thrown like any other unexpected
317
- * status, and its `Location` is never contacted.
318
- *
319
- * With `bearerToken` configured, every request — authentication, linking and
320
- * the identity lookup alike — carries `Authorization: Bearer <token>`, so the
321
- * Store can refuse a caller that is not this deployment; without it, no
322
- * request carries an `Authorization` header. The Store says it refused THIS
323
- * deployment by answering `401` or `403` with a `Bearer` challenge (RFC 6750
324
- * §3), and that answer, while a token was sent, throws a
325
- * {@link StoreCredentialRefusedError} on every request — never "no such user"
326
- * or a refused link, which is what either status without the challenge still
327
- * means.
328
- *
329
- * A transport's own error is never thrown: undici's parser errors quote the
330
- * bytes they rejected, and a peer that reflects the request puts the
331
- * `Authorization` header — or a password — there. A request that cannot be
332
- * made, or an answer that cannot be read, throws a `StoreTransportError`
333
- * (`src/repositories/storeErrors.mts`): a fixed message naming the endpoint
334
- * and what failed, at most an allowlisted transport code, no cause.
335
- *
336
- * Every message names an endpoint by its origin and path alone
337
- * (`endpointForMessage`, `src/endpointUrl.mts`): a Store URL may carry a
338
- * query string, and a query may carry a credential, while every caller logs
339
- * what this throws.
152
+ * each is validated at **construction** (`src/endpointUrl.mts`), and no
153
+ * request follows a redirect. With `bearerToken`, every request carries
154
+ * `Authorization: Bearer <token>`, and a `401` or `403` with a `Bearer`
155
+ * challenge throws a `StoreCredentialRefusedError`. A transport's own
156
+ * error is never thrown, because it may quote the request: a failure throws a
157
+ * `StoreTransportError` with a fixed message, at most an allowlisted transport
158
+ * code, and no cause. Every message names an endpoint by origin and path
159
+ * alone.
340
160
  */
341
161
  export class HttpUserRepository {
342
162
  /**
@@ -353,29 +173,40 @@ export class HttpUserRepository {
353
173
  findSubjectByFederatedIdentityUrl;
354
174
  coverage;
355
175
  /**
356
- * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
176
+ * See {@link UserRepository.linkFederatedIdentity}. Present only when
357
177
  * `linkFederatedIdentityUrl` is configured, which is how the federation routes
358
178
  * know to refuse `?link=1` up front instead of at the callback.
359
179
  */
360
180
  linkFederatedIdentity;
361
181
  /**
362
- * #613 — see {@link UserRepository.supportsFederatedIdentityLookup}. Present
182
+ * See {@link UserRepository.supportsFederatedIdentityLookup}. Present
363
183
  * together with the lookup, only when `findSubjectByFederatedIdentityUrl` is
364
184
  * configured: absent, a deployment that requires the lookup is refused at
365
185
  * boot by the method's name, which is the message that says what to set.
366
186
  * Answers from the declaration alone — the Store is not asked at boot.
367
187
  */
368
188
  supportsFederatedIdentityLookup;
369
- /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
189
+ /** See {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
370
190
  findSubjectByFederatedIdentity;
371
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
191
+ /**
192
+ * See {@link UserRepository.markMfaEnrolled}. Present only when
193
+ * `markMfaEnrolledUrl` is configured, which is how
194
+ * `supportsMfaEnrollmentWitness` detects it; written as the Store's MFA
195
+ * endpoints are (`src/mfa/markEnrolled.mts`).
196
+ */
197
+ markMfaEnrolled;
198
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, markMfaEnrolledUrl, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
372
199
  this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
373
200
  this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
374
- this.#authorization = bearerAuthorization(bearerToken);
201
+ this.#authorization = bearerAuthorization(bearerToken, OWNER);
375
202
  if (linkFederatedIdentityUrl !== undefined) {
376
203
  this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
377
204
  this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
378
205
  }
206
+ if (markMfaEnrolledUrl !== undefined) {
207
+ const url = assertSecureEndpoint(markMfaEnrolledUrl, "markMfaEnrolledUrl");
208
+ this.markMfaEnrolled = (subject, enrolled) => markMfaEnrolledAtStore(url, this.settings(), OWNER, subject, enrolled);
209
+ }
379
210
  this.coverage = validateCoverage(federatedIdentityLookupCoverage);
380
211
  if (findSubjectByFederatedIdentityUrl !== undefined) {
381
212
  this.findSubjectByFederatedIdentityUrl = assertSecureEndpoint(findSubjectByFederatedIdentityUrl, "findSubjectByFederatedIdentityUrl");
@@ -392,38 +223,23 @@ export class HttpUserRepository {
392
223
  throw new Error(`HttpUserRepository: "${COVERAGE_FIELD}" declares what the Store's lookup covers, and no ` +
393
224
  '"findSubjectByFederatedIdentityUrl" names that lookup — set the URL, or remove the declaration');
394
225
  }
395
- if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
396
- throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
397
- `${MAX_TIMEOUT_MS} milliseconds`);
398
- }
399
- this.timeout = timeout;
400
- if (!isPositiveIntegerWithin(maxResponseBytes, Number.MAX_SAFE_INTEGER)) {
401
- throw new Error('HttpUserRepository: "maxResponseBytes" must be a positive integer');
402
- }
403
- this.maxResponseBytes = maxResponseBytes;
404
- }
405
- /** What every request carries: the body's type and, when configured, the credential. */
406
- headers() {
407
- return this.#authorization === undefined
408
- ? { "Content-Type": "application/json" }
409
- : { "Content-Type": "application/json", Authorization: this.#authorization };
226
+ this.timeout = checkStoreTimeout(timeout, OWNER);
227
+ this.maxResponseBytes = checkStoreResponseCap(maxResponseBytes, OWNER);
410
228
  }
411
229
  /**
412
- * Throws {@link StoreCredentialRefusedError} when a non-`2xx` answer is the
413
- * Store refusing this deployment's credential: a `401` or `403` with a
414
- * `Bearer` challenge, to a request that carried the token. Read before any
415
- * other reading of the status — without it a token the Store does not
416
- * accept is every login "no such user" and every link "refused", with
417
- * nothing logged. Without a token sent, a challenge is not about one, and a
418
- * Store whose stack challenges every refusal keeps the wire meaning it has
419
- * always had.
230
+ * What every request is sent with: the credential, when configured, the
231
+ * deadline and the cap. A `401` or `403` with a `Bearer` challenge to a
232
+ * request that carried the credential throws `StoreCredentialRefusedError`
233
+ * before any other reading of the status (`postToStore`): otherwise a
234
+ * token the Store does not accept is every login "no such user" and every
235
+ * link "refused", with nothing logged.
420
236
  */
421
- assertCredentialAccepted(res, endpoint) {
422
- if (this.#authorization !== undefined &&
423
- (res.status === 401 || res.status === 403) &&
424
- hasBearerChallenge(res.headers.get("www-authenticate"))) {
425
- throw new StoreCredentialRefusedError(endpoint, res.status);
426
- }
237
+ settings() {
238
+ return {
239
+ authorization: this.#authorization,
240
+ timeout: this.timeout,
241
+ maxResponseBytes: this.maxResponseBytes,
242
+ };
427
243
  }
428
244
  async authenticate(username, password) {
429
245
  // Without `acceptConflict` the sentinel is never produced — a 409 throws.
@@ -433,8 +249,8 @@ export class HttpUserRepository {
433
249
  return this.post(this.authenticateByTokenUrl, { token });
434
250
  }
435
251
  /**
436
- * POST `{ userId, provider, sub, token, claims }` to the Store's link endpoint
437
- * (#482). A `2xx` `User` is the linked account; `401` / `403` is the Store's
252
+ * POST `{ userId, provider, sub, token, claims }` to the Store's link
253
+ * endpoint. A `2xx` `User` is the linked account; `401` / `403` is the Store's
438
254
  * refusal (policy — an unverified or relay address, one identity per
439
255
  * provider, …); `409` says the identity is already someone else's.
440
256
  */
@@ -455,7 +271,7 @@ export class HttpUserRepository {
455
271
  }
456
272
  /**
457
273
  * POST `{ provider, issuer, clientId, sub, claims }` to the Store's lookup
458
- * (#613) and read one of the port's three answers. Answered locally, with
274
+ * and read one of the port's three answers. Answered locally, with
459
275
  * no request, where the declaration already decides: a registration nobody
460
276
  * declared is `registration_not_covered`, and a declared one arriving
461
277
  * without a claim its strategy needs is `identity_not_resolvable`. Every
@@ -482,191 +298,79 @@ export class HttpUserRepository {
482
298
  }
483
299
  /**
484
300
  * The lookup's transport: the same deadline, cap and abort as {@link post},
485
- * and like it never follows a redirect — the body carries a verified
486
- * identity, and a `Location` is not a configured endpoint. None of
487
- * {@link post}'s readings, though. A `401`/`403` is not "nobody", a `409` is
488
- * not a conflict, a `404` is not an absence: only a `2xx` carrying one of the
489
- * three answers is an answer, and everything else throws — as an outage,
490
- * which is what a lookup that could not be made is — a `401` or `403` with a
491
- * `Bearer` challenge, to a request that carried the token, as the refused
492
- * credential it is. What is thrown names the endpoint by origin and path — and, for an answer
493
- * with a status, the status; for a transport failure, at most its code — and
494
- * never the body, the identity, a status text or an underlying cause.
301
+ * and like it never follows a redirect (the body carries a verified identity,
302
+ * and a `Location` is not a configured endpoint). None of {@link post}'s
303
+ * readings: only a `2xx` carrying one of the three answers is an answer.
304
+ * Everything else throws as an outage, except a `401` or `403` with a
305
+ * `Bearer` challenge to a request that carried the token, which throws as the
306
+ * refused credential it is. What is thrown names the endpoint by origin and
307
+ * path, plus the status for an answer or at most the code for a transport
308
+ * failure, and never the body, the identity, a status text or a cause.
495
309
  */
496
310
  async postLookup(url, body) {
497
311
  // What every message names: origin and path, never the query.
498
312
  const endpoint = endpointForMessage(url);
499
- const controller = new AbortController();
500
- let timedOut = false;
501
- const timeoutError = () => {
502
- const error = new Error(`HttpUserRepository: request to ${endpoint} timed out after ${this.timeout}ms`);
503
- error.name = "TimeoutError";
504
- return error;
505
- };
506
- let fireDeadline = () => { };
507
- const deadline = new Promise((_resolve, reject) => {
508
- fireDeadline = () => reject(timeoutError());
509
- });
510
- deadline.catch(() => { });
511
- const timer = setTimeout(() => {
512
- timedOut = true;
513
- controller.abort();
514
- fireDeadline();
515
- }, this.timeout);
313
+ const { response, text } = await postToStore(url, body, this.settings(), {
314
+ owner: OWNER,
315
+ unreachable: `HttpUserRepository: identity lookup at ${endpoint} could not be reached`,
316
+ closed: `HttpUserRepository: identity lookup at ${endpoint}: the connection closed before a complete response arrived`,
317
+ malformed: `HttpUserRepository: identity lookup at ${endpoint} answered with a malformed HTTP response`,
318
+ unreadable: `HttpUserRepository: identity lookup at ${endpoint} could not be read`,
319
+ }, isSuccess);
320
+ if (text === undefined) {
321
+ throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered HTTP ${response.status}`);
322
+ }
323
+ let parsed;
516
324
  try {
517
- let res;
518
- try {
519
- res = await fetch(url, {
520
- method: "POST",
521
- headers: this.headers(),
522
- body: JSON.stringify(body),
523
- signal: controller.signal,
524
- redirect: "manual",
525
- });
526
- }
527
- catch (err) {
528
- if (timedOut && isAbortError(err))
529
- throw timeoutError();
530
- // A fixed message, without the cause: what a transport reports may
531
- // quote what it was sending.
532
- throw requestFailure(err, {
533
- unreachable: `HttpUserRepository: identity lookup at ${endpoint} could not be reached`,
534
- closed: `HttpUserRepository: identity lookup at ${endpoint}: the connection closed before a complete response arrived`,
535
- malformed: `HttpUserRepository: identity lookup at ${endpoint} answered with a malformed HTTP response`,
536
- });
537
- }
538
- if (!res.ok) {
539
- discardBody(res);
540
- this.assertCredentialAccepted(res, endpoint);
541
- throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered HTTP ${res.status}`);
542
- }
543
- let raw;
544
- try {
545
- raw = await readBodyCapped(res, this.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, `HttpUserRepository: identity lookup at ${endpoint} could not be read`));
546
- }
547
- catch (err) {
548
- if (timedOut)
549
- throw timeoutError();
550
- throw err;
551
- }
552
- let parsed;
553
- try {
554
- parsed = JSON.parse(raw);
555
- }
556
- catch {
557
- throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
558
- }
559
- const answer = lookupAnswer(parsed);
560
- if (answer === undefined) {
561
- throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered a body that is not one of the ` +
562
- "port's answers (linked / unlinked / indeterminate)");
563
- }
564
- return answer;
325
+ parsed = JSON.parse(text);
326
+ }
327
+ catch {
328
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
565
329
  }
566
- finally {
567
- clearTimeout(timer);
330
+ const answer = lookupAnswer(parsed);
331
+ if (answer === undefined) {
332
+ throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered a body that is not one of the ` +
333
+ "port's answers (linked / unlinked / indeterminate)");
568
334
  }
335
+ return answer;
569
336
  }
570
337
  async post(url, body, options = {}) {
571
338
  // What every message names: origin and path, never the query.
572
339
  const endpoint = endpointForMessage(url);
573
- const controller = new AbortController();
574
- let timedOut = false;
575
- // One absolute deadline for the whole exchange, expressed twice: as the
576
- // abort signal `fetch` understands, and as a promise the body read can be
577
- // raced against. `.catch` is attached up front so an exchange that
578
- // finishes first — the overwhelmingly common case, where the timer is
579
- // cleared and this never rejects — cannot leave an unhandled rejection.
580
- // Named as the lookup's is: a reporter that classifies by `name` —
581
- // federation-grants' reads `TimeoutError` as `timeout` — sees one kind
582
- // of timeout whichever request it came from.
583
- const timeoutError = () => {
584
- const error = new Error(`HttpUserRepository: request to ${endpoint} timed out after ${this.timeout}ms`);
585
- error.name = "TimeoutError";
586
- return error;
587
- };
588
- let fireDeadline = () => { };
589
- const deadline = new Promise((_resolve, reject) => {
590
- fireDeadline = () => reject(timeoutError());
591
- });
592
- deadline.catch(() => { });
593
- const timer = setTimeout(() => {
594
- timedOut = true;
595
- controller.abort();
596
- fireDeadline();
597
- }, this.timeout);
598
- try {
599
- let res;
340
+ // Never follows a redirect (`postToStore`): a 307 or 308 would re-send
341
+ // the body — a password, a token, a link request — to a `Location` the
342
+ // https rule never checked, and a 3xx is not a 2xx, 401, 403 or 409, so
343
+ // it throws below as an unexpected status.
344
+ const { response, text } = await postToStore(url, body, this.settings(), {
345
+ owner: OWNER,
346
+ unreachable: `HttpUserRepository: request to ${endpoint} could not be reached`,
347
+ closed: `HttpUserRepository: the connection to ${endpoint} closed before a complete response arrived`,
348
+ malformed: `HttpUserRepository: the Store at ${endpoint} answered with a malformed HTTP response`,
349
+ unreadable: `HttpUserRepository: response from ${endpoint} could not be read`,
350
+ }, isSuccess);
351
+ if (text !== undefined) {
352
+ let parsed;
600
353
  try {
601
- res = await fetch(url, {
602
- method: "POST",
603
- headers: this.headers(),
604
- body: JSON.stringify(body),
605
- signal: controller.signal,
606
- // Never followed: a 307 or 308 would re-send the body — a
607
- // password, a token, a link request — to a `Location` the https
608
- // rule never checked, and after any redirect the answer from
609
- // there would be taken as the user. Node's fetch hands the 3xx
610
- // back as it is (a browser-spec runtime would hand back an opaque
611
- // redirect, status 0); either way it is not a 2xx, 401, 403 or
612
- // 409, so it throws below as an unexpected status.
613
- redirect: "manual",
614
- });
615
- }
616
- catch (err) {
617
- if (timedOut && isAbortError(err))
618
- throw timeoutError();
619
- // Never the transport's own error: it may quote what it was
620
- // sending — the credential, the password — or what came back.
621
- throw requestFailure(err, {
622
- unreachable: `HttpUserRepository: request to ${endpoint} could not be reached`,
623
- closed: `HttpUserRepository: the connection to ${endpoint} closed before a complete response arrived`,
624
- malformed: `HttpUserRepository: the Store at ${endpoint} answered with a malformed HTTP response`,
625
- });
354
+ parsed = JSON.parse(text);
626
355
  }
627
- if (res.ok) {
628
- let raw;
629
- try {
630
- raw = await readBodyCapped(res, this.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, `HttpUserRepository: response from ${endpoint} could not be read`));
631
- }
632
- catch (err) {
633
- // Whatever the deadline interrupted is a timeout; everything
634
- // else readBodyCapped throws is already the adapter's own.
635
- if (timedOut)
636
- throw timeoutError();
637
- throw err;
638
- }
639
- let parsed;
640
- try {
641
- parsed = JSON.parse(raw);
642
- }
643
- catch {
644
- // Same class of failure as the shape check below: the Store is
645
- // broken, not the credential. Reported as ours rather than as a
646
- // bare SyntaxError with no indication of where it came from.
647
- throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
648
- }
649
- if (!isUser(parsed)) {
650
- // Upstream returned 2xx with an unexpected shape — this is an
651
- // "upstream is broken" case, not a "user not found" case, so
652
- // throw rather than return null. The thrown error propagates
653
- // as a 500 to the client (correct: upstream-service failure).
654
- throw new Error(`HttpUserRepository: upstream ${endpoint} returned an invalid User shape`);
655
- }
656
- return parsed;
657
- }
658
- discardBody(res);
659
- this.assertCredentialAccepted(res, endpoint);
660
- if (options.acceptConflict && res.status === 409) {
661
- return CONFLICT;
356
+ catch {
357
+ // Same class of failure as the shape check below: the Store is
358
+ // broken, not the credential.
359
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
662
360
  }
663
- if (res.status === 401 || res.status === 403) {
664
- return null;
361
+ if (!isUser(parsed)) {
362
+ // A 2xx with an unexpected shape is an upstream failure, not "no
363
+ // such user": thrown rather than answered null.
364
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned an invalid User shape`);
665
365
  }
666
- throw new Error(`Unexpected HTTP status ${res.status} from ${endpoint}`);
366
+ return parsed;
367
+ }
368
+ if (options.acceptConflict && response.status === 409) {
369
+ return CONFLICT;
667
370
  }
668
- finally {
669
- clearTimeout(timer);
371
+ if (response.status === 401 || response.status === 403) {
372
+ return null;
670
373
  }
374
+ throw new Error(`Unexpected HTTP status ${response.status} from ${endpoint}`);
671
375
  }
672
376
  }