@o3co/auth-provider-foundation 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +147 -61
- package/README.md +368 -81
- 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 +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +20 -0
- package/dist/repositories/HttpUserRepository.d.mts +111 -7
- package/dist/repositories/HttpUserRepository.d.mts.map +1 -1
- package/dist/repositories/HttpUserRepository.mjs +434 -30
- 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
|
@@ -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
|
/**
|
|
@@ -51,6 +54,154 @@ function isUser(v) {
|
|
|
51
54
|
const o = v;
|
|
52
55
|
return typeof o.id === "string" && typeof o.username === "string";
|
|
53
56
|
}
|
|
57
|
+
const COVERAGE_FIELD = "federatedIdentityLookupCoverage";
|
|
58
|
+
const COVERAGE_KEYS = new Set([
|
|
59
|
+
"provider",
|
|
60
|
+
"issuer",
|
|
61
|
+
"clientId",
|
|
62
|
+
"requiredClaims",
|
|
63
|
+
]);
|
|
64
|
+
/** A claim name: printable ASCII, no spaces (the shape the connection side accepts). */
|
|
65
|
+
const CLAIM_NAME = /^[\x21-\x7E]{1,256}$/;
|
|
66
|
+
const FORBIDDEN_CLAIM_NAMES = new Set([
|
|
67
|
+
"__proto__",
|
|
68
|
+
"constructor",
|
|
69
|
+
"prototype",
|
|
70
|
+
]);
|
|
71
|
+
/** A registration field: a non-empty string carrying no surrounding whitespace, compared exactly. */
|
|
72
|
+
const exactString = (value) => typeof value === "string" && value.length > 0 && value.trim() === value;
|
|
73
|
+
const coverageError = (at, problem) => new Error(`HttpUserRepository: "${at}" ${problem}`);
|
|
74
|
+
/**
|
|
75
|
+
* The declaration, checked field by field and frozen — the list, each entry
|
|
76
|
+
* and each claim list — so that neither a later mutation of the caller's list
|
|
77
|
+
* nor anything in-process holding the repository can widen what the boot
|
|
78
|
+
* probe accepts. Diagnostics name the option, the entry and the field (a
|
|
79
|
+
* field NAME an operator wrote wrongly is quoted) — never a value: a
|
|
80
|
+
* registration's client id is configuration an operator may not want in a
|
|
81
|
+
* log line.
|
|
82
|
+
*/
|
|
83
|
+
function validateCoverage(value) {
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return Object.freeze([]);
|
|
86
|
+
if (!Array.isArray(value)) {
|
|
87
|
+
throw coverageError(COVERAGE_FIELD, "must be a list of registrations");
|
|
88
|
+
}
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const entries = value.map((entry, index) => {
|
|
91
|
+
const at = `${COVERAGE_FIELD}[${index}]`;
|
|
92
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
93
|
+
throw coverageError(at, "must be an object with provider, issuer, clientId and requiredClaims");
|
|
94
|
+
}
|
|
95
|
+
for (const key of Object.keys(entry)) {
|
|
96
|
+
if (!COVERAGE_KEYS.has(key))
|
|
97
|
+
throw coverageError(at, `has a field it may not have: "${key}"`);
|
|
98
|
+
}
|
|
99
|
+
// Own properties only: a field that lives on the entry's prototype is
|
|
100
|
+
// not one the operator wrote.
|
|
101
|
+
const own = (field) => Object.hasOwn(entry, field) ? entry[field] : undefined;
|
|
102
|
+
const provider = own("provider");
|
|
103
|
+
const issuer = own("issuer");
|
|
104
|
+
const clientId = own("clientId");
|
|
105
|
+
const requiredClaims = own("requiredClaims");
|
|
106
|
+
for (const [field, candidate] of [
|
|
107
|
+
["provider", provider],
|
|
108
|
+
["issuer", issuer],
|
|
109
|
+
["clientId", clientId],
|
|
110
|
+
]) {
|
|
111
|
+
if (!exactString(candidate)) {
|
|
112
|
+
throw coverageError(`${at}.${field}`, "must be a non-empty string without surrounding whitespace");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(requiredClaims)) {
|
|
116
|
+
throw coverageError(`${at}.requiredClaims`, "must be a list of claim names (empty for none)");
|
|
117
|
+
}
|
|
118
|
+
const names = new Set();
|
|
119
|
+
for (const name of requiredClaims) {
|
|
120
|
+
if (typeof name !== "string" || !CLAIM_NAME.test(name) || FORBIDDEN_CLAIM_NAMES.has(name)) {
|
|
121
|
+
throw coverageError(`${at}.requiredClaims`, "holds a name that is not a claim name");
|
|
122
|
+
}
|
|
123
|
+
if (names.has(name))
|
|
124
|
+
throw coverageError(`${at}.requiredClaims`, "lists a claim twice");
|
|
125
|
+
names.add(name);
|
|
126
|
+
}
|
|
127
|
+
const key = JSON.stringify([provider, issuer, clientId]);
|
|
128
|
+
if (seen.has(key))
|
|
129
|
+
throw coverageError(at, "declares a registration an earlier entry already declares");
|
|
130
|
+
seen.add(key);
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
provider: provider,
|
|
133
|
+
issuer: issuer,
|
|
134
|
+
clientId: clientId,
|
|
135
|
+
requiredClaims: Object.freeze([...names]),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return Object.freeze(entries);
|
|
139
|
+
}
|
|
140
|
+
/** A lookup answer if it is one the port defines — a fresh object of the contract's fields, nothing else. */
|
|
141
|
+
function lookupAnswer(value) {
|
|
142
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
143
|
+
return undefined;
|
|
144
|
+
const answer = value;
|
|
145
|
+
switch (answer.kind) {
|
|
146
|
+
case "linked":
|
|
147
|
+
return typeof answer.subject === "string" && answer.subject.length > 0
|
|
148
|
+
? { kind: "linked", subject: answer.subject }
|
|
149
|
+
: undefined;
|
|
150
|
+
case "unlinked":
|
|
151
|
+
return { kind: "unlinked" };
|
|
152
|
+
case "indeterminate":
|
|
153
|
+
return answer.reason === "registration_not_covered" ||
|
|
154
|
+
answer.reason === "identity_not_resolvable"
|
|
155
|
+
? { kind: "indeterminate", reason: answer.reason }
|
|
156
|
+
: undefined;
|
|
157
|
+
default:
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* RFC 6750 §2.1 `b64token` — the characters a bearer credential may carry.
|
|
163
|
+
* Nothing in it is whitespace or a control character, so a token that
|
|
164
|
+
* matches cannot break the header it rides in.
|
|
165
|
+
*/
|
|
166
|
+
const B64TOKEN = /^[A-Za-z0-9\-._~+/]+=*$/;
|
|
167
|
+
const BEARER_TOKEN_FIELD = "bearerToken";
|
|
168
|
+
/**
|
|
169
|
+
* The credential presented to the Store, checked (#285's rule: at
|
|
170
|
+
* construction, so a deployment that would send an unusable one fails at
|
|
171
|
+
* boot) and turned into the `Authorization` value; `undefined` when none is
|
|
172
|
+
* configured, which sends no `Authorization` header at all.
|
|
173
|
+
*
|
|
174
|
+
* The shape is refused here and not left to `fetch`: a header value `fetch`
|
|
175
|
+
* refuses is one it QUOTES in the `TypeError` it throws, on the request path,
|
|
176
|
+
* where the session routes log what is thrown. The strength is core's
|
|
177
|
+
* shared-secret floor (`MIN_SECRET_ENTROPY_BYTES`, measured on the decoded
|
|
178
|
+
* length as `SESSION_SECRET` is): whoever holds this token speaks to the Store
|
|
179
|
+
* as auth.provider — resolves an identity to its user, links one to any user.
|
|
180
|
+
* No message quotes the value.
|
|
181
|
+
*/
|
|
182
|
+
function bearerAuthorization(value) {
|
|
183
|
+
if (value === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
const refuse = (problem) => new Error(`HttpUserRepository: "${BEARER_TOKEN_FIELD}" ${problem}`);
|
|
186
|
+
if (typeof value !== "string")
|
|
187
|
+
throw refuse("must be a string");
|
|
188
|
+
if (value === "") {
|
|
189
|
+
throw refuse('must not be empty — HOCON substitutes an exported-but-empty variable as ""; ' +
|
|
190
|
+
"leave it unset to send no Authorization header");
|
|
191
|
+
}
|
|
192
|
+
if (!B64TOKEN.test(value)) {
|
|
193
|
+
throw refuse("must be a bare RFC 6750 token: letters, digits and - . _ ~ + /, then optional = padding — " +
|
|
194
|
+
'no whitespace, no line break, and no "Bearer " prefix (the scheme is added)');
|
|
195
|
+
}
|
|
196
|
+
const actualBytes = measureSecretEntropyBytes(value);
|
|
197
|
+
if (actualBytes < MIN_SECRET_ENTROPY_BYTES) {
|
|
198
|
+
throw new Error(`HttpUserRepository: ${describeWeakSecret(actualBytes, {
|
|
199
|
+
configKey: "repositories.user.http.bearerToken",
|
|
200
|
+
envVar: "CLIENT_USER_BEARER_TOKEN",
|
|
201
|
+
})}`);
|
|
202
|
+
}
|
|
203
|
+
return `Bearer ${value}`;
|
|
204
|
+
}
|
|
54
205
|
/** Whether `value` is a positive integer that fits `bound`. */
|
|
55
206
|
function isPositiveIntegerWithin(value, bound) {
|
|
56
207
|
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
|
|
@@ -81,12 +232,17 @@ function discardBody(res) {
|
|
|
81
232
|
* before a byte of it is read, but the streaming count is the load-bearing
|
|
82
233
|
* half: a hostile Store simply omits the header (or lies), and chunked transfer
|
|
83
234
|
* encoding has none to omit.
|
|
235
|
+
*
|
|
236
|
+
* Everything it throws is the adapter's own: the cap, the deadline's
|
|
237
|
+
* rejection, or — for a stream that broke mid-read — what `unreadable` makes
|
|
238
|
+
* of the transport's error, which is never passed on as it is. `endpoint` is
|
|
239
|
+
* how its messages name the Store: origin and path (`endpointForMessage`).
|
|
84
240
|
*/
|
|
85
|
-
async function readBodyCapped(res, limit,
|
|
241
|
+
async function readBodyCapped(res, limit, endpoint, deadline, unreadable) {
|
|
86
242
|
const declared = Number(res.headers.get("content-length"));
|
|
87
243
|
if (Number.isFinite(declared) && declared > limit) {
|
|
88
244
|
discardBody(res);
|
|
89
|
-
throw new Error(`HttpUserRepository: upstream ${
|
|
245
|
+
throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap ` +
|
|
90
246
|
`(Content-Length: ${declared})`);
|
|
91
247
|
}
|
|
92
248
|
if (res.body === null)
|
|
@@ -102,12 +258,17 @@ async function readBodyCapped(res, limit, url, deadline) {
|
|
|
102
258
|
// in flight, which is exactly the slow-loris shape — headers arrive
|
|
103
259
|
// promptly, then the body dribbles or stops. One absolute deadline
|
|
104
260
|
// for the whole exchange, not a fresh one per chunk.
|
|
105
|
-
const { done, value } = await Promise.race([
|
|
261
|
+
const { done, value } = await Promise.race([
|
|
262
|
+
reader.read().catch((err) => {
|
|
263
|
+
throw unreadable(err);
|
|
264
|
+
}),
|
|
265
|
+
deadline,
|
|
266
|
+
]);
|
|
106
267
|
if (done)
|
|
107
268
|
break;
|
|
108
269
|
read += value.byteLength;
|
|
109
270
|
if (read > limit) {
|
|
110
|
-
throw new Error(`HttpUserRepository: upstream ${
|
|
271
|
+
throw new Error(`HttpUserRepository: upstream ${endpoint} response exceeds the ${limit}-byte cap`);
|
|
111
272
|
}
|
|
112
273
|
text += decoder.decode(value, { stream: true });
|
|
113
274
|
}
|
|
@@ -127,8 +288,9 @@ async function readBodyCapped(res, limit, url, deadline) {
|
|
|
127
288
|
* A deliberately shallow check. An aborted `fetch` rejects with the
|
|
128
289
|
* `AbortError` directly; the wrapping that `fetch` does apply is for network
|
|
129
290
|
* failures, which are not aborts. If some runtime did wrap one, the request
|
|
130
|
-
* still fails —
|
|
131
|
-
*
|
|
291
|
+
* still fails — as a `StoreTransportError` ("could not be reached") instead of
|
|
292
|
+
* a `TimeoutError`, a misnamed failure rather than a missed one, and not worth
|
|
293
|
+
* an untestable `cause` walk.
|
|
132
294
|
* A stalled *body* is not covered here at all: that is the deadline race in
|
|
133
295
|
* `readBodyCapped`, which does not depend on abort semantics.
|
|
134
296
|
*/
|
|
@@ -143,31 +305,93 @@ function isAbortError(err) {
|
|
|
143
305
|
* `UserRepository` backed by "the Store" — the upstream user service defined
|
|
144
306
|
* on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
|
|
145
307
|
*
|
|
146
|
-
*
|
|
147
|
-
* **construction**: a deployment configured with an
|
|
148
|
-
* at boot rather than leaking the first user's
|
|
149
|
-
* accepted for loopback hosts only — see
|
|
150
|
-
* carve-out and its rationale.
|
|
308
|
+
* Every endpoint receives a plaintext credential or a verified identity, so
|
|
309
|
+
* each is validated at **construction**: a deployment configured with an
|
|
310
|
+
* `http://` Store URL fails at boot rather than leaking the first user's
|
|
311
|
+
* password (#285). `http://` is accepted for loopback hosts only — see
|
|
312
|
+
* `src/endpointUrl.mts` for the carve-out and its rationale.
|
|
313
|
+
*
|
|
314
|
+
* No request follows a redirect, so a URL that passed that check is the only
|
|
315
|
+
* place its body is ever sent and the only one whose answer is taken: a `3xx`
|
|
316
|
+
* from the Store is an upstream failure, thrown like any other unexpected
|
|
317
|
+
* status, and its `Location` is never contacted.
|
|
318
|
+
*
|
|
319
|
+
* With `bearerToken` configured, every request — authentication, linking and
|
|
320
|
+
* the identity lookup alike — carries `Authorization: Bearer <token>`, so the
|
|
321
|
+
* Store can refuse a caller that is not this deployment; without it, no
|
|
322
|
+
* request carries an `Authorization` header. The Store says it refused THIS
|
|
323
|
+
* deployment by answering `401` or `403` with a `Bearer` challenge (RFC 6750
|
|
324
|
+
* §3), and that answer, while a token was sent, throws a
|
|
325
|
+
* {@link StoreCredentialRefusedError} on every request — never "no such user"
|
|
326
|
+
* or a refused link, which is what either status without the challenge still
|
|
327
|
+
* means.
|
|
328
|
+
*
|
|
329
|
+
* A transport's own error is never thrown: undici's parser errors quote the
|
|
330
|
+
* bytes they rejected, and a peer that reflects the request puts the
|
|
331
|
+
* `Authorization` header — or a password — there. A request that cannot be
|
|
332
|
+
* made, or an answer that cannot be read, throws a `StoreTransportError`
|
|
333
|
+
* (`src/repositories/storeErrors.mts`): a fixed message naming the endpoint
|
|
334
|
+
* and what failed, at most an allowlisted transport code, no cause.
|
|
335
|
+
*
|
|
336
|
+
* Every message names an endpoint by its origin and path alone
|
|
337
|
+
* (`endpointForMessage`, `src/endpointUrl.mts`): a Store URL may carry a
|
|
338
|
+
* query string, and a query may carry a credential, while every caller logs
|
|
339
|
+
* what this throws.
|
|
151
340
|
*/
|
|
152
341
|
export class HttpUserRepository {
|
|
342
|
+
/**
|
|
343
|
+
* `Bearer <token>`, or `undefined` for none. An ECMAScript private field
|
|
344
|
+
* rather than a TypeScript `private` one: `inspect()` and `JSON.stringify`
|
|
345
|
+
* see every other field of a repository handed to a logger, and not this.
|
|
346
|
+
*/
|
|
347
|
+
#authorization;
|
|
153
348
|
authenticateUrl;
|
|
154
349
|
authenticateByTokenUrl;
|
|
155
350
|
timeout;
|
|
156
351
|
maxResponseBytes;
|
|
157
352
|
linkFederatedIdentityUrl;
|
|
353
|
+
findSubjectByFederatedIdentityUrl;
|
|
354
|
+
coverage;
|
|
158
355
|
/**
|
|
159
356
|
* #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
|
|
160
357
|
* `linkFederatedIdentityUrl` is configured, which is how the federation routes
|
|
161
358
|
* know to refuse `?link=1` up front instead of at the callback.
|
|
162
359
|
*/
|
|
163
360
|
linkFederatedIdentity;
|
|
164
|
-
|
|
361
|
+
/**
|
|
362
|
+
* #613 — see {@link UserRepository.supportsFederatedIdentityLookup}. Present
|
|
363
|
+
* together with the lookup, only when `findSubjectByFederatedIdentityUrl` is
|
|
364
|
+
* configured: absent, a deployment that requires the lookup is refused at
|
|
365
|
+
* boot by the method's name, which is the message that says what to set.
|
|
366
|
+
* Answers from the declaration alone — the Store is not asked at boot.
|
|
367
|
+
*/
|
|
368
|
+
supportsFederatedIdentityLookup;
|
|
369
|
+
/** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
|
|
370
|
+
findSubjectByFederatedIdentity;
|
|
371
|
+
constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, bearerToken, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
|
|
165
372
|
this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
|
|
166
373
|
this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
|
|
374
|
+
this.#authorization = bearerAuthorization(bearerToken);
|
|
167
375
|
if (linkFederatedIdentityUrl !== undefined) {
|
|
168
376
|
this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
|
|
169
377
|
this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
|
|
170
378
|
}
|
|
379
|
+
this.coverage = validateCoverage(federatedIdentityLookupCoverage);
|
|
380
|
+
if (findSubjectByFederatedIdentityUrl !== undefined) {
|
|
381
|
+
this.findSubjectByFederatedIdentityUrl = assertSecureEndpoint(findSubjectByFederatedIdentityUrl, "findSubjectByFederatedIdentityUrl");
|
|
382
|
+
this.supportsFederatedIdentityLookup = (registration, identityClaims) =>
|
|
383
|
+
// A list, as the port types it: `includes` on a string would match
|
|
384
|
+
// a substring, and a caller that is not the boot probe may hand
|
|
385
|
+
// over anything.
|
|
386
|
+
Array.isArray(identityClaims) &&
|
|
387
|
+
(this.declared(registration)?.requiredClaims.every((name) => identityClaims.includes(name)) ??
|
|
388
|
+
false);
|
|
389
|
+
this.findSubjectByFederatedIdentity = (identity) => this.lookupViaHttp(identity);
|
|
390
|
+
}
|
|
391
|
+
else if (this.coverage.length > 0) {
|
|
392
|
+
throw new Error(`HttpUserRepository: "${COVERAGE_FIELD}" declares what the Store's lookup covers, and no ` +
|
|
393
|
+
'"findSubjectByFederatedIdentityUrl" names that lookup — set the URL, or remove the declaration');
|
|
394
|
+
}
|
|
171
395
|
if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
|
|
172
396
|
throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
|
|
173
397
|
`${MAX_TIMEOUT_MS} milliseconds`);
|
|
@@ -178,6 +402,29 @@ export class HttpUserRepository {
|
|
|
178
402
|
}
|
|
179
403
|
this.maxResponseBytes = maxResponseBytes;
|
|
180
404
|
}
|
|
405
|
+
/** What every request carries: the body's type and, when configured, the credential. */
|
|
406
|
+
headers() {
|
|
407
|
+
return this.#authorization === undefined
|
|
408
|
+
? { "Content-Type": "application/json" }
|
|
409
|
+
: { "Content-Type": "application/json", Authorization: this.#authorization };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Throws {@link StoreCredentialRefusedError} when a non-`2xx` answer is the
|
|
413
|
+
* Store refusing this deployment's credential: a `401` or `403` with a
|
|
414
|
+
* `Bearer` challenge, to a request that carried the token. Read before any
|
|
415
|
+
* other reading of the status — without it a token the Store does not
|
|
416
|
+
* accept is every login "no such user" and every link "refused", with
|
|
417
|
+
* nothing logged. Without a token sent, a challenge is not about one, and a
|
|
418
|
+
* Store whose stack challenges every refusal keeps the wire meaning it has
|
|
419
|
+
* always had.
|
|
420
|
+
*/
|
|
421
|
+
assertCredentialAccepted(res, endpoint) {
|
|
422
|
+
if (this.#authorization !== undefined &&
|
|
423
|
+
(res.status === 401 || res.status === 403) &&
|
|
424
|
+
hasBearerChallenge(res.headers.get("www-authenticate"))) {
|
|
425
|
+
throw new StoreCredentialRefusedError(endpoint, res.status);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
181
428
|
async authenticate(username, password) {
|
|
182
429
|
// Without `acceptConflict` the sentinel is never produced — a 409 throws.
|
|
183
430
|
return this.post(this.authenticateUrl, { email: username, password });
|
|
@@ -200,7 +447,129 @@ export class HttpUserRepository {
|
|
|
200
447
|
return { ok: false, reason: "refused" };
|
|
201
448
|
return { ok: true, user: answer };
|
|
202
449
|
}
|
|
450
|
+
/** The declaration for a registration, if the operator made one. Exact on all three. */
|
|
451
|
+
declared(registration) {
|
|
452
|
+
return this.coverage.find((entry) => entry.provider === registration.provider &&
|
|
453
|
+
entry.issuer === registration.issuer &&
|
|
454
|
+
entry.clientId === registration.clientId);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* POST `{ provider, issuer, clientId, sub, claims }` to the Store's lookup
|
|
458
|
+
* (#613) and read one of the port's three answers. Answered locally, with
|
|
459
|
+
* no request, where the declaration already decides: a registration nobody
|
|
460
|
+
* declared is `registration_not_covered`, and a declared one arriving
|
|
461
|
+
* without a claim its strategy needs is `identity_not_resolvable`. Every
|
|
462
|
+
* claim the connection supplied is sent, not only the required ones: what
|
|
463
|
+
* the Store matches on is the Store's business.
|
|
464
|
+
*/
|
|
465
|
+
async lookupViaHttp(identity) {
|
|
466
|
+
const entry = this.declared(identity);
|
|
467
|
+
if (entry === undefined)
|
|
468
|
+
return { kind: "indeterminate", reason: "registration_not_covered" };
|
|
469
|
+
for (const name of entry.requiredClaims) {
|
|
470
|
+
const value = Object.hasOwn(identity.claims, name) ? identity.claims[name] : undefined;
|
|
471
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
472
|
+
return { kind: "indeterminate", reason: "identity_not_resolvable" };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return this.postLookup(this.findSubjectByFederatedIdentityUrl, {
|
|
476
|
+
provider: identity.provider,
|
|
477
|
+
issuer: identity.issuer,
|
|
478
|
+
clientId: identity.clientId,
|
|
479
|
+
sub: identity.sub,
|
|
480
|
+
claims: { ...identity.claims },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The lookup's transport: the same deadline, cap and abort as {@link post},
|
|
485
|
+
* and like it never follows a redirect — the body carries a verified
|
|
486
|
+
* identity, and a `Location` is not a configured endpoint. None of
|
|
487
|
+
* {@link post}'s readings, though. A `401`/`403` is not "nobody", a `409` is
|
|
488
|
+
* not a conflict, a `404` is not an absence: only a `2xx` carrying one of the
|
|
489
|
+
* three answers is an answer, and everything else throws — as an outage,
|
|
490
|
+
* which is what a lookup that could not be made is — a `401` or `403` with a
|
|
491
|
+
* `Bearer` challenge, to a request that carried the token, as the refused
|
|
492
|
+
* credential it is. What is thrown names the endpoint by origin and path — and, for an answer
|
|
493
|
+
* with a status, the status; for a transport failure, at most its code — and
|
|
494
|
+
* never the body, the identity, a status text or an underlying cause.
|
|
495
|
+
*/
|
|
496
|
+
async postLookup(url, body) {
|
|
497
|
+
// What every message names: origin and path, never the query.
|
|
498
|
+
const endpoint = endpointForMessage(url);
|
|
499
|
+
const controller = new AbortController();
|
|
500
|
+
let timedOut = false;
|
|
501
|
+
const timeoutError = () => {
|
|
502
|
+
const error = new Error(`HttpUserRepository: request to ${endpoint} timed out after ${this.timeout}ms`);
|
|
503
|
+
error.name = "TimeoutError";
|
|
504
|
+
return error;
|
|
505
|
+
};
|
|
506
|
+
let fireDeadline = () => { };
|
|
507
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
508
|
+
fireDeadline = () => reject(timeoutError());
|
|
509
|
+
});
|
|
510
|
+
deadline.catch(() => { });
|
|
511
|
+
const timer = setTimeout(() => {
|
|
512
|
+
timedOut = true;
|
|
513
|
+
controller.abort();
|
|
514
|
+
fireDeadline();
|
|
515
|
+
}, this.timeout);
|
|
516
|
+
try {
|
|
517
|
+
let res;
|
|
518
|
+
try {
|
|
519
|
+
res = await fetch(url, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: this.headers(),
|
|
522
|
+
body: JSON.stringify(body),
|
|
523
|
+
signal: controller.signal,
|
|
524
|
+
redirect: "manual",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (timedOut && isAbortError(err))
|
|
529
|
+
throw timeoutError();
|
|
530
|
+
// A fixed message, without the cause: what a transport reports may
|
|
531
|
+
// quote what it was sending.
|
|
532
|
+
throw requestFailure(err, {
|
|
533
|
+
unreachable: `HttpUserRepository: identity lookup at ${endpoint} could not be reached`,
|
|
534
|
+
closed: `HttpUserRepository: identity lookup at ${endpoint}: the connection closed before a complete response arrived`,
|
|
535
|
+
malformed: `HttpUserRepository: identity lookup at ${endpoint} answered with a malformed HTTP response`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (!res.ok) {
|
|
539
|
+
discardBody(res);
|
|
540
|
+
this.assertCredentialAccepted(res, endpoint);
|
|
541
|
+
throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered HTTP ${res.status}`);
|
|
542
|
+
}
|
|
543
|
+
let raw;
|
|
544
|
+
try {
|
|
545
|
+
raw = await readBodyCapped(res, this.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, `HttpUserRepository: identity lookup at ${endpoint} could not be read`));
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
if (timedOut)
|
|
549
|
+
throw timeoutError();
|
|
550
|
+
throw err;
|
|
551
|
+
}
|
|
552
|
+
let parsed;
|
|
553
|
+
try {
|
|
554
|
+
parsed = JSON.parse(raw);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
|
|
558
|
+
}
|
|
559
|
+
const answer = lookupAnswer(parsed);
|
|
560
|
+
if (answer === undefined) {
|
|
561
|
+
throw new Error(`HttpUserRepository: identity lookup at ${endpoint} answered a body that is not one of the ` +
|
|
562
|
+
"port's answers (linked / unlinked / indeterminate)");
|
|
563
|
+
}
|
|
564
|
+
return answer;
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
203
570
|
async post(url, body, options = {}) {
|
|
571
|
+
// What every message names: origin and path, never the query.
|
|
572
|
+
const endpoint = endpointForMessage(url);
|
|
204
573
|
const controller = new AbortController();
|
|
205
574
|
let timedOut = false;
|
|
206
575
|
// One absolute deadline for the whole exchange, expressed twice: as the
|
|
@@ -208,9 +577,17 @@ export class HttpUserRepository {
|
|
|
208
577
|
// raced against. `.catch` is attached up front so an exchange that
|
|
209
578
|
// finishes first — the overwhelmingly common case, where the timer is
|
|
210
579
|
// cleared and this never rejects — cannot leave an unhandled rejection.
|
|
580
|
+
// Named as the lookup's is: a reporter that classifies by `name` —
|
|
581
|
+
// federation-grants' reads `TimeoutError` as `timeout` — sees one kind
|
|
582
|
+
// of timeout whichever request it came from.
|
|
583
|
+
const timeoutError = () => {
|
|
584
|
+
const error = new Error(`HttpUserRepository: request to ${endpoint} timed out after ${this.timeout}ms`);
|
|
585
|
+
error.name = "TimeoutError";
|
|
586
|
+
return error;
|
|
587
|
+
};
|
|
211
588
|
let fireDeadline = () => { };
|
|
212
589
|
const deadline = new Promise((_resolve, reject) => {
|
|
213
|
-
fireDeadline = () => reject(
|
|
590
|
+
fireDeadline = () => reject(timeoutError());
|
|
214
591
|
});
|
|
215
592
|
deadline.catch(() => { });
|
|
216
593
|
const timer = setTimeout(() => {
|
|
@@ -219,14 +596,46 @@ export class HttpUserRepository {
|
|
|
219
596
|
fireDeadline();
|
|
220
597
|
}, this.timeout);
|
|
221
598
|
try {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
599
|
+
let res;
|
|
600
|
+
try {
|
|
601
|
+
res = await fetch(url, {
|
|
602
|
+
method: "POST",
|
|
603
|
+
headers: this.headers(),
|
|
604
|
+
body: JSON.stringify(body),
|
|
605
|
+
signal: controller.signal,
|
|
606
|
+
// Never followed: a 307 or 308 would re-send the body — a
|
|
607
|
+
// password, a token, a link request — to a `Location` the https
|
|
608
|
+
// rule never checked, and after any redirect the answer from
|
|
609
|
+
// there would be taken as the user. Node's fetch hands the 3xx
|
|
610
|
+
// back as it is (a browser-spec runtime would hand back an opaque
|
|
611
|
+
// redirect, status 0); either way it is not a 2xx, 401, 403 or
|
|
612
|
+
// 409, so it throws below as an unexpected status.
|
|
613
|
+
redirect: "manual",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
if (timedOut && isAbortError(err))
|
|
618
|
+
throw timeoutError();
|
|
619
|
+
// Never the transport's own error: it may quote what it was
|
|
620
|
+
// sending — the credential, the password — or what came back.
|
|
621
|
+
throw requestFailure(err, {
|
|
622
|
+
unreachable: `HttpUserRepository: request to ${endpoint} could not be reached`,
|
|
623
|
+
closed: `HttpUserRepository: the connection to ${endpoint} closed before a complete response arrived`,
|
|
624
|
+
malformed: `HttpUserRepository: the Store at ${endpoint} answered with a malformed HTTP response`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
228
627
|
if (res.ok) {
|
|
229
|
-
|
|
628
|
+
let raw;
|
|
629
|
+
try {
|
|
630
|
+
raw = await readBodyCapped(res, this.maxResponseBytes, endpoint, deadline, (err) => readFailure(err, `HttpUserRepository: response from ${endpoint} could not be read`));
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
// Whatever the deadline interrupted is a timeout; everything
|
|
634
|
+
// else readBodyCapped throws is already the adapter's own.
|
|
635
|
+
if (timedOut)
|
|
636
|
+
throw timeoutError();
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
230
639
|
let parsed;
|
|
231
640
|
try {
|
|
232
641
|
parsed = JSON.parse(raw);
|
|
@@ -235,31 +644,26 @@ export class HttpUserRepository {
|
|
|
235
644
|
// Same class of failure as the shape check below: the Store is
|
|
236
645
|
// broken, not the credential. Reported as ours rather than as a
|
|
237
646
|
// bare SyntaxError with no indication of where it came from.
|
|
238
|
-
throw new Error(`HttpUserRepository: upstream ${
|
|
647
|
+
throw new Error(`HttpUserRepository: upstream ${endpoint} returned a non-JSON body`);
|
|
239
648
|
}
|
|
240
649
|
if (!isUser(parsed)) {
|
|
241
650
|
// Upstream returned 2xx with an unexpected shape — this is an
|
|
242
651
|
// "upstream is broken" case, not a "user not found" case, so
|
|
243
652
|
// throw rather than return null. The thrown error propagates
|
|
244
653
|
// as a 500 to the client (correct: upstream-service failure).
|
|
245
|
-
throw new Error(`HttpUserRepository: upstream ${
|
|
654
|
+
throw new Error(`HttpUserRepository: upstream ${endpoint} returned an invalid User shape`);
|
|
246
655
|
}
|
|
247
656
|
return parsed;
|
|
248
657
|
}
|
|
249
658
|
discardBody(res);
|
|
659
|
+
this.assertCredentialAccepted(res, endpoint);
|
|
250
660
|
if (options.acceptConflict && res.status === 409) {
|
|
251
661
|
return CONFLICT;
|
|
252
662
|
}
|
|
253
663
|
if (res.status === 401 || res.status === 403) {
|
|
254
664
|
return null;
|
|
255
665
|
}
|
|
256
|
-
throw new Error(`Unexpected HTTP status ${res.status} from ${
|
|
257
|
-
}
|
|
258
|
-
catch (err) {
|
|
259
|
-
if (timedOut && isAbortError(err)) {
|
|
260
|
-
throw new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
|
|
261
|
-
}
|
|
262
|
-
throw err;
|
|
666
|
+
throw new Error(`Unexpected HTTP status ${res.status} from ${endpoint}`);
|
|
263
667
|
}
|
|
264
668
|
finally {
|
|
265
669
|
clearTimeout(timer);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Store refused the credential this deployment presented: a `401` or a
|
|
3
|
+
* `403` carrying a `Bearer` challenge (RFC 6750 §3 — `invalid_token`,
|
|
4
|
+
* `insufficient_scope`) to a request that sent `bearerToken`. An outage, not
|
|
5
|
+
* an answer about the user: thrown, so every caller answers it as it answers
|
|
6
|
+
* any Store failure.
|
|
7
|
+
*
|
|
8
|
+
* `name` is part of the contract. A caller that does not depend on this
|
|
9
|
+
* package recognises the refusal by it — an operator reading the
|
|
10
|
+
* federation-grants callback's `federation_grant_callback_unavailable` line
|
|
11
|
+
* finds it as the projected error's `name`. The message names the endpoint —
|
|
12
|
+
* its origin and path, never a query or fragment, however it is handed one —
|
|
13
|
+
* the status and the option to check; never the token, and nothing the Store
|
|
14
|
+
* wrote.
|
|
15
|
+
*
|
|
16
|
+
* The Store's status is `storeStatus`, never `status` or `statusCode`: those
|
|
17
|
+
* are what Express's finalhandler, http-errors and the standalone's terminal
|
|
18
|
+
* handler read as the status to ANSWER with, and a 4xx there is taken for a
|
|
19
|
+
* client error — the Store's 401 would reach the browser as its own, and go
|
|
20
|
+
* unlogged.
|
|
21
|
+
*/
|
|
22
|
+
export declare class StoreCredentialRefusedError extends Error {
|
|
23
|
+
readonly storeStatus: 401 | 403;
|
|
24
|
+
constructor(url: string, status: 401 | 403);
|
|
25
|
+
}
|
|
26
|
+
/** What went wrong with the exchange, when it was not the Store's answer. */
|
|
27
|
+
export type StoreTransportFailure =
|
|
28
|
+
/**
|
|
29
|
+
* No connection to exchange on: refused, DNS, TLS, a route or host the
|
|
30
|
+
* network cannot reach. The network path, or TLS to the Store.
|
|
31
|
+
*/
|
|
32
|
+
"unreachable"
|
|
33
|
+
/**
|
|
34
|
+
* A connection that closed — or was reset — before a complete response
|
|
35
|
+
* arrived: before any byte, after an interim `1xx`, mid-head, or a pooled
|
|
36
|
+
* keep-alive connection the Store, a proxy or an idle timeout closed
|
|
37
|
+
* between two requests. The transport cannot tell which, so this says no
|
|
38
|
+
* more than that; it is not the network path, and not a malformed answer.
|
|
39
|
+
*/
|
|
40
|
+
| "connection_closed"
|
|
41
|
+
/**
|
|
42
|
+
* The Store (or whatever answers at the URL) sent a response head the
|
|
43
|
+
* transport cannot take: the parser refused the status line or a header,
|
|
44
|
+
* or the head outgrew the size limit. The Store's answer, or a proxy's.
|
|
45
|
+
*/
|
|
46
|
+
| "malformed_response"
|
|
47
|
+
/** An HTTP answer arrived, and its body broke before it was read. */
|
|
48
|
+
| "unreadable";
|
|
49
|
+
/**
|
|
50
|
+
* The Store could not be reached, or what it answered could not be read — a
|
|
51
|
+
* transport failure rather than an answer. Thrown so every caller answers it
|
|
52
|
+
* as it answers any Store failure; `name` is part of the contract (the
|
|
53
|
+
* federation-grants callback's outage line carries it as the projected
|
|
54
|
+
* error's `name`), as are `reason` and `code`.
|
|
55
|
+
*
|
|
56
|
+
* Built only from what an operator can act on: a fixed message naming the
|
|
57
|
+
* endpoint and the failure, and `code` — a transport code from the allowlist
|
|
58
|
+
* below, when there is one. Never a `cause`: the transport's own error may
|
|
59
|
+
* quote what was sent or received. And no `status` (see
|
|
60
|
+
* {@link StoreCredentialRefusedError}).
|
|
61
|
+
*/
|
|
62
|
+
export declare class StoreTransportError extends Error {
|
|
63
|
+
readonly reason: StoreTransportFailure;
|
|
64
|
+
readonly code: string | undefined;
|
|
65
|
+
constructor(message: string, reason: StoreTransportFailure, code?: string);
|
|
66
|
+
}
|
|
67
|
+
/** The first allowlisted `code` on `err` or its causes. */
|
|
68
|
+
export declare function transportCode(err: unknown): string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* A request that failed before a response could be taken, as its reason's
|
|
71
|
+
* message — `unreachable`, `closed` or `malformed` — with the code, when
|
|
72
|
+
* there is one. Only `unreachable` points an operator at the network.
|
|
73
|
+
*/
|
|
74
|
+
export declare function requestFailure(err: unknown, messages: {
|
|
75
|
+
readonly unreachable: string;
|
|
76
|
+
readonly closed: string;
|
|
77
|
+
readonly malformed: string;
|
|
78
|
+
}): StoreTransportError;
|
|
79
|
+
/** An answer whose body broke before it was read. */
|
|
80
|
+
export declare function readFailure(err: unknown, message: string): StoreTransportError;
|
|
81
|
+
//# sourceMappingURL=storeErrors.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storeErrors.d.mts","sourceRoot":"","sources":["../../src/repositories/storeErrors.mts"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACrD,QAAQ,CAAC,WAAW,EAAE,GAAG,GAAG,GAAG,CAAC;gBAEpB,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG;CAS1C;AAED,6EAA6E;AAC7E,MAAM,MAAM,qBAAqB;AAChC;;;GAGG;AACD,aAAa;AACf;;;;;;GAMG;GACD,mBAAmB;AACrB;;;;GAIG;GACD,oBAAoB;AACtB,qEAAqE;GACnE,YAAY,CAAC;AAEhB;;;;;;;;;;;;GAYG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBAEtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,IAAI,CAAC,EAAE,MAAM;CAMzE;AA4DD,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAW9D;AAwCD;;;;GAIG;AACH,wBAAgB,cAAc,CAC7B,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE;IACT,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B,GACC,mBAAmB,CAUrB;AAED,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,mBAAmB,CAG9E"}
|