@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.
- package/README.ja.md +147 -61
- package/README.md +315 -104
- package/dist/endpointUrl.d.mts +20 -0
- package/dist/endpointUrl.d.mts.map +1 -1
- package/dist/endpointUrl.mjs +30 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +6 -0
- package/dist/repositories/HttpUserRepository.d.mts +63 -14
- package/dist/repositories/HttpUserRepository.d.mts.map +1 -1
- package/dist/repositories/HttpUserRepository.mjs +206 -47
- package/dist/repositories/storeErrors.d.mts +81 -0
- package/dist/repositories/storeErrors.d.mts.map +1 -0
- package/dist/repositories/storeErrors.mjs +193 -0
- package/dist/repositories/wwwAuthenticate.d.mts +10 -0
- package/dist/repositories/wwwAuthenticate.d.mts.map +1 -0
- package/dist/repositories/wwwAuthenticate.mjs +58 -0
- package/package.json +4 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
*
|
|
30
|
-
* **construction**: a deployment configured with an
|
|
31
|
-
* at boot rather than leaking the first user's
|
|
32
|
-
* accepted for loopback hosts only — see
|
|
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
|
|
95
|
-
*
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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,
|
|
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 {
|
|
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,
|
|
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 ${
|
|
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([
|
|
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 ${
|
|
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 —
|
|
235
|
-
*
|
|
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
|
-
*
|
|
251
|
-
* **construction**: a deployment configured with an
|
|
252
|
-
* at boot rather than leaking the first user's
|
|
253
|
-
* accepted for loopback hosts only — see
|
|
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
|
|
371
|
-
*
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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 ${
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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"}
|