@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The failures `HttpUserRepository` throws about the exchange with the Store
|
|
18
|
+
* rather than about a user: the Store refusing this deployment's credential,
|
|
19
|
+
* and a transport that failed. Each is built from an allowlist — the
|
|
20
|
+
* endpoint (origin and path), a status, a transport code — and never from
|
|
21
|
+
* what the Store or the transport said, which may quote the request.
|
|
22
|
+
*/
|
|
23
|
+
import { endpointForMessage } from "../endpointUrl.mjs";
|
|
24
|
+
/**
|
|
25
|
+
* The Store refused the credential this deployment presented: a `401` or a
|
|
26
|
+
* `403` carrying a `Bearer` challenge (RFC 6750 §3 — `invalid_token`,
|
|
27
|
+
* `insufficient_scope`) to a request that sent `bearerToken`. An outage, not
|
|
28
|
+
* an answer about the user: thrown, so every caller answers it as it answers
|
|
29
|
+
* any Store failure.
|
|
30
|
+
*
|
|
31
|
+
* `name` is part of the contract. A caller that does not depend on this
|
|
32
|
+
* package recognises the refusal by it — an operator reading the
|
|
33
|
+
* federation-grants callback's `federation_grant_callback_unavailable` line
|
|
34
|
+
* finds it as the projected error's `name`. The message names the endpoint —
|
|
35
|
+
* its origin and path, never a query or fragment, however it is handed one —
|
|
36
|
+
* the status and the option to check; never the token, and nothing the Store
|
|
37
|
+
* wrote.
|
|
38
|
+
*
|
|
39
|
+
* The Store's status is `storeStatus`, never `status` or `statusCode`: those
|
|
40
|
+
* are what Express's finalhandler, http-errors and the standalone's terminal
|
|
41
|
+
* handler read as the status to ANSWER with, and a 4xx there is taken for a
|
|
42
|
+
* client error — the Store's 401 would reach the browser as its own, and go
|
|
43
|
+
* unlogged.
|
|
44
|
+
*/
|
|
45
|
+
export class StoreCredentialRefusedError extends Error {
|
|
46
|
+
storeStatus;
|
|
47
|
+
constructor(url, status) {
|
|
48
|
+
super(`HttpUserRepository: the Store at ${endpointForMessage(url)} refused this deployment's credential ` +
|
|
49
|
+
`(HTTP ${status} with a Bearer challenge) — bearerToken (CLIENT_USER_BEARER_TOKEN) is ` +
|
|
50
|
+
"not a token the Store accepts");
|
|
51
|
+
this.name = "StoreCredentialRefusedError";
|
|
52
|
+
this.storeStatus = status;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The Store could not be reached, or what it answered could not be read — a
|
|
57
|
+
* transport failure rather than an answer. Thrown so every caller answers it
|
|
58
|
+
* as it answers any Store failure; `name` is part of the contract (the
|
|
59
|
+
* federation-grants callback's outage line carries it as the projected
|
|
60
|
+
* error's `name`), as are `reason` and `code`.
|
|
61
|
+
*
|
|
62
|
+
* Built only from what an operator can act on: a fixed message naming the
|
|
63
|
+
* endpoint and the failure, and `code` — a transport code from the allowlist
|
|
64
|
+
* below, when there is one. Never a `cause`: the transport's own error may
|
|
65
|
+
* quote what was sent or received. And no `status` (see
|
|
66
|
+
* {@link StoreCredentialRefusedError}).
|
|
67
|
+
*/
|
|
68
|
+
export class StoreTransportError extends Error {
|
|
69
|
+
reason;
|
|
70
|
+
code;
|
|
71
|
+
constructor(message, reason, code) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "StoreTransportError";
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
this.code = code;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Transport codes an operator can act on. A transport's error is never passed
|
|
80
|
+
* on: undici's parser errors carry the bytes they choked on as `data`, and a
|
|
81
|
+
* peer that reflects the request — a broken proxy, a debugging echo — puts the
|
|
82
|
+
* `Authorization` header there. A code from this list, or of one of the
|
|
83
|
+
* closed families below, found on the error or its causes, is all that is
|
|
84
|
+
* kept of one.
|
|
85
|
+
*/
|
|
86
|
+
const TRANSPORT_CODES = new Set([
|
|
87
|
+
"ECONNREFUSED",
|
|
88
|
+
"ECONNRESET",
|
|
89
|
+
"ECONNABORTED",
|
|
90
|
+
"ENOTFOUND",
|
|
91
|
+
"EAI_AGAIN",
|
|
92
|
+
"ETIMEDOUT",
|
|
93
|
+
"EHOSTUNREACH",
|
|
94
|
+
"ENETUNREACH",
|
|
95
|
+
"EPIPE",
|
|
96
|
+
"EPROTO",
|
|
97
|
+
"CERT_HAS_EXPIRED",
|
|
98
|
+
"CERT_NOT_YET_VALID",
|
|
99
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
100
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
101
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
102
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
103
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* Families of codes, each a closed vocabulary of its producer: undici's own
|
|
107
|
+
* (`UND_ERR_SOCKET`, `UND_ERR_HEADERS_OVERFLOW`, …), llhttp's parser errors
|
|
108
|
+
* (`HPE_INVALID_HEADER_TOKEN`, … — where the runtime sets them), and
|
|
109
|
+
* OpenSSL's (`ERR_SSL_WRONG_VERSION_NUMBER` — an https URL on a port that
|
|
110
|
+
* speaks plain HTTP; `ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE` — OpenSSL 3's
|
|
111
|
+
* name for a TLS 1.2 handshake the Store refused, one slash in it). Bounded,
|
|
112
|
+
* so a value that merely starts like one is not kept whole.
|
|
113
|
+
*/
|
|
114
|
+
const CODE_FAMILIES = [
|
|
115
|
+
/^UND_ERR_[A-Z_]{1,48}$/,
|
|
116
|
+
/^HPE_[A-Z_]{1,48}$/,
|
|
117
|
+
/^ERR_SSL_[A-Z0-9_]{1,64}(?:\/[A-Z0-9_]{1,64})?$/,
|
|
118
|
+
];
|
|
119
|
+
const MAX_CAUSE_DEPTH = 4;
|
|
120
|
+
/** Each of `err` and its causes, a few levels deep. */
|
|
121
|
+
function* causes(err) {
|
|
122
|
+
let current = err;
|
|
123
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth++) {
|
|
124
|
+
yield current;
|
|
125
|
+
current = current.cause;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** The first allowlisted `code` on `err` or its causes. */
|
|
129
|
+
export function transportCode(err) {
|
|
130
|
+
for (const error of causes(err)) {
|
|
131
|
+
const code = error.code;
|
|
132
|
+
if (typeof code === "string" &&
|
|
133
|
+
(TRANSPORT_CODES.has(code) || CODE_FAMILIES.some((family) => family.test(code)))) {
|
|
134
|
+
return code;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
/** Codes that say the connection closed or was reset under the request. */
|
|
140
|
+
const CLOSED_CODES = new Set([
|
|
141
|
+
"UND_ERR_SOCKET",
|
|
142
|
+
"ECONNRESET",
|
|
143
|
+
"EPIPE",
|
|
144
|
+
"ECONNABORTED",
|
|
145
|
+
]);
|
|
146
|
+
/**
|
|
147
|
+
* Which failure a rejected request is, read by the error's name and code
|
|
148
|
+
* only. A head the transport cannot take — an `HTTPParserError` (an `HPE_*`
|
|
149
|
+
* code where the runtime sets one), `UND_ERR_HEADERS_OVERFLOW` — is
|
|
150
|
+
* `malformed_response`. A connection closed or reset under the request —
|
|
151
|
+
* undici's `UND_ERR_SOCKET` ("other side closed"), `ECONNRESET`, `EPIPE` — is
|
|
152
|
+
* `connection_closed`, whatever the socket had read: undici counts bytes over
|
|
153
|
+
* the socket's life, and fetch reuses keep-alive connections, so a pooled
|
|
154
|
+
* connection closed between two requests has read an entire earlier answer.
|
|
155
|
+
* Anything else is `unreachable`.
|
|
156
|
+
*/
|
|
157
|
+
function requestFailureReason(err) {
|
|
158
|
+
let closed = false;
|
|
159
|
+
for (const error of causes(err)) {
|
|
160
|
+
const { name, code } = error;
|
|
161
|
+
if (name === "HTTPParserError")
|
|
162
|
+
return "malformed_response";
|
|
163
|
+
if (typeof code !== "string")
|
|
164
|
+
continue;
|
|
165
|
+
if (code.startsWith("HPE_") || code === "UND_ERR_HEADERS_OVERFLOW") {
|
|
166
|
+
return "malformed_response";
|
|
167
|
+
}
|
|
168
|
+
if (CLOSED_CODES.has(code))
|
|
169
|
+
closed = true;
|
|
170
|
+
}
|
|
171
|
+
return closed ? "connection_closed" : "unreachable";
|
|
172
|
+
}
|
|
173
|
+
const withCode = (message, code) => code === undefined ? message : `${message} (${code})`;
|
|
174
|
+
/**
|
|
175
|
+
* A request that failed before a response could be taken, as its reason's
|
|
176
|
+
* message — `unreachable`, `closed` or `malformed` — with the code, when
|
|
177
|
+
* there is one. Only `unreachable` points an operator at the network.
|
|
178
|
+
*/
|
|
179
|
+
export function requestFailure(err, messages) {
|
|
180
|
+
const code = transportCode(err);
|
|
181
|
+
const reason = requestFailureReason(err);
|
|
182
|
+
const message = reason === "malformed_response"
|
|
183
|
+
? messages.malformed
|
|
184
|
+
: reason === "connection_closed"
|
|
185
|
+
? messages.closed
|
|
186
|
+
: messages.unreachable;
|
|
187
|
+
return new StoreTransportError(withCode(message, code), reason, code);
|
|
188
|
+
}
|
|
189
|
+
/** An answer whose body broke before it was read. */
|
|
190
|
+
export function readFailure(err, message) {
|
|
191
|
+
const code = transportCode(err);
|
|
192
|
+
return new StoreTransportError(withCode(message, code), "unreadable", code);
|
|
193
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a `WWW-Authenticate` value carries a `Bearer` challenge (RFC 6750
|
|
3
|
+
* §3). Quoted strings are blanked first, so `realm="… Bearer …"` is not one;
|
|
4
|
+
* several header lines arrive joined by `, `, which this reads as the
|
|
5
|
+
* challenge list it is. A malformed line — a quoted string never closed —
|
|
6
|
+
* hides whatever follows it, including a challenge on a later line: the
|
|
7
|
+
* answer is then read as it would be without one, never the other way.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasBearerChallenge(value: string | null): boolean;
|
|
10
|
+
//# sourceMappingURL=wwwAuthenticate.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wwwAuthenticate.d.mts","sourceRoot":"","sources":["../../src/repositories/wwwAuthenticate.mts"],"names":[],"mappings":"AAmDA;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAEhE"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Reading a `WWW-Authenticate` answer from the Store (RFC 9110 §11.6.1).
|
|
18
|
+
*
|
|
19
|
+
* `HttpUserRepository` asks one question of it: does the answer carry a
|
|
20
|
+
* `Bearer` challenge (RFC 6750 §3) — the Store saying it refused the
|
|
21
|
+
* credential this deployment presented, not the user. Only the scheme is
|
|
22
|
+
* read; nothing the Store wrote after it is kept, so nothing it wrote can
|
|
23
|
+
* reach an error this adapter throws.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* A quoted-string (RFC 9110 §5.6.4), escapes included — and one left
|
|
27
|
+
* unterminated runs to the end of the value, so what follows its opening
|
|
28
|
+
* quote is its content, never a challenge. Each character is consumed once
|
|
29
|
+
* (a backslash with the character after it, when there is one), so a value
|
|
30
|
+
* of escaped quotes with no closing one costs a scan: matching up to a
|
|
31
|
+
* closing quote that never comes retried from every quote, which a 64 KiB
|
|
32
|
+
* value turned into seconds per answer.
|
|
33
|
+
*/
|
|
34
|
+
const QUOTED_STRING = /"(?:[^"\\]|\\[\s\S]?)*(?:"|$)/g;
|
|
35
|
+
/**
|
|
36
|
+
* An auth-scheme `Bearer`, case-insensitive (RFC 9110 §11.1), where a
|
|
37
|
+
* challenge begins — the value's start or after a comma — and followed by the
|
|
38
|
+
* value's end, a comma, or whitespace that does not lead to `=` (which would
|
|
39
|
+
* make `bearer` a parameter's name, not a scheme: `bearer = 1` is one).
|
|
40
|
+
*
|
|
41
|
+
* The whitespace after the scheme is taken whole, once: the lookahead
|
|
42
|
+
* captures the entire run and the backreference consumes exactly it, and a
|
|
43
|
+
* lookahead is never re-entered, so a long run followed by `=` is not
|
|
44
|
+
* retried from every shorter run. `(?<=[ \t])` then requires that the run
|
|
45
|
+
* was not empty, so `bearerish` is not the scheme.
|
|
46
|
+
*/
|
|
47
|
+
const BEARER_CHALLENGE = /(?:^|,)[ \t]*bearer(?=([ \t]*))\1(?:$|,|(?<=[ \t])(?!=))/i;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a `WWW-Authenticate` value carries a `Bearer` challenge (RFC 6750
|
|
50
|
+
* §3). Quoted strings are blanked first, so `realm="… Bearer …"` is not one;
|
|
51
|
+
* several header lines arrive joined by `, `, which this reads as the
|
|
52
|
+
* challenge list it is. A malformed line — a quoted string never closed —
|
|
53
|
+
* hides whatever follows it, including a challenge on a later line: the
|
|
54
|
+
* answer is then read as it would be without one, never the other way.
|
|
55
|
+
*/
|
|
56
|
+
export function hasBearerChallenge(value) {
|
|
57
|
+
return value !== null && BEARER_CHALLENGE.test(value.replace(QUOTED_STRING, '""'));
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-provider-foundation",
|
|
3
3
|
"description": "Production HTTP user-authentication adapter for auth.provider",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.0.0"
|
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
-
"README.md"
|
|
21
|
-
"LICENSE"
|
|
20
|
+
"README.md"
|
|
22
21
|
],
|
|
23
22
|
"repository": {
|
|
24
23
|
"type": "git",
|
|
@@ -32,15 +31,14 @@
|
|
|
32
31
|
}
|
|
33
32
|
},
|
|
34
33
|
"peerDependencies": {
|
|
35
|
-
"@o3co/auth-provider-core": "^0.
|
|
34
|
+
"@o3co/auth-provider-core": "^0.16.0"
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"@types/node": "^26.5.1",
|
|
39
38
|
"msw": "^2.15.0",
|
|
40
|
-
"pino": "^10.3.1",
|
|
41
39
|
"vitest": "^5.0.0",
|
|
42
40
|
"@vitest/coverage-v8": "^5.0.0",
|
|
43
|
-
"@o3co/auth-provider-core": "0.
|
|
41
|
+
"@o3co/auth-provider-core": "0.16.0"
|
|
44
42
|
},
|
|
45
43
|
"scripts": {
|
|
46
44
|
"prebuild": "rimraf dist",
|