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