@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.
@@ -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.15.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.15.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.15.0"
41
+ "@o3co/auth-provider-core": "0.16.0"
44
42
  },
45
43
  "scripts": {
46
44
  "prebuild": "rimraf dist",