@o3co/auth-provider-foundation 0.9.0 → 0.10.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 +16 -0
- package/README.md +44 -0
- package/dist/endpointUrl.d.mts +59 -0
- package/dist/endpointUrl.d.mts.map +1 -0
- package/dist/endpointUrl.mjs +129 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +31 -14
- package/dist/repositories/HttpUserRepository.d.mts +20 -1
- package/dist/repositories/HttpUserRepository.d.mts.map +1 -1
- package/dist/repositories/HttpUserRepository.mjs +158 -5
- package/package.json +7 -7
package/README.ja.md
CHANGED
|
@@ -41,6 +41,7 @@ class HttpUserRepository implements UserRepository {
|
|
|
41
41
|
authenticateUrl: string; // ユーザー名・パスワード認証用の POST エンドポイント
|
|
42
42
|
authenticateByTokenUrl: string; // トークン認証用の POST エンドポイント
|
|
43
43
|
timeout: number; // リクエストタイムアウト(ミリ秒)
|
|
44
|
+
maxResponseBytes?: number; // レスポンスボディの上限、デフォルト 1 MiB
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
// authenticateUrl に対して { email, password } を POST
|
|
@@ -53,6 +54,21 @@ class HttpUserRepository implements UserRepository {
|
|
|
53
54
|
|
|
54
55
|
- HTTP 401 または 403 の場合は `null` を返す。
|
|
55
56
|
- その他の非 OK ステータスの場合はエラーをスローする。
|
|
57
|
+
- 上流が 2xx で `User`(`{ id: string, username: string, … }`)以外の JSON を返した場合もスローする — 「ユーザーが見つからない」ではなく「上流が壊れている」ケースのため。
|
|
58
|
+
|
|
59
|
+
### コンストラクタでの検証
|
|
60
|
+
|
|
61
|
+
すべてのオプションは**コンストラクタ**で検証される。設定を誤ったデプロイは最初のログイン時ではなく起動時に失敗する。
|
|
62
|
+
|
|
63
|
+
**両方の URL は `https://` でなければならない。** これらは平文のユーザー資格情報(`authenticateUrl` にはパスワード、`authenticateByTokenUrl` にはトークン)を運ぶため、`http://` は接続を弱めるだけでなく、経路上のすべてのホップに資格情報を公開する。
|
|
64
|
+
|
|
65
|
+
**唯一の例外は loopback。** ホストが `localhost`、`127.0.0.0/8` 内のアドレス、`[::1]` のいずれかであれば `http://` を許可する。この通信はマシンの外に出ないため、ローカル開発およびインプロセスのテストフィクスチャは証明書を必要としない。それ以外のホストは**プライベートレンジのアドレスやコンテナネットワークのサービス名を含めて** `https://` が必須(`http://10.0.0.5/…`、`http://user-service/…` は拒否される)。これらはデプロイが端から端まで制御していないネットワークを越えるものであり、「内部」は「暗号化済み」の同義語ではない。URL に資格情報を埋め込んだもの(`https://user:pass@…`)も拒否する。
|
|
66
|
+
|
|
67
|
+
これは [`@o3co/auth-provider-core`](../core/README.ja.md) の `oauth.jwt.issuer` と同じルールで、例外の範囲を `127.0.0.1` 単一アドレスから `127.0.0.0/8` ブロック全体へ広げ、クエリ文字列を許可している(issuer は持てないが、POST エンドポイントは正当に持ちうる)。
|
|
68
|
+
|
|
69
|
+
**`timeout` は `2147483647` ミリ秒以下の正の整数でなければならない。** `0`・負数・`NaN` は `setTimeout` ではいずれも「即時発火」に丸められ(=すべてのリクエストが中断される)、Node のタイマー範囲を超える値は 1ms に丸められるため、「長めに待つ」つもりの設定が最短のタイムアウトになってしまう。デッドラインは**ボディ読み取りを含む**やり取り全体に適用される。ボディ読み取りは abort signal 頼みではなくデッドラインとの race で打ち切る — 実行中の `read()` は abort では確実に中断されないため。これはヘッダだけ即座に返してボディを止める slow-loris の形であり、race がなければ永久にハングする。超過したリクエストはエンドポイント名を含む `timed out after <n>ms` エラーで reject される。
|
|
70
|
+
|
|
71
|
+
**`maxResponseBytes` は正の整数でなければならない。** デフォルトは `DEFAULT_MAX_RESPONSE_BYTES`(1 MiB)。上限は `Content-Length` に対してもストリーム読み取り中にも適用されるため、ヘッダを省略する(あるいは偽る)上流も途中で打ち切られ、メモリを食い潰すことはできない。
|
|
56
72
|
|
|
57
73
|
## 使い方
|
|
58
74
|
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ class HttpUserRepository implements UserRepository {
|
|
|
41
41
|
authenticateUrl: string; // POST endpoint for username/password auth
|
|
42
42
|
authenticateByTokenUrl: string; // POST endpoint for token-based auth
|
|
43
43
|
timeout: number; // request timeout in milliseconds
|
|
44
|
+
maxResponseBytes?: number; // response body cap, default 1 MiB
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
// POST authenticateUrl with body: { email, password }
|
|
@@ -53,6 +54,49 @@ class HttpUserRepository implements UserRepository {
|
|
|
53
54
|
|
|
54
55
|
- Returns `null` on HTTP 401 or 403.
|
|
55
56
|
- Throws an error on any other non-OK HTTP status.
|
|
57
|
+
- Throws when the upstream returns 2xx with a body that is not a JSON `User`
|
|
58
|
+
(`{ id: string, username: string, … }`) — an upstream failure, not a
|
|
59
|
+
"user not found".
|
|
60
|
+
|
|
61
|
+
### Constructor validation
|
|
62
|
+
|
|
63
|
+
Every option is validated in the **constructor**, so a misconfigured deployment
|
|
64
|
+
fails at boot rather than at the first login attempt.
|
|
65
|
+
|
|
66
|
+
**Both URLs must use `https://`.** They carry plaintext user credentials — a
|
|
67
|
+
password on `authenticateUrl`, a token on `authenticateByTokenUrl` — so an
|
|
68
|
+
`http://` URL does not merely weaken the connection, it publishes the credential
|
|
69
|
+
to every hop on the path.
|
|
70
|
+
|
|
71
|
+
**The one carve-out is loopback:** `http://` is accepted when the host is
|
|
72
|
+
`localhost`, an address in `127.0.0.0/8`, or `[::1]`. That traffic never leaves
|
|
73
|
+
the machine, so local development and in-process test fixtures need no
|
|
74
|
+
certificate. Every other host must use `https://`, **including private-range
|
|
75
|
+
addresses and container-network service names** (`http://10.0.0.5/…`,
|
|
76
|
+
`http://user-service/…` are refused): those cross a network the deployment does
|
|
77
|
+
not control end to end, and "internal" is not a synonym for "encrypted". URLs
|
|
78
|
+
embedding credentials (`https://user:pass@…`) are refused as well.
|
|
79
|
+
|
|
80
|
+
This is the same rule `oauth.jwt.issuer` applies in
|
|
81
|
+
[`@o3co/auth-provider-core`](../core/README.md), with the carve-out widened from
|
|
82
|
+
the single address `127.0.0.1` to the whole `127.0.0.0/8` block, and query
|
|
83
|
+
strings allowed (an issuer may not carry one; a POST endpoint legitimately may).
|
|
84
|
+
|
|
85
|
+
**`timeout` must be a positive integer** no greater than `2147483647`
|
|
86
|
+
milliseconds. `0`, a negative number, and `NaN` all clamp to "fire immediately"
|
|
87
|
+
in `setTimeout` — which would abort every request — and a value above Node's
|
|
88
|
+
timer range clamps to 1ms, turning "be patient" into the most impatient setting
|
|
89
|
+
available. The deadline covers the whole exchange, **body included**: the body
|
|
90
|
+
read is raced against it rather than relying on the abort signal, because
|
|
91
|
+
aborting a request does not reliably interrupt a read already in flight. That is
|
|
92
|
+
the slow-loris shape — headers arrive promptly, then the body dribbles or stops
|
|
93
|
+
— and without the race it hangs forever. A request that outlives the deadline
|
|
94
|
+
rejects with a `timed out after <n>ms` error naming the endpoint.
|
|
95
|
+
|
|
96
|
+
**`maxResponseBytes` must be a positive integer**, defaulting to
|
|
97
|
+
`DEFAULT_MAX_RESPONSE_BYTES` (1 MiB). The cap is enforced against
|
|
98
|
+
`Content-Length` *and* while streaming, so a Store that omits the header — or
|
|
99
|
+
lies in it — is still cut off rather than allowed to exhaust memory.
|
|
56
100
|
|
|
57
101
|
## Usage Example
|
|
58
102
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport validation for the Store endpoints `HttpUserRepository` posts to.
|
|
3
|
+
*
|
|
4
|
+
* These endpoints receive **plaintext user credentials** — a password on
|
|
5
|
+
* `authenticateUrl`, a bearer-ish token on `authenticateByTokenUrl`. An
|
|
6
|
+
* `http://` URL therefore does not merely weaken the connection, it publishes
|
|
7
|
+
* the credential to every hop on the path. A single mistyped environment
|
|
8
|
+
* variable was enough to do that, and nothing anywhere refused it (#285).
|
|
9
|
+
*
|
|
10
|
+
* ## The loopback carve-out
|
|
11
|
+
*
|
|
12
|
+
* `http://` is accepted for **loopback hosts only** — `localhost`, anything in
|
|
13
|
+
* `127.0.0.0/8`, and `[::1]`. Traffic to a loopback address never leaves the
|
|
14
|
+
* machine, so there is no path to eavesdrop on, and requiring TLS there would
|
|
15
|
+
* force every local development setup and every in-process test fixture to
|
|
16
|
+
* provision a certificate for no security gain. Any other host — including a
|
|
17
|
+
* private-range address such as `10.0.0.5` or a service name on a container
|
|
18
|
+
* network — must use `https://`: those cross a network the deployment does not
|
|
19
|
+
* control end to end, and "internal" is not a synonym for "encrypted".
|
|
20
|
+
*
|
|
21
|
+
* The rule matches `oauth.jwt.issuer`'s (`checkCanonicalIssuer` in
|
|
22
|
+
* `@o3co/auth-provider-core`) so operators meet one policy, not two. It is kept
|
|
23
|
+
* separate rather than reused because the constraints genuinely differ: an
|
|
24
|
+
* issuer may not carry a query string or fragment (OIDC Discovery derives the
|
|
25
|
+
* metadata URL from it), while a Store endpoint is an ordinary POST target for
|
|
26
|
+
* which `?tenant=acme` is legitimate. The loopback *predicate* the carve-out
|
|
27
|
+
* runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
|
|
28
|
+
* `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
|
|
29
|
+
* session redirect policy consumes, so the carve-outs cannot drift apart.
|
|
30
|
+
*/
|
|
31
|
+
import { isLoopbackHostname } from "@o3co/auth-provider-core";
|
|
32
|
+
export { isLoopbackHostname };
|
|
33
|
+
/** Why a candidate endpoint was rejected, phrased for a boot-time error. */
|
|
34
|
+
export type EndpointRejection = "not-a-string" | "empty" | "not-absolute-url" | "unsupported-scheme" | "insecure-scheme" | "has-credentials";
|
|
35
|
+
/**
|
|
36
|
+
* Returns `null` when `value` is a usable Store endpoint, otherwise the reason
|
|
37
|
+
* it is not. See the module comment for the loopback carve-out.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkSecureEndpoint(value: unknown): EndpointRejection | null;
|
|
40
|
+
/**
|
|
41
|
+
* Operator-facing explanation for each rejection reason.
|
|
42
|
+
*
|
|
43
|
+
* This is what an operator reads at boot when their Store URL is refused, so
|
|
44
|
+
* every message states the **actual** rule rather than a simplification of it.
|
|
45
|
+
* In particular both scheme messages name the loopback carve-out: saying only
|
|
46
|
+
* "must use https" would contradict a policy that does accept `http://` on
|
|
47
|
+
* loopback, and send someone hunting for a certificate they do not need.
|
|
48
|
+
*/
|
|
49
|
+
export declare function describeEndpointRejection(reason: EndpointRejection): string;
|
|
50
|
+
/**
|
|
51
|
+
* Returns `value` when it is a usable Store endpoint, otherwise throws naming
|
|
52
|
+
* `field` and the reason.
|
|
53
|
+
*
|
|
54
|
+
* The rejected value is deliberately **not** echoed into the message: it is
|
|
55
|
+
* operator-supplied configuration that may embed a secret, and a boot error
|
|
56
|
+
* lands in logs.
|
|
57
|
+
*/
|
|
58
|
+
export declare function assertSecureEndpoint(value: unknown, field: string): string;
|
|
59
|
+
//# sourceMappingURL=endpointUrl.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"endpointUrl.d.mts","sourceRoot":"","sources":["../src/endpointUrl.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAC1B,cAAc,GACd,OAAO,GACP,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,iBAAiB,CAAC;AAKrB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAyB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAS1E"}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* Transport validation for the Store endpoints `HttpUserRepository` posts to.
|
|
18
|
+
*
|
|
19
|
+
* These endpoints receive **plaintext user credentials** — a password on
|
|
20
|
+
* `authenticateUrl`, a bearer-ish token on `authenticateByTokenUrl`. An
|
|
21
|
+
* `http://` URL therefore does not merely weaken the connection, it publishes
|
|
22
|
+
* the credential to every hop on the path. A single mistyped environment
|
|
23
|
+
* variable was enough to do that, and nothing anywhere refused it (#285).
|
|
24
|
+
*
|
|
25
|
+
* ## The loopback carve-out
|
|
26
|
+
*
|
|
27
|
+
* `http://` is accepted for **loopback hosts only** — `localhost`, anything in
|
|
28
|
+
* `127.0.0.0/8`, and `[::1]`. Traffic to a loopback address never leaves the
|
|
29
|
+
* machine, so there is no path to eavesdrop on, and requiring TLS there would
|
|
30
|
+
* force every local development setup and every in-process test fixture to
|
|
31
|
+
* provision a certificate for no security gain. Any other host — including a
|
|
32
|
+
* private-range address such as `10.0.0.5` or a service name on a container
|
|
33
|
+
* network — must use `https://`: those cross a network the deployment does not
|
|
34
|
+
* control end to end, and "internal" is not a synonym for "encrypted".
|
|
35
|
+
*
|
|
36
|
+
* The rule matches `oauth.jwt.issuer`'s (`checkCanonicalIssuer` in
|
|
37
|
+
* `@o3co/auth-provider-core`) so operators meet one policy, not two. It is kept
|
|
38
|
+
* separate rather than reused because the constraints genuinely differ: an
|
|
39
|
+
* issuer may not carry a query string or fragment (OIDC Discovery derives the
|
|
40
|
+
* metadata URL from it), while a Store endpoint is an ordinary POST target for
|
|
41
|
+
* which `?tenant=acme` is legitimate. The loopback *predicate* the carve-out
|
|
42
|
+
* runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
|
|
43
|
+
* `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
|
|
44
|
+
* session redirect policy consumes, so the carve-outs cannot drift apart.
|
|
45
|
+
*/
|
|
46
|
+
import { isLoopbackHostname } from "@o3co/auth-provider-core";
|
|
47
|
+
// Re-exported unchanged: this module's callers (and its tests) read the
|
|
48
|
+
// predicate as part of the endpoint-validation surface. The definition lives
|
|
49
|
+
// in core (#364) — re-exporting is the sanctioned way to surface it.
|
|
50
|
+
export { isLoopbackHostname };
|
|
51
|
+
/** Matches `scheme://` — the shape that distinguishes an absolute URL from a bare host. */
|
|
52
|
+
const ABSOLUTE_URL_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
53
|
+
/**
|
|
54
|
+
* Returns `null` when `value` is a usable Store endpoint, otherwise the reason
|
|
55
|
+
* it is not. See the module comment for the loopback carve-out.
|
|
56
|
+
*/
|
|
57
|
+
export function checkSecureEndpoint(value) {
|
|
58
|
+
if (typeof value !== "string")
|
|
59
|
+
return "not-a-string";
|
|
60
|
+
if (value === "")
|
|
61
|
+
return "empty";
|
|
62
|
+
// `new URL("users.example.com:3000")` succeeds with `users.example.com:` as
|
|
63
|
+
// the scheme and no host, so a bare host is reported as the missing-scheme
|
|
64
|
+
// mistake it is rather than as an exotic scheme.
|
|
65
|
+
if (!ABSOLUTE_URL_PREFIX.test(value))
|
|
66
|
+
return "not-absolute-url";
|
|
67
|
+
let url;
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(value);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "not-absolute-url";
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
75
|
+
return "unsupported-scheme";
|
|
76
|
+
// No empty-host check is needed below: `http` and `https` are "special"
|
|
77
|
+
// schemes, for which the WHATWG parser requires a non-empty host — a
|
|
78
|
+
// host-less `https://` or `https://:8080/x` throws above and is reported as
|
|
79
|
+
// `not-absolute-url` rather than reaching here with `hostname === ""`.
|
|
80
|
+
if (url.protocol === "http:" && !isLoopbackHostname(url.hostname))
|
|
81
|
+
return "insecure-scheme";
|
|
82
|
+
if (url.username !== "" || url.password !== "")
|
|
83
|
+
return "has-credentials";
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Operator-facing explanation for each rejection reason.
|
|
88
|
+
*
|
|
89
|
+
* This is what an operator reads at boot when their Store URL is refused, so
|
|
90
|
+
* every message states the **actual** rule rather than a simplification of it.
|
|
91
|
+
* In particular both scheme messages name the loopback carve-out: saying only
|
|
92
|
+
* "must use https" would contradict a policy that does accept `http://` on
|
|
93
|
+
* loopback, and send someone hunting for a certificate they do not need.
|
|
94
|
+
*/
|
|
95
|
+
export function describeEndpointRejection(reason) {
|
|
96
|
+
switch (reason) {
|
|
97
|
+
case "not-a-string":
|
|
98
|
+
return "must be a string";
|
|
99
|
+
case "empty":
|
|
100
|
+
return 'must not be empty (an unset or blank environment variable substitutes as "")';
|
|
101
|
+
case "not-absolute-url":
|
|
102
|
+
return ("must be an absolute URL with a host (e.g. https://users.example.com/authenticate), " +
|
|
103
|
+
"not a bare host or a path");
|
|
104
|
+
case "unsupported-scheme":
|
|
105
|
+
return ("must use https, or http for a loopback host (localhost, 127.0.0.0/8, [::1]) — " +
|
|
106
|
+
"no other scheme is accepted");
|
|
107
|
+
case "insecure-scheme":
|
|
108
|
+
return ("must use https — it carries plaintext user credentials; http is accepted only for " +
|
|
109
|
+
"a loopback host (localhost, 127.0.0.0/8, [::1])");
|
|
110
|
+
case "has-credentials":
|
|
111
|
+
return "must not embed credentials in the URL";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Returns `value` when it is a usable Store endpoint, otherwise throws naming
|
|
116
|
+
* `field` and the reason.
|
|
117
|
+
*
|
|
118
|
+
* The rejected value is deliberately **not** echoed into the message: it is
|
|
119
|
+
* operator-supplied configuration that may embed a secret, and a boot error
|
|
120
|
+
* lands in logs.
|
|
121
|
+
*/
|
|
122
|
+
export function assertSecureEndpoint(value, field) {
|
|
123
|
+
const rejection = checkSecureEndpoint(value);
|
|
124
|
+
if (rejection !== null) {
|
|
125
|
+
throw new Error(`HttpUserRepository: "${field}" ${describeEndpointRejection(rejection)} ` +
|
|
126
|
+
`(reason: ${rejection})`);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -2,5 +2,5 @@ import type { AdapterFactory, UserRepository } from "@o3co/auth-provider-core";
|
|
|
2
2
|
export declare const registerBuiltinAdapters: (factories: {
|
|
3
3
|
userFactory: AdapterFactory<UserRepository>;
|
|
4
4
|
}) => void;
|
|
5
|
-
export { HttpUserRepository } from "./repositories/HttpUserRepository.mjs";
|
|
5
|
+
export { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
|
|
6
6
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA6B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IAkBH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,kBAAkB,GAClB,MAAM,uCAAuC,CAAC"}
|
package/dist/index.mjs
CHANGED
|
@@ -13,7 +13,31 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import { HttpUserRepository } from "./repositories/HttpUserRepository.mjs";
|
|
16
|
+
import { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
|
|
17
|
+
/** Default request deadline, in milliseconds, when the config names none. */
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
19
|
+
/**
|
|
20
|
+
* Coerces a numeric config value that may arrive as a string.
|
|
21
|
+
*
|
|
22
|
+
* HOCON environment substitution yields strings, so `CLIENT_USER_TIMEOUT=1234`
|
|
23
|
+
* reaches the builder as `"1234"`. An *absent* key takes `fallback`; anything
|
|
24
|
+
* present but unreadable becomes `NaN` and is rejected by the constructor,
|
|
25
|
+
* which owns the range rules and names the field in its message.
|
|
26
|
+
*
|
|
27
|
+
* The distinction matters because HOCON substitutes a **blank** environment
|
|
28
|
+
* variable as `""` — a very ordinary shape in a `.env` file or an empty
|
|
29
|
+
* ConfigMap key — and `Number("")` is `0`. This used to fall back to the
|
|
30
|
+
* default, hiding the misconfiguration; it is now a boot failure (#285).
|
|
31
|
+
*/
|
|
32
|
+
const toNumber = (value, fallback) => {
|
|
33
|
+
if (value === undefined || value === null)
|
|
34
|
+
return fallback;
|
|
35
|
+
if (typeof value === "number")
|
|
36
|
+
return value;
|
|
37
|
+
if (typeof value === "string")
|
|
38
|
+
return Number(value.trim());
|
|
39
|
+
return Number.NaN;
|
|
40
|
+
};
|
|
17
41
|
export const registerBuiltinAdapters = (factories) => {
|
|
18
42
|
factories.userFactory.register("http", (config) => {
|
|
19
43
|
if (typeof config.authenticateUrl !== "string") {
|
|
@@ -22,22 +46,15 @@ export const registerBuiltinAdapters = (factories) => {
|
|
|
22
46
|
if (typeof config.authenticateByTokenUrl !== "string") {
|
|
23
47
|
throw new Error('HttpUserRepository requires "authenticateByTokenUrl" in config');
|
|
24
48
|
}
|
|
49
|
+
// Every remaining check — https-or-loopback, positive-integer timeout,
|
|
50
|
+
// positive-integer cap — lives in the constructor, so a repository built
|
|
51
|
+
// by hand is validated exactly as one built from config.
|
|
25
52
|
return new HttpUserRepository({
|
|
26
53
|
authenticateUrl: config.authenticateUrl,
|
|
27
54
|
authenticateByTokenUrl: config.authenticateByTokenUrl,
|
|
28
|
-
timeout: (
|
|
29
|
-
|
|
30
|
-
return config.timeout;
|
|
31
|
-
}
|
|
32
|
-
if (typeof config.timeout === "string") {
|
|
33
|
-
const n = Number(config.timeout);
|
|
34
|
-
if (Number.isFinite(n) && n > 0) {
|
|
35
|
-
return n;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return 5000;
|
|
39
|
-
})(),
|
|
55
|
+
timeout: toNumber(config.timeout, DEFAULT_TIMEOUT_MS),
|
|
56
|
+
maxResponseBytes: toNumber(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
|
|
40
57
|
});
|
|
41
58
|
});
|
|
42
59
|
};
|
|
43
|
-
export { HttpUserRepository } from "./repositories/HttpUserRepository.mjs";
|
|
60
|
+
export { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import type { User, UserRepository } from "@o3co/auth-provider-core";
|
|
2
|
+
/**
|
|
3
|
+
* Default ceiling on an upstream response body, in bytes.
|
|
4
|
+
*
|
|
5
|
+
* A `User` record is a few hundred bytes; 1 MiB is generous for one carrying
|
|
6
|
+
* custom claims and small enough that a hostile or broken Store cannot walk the
|
|
7
|
+
* process out of memory one login at a time (#285).
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* `UserRepository` backed by an upstream HTTP user service ("the Store").
|
|
12
|
+
*
|
|
13
|
+
* Both endpoints receive plaintext user credentials, so both are validated at
|
|
14
|
+
* **construction**: a deployment configured with an `http://` Store URL fails
|
|
15
|
+
* at boot rather than leaking the first user's password (#285). `http://` is
|
|
16
|
+
* accepted for loopback hosts only — see `src/endpointUrl.mts` for the
|
|
17
|
+
* carve-out and its rationale.
|
|
18
|
+
*/
|
|
2
19
|
export declare class HttpUserRepository implements UserRepository {
|
|
3
20
|
private authenticateUrl;
|
|
4
21
|
private authenticateByTokenUrl;
|
|
5
22
|
private timeout;
|
|
6
|
-
|
|
23
|
+
private maxResponseBytes;
|
|
24
|
+
constructor({ authenticateUrl, authenticateByTokenUrl, timeout, maxResponseBytes, }: {
|
|
7
25
|
authenticateUrl: string;
|
|
8
26
|
authenticateByTokenUrl: string;
|
|
9
27
|
timeout: number;
|
|
28
|
+
maxResponseBytes?: number;
|
|
10
29
|
});
|
|
11
30
|
authenticate(username: string, password: string): Promise<User | null>;
|
|
12
31
|
authenticateByToken(token: string): Promise<User | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGrE;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAkItD;;;;;;;;GAQG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;gBAErB,EACX,eAAe,EACf,sBAAsB,EACtB,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IAqBK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAItE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;YAIhD,IAAI;CAqElB"}
|
|
@@ -13,6 +13,21 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
import { assertSecureEndpoint } from "../endpointUrl.mjs";
|
|
17
|
+
/**
|
|
18
|
+
* Default ceiling on an upstream response body, in bytes.
|
|
19
|
+
*
|
|
20
|
+
* A `User` record is a few hundred bytes; 1 MiB is generous for one carrying
|
|
21
|
+
* custom claims and small enough that a hostile or broken Store cannot walk the
|
|
22
|
+
* process out of memory one login at a time (#285).
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Largest delay Node's timer subsystem represents. Anything above it is
|
|
27
|
+
* silently clamped to 1ms — so an operator writing a very large number meaning
|
|
28
|
+
* "be patient" would otherwise get the most impatient timeout possible.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
16
31
|
/**
|
|
17
32
|
* Runtime guard for the upstream user-service response. The previous
|
|
18
33
|
* `(await res.json()) as User` was a compile-time cast only — a malformed
|
|
@@ -34,14 +49,120 @@ function isUser(v) {
|
|
|
34
49
|
const o = v;
|
|
35
50
|
return typeof o.id === "string" && typeof o.username === "string";
|
|
36
51
|
}
|
|
52
|
+
/** Whether `value` is a positive integer that fits `bound`. */
|
|
53
|
+
function isPositiveIntegerWithin(value, bound) {
|
|
54
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Releases a response body we are not going to read.
|
|
58
|
+
*
|
|
59
|
+
* Without this an error or 401 leaves the body unconsumed, and undici holds the
|
|
60
|
+
* socket until the response is garbage collected rather than returning it to
|
|
61
|
+
* the keep-alive pool — a slow leak on the failure path, which is exactly the
|
|
62
|
+
* path a struggling deployment spends its time on.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately not awaited: cancelling is a signal to the transport, and how
|
|
65
|
+
* long the peer takes to act on it is the peer's business. Awaiting would hand
|
|
66
|
+
* a hostile Store a second way to stall the caller — the one the request
|
|
67
|
+
* deadline exists to close — and some interceptors never settle it at all.
|
|
68
|
+
*/
|
|
69
|
+
function discardBody(res) {
|
|
70
|
+
res.body?.cancel().catch(() => {
|
|
71
|
+
// Already consumed, already errored, or aborted — nothing to release.
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads at most `limit` bytes of `res` and returns them as text, throwing once
|
|
76
|
+
* the limit is passed.
|
|
77
|
+
*
|
|
78
|
+
* `Content-Length` is checked first so an honest oversized response is refused
|
|
79
|
+
* before a byte of it is read, but the streaming count is the load-bearing
|
|
80
|
+
* half: a hostile Store simply omits the header (or lies), and chunked transfer
|
|
81
|
+
* encoding has none to omit.
|
|
82
|
+
*/
|
|
83
|
+
async function readBodyCapped(res, limit, url, deadline) {
|
|
84
|
+
const declared = Number(res.headers.get("content-length"));
|
|
85
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
86
|
+
discardBody(res);
|
|
87
|
+
throw new Error(`HttpUserRepository: upstream ${url} response exceeds the ${limit}-byte cap ` +
|
|
88
|
+
`(Content-Length: ${declared})`);
|
|
89
|
+
}
|
|
90
|
+
if (res.body === null)
|
|
91
|
+
return "";
|
|
92
|
+
const reader = res.body.getReader();
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
let text = "";
|
|
95
|
+
let read = 0;
|
|
96
|
+
try {
|
|
97
|
+
for (;;) {
|
|
98
|
+
// Raced against the deadline rather than relying on `signal` alone:
|
|
99
|
+
// aborting a request does not reliably interrupt a `read()` already
|
|
100
|
+
// in flight, which is exactly the slow-loris shape — headers arrive
|
|
101
|
+
// promptly, then the body dribbles or stops. One absolute deadline
|
|
102
|
+
// for the whole exchange, not a fresh one per chunk.
|
|
103
|
+
const { done, value } = await Promise.race([reader.read(), deadline]);
|
|
104
|
+
if (done)
|
|
105
|
+
break;
|
|
106
|
+
read += value.byteLength;
|
|
107
|
+
if (read > limit) {
|
|
108
|
+
throw new Error(`HttpUserRepository: upstream ${url} response exceeds the ${limit}-byte cap`);
|
|
109
|
+
}
|
|
110
|
+
text += decoder.decode(value, { stream: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
// Tears down the connection when we bail out early; a no-op once the
|
|
115
|
+
// stream has completed on its own. Not awaited, for the reason given on
|
|
116
|
+
// `discardBody`.
|
|
117
|
+
reader.cancel().catch(() => { });
|
|
118
|
+
}
|
|
119
|
+
return text + decoder.decode();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether `err` is the abort our own deadline raised on the `fetch` itself —
|
|
123
|
+
* the case where the response headers never arrive.
|
|
124
|
+
*
|
|
125
|
+
* A deliberately shallow check. An aborted `fetch` rejects with the
|
|
126
|
+
* `AbortError` directly; the wrapping that `fetch` does apply is for network
|
|
127
|
+
* failures, which are not aborts. If some runtime did wrap one, the request
|
|
128
|
+
* still fails — it would simply surface the runtime's message instead of ours,
|
|
129
|
+
* which is a cosmetic difference and not worth an untestable `cause` walk.
|
|
130
|
+
* A stalled *body* is not covered here at all: that is the deadline race in
|
|
131
|
+
* `readBodyCapped`, which does not depend on abort semantics.
|
|
132
|
+
*/
|
|
133
|
+
function isAbortError(err) {
|
|
134
|
+
// Optional chaining rather than a `typeof` guard: it covers `null`,
|
|
135
|
+
// `undefined` and a thrown primitive in the same expression, with no
|
|
136
|
+
// branch that only a contrived throw could reach.
|
|
137
|
+
const name = err?.name;
|
|
138
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `UserRepository` backed by an upstream HTTP user service ("the Store").
|
|
142
|
+
*
|
|
143
|
+
* Both endpoints receive plaintext user credentials, so both are validated at
|
|
144
|
+
* **construction**: a deployment configured with an `http://` Store URL fails
|
|
145
|
+
* at boot rather than leaking the first user's password (#285). `http://` is
|
|
146
|
+
* accepted for loopback hosts only — see `src/endpointUrl.mts` for the
|
|
147
|
+
* carve-out and its rationale.
|
|
148
|
+
*/
|
|
37
149
|
export class HttpUserRepository {
|
|
38
150
|
authenticateUrl;
|
|
39
151
|
authenticateByTokenUrl;
|
|
40
152
|
timeout;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.
|
|
153
|
+
maxResponseBytes;
|
|
154
|
+
constructor({ authenticateUrl, authenticateByTokenUrl, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
|
|
155
|
+
this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
|
|
156
|
+
this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
|
|
157
|
+
if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
|
|
158
|
+
throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
|
|
159
|
+
`${MAX_TIMEOUT_MS} milliseconds`);
|
|
160
|
+
}
|
|
44
161
|
this.timeout = timeout;
|
|
162
|
+
if (!isPositiveIntegerWithin(maxResponseBytes, Number.MAX_SAFE_INTEGER)) {
|
|
163
|
+
throw new Error('HttpUserRepository: "maxResponseBytes" must be a positive integer');
|
|
164
|
+
}
|
|
165
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
45
166
|
}
|
|
46
167
|
async authenticate(username, password) {
|
|
47
168
|
return this.post(this.authenticateUrl, { email: username, password });
|
|
@@ -51,7 +172,22 @@ export class HttpUserRepository {
|
|
|
51
172
|
}
|
|
52
173
|
async post(url, body) {
|
|
53
174
|
const controller = new AbortController();
|
|
54
|
-
|
|
175
|
+
let timedOut = false;
|
|
176
|
+
// One absolute deadline for the whole exchange, expressed twice: as the
|
|
177
|
+
// abort signal `fetch` understands, and as a promise the body read can be
|
|
178
|
+
// raced against. `.catch` is attached up front so an exchange that
|
|
179
|
+
// finishes first — the overwhelmingly common case, where the timer is
|
|
180
|
+
// cleared and this never rejects — cannot leave an unhandled rejection.
|
|
181
|
+
let fireDeadline = () => { };
|
|
182
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
183
|
+
fireDeadline = () => reject(new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`));
|
|
184
|
+
});
|
|
185
|
+
deadline.catch(() => { });
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
timedOut = true;
|
|
188
|
+
controller.abort();
|
|
189
|
+
fireDeadline();
|
|
190
|
+
}, this.timeout);
|
|
55
191
|
try {
|
|
56
192
|
const res = await fetch(url, {
|
|
57
193
|
method: "POST",
|
|
@@ -60,7 +196,17 @@ export class HttpUserRepository {
|
|
|
60
196
|
signal: controller.signal,
|
|
61
197
|
});
|
|
62
198
|
if (res.ok) {
|
|
63
|
-
const
|
|
199
|
+
const raw = await readBodyCapped(res, this.maxResponseBytes, url, deadline);
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(raw);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Same class of failure as the shape check below: the Store is
|
|
206
|
+
// broken, not the credential. Reported as ours rather than as a
|
|
207
|
+
// bare SyntaxError with no indication of where it came from.
|
|
208
|
+
throw new Error(`HttpUserRepository: upstream ${url} returned a non-JSON body`);
|
|
209
|
+
}
|
|
64
210
|
if (!isUser(parsed)) {
|
|
65
211
|
// Upstream returned 2xx with an unexpected shape — this is an
|
|
66
212
|
// "upstream is broken" case, not a "user not found" case, so
|
|
@@ -70,11 +216,18 @@ export class HttpUserRepository {
|
|
|
70
216
|
}
|
|
71
217
|
return parsed;
|
|
72
218
|
}
|
|
219
|
+
discardBody(res);
|
|
73
220
|
if (res.status === 401 || res.status === 403) {
|
|
74
221
|
return null;
|
|
75
222
|
}
|
|
76
223
|
throw new Error(`Unexpected HTTP status ${res.status} from ${url}`);
|
|
77
224
|
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (timedOut && isAbortError(err)) {
|
|
227
|
+
throw new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
78
231
|
finally {
|
|
79
232
|
clearTimeout(timer);
|
|
80
233
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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.10.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "./dist/index.mjs",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"@o3co/auth-provider-core": "^0.0.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@types/node": "^
|
|
39
|
-
"msw": "^2.
|
|
38
|
+
"@types/node": "^26.2.0",
|
|
39
|
+
"msw": "^2.15.0",
|
|
40
40
|
"pino": "^10.3.1",
|
|
41
|
-
"vitest": "^4.1.
|
|
42
|
-
"@vitest/coverage-v8": "^4.1.
|
|
43
|
-
"@o3co/auth-provider-core": "0.
|
|
41
|
+
"vitest": "^4.1.10",
|
|
42
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
43
|
+
"@o3co/auth-provider-core": "0.10.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"prebuild": "rimraf dist",
|