@o3co/auth-provider-foundation 0.15.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.
@@ -1,4 +1,4 @@
1
- import type { FederatedIdentityLink, FederatedIdentityLookup, FederatedIdentityLookupResult, FederatedIdentityRegistration, LinkFederatedIdentityResult, User, UserRepository } from "@o3co/auth-provider-core";
1
+ import { type FederatedIdentityLink, type FederatedIdentityLookup, type FederatedIdentityLookupResult, type FederatedIdentityRegistration, type LinkFederatedIdentityResult, type User, type UserRepository } from "@o3co/auth-provider-core";
2
2
  /**
3
3
  * Default ceiling on an upstream response body, in bytes.
4
4
  *
@@ -26,13 +26,41 @@ export interface FederatedIdentityLookupCoverage {
26
26
  * `UserRepository` backed by "the Store" — the upstream user service defined
27
27
  * on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
28
28
  *
29
- * Both endpoints receive plaintext user credentials, so both are validated at
30
- * **construction**: a deployment configured with an `http://` Store URL fails
31
- * at boot rather than leaking the first user's password (#285). `http://` is
32
- * accepted for loopback hosts only — see `src/endpointUrl.mts` for the
33
- * carve-out and its rationale.
29
+ * Every endpoint receives a plaintext credential or a verified identity, so
30
+ * each is validated at **construction**: a deployment configured with an
31
+ * `http://` Store URL fails at boot rather than leaking the first user's
32
+ * password (#285). `http://` is accepted for loopback hosts only — see
33
+ * `src/endpointUrl.mts` for the carve-out and its rationale.
34
+ *
35
+ * No request follows a redirect, so a URL that passed that check is the only
36
+ * place its body is ever sent and the only one whose answer is taken: a `3xx`
37
+ * from the Store is an upstream failure, thrown like any other unexpected
38
+ * status, and its `Location` is never contacted.
39
+ *
40
+ * With `bearerToken` configured, every request — authentication, linking and
41
+ * the identity lookup alike — carries `Authorization: Bearer <token>`, so the
42
+ * Store can refuse a caller that is not this deployment; without it, no
43
+ * request carries an `Authorization` header. The Store says it refused THIS
44
+ * deployment by answering `401` or `403` with a `Bearer` challenge (RFC 6750
45
+ * §3), and that answer, while a token was sent, throws a
46
+ * {@link StoreCredentialRefusedError} on every request — never "no such user"
47
+ * or a refused link, which is what either status without the challenge still
48
+ * means.
49
+ *
50
+ * A transport's own error is never thrown: undici's parser errors quote the
51
+ * bytes they rejected, and a peer that reflects the request puts the
52
+ * `Authorization` header — or a password — there. A request that cannot be
53
+ * made, or an answer that cannot be read, throws a `StoreTransportError`
54
+ * (`src/repositories/storeErrors.mts`): a fixed message naming the endpoint
55
+ * and what failed, at most an allowlisted transport code, no cause.
56
+ *
57
+ * Every message names an endpoint by its origin and path alone
58
+ * (`endpointForMessage`, `src/endpointUrl.mts`): a Store URL may carry a
59
+ * query string, and a query may carry a credential, while every caller logs
60
+ * what this throws.
34
61
  */
