@o3co/auth-provider-foundation 0.14.0 → 0.16.0

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.
@@ -13,7 +13,10 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { assertSecureEndpoint } from "../endpointUrl.mjs";
16
+ import { describeWeakSecret, MIN_SECRET_ENTROPY_BYTES, measureSecretEntropyBytes, } from "@o3co/auth-provider-core";
17
+ import { assertSecureEndpoint, endpointForMessage } from "../endpointUrl.mjs";
18
+ import { readFailure, requestFailure, StoreCredentialRefusedError } from "./storeErrors.mjs";
19
+ import { hasBearerChallenge } from "./wwwAuthenticate.mjs";
17
20
  /** The Store answered 409 to a link request: the identity is already someone else's (#482). */
18
21
  const CONFLICT = Symbol("conflict");
19
22
  /**
@@ -51,6 +54,154 @@ function isUser(v) {
51
54
  const o = v;
52
55
  return typeof o.id === "string" && typeof o.username === "string";
53
56
  }
57
+ const COVERAGE_FIELD = "federatedIdentityLookupCoverage";
58
+ const COVERAGE_KEYS = new Set([
59
+ "provider",
60
+ "issuer",
61
+ "clientId",
62
+ "requiredClaims",
63
+ ]);
64
+ /** A claim name: printable ASCII, no spaces (the shape the connection side accepts). */
65
+ const CLAIM_NAME = /^[\x21-\x7E]{1,256}$/;
66
+ const FORBIDDEN_CLAIM_NAMES = new Set([
67
+ "__proto__",
68
+ "constructor",
69
+ "prototype",
70
+ ]);
71
+ /** A registration field: a non-empty string carrying no surrounding whitespace, compared exactly. */
72
+ const exactString = (value) => typeof value === "string" && value.length > 0 && value.trim() === value;
73
+ const coverageError = (at, problem) => new Error(`HttpUserRepository: "${at}" ${problem}`);
74
+ /**
75
+ * The declaration, checked field by field and frozen — the list, each entry
76
+ * and each claim list — so that neither a later mutation of the caller's list
77
+ * nor anything in-process holding the repository can widen what the boot
78
+ * probe accepts. Diagnostics name the option, the entry and the field (a
79
+ * field NAME an operator wrote wrongly is quoted) — never a value: a
80
+ * registration's client id is configuration an operator may not want in a
81
+ * log line.
82
+ */
83
+ function validateCoverage(value) {
84
+ if (value === undefined)
85
+ return Object.freeze([]);
86
+ if (!Array.isArray(value)) {
87
+ throw coverageError(COVERAGE_FIELD, "must be a list of registrations");
88
+ }
89
+ const seen = new Set();
90
+ const entries = value.map((entry, index) => {
91
+ const at = `${COVERAGE_FIELD}[${index}]`;
92
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
93
+ throw coverageError(at, "must be an object with provider, issuer, clientId and requiredClaims");
94
+ }
95
+ for (const key of Object.keys(entry)) {
96
+ if (!COVERAGE_KEYS.has(key))
97
+ throw coverageError(at, `has a field it may not have: "${key}"`);
98
+ }
99
+ // Own properties only: a field that lives on the entry's prototype is
100
+ // not one the operator wrote.
101
+ const own = (field) => Object.hasOwn(entry, field) ? entry[field] : undefined;
102
+ const provider = own("provider");
103
+ const issuer = own("issuer");
104
+ const clientId = own("clientId");
105
+ const requiredClaims = own("requiredClaims");
106
+ for (const [field, candidate] of [
107
+ ["provider", provider],
108
+ ["issuer", issuer],
109
+ ["clientId", clientId],
110
+ ]) {
111
+ if (!exactString(candidate)) {
112
+ throw coverageError(`${at}.${field}`, "must be a non-empty string without surrounding whitespace");
113
+ }
114
+ }
115
+ if (!Array.isArray(requiredClaims)) {
116
+ throw coverageError(`${at}.requiredClaims`, "must be a list of claim names (empty for none)");
117
+ }
118
+ const names = new Set();
119
+ for (const name of requiredClaims) {
120
+ if (typeof name !== "string" || !CLAIM_NAME.test(name) || FORBIDDEN_CLAIM_NAMES.has(name)) {
121
+ throw coverageError(`${at}.requiredClaims`, "holds a name that is not a claim name");
122
+ }
123
+ if (names.has(name))
124
+ throw coverageError(`${at}.requiredClaims`, "lists a claim twice");
125
+ names.add(name);
126
+ }
127
+ const key = JSON.stringify([provider, issuer, clientId]);
128
+ if (seen.has(key))
129
+ throw coverageError(at, "declares a registration an earlier entry already declares");
130
+ seen.add(key);
131
+ return Object.freeze({
132
+ provider: provider,
133
+ issuer: issuer,
134
+ clientId: clientId,
135
+ requiredClaims: Object.freeze([...names]),
136
+ });
137
+ });
138
+ return Object.freeze(entries);
139
+ }
140
+ /** A lookup answer if it is one the port defines — a fresh object of the contract's fields, nothing else. */
141
+ function lookupAnswer(value) {
142
+ if (typeof value !== "object" || value === null || Array.isArray(value))
143
+ return undefined;
144
+ const answer = value;
145
+ switch (answer.kind) {
146
+ case "linked":
147
+ return typeof answer.subject === "string" && answer.subject.length > 0
148
+ ? { kind: "linked", subject: answer.subject }
149
+ : undefined;
150
+ case "unlinked":
151
+ return { kind: "unlinked" };
152
+ case "indeterminate":
153
+ return answer.reason === "registration_not_covered" ||
154
+ answer.reason === "identity_not_resolvable"
155
+ ? { kind: "indeterminate", reason: answer.reason }
156
+ : undefined;
157
+ default:
158
+ return undefined;
159
+ }
160
+ }
161
+ /**
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
+ }
54
205
  /** Whether `value` is a positive integer that fits `bound`. */