35
62
  export declare class HttpUserRepository implements UserRepository {
63
+ #private;
36
64
  private authenticateUrl;
37
65
  private authenticateByTokenUrl;
38
66
  private timeout;
@@ -56,7 +84,7 @@ export declare class HttpUserRepository implements UserRepository {
56
84
  readonly supportsFederatedIdentityLookup?: (registration: FederatedIdentityRegistration, identityClaims: readonly string[]) => boolean;
57
85
  /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
58
86
  readonly findSubjectByFederatedIdentity?: (identity: FederatedIdentityLookup) => Promise<FederatedIdentityLookupResult>;
59
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, timeout, maxResponseBytes, }: {
87
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, bearerToken, timeout, maxResponseBytes, }: {
60
88
  authenticateUrl: string;
61
89
  authenticateByTokenUrl: string;
62
90
  /** #482: optional; the Store's link endpoint. Same https rule as the other two. */
@@ -65,9 +93,28 @@ export declare class HttpUserRepository implements UserRepository {
65
93
  findSubjectByFederatedIdentityUrl?: string;
66
94
  /** #613: which registrations the Store's lookup covers. Needs the URL; `[]` covers none. */
67
95
  federatedIdentityLookupCoverage?: readonly FederatedIdentityLookupCoverage[];
96
+ /**
97
+ * Optional; sent to the Store on every request as `Authorization: Bearer
98
+ * <token>`. A bare RFC 6750 token (no scheme) of at least
99
+ * `MIN_SECRET_ENTROPY_BYTES` of key material — `openssl rand -hex 32`.
100
+ */
101
+ bearerToken?: string;
68
102
  timeout: number;
69
103
  maxResponseBytes?: number;
70
104
  });
105
+ /** What every request carries: the body's type and, when configured, the credential. */
106
+ private headers;
107
+ /**
108
+ * Throws {@link StoreCredentialRefusedError} when a non-`2xx` answer is the
109
+ * Store refusing this deployment's credential: a `401` or `403` with a
110
+ * `Bearer` challenge, to a request that carried the token. Read before any
111
+ * other reading of the status — without it a token the Store does not
112
+ * accept is every login "no such user" and every link "refused", with
113
+ * nothing logged. Without a token sent, a challenge is not about one, and a
114
+ * Store whose stack challenges every refusal keeps the wire meaning it has
115
+ * always had.
116
+ */
117
+ private assertCredentialAccepted;
71
118
  authenticate(username: string, password: string): Promise<User | null>;
72
119
  authenticateByToken(token: string): Promise<User | null>;
73
120
  /**
@@ -91,14 +138,16 @@ export declare class HttpUserRepository implements UserRepository {
91
138
  private lookupViaHttp;
92
139
  /**
93
140
  * The lookup's transport: the same deadline, cap and abort as {@link post},
94
- * and none of its readings. A `401`/`403` is not "nobody", a `409` is not a
95
- * conflict, a `404` is not an absence: only a `2xx` carrying one of the
141
+ * and like it never follows a redirect — the body carries a verified
142
+ * identity, and a `Location` is not a configured endpoint. None of
143
+ * {@link post}'s readings, though. A `401`/`403` is not "nobody", a `409` is
144
+ * not a conflict, a `404` is not an absence: only a `2xx` carrying one of the
96
145
  * three answers is an answer, and everything else throws — as an outage,
97
- * which is what a lookup that could not be made is. Redirects are never
98
- * followed: the body carries a verified identity, and a `Location` is not a
99
- * configured endpoint. What is thrown names the endpoint — and, for an
100
- * answer with a status, the status — and never the body, the identity, a
101
- * status text or an underlying cause.
146
+ * which is what a lookup that could not be made is — a `401` or `403` with a
147
+ * `Bearer` challenge, to a request that carried the token, as the refused
148
+ * credential it is. What is thrown names the endpoint by origin and path — and, for an answer
149
+ * with a status, the status; for a transport failure, at most its code — and
150
+ * never the body, the identity, a status text or an underlying cause.
102
151
  */
103
152
  private postLookup;
104
153
  private post;
@@ -1 +1 @@
1
- {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACX,qBAAqB,EACrB,uBAAuB,EACvB,6BAA6B,EAC7B,6BAA6B,EAC7B,2BAA2B,EAC3B,IAAI,EACJ,cAAc,EACd,MAAM,0BAA0B,CAAC;AAMlC;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AA8BtD;;;;;;;;GAQG;AACH,MAAM,WAAW,+BAA+B;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AA4ND;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,wBAAwB,CAAC,CAAS;IAC1C,OAAO,CAAC,iCAAiC,CAAC,CAAS;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;IACtE;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,qBAAqB,KAC3B,OAAO,CAAC,2BAA2B,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,+BAA+B,CAAC,EAAE,CAC1C,YAAY,EAAE,6BAA6B,EAC3C,cAAc,EAAE,SAAS,MAAM,EAAE,KAC7B,OAAO,CAAC;IACb,gGAAgG;IAChG,QAAQ,CAAC,8BAA8B,CAAC,EAAE,CACzC,QAAQ,EAAE,uBAAuB,KAC7B,OAAO,CAAC,6BAA6B,CAAC,CAAC;gBAEhC,EACX,eAAe,EACf,sBAAsB,EACtB,wBAAwB,EACxB,iCAAiC,EACjC,+BAA+B,EAC/B,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,mFAAmF;QACnF,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,iGAAiG;QACjG,iCAAiC,CAAC,EAAE,MAAM,CAAC;QAC3C,4FAA4F;QAC5F,+BAA+B,CAAC,EAAE,SAAS,+BAA+B,EAAE,CAAC;QAC7E,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IAkDK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAKtE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAI9D;;;;;OAKG;YACW,WAAW;IAWzB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAWhB;;;;;;;;OAQG;YACW,aAAa;IAoB3B;;;;;;;;;;OAUG;YACW,UAAU;YAsEV,IAAI;CA4ElB"}
1
+ {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,EAEN,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,6BAA6B,EAClC,KAAK,6BAA6B,EAClC,KAAK,2BAA2B,EAGhC,KAAK,IAAI,EACT,KAAK,cAAc,EACnB,MAAM,0BAA0B,CAAC;AAQlC;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AA8BtD;;;;;;;;GAQG;AACH,MAAM,WAAW,+BAA+B;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AA4RD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,qBAAa,kBAAmB,YAAW,cAAc;;IAOxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,wBAAwB,CAAC,CAAS;IAC1C,OAAO,CAAC,iCAAiC,CAAC,CAAS;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;IACtE;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,qBAAqB,KAC3B,OAAO,CAAC,2BAA2B,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,+BAA+B,CAAC,EAAE,CAC1C,YAAY,EAAE,6BAA6B,EAC3C,cAAc,EAAE,SAAS,MAAM,EAAE,KAC7B,OAAO,CAAC;IACb,gGAAgG;IAChG,QAAQ,CAAC,8BAA8B,CAAC,EAAE,CACzC,QAAQ,EAAE,uBAAuB,KAC7B,OAAO,CAAC,6BAA6B,CAAC,CAAC;gBAEhC,EACX,eAAe,EACf,sBAAsB,EACtB,wBAAwB,EACxB,iCAAiC,EACjC,+BAA+B,EAC/B,WAAW,EACX,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,mFAAmF;QACnF,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,iGAAiG;QACjG,iCAAiC,CAAC,EAAE,MAAM,CAAC;QAC3C,4FAA4F;QAC5F,+BAA+B,CAAC,EAAE,SAAS,+BAA+B,EAAE,CAAC;QAC7E;;;;WAIG;QACH,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IAmDD,wFAAwF;IACxF,OAAO,CAAC,OAAO;IAMf;;;;;;;;;OASG;IACH,OAAO,CAAC,wBAAwB;IAU1B,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAKtE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAI9D;;;;;OAKG;YACW,WAAW;IAWzB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAWhB;;;;;;;;OAQG;YACW,aAAa;IAoB3B;;;;;;;;;;;;OAYG;YACW,UAAU;YA8EV,IAAI;CAiHlB"}
@@ -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
  /**
@@ -155,6 +158,50 @@ function lookupAnswer(value) {
155
158
  return undefined;
156
159
  }
157
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
+ }
158
205
  /** Whether `value` is a positive integer that fits `bound`. */
159
206
  function isPositiveIntegerWithin(value, bound) {
160
207
  return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
@@ -185,12 +232,17 @@ function discardBody(res) {
185
232
  * before a byte of it is read, but the streaming count is the load-bearing
186
233
  * half: a hostile Store simply omits the header (or lies), and chunked transfer
187
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`).
188
240
  */
189
- async function readBodyCapped(res, limit, url, deadline) {
241
+ async function readBodyCapped(res, limit, endpoint, deadline, unreadable) {
190
242
  const declared = Number(res.headers.get("content-length"));
191
243
  if (Number.isFinite(declared) && declared > limit) {
192
244
  discardBody(res);
193
- 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 ` +
194
246
  `(Content-Length: ${declared})`);
195
247
  }
196
248
  if (res.body === null)
@@ -206,12 +258,17 @@ async function readBodyCapped(res, limit, url, deadline) {
206
258
  // in flight, which is exactly the slow-loris shape — headers arrive
207
259
  // promptly, then the body dribbles or stops. One absolute deadline
208
260
  // for the whole exchange, not a fresh one per chunk.
209
- 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
+ ]);
210
267
  if (done)
211
268
  break;
212
269
  read += value.byteLength;
213
270
  if (read > limit) {
214
- 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`);
215
272
  }
216
273
  text += decoder.decode(value, { stream: true });
217
274
  }
@@ -231,8 +288,9 @@ async function readBodyCapped(res, limit, url, deadline) {
231
288
  * A deliberately shallow check. An aborted `fetch` rejects with the
232
289
  * `AbortError` directly; the wrapping that `fetch` does apply is for network
233
290
  * failures, which are not aborts. If some runtime did wrap one, the request
234
- * still fails — it would simply surface the runtime's message instead of ours,
235
- * 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.
236
294
  * A stalled *body* is not covered here at all: that is the deadline race in
237
295
  * `readBodyCapped`, which does not depend on abort semantics.
238
296
  */
@@ -247,13 +305,46 @@ function isAbortError(err) {
247
305
  * `UserRepository` backed by "the Store" — the upstream user service defined
248
306
  * on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
249
307
  *
250
- * Both endpoints receive plaintext user credentials, so both are validated at
251
- * **construction**: a deployment configured with an `http://` Store URL fails
252
- * at boot rather than leaking the first user's password (#285). `http://` is
253
- * accepted for loopback hosts only — see `src/endpointUrl.mts` for the
254
- * 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.
255
340
  */
256
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;
257
348
  authenticateUrl;
258
349
  authenticateByTokenUrl;
259
350
  timeout;
@@ -277,9 +368,10 @@ export class HttpUserRepository {
277
368
  supportsFederatedIdentityLookup;
278
369
  /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
279
370
  findSubjectByFederatedIdentity;
280
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
371
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
281
372
  this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
282
373
  this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
374
+ this.#authorization = bearerAuthorization(bearerToken);
283
375
  if (linkFederatedIdentityUrl !== undefined) {
284
376
  this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
285
377
  this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
@@ -310,6 +402,29 @@ export class HttpUserRepository {
310
402
  }
311
403
  this.maxResponseBytes = maxResponseBytes;
312
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
+ }
313
428
  async authenticate(username, password) {
314
429
  // Without `acceptConflict` the sentinel is never produced — a 409 throws.
315
430
  return this.post(this.authenticateUrl, { email: username, password });
@@ -367,20 +482,24 @@ export class HttpUserRepository {
367
482
  }
368
483
  /**
369
484
  * The lookup's transport: the same deadline, cap and abort as {@link post},
370
- * and none of its readings. A `401`/`403` is not "nobody", a `409` is not a
371
- * conflict, a `404` is not an absence: only a `2xx` carrying one of the
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
372
489
  * three answers is an answer, and everything else throws — as an outage,
373
- * which is what a lookup that could not be made is. Redirects are never
374
- * followed: the body carries a verified identity, and a `Location` is not a
375
- * configured endpoint. What is thrown names the endpoint — and, for an
376
- * answer with a status, the status — and never the body, the identity, a
377
- * status text or an underlying cause.
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.
378
495
  */
379
496
  async postLookup(url, body) {
497
+ // What every message names: origin and path, never the query.
498
+ const endpoint = endpointForMessage(url);
380
499
  const controller = new AbortController();
381
500
  let timedOut = false;
382
501
  const timeoutError = () => {
383
- const error = new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
502
+ const error = new Error(`HttpUserRepository: request to ${endpoint} timed out after ${this.timeout}ms`);
384
503
  error.name = "TimeoutError";
385
504
  return error;
386
505
  };
@@ -399,7 +518,7 @@ export class HttpUserRepository {
399
518
  try {
400
519
  res = await fetch(url, {
401
520
  method: "POST",
402
- headers: { "Content-Type": "application/json" },
521
+ headers: this.headers(),
403
522
  body: JSON.stringify(body),
404
523
  signal: controller.signal,
405
524
  redirect: "manual",
@@ -410,33 +529,36 @@ export class HttpUserRepository {
410
529
  throw timeoutError();
411
530
  // A fixed message, without the cause: what a transport reports may
412
531
  // quote what it was sending.
413
- throw new Error(`HttpUserRepository: identity lookup at ${url} could not be reached`);
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
+ });
414
537
  }
415
538
  if (!res.ok) {
416
539
  discardBody(res);
417
- throw new Error(`HttpUserRepository: identity lookup at ${url} answered HTTP ${res.status}`);
540
+ this.assertCredentialAccepted(res, endpoint);
541
+ throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered HTTP ${res.status}`);
418
542
  }
419
543
  let raw;
420
544
  try {
421
- raw = await readBodyCapped(res, this.maxResponseBytes, url, deadline);
545
+ raw = await readBodyCapped(res, this.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, `HttpUserRepository: identity lookup at ${endpoint} could not be read`));
422
546
  }
423
547
  catch (err) {
424
548
  if (timedOut)
425
549
  throw timeoutError();
426
- if (err instanceof Error && err.message.startsWith("HttpUserRepository:"))
427
- throw err;
428
- throw new Error(`HttpUserRepository: identity lookup at ${url} could not be read`);
550
+ throw err;
429
551
  }
430
552
  let parsed;
431
553
  try {
432
554
  parsed = JSON.parse(raw);
433
555
  }
434
556
  catch {
435
- throw new Error(`HttpUserRepository: upstream ${url} returned a non-JSON body`);
557
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
436
558
  }
437
559
  const answer = lookupAnswer(parsed);
438
560
  if (answer === undefined) {
439
- throw new Error(`HttpUserRepository: identity lookup at ${url} answered a body that is not one of the ` +
561
+ throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered a body that is not one of the ` +
440
562
  "port's answers (linked / unlinked / indeterminate)");
441
563
  }
442
564
  return answer;
@@ -446,6 +568,8 @@ export class HttpUserRepository {
446
568
  }
447
569
  }
448
570
  async post(url, body, options = {}) {
571
+ // What every message names: origin and path, never the query.
572
+ const endpoint = endpointForMessage(url);
449
573
  const controller = new AbortController();
450
574
  let timedOut = false;
451
575
  // One absolute deadline for the whole exchange, expressed twice: as the
@@ -453,9 +577,17 @@ export class HttpUserRepository {
453
577
  // raced against. `.catch` is attached up front so an exchange that
454
578
  // finishes first — the overwhelmingly common case, where the timer is
455
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
+ };
456
588
  let fireDeadline = () => { };
457
589
  const deadline = new Promise((_resolve, reject) => {
458
- fireDeadline = () => reject(new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`));
590
+ fireDeadline = () => reject(timeoutError());
459
591
  });
460
592
  deadline.catch(() => { });
461
593
  const timer = setTimeout(() => {
@@ -464,14 +596,46 @@ export class HttpUserRepository {
464
596
  fireDeadline();
465
597
  }, this.timeout);
466
598
  try {
467
- const res = await fetch(url, {
468
- method: "POST",
469
- headers: { "Content-Type": "application/json" },
470
- body: JSON.stringify(body),
471
- signal: controller.signal,
472
- });
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
+ }
473
627
  if (res.ok) {
474
- 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
+ }
475
639
  let parsed;
476
640
  try {
477
641
  parsed = JSON.parse(raw);
@@ -480,31 +644,26 @@ export class HttpUserRepository {
480
644
  // Same class of failure as the shape check below: the Store is
481
645
  // broken, not the credential. Reported as ours rather than as a
482
646
  // bare SyntaxError with no indication of where it came from.
483
- throw new Error(`HttpUserRepository: upstream ${url} returned a non-JSON body`);
647
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
484
648
  }
485
649
  if (!isUser(parsed)) {
486
650
  // Upstream returned 2xx with an unexpected shape — this is an
487
651
  // "upstream is broken" case, not a "user not found" case, so
488
652
  // throw rather than return null. The thrown error propagates
489
653
  // as a 500 to the client (correct: upstream-service failure).
490
- throw new Error(`HttpUserRepository: upstream ${url} returned an invalid User shape`);
654
+ throw new Error(`HttpUserRepository: upstream ${endpoint} returned an invalid User shape`);
491
655
  }
492
656
  return parsed;
493
657
  }
494
658
  discardBody(res);
659
+ this.assertCredentialAccepted(res, endpoint);
495
660
  if (options.acceptConflict && res.status === 409) {
496
661
  return CONFLICT;
497
662
  }
498
663
  if (res.status === 401 || res.status === 403) {
499
664
  return null;
500
665
  }
501
- throw new Error(`Unexpected HTTP status ${res.status} from ${url}`);
502
- }
503
- catch (err) {
504
- if (timedOut && isAbortError(err)) {
505
- throw new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
506
- }
507
- throw err;
666
+ throw new Error(`Unexpected HTTP status ${res.status} from ${endpoint}`);
508
667
  }
509
668
  finally {
510
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"}