55
206
  function isPositiveIntegerWithin(value, bound) {
56
207
  return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
@@ -81,12 +232,17 @@ function discardBody(res) {
81
232
  * before a byte of it is read, but the streaming count is the load-bearing
82
233
  * half: a hostile Store simply omits the header (or lies), and chunked transfer
83
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`).
84
240
  */
85
- async function readBodyCapped(res, limit, url, deadline) {
241
+ async function readBodyCapped(res, limit, endpoint, deadline, unreadable) {
86
242
  const declared = Number(res.headers.get("content-length"));
87
243
  if (Number.isFinite(declared) && declared > limit) {
88
244
  discardBody(res);
89
- throw new Error(`HttpUserRepository: upstream ${url} response exceeds the ${limit}-byte cap ` +
245
+ throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap ` +
90
246
  `(Content-Length: ${declared})`);
91
247
  }
92
248
  if (res.body === null)
@@ -102,12 +258,17 @@ async function readBodyCapped(res, limit, url, deadline) {
102
258
  // in flight, which is exactly the slow-loris shape — headers arrive
103
259
  // promptly, then the body dribbles or stops. One absolute deadline
104
260
  // for the whole exchange, not a fresh one per chunk.
105
- const { done, value } = await Promise.race([reader.read(), deadline]);
261
+ const { done, value } = await Promise.race([
262
+ reader.read().catch((err) => {
263
+ throw unreadable(err);
264
+ }),
265
+ deadline,
266
+ ]);
106
267
  if (done)
107
268
  break;
108
269
  read += value.byteLength;
109
270
  if (read > limit) {
110
- throw new Error(`HttpUserRepository: upstream ${url} response exceeds the ${limit}-byte cap`);
271
+ throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap`);
111
272
  }
112
273
  text += decoder.decode(value, { stream: true });
113
274
  }
@@ -127,8 +288,9 @@ async function readBodyCapped(res, limit, url, deadline) {
127
288
  * A deliberately shallow check. An aborted `fetch` rejects with the
128
289
  * `AbortError` directly; the wrapping that `fetch` does apply is for network
129
290
  * failures, which are not aborts. If some runtime did wrap one, the request
130
- * still fails — it would simply surface the runtime's message instead of ours,
131
- * which is a cosmetic difference and not worth an untestable `cause` walk.
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.
132
294
  * A stalled *body* is not covered here at all: that is the deadline race in
133
295
  * `readBodyCapped`, which does not depend on abort semantics.
134
296
  */
@@ -143,31 +305,93 @@ function isAbortError(err) {
143
305
  * `UserRepository` backed by "the Store" — the upstream user service defined
144
306
  * on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
145
307
  *
146
- * Both endpoints receive plaintext user credentials, so both are validated at
147
- * **construction**: a deployment configured with an `http://` Store URL fails
148
- * at boot rather than leaking the first user's password (#285). `http://` is
149
- * accepted for loopback hosts only — see `src/endpointUrl.mts` for the
150
- * carve-out and its rationale.
308
+ * 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.
151
340
  */
152
341
  export class HttpUserRepository {
342
+ /**
343
+ * `Bearer <token>`, or `undefined` for none. An ECMAScript private field
344
+ * rather than a TypeScript `private` one: `inspect()` and `JSON.stringify`
345
+ * see every other field of a repository handed to a logger, and not this.
346
+ */
347
+ #authorization;
153
348
  authenticateUrl;
154
349
  authenticateByTokenUrl;
155
350
  timeout;
156
351
  maxResponseBytes;
157
352
  linkFederatedIdentityUrl;
353
+ findSubjectByFederatedIdentityUrl;
354
+ coverage;
158
355
  /**
159
356
  * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
160
357
  * `linkFederatedIdentityUrl` is configured, which is how the federation routes
161
358
  * know to refuse `?link=1` up front instead of at the callback.
162
359
  */
163
360
  linkFederatedIdentity;
164
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
361
+ /**
362
+ * #613 — see {@link UserRepository.supportsFederatedIdentityLookup}. Present
363
+ * together with the lookup, only when `findSubjectByFederatedIdentityUrl` is
364
+ * configured: absent, a deployment that requires the lookup is refused at
365
+ * boot by the method's name, which is the message that says what to set.
366
+ * Answers from the declaration alone — the Store is not asked at boot.
367
+ */
368
+ supportsFederatedIdentityLookup;
369
+ /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
370
+ findSubjectByFederatedIdentity;
371
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
165
372
  this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
166
373
  this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
374
+ this.#authorization = bearerAuthorization(bearerToken);
167
375
  if (linkFederatedIdentityUrl !== undefined) {
168
376
  this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
169
377
  this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
170
378
  }
379
+ this.coverage = validateCoverage(federatedIdentityLookupCoverage);
380
+ if (findSubjectByFederatedIdentityUrl !== undefined) {
381
+ this.findSubjectByFederatedIdentityUrl = assertSecureEndpoint(findSubjectByFederatedIdentityUrl, "findSubjectByFederatedIdentityUrl");
382
+ this.supportsFederatedIdentityLookup = (registration, identityClaims) =>
383
+ // A list, as the port types it: `includes` on a string would match
384
+ // a substring, and a caller that is not the boot probe may hand
385
+ // over anything.
386
+ Array.isArray(identityClaims) &&
387
+ (this.declared(registration)?.requiredClaims.every((name) => identityClaims.includes(name)) ??
388
+ false);
389
+ this.findSubjectByFederatedIdentity = (identity) => this.lookupViaHttp(identity);
390
+ }
391
+ else if (this.coverage.length > 0) {
392
+ throw new Error(`HttpUserRepository: "${COVERAGE_FIELD}" declares what the Store's lookup covers, and no ` +
393
+ '"findSubjectByFederatedIdentityUrl" names that lookup — set the URL, or remove the declaration');
394
+ }
171
395
  if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
172
396
  throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
173
397
  `${MAX_TIMEOUT_MS} milliseconds`);
@@ -178,6 +402,29 @@ export class HttpUserRepository {
178
402
  }
179
403
  this.maxResponseBytes = maxResponseBytes;
180
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 };
410
+ }
411
+ /**
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.
420
+ */
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
+ }
427
+ }
181
428
  async authenticate(username, password) {
182
429
  // Without `acceptConflict` the sentinel is never produced — a 409 throws.
183
430
  return this.post(this.authenticateUrl, { email: username, password });
@@ -200,7 +447,129 @@ export class HttpUserRepository {
200
447
  return { ok: false, reason: "refused" };
201
448
  return { ok: true, user: answer };
202
449
  }
450
+ /** The declaration for a registration, if the operator made one. Exact on all three. */
451
+ declared(registration) {
452
+ return this.coverage.find((entry) => entry.provider === registration.provider &&
453
+ entry.issuer === registration.issuer &&
454
+ entry.clientId === registration.clientId);
455
+ }
456
+ /**
457
+ * 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
459
+ * no request, where the declaration already decides: a registration nobody
460
+ * declared is `registration_not_covered`, and a declared one arriving
461
+ * without a claim its strategy needs is `identity_not_resolvable`. Every
462
+ * claim the connection supplied is sent, not only the required ones: what
463
+ * the Store matches on is the Store's business.
464
+ */
465
+ async lookupViaHttp(identity) {
466
+ const entry = this.declared(identity);
467
+ if (entry === undefined)
468
+ return { kind: "indeterminate", reason: "registration_not_covered" };
469
+ for (const name of entry.requiredClaims) {
470
+ const value = Object.hasOwn(identity.claims, name) ? identity.claims[name] : undefined;
471
+ if (typeof value !== "string" || value.length === 0) {
472
+ return { kind: "indeterminate", reason: "identity_not_resolvable" };
473
+ }
474
+ }
475
+ return this.postLookup(this.findSubjectByFederatedIdentityUrl, {
476
+ provider: identity.provider,
477
+ issuer: identity.issuer,
478
+ clientId: identity.clientId,
479
+ sub: identity.sub,
480
+ claims: { ...identity.claims },
481
+ });
482
+ }
483
+ /**
484
+ * 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.
495
+ */
496
+ async postLookup(url, body) {
497
+ // What every message names: origin and path, never the query.
498
+ 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);
516
+ 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;
565
+ }
566
+ finally {
567
+ clearTimeout(timer);
568
+ }
569
+ }
203
570
  async post(url, body, options = {}) {
571
+ // What every message names: origin and path, never the query.
572
+ const endpoint = endpointForMessage(url);
204
573
  const controller = new AbortController();
205
574
  let timedOut = false;
206
575
  // One absolute deadline for the whole exchange, expressed twice: as the
@@ -208,9 +577,17 @@ export class HttpUserRepository {
208
577
  // raced against. `.catch` is attached up front so an exchange that
209
578
  // finishes first — the overwhelmingly common case, where the timer is
210
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
+ };
211
588
  let fireDeadline = () => { };
212
589
  const deadline = new Promise((_resolve, reject) => {
213
- fireDeadline = () => reject(new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`));
590
+ fireDeadline = () => reject(timeoutError());
214
591
  });
215
592
  deadline.catch(() => { });
216
593
  const timer = setTimeout(() => {
@@ -219,14 +596,46 @@ export class HttpUserRepository {
219
596
  fireDeadline();
220
597
  }, this.timeout);
221
598
  try {
222
- const res = await fetch(url, {
223
- method: "POST",
224
- headers: { "Content-Type": "application/json" },
225
- body: JSON.stringify(body),
226
- signal: controller.signal,
227
- });
599
+ let res;
600
+ 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
+ });
626
+ }
228
627
  if (res.ok) {
229
- const raw = await readBodyCapped(res, this.maxResponseBytes, url, deadline);
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
+ }
230
639
  let parsed;
231
640
  try {
232
641
  parsed = JSON.parse(raw);
@@ -235,31 +644,26 @@ export class HttpUserRepository {
235
644
  // Same class of failure as the shape check below: the Store is
236
645
  // broken, not the credential. Reported as ours rather than as a
237
646
  // bare SyntaxError with no indication of where it came from.
238
- throw new Error(`HttpUserRepository: upstream ${url} returned a non-JSON body`);
647
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
239
648
  }
240
649
  if (!isUser(parsed)) {
241
650
  // Upstream returned 2xx with an unexpected shape — this is an
242
651
  // "upstream is broken" case, not a "user not found" case, so
243
652
  // throw rather than return null. The thrown error propagates
244
653
  // as a 500 to the client (correct: upstream-service failure).
245
- throw new Error(`HttpUserRepository: upstream ${url} returned an invalid User shape`);
654
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned an invalid User shape`);
246
655
  }
247
656
  return parsed;
248
657
  }
249
658
  discardBody(res);
659
+ this.assertCredentialAccepted(res, endpoint);
250
660
  if (options.acceptConflict && res.status === 409) {
251
661
  return CONFLICT;
252
662
  }
253
663
  if (res.status === 401 || res.status === 403) {
254
664
  return null;
255
665
  }
256
- throw new Error(`Unexpected HTTP status ${res.status} from ${url}`);
257
- }
258
- catch (err) {
259
- if (timedOut && isAbortError(err)) {
260
- throw new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
261
- }
262
- throw err;
666
+ throw new Error(`Unexpected HTTP status ${res.status} from ${endpoint}`);
263
667
  }
264
668
  finally {
265
669
  clearTimeout(timer);
@@ -0,0 +1,81 @@
1
+ /**
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.
7
+ *
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.
21
+ */
22
+ export declare class StoreCredentialRefusedError extends Error {
23
+ readonly storeStatus: 401 | 403;
24
+ constructor(url: string, status: 401 | 403);
25
+ }
26
+ /** What went wrong with the exchange, when it was not the Store's answer. */
27
+ export type StoreTransportFailure =
28
+ /**
29
+ * No connection to exchange on: refused, DNS, TLS, a route or host the
30
+ * network cannot reach. The network path, or TLS to the Store.
31
+ */
32
+ "unreachable"
33
+ /**
34
+ * A connection that closed — or was reset — before a complete response
35
+ * arrived: before any byte, after an interim `1xx`, mid-head, or a pooled
36
+ * keep-alive connection the Store, a proxy or an idle timeout closed
37
+ * between two requests. The transport cannot tell which, so this says no
38
+ * more than that; it is not the network path, and not a malformed answer.
39
+ */
40
+ | "connection_closed"
41
+ /**
42
+ * The Store (or whatever answers at the URL) sent a response head the
43
+ * transport cannot take: the parser refused the status line or a header,
44
+ * or the head outgrew the size limit. The Store's answer, or a proxy's.
45
+ */
46
+ | "malformed_response"
47
+ /** An HTTP answer arrived, and its body broke before it was read. */
48
+ | "unreadable";
49
+ /**
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`.
55
+ *
56
+ * 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}).
61
+ */
62
+ export declare class StoreTransportError extends Error {
63
+ readonly reason: StoreTransportFailure;
64
+ readonly code: string | undefined;
65
+ constructor(message: string, reason: StoreTransportFailure, code?: string);
66
+ }
67
+ /** The first allowlisted `code` on `err` or its causes. */
68
+ export declare function transportCode(err: unknown): string | undefined;
69
+ /**
70
+ * A request that failed before a response could be taken, as its reason's
71
+ * message — `unreachable`, `closed` or `malformed` — with the code, when
72
+ * there is one. Only `unreachable` points an operator at the network.
73
+ */
74
+ export declare function requestFailure(err: unknown, messages: {
75
+ readonly unreachable: string;
76
+ readonly closed: string;
77
+ readonly malformed: string;
78
+ }): StoreTransportError;
79
+ /** An answer whose body broke before it was read. */
80
+ export declare function readFailure(err: unknown, message: string): StoreTransportError;
81
+ //# sourceMappingURL=storeErrors.d.mts.map
@@ -0,0 +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"}