keycloak-sdk 1.0.0 → 1.0.1
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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/lib/keycloak_sdk/admin/call.rb +21 -1
- data/lib/keycloak_sdk/admin/clients.rb +5 -5
- data/lib/keycloak_sdk/admin/groups.rb +5 -5
- data/lib/keycloak_sdk/admin/realms.rb +3 -3
- data/lib/keycloak_sdk/admin/roles.rb +5 -5
- data/lib/keycloak_sdk/admin/users.rb +5 -5
- data/lib/keycloak_sdk/config.rb +24 -0
- data/lib/keycloak_sdk/jwks_store.rb +101 -5
- data/lib/keycloak_sdk/oidc_endpoints.rb +16 -3
- data/lib/keycloak_sdk/token_provider.rb +11 -0
- data/lib/keycloak_sdk/tokens.rb +40 -2
- data/lib/keycloak_sdk/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ced74e5c197f31bcea44f678715d2c2b30d8cfe6b597393a200bda451057f582
|
|
4
|
+
data.tar.gz: ff1f1781cfc00acfcca781217eedb6be8f76c7efc72f93b49940f9a85882f1b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e4564e3f092dc1161a80e4bd0bb854dd1c05b4c57dd9552db23c8607f5aace485dc5fd160dddcae0e7c27989ddf4b0f4a889e6f365345dc847504afd61c4dea
|
|
7
|
+
data.tar.gz: 2b4df9762901e5b1c7f43372952a298ee82392a6ab95cabbfd974c737213813266d7c206dcea5ee5f3509330f71a6674edc8ae5017c77a6769783cedb5356e16
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Authentication (OIDC / OAuth2) and the Admin REST API for [Keycloak](https://www
|
|
|
4
4
|
|
|
5
5
|
Part of a **nine-language polyglot SDK** (Java · Python · Node · Go · C# · PHP · Rust · Ruby · Kotlin) — one API surface, isomorphic across all of them: [github.com/xzawed/KeyCloakSDK](https://github.com/xzawed/KeyCloakSDK).
|
|
6
6
|
|
|
7
|
-
> **`1.0.
|
|
7
|
+
> **`1.0.1` is on RubyGems** — a patch release of security and correctness fixes on top of `1.0.0`, the first release carrying the stability guarantee. A bare `gem install keycloak-sdk` resolves it. Admin path segments are now escaped (an unescaped name could redirect a call to another endpoint), the token provider's default `inspect` no longer prints the cached access token, a token whose expiry is unknown is treated as expired, JWKS responses are size-capped, an empty `200` JWKS key set no longer replaces a good cached one, failed JWKS fetches back off instead of hitting the IdP on every validation, and a token response of the wrong shape is no longer returned as a success. ⚠️ **A `server_url` that is not an absolute http(s) URL now raises `ConfigError` when the config is built**, instead of a stdlib exception on the first call.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -64,8 +64,8 @@ The five admin resources — `users` / `clients` / `roles` / `groups` / `realms`
|
|
|
64
64
|
The SDK replaces the unsafe library defaults rather than inheriting them:
|
|
65
65
|
|
|
66
66
|
- **Algorithm pinning** — the header-supplied `alg` is never trusted, so `alg: none` and HS/RS confusion are rejected structurally: the pin is applied before key lookup and signature verification, not after.
|
|
67
|
-
- **Strict claim checks** — exact `iss` match, `aud` containment, mandatory `exp`, `nbf
|
|
68
|
-
- **DoS-safe JWKS** — a refetch is triggered only by an unresolved key ID and never by a bad signature, and is rate-limited to a minimum interval (`jwks_min_refetch`, 30s by default)
|
|
67
|
+
- **Strict claim checks** — exact `iss` match, `aud` containment, mandatory `exp`, `nbf` enforced when present, and a bounded clock skew.
|
|
68
|
+
- **DoS-safe JWKS** — a refetch is triggered only by an unresolved key ID and never by a bad signature, and is rate-limited to a minimum interval (`jwks_min_refetch`, 30s by default) — so **once the key set has been fetched**, no volume of forged tokens makes the SDK issue more than one JWKS request per interval. A *failed* fetch is bounded separately: consecutive failures back off exponentially (0.2s, doubling, capped at 5s, with jitter), and inside that window the SDK **fails fast without contacting the IdP**. So a cold cache during an IdP outage no longer turns every validation into a request (measured: 20 attempts → 1 request, down from 20). ⚠️ The SDK never sleeps — it returns the error immediately, so retry pacing stays the caller's decision.
|
|
69
69
|
- **OIDC nonce / `id_token` replay protection** — `create_authorization_request` always issues a nonce (same default as `state:`) and puts it on the authorization URL. Pass it back as `exchange_code(expected_nonce:)` and the SDK fully validates the `id_token` before comparing the nonce claim. Omit `expected_nonce:` and id_token validation is skipped (same opt-out as the other eight languages).
|
|
70
70
|
- **Secret handling** — `Config`, `TokenSet`, and `AuthorizationRequest` mask secrets and tokens in `#inspect` (`***`, no prefix leak), TLS verification is on by default, timeouts are always applied, and redirect-following middleware is never installed (SSRF hardening).
|
|
71
71
|
|
|
@@ -73,11 +73,11 @@ Masking covers this SDK's own `#inspect`; it cannot cover what your logging fram
|
|
|
73
73
|
|
|
74
74
|
## Versioning and support
|
|
75
75
|
|
|
76
|
-
This SDK is **`1.0`** and follows SemVer: a breaking change to the public API requires a **major** bump. That promise is machine-backed — CI diffs this lane's public API against the **previously published artifact** on every build (`yard diff`), and a removal
|
|
76
|
+
This SDK is **`1.0`** and follows SemVer: a breaking change to the public API requires a **major** bump. That promise is machine-backed — CI diffs this lane's public API against the **previously published artifact** on every build (`yard diff`), and a removal fails the build. ⚠️ **This lane’s gate is narrower than its siblings’**: Ruby has no equivalent of `japicmp`/`gorelease`, so the gate catches a **removed** public object but not a changed method signature — that case is caught by review, not by machine. ⚠️ **The gate compares the API _surface_.** A change that leaves the surface identical but alters behaviour is not caught by it, so read the release notes before upgrading.
|
|
77
77
|
|
|
78
78
|
Only the newest released version of each language SDK receives security fixes; there are no long-term-support lines and older releases are not backported to.
|
|
79
79
|
|
|
80
|
-
**Each of the nine languages versions independently.** All nine reached `1.0.0`
|
|
80
|
+
**Each of the nine languages versions independently.** All nine reached `1.0.0` in the same release wave because they earned the same guarantee at the same time — they do **not** move in lockstep afterwards.
|
|
81
81
|
|
|
82
82
|
## Documentation
|
|
83
83
|
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
3
5
|
module KeycloakSdk
|
|
4
6
|
module Admin
|
|
5
|
-
# 리소스 공용: 요청 실행 + 오류 경계 변환 + Location 헤더 id
|
|
7
|
+
# 리소스 공용: 요청 실행 + 오류 경계 변환 + Location 헤더 id 추출 + 경로 세그먼트 이스케이프.
|
|
6
8
|
module Call
|
|
7
9
|
private
|
|
8
10
|
|
|
11
|
+
# 경로 세그먼트 하나를 이스케이프한다. **모든 admin 리소스가 이것을 거쳐야 한다.**
|
|
12
|
+
#
|
|
13
|
+
# 날 문자열 보간은 두 가지를 깬다:
|
|
14
|
+
# (1) "../" 를 담은 값이 경로를 재작성한다 — Faraday 가 base_url 기준으로 정규화하므로
|
|
15
|
+
# `users/../../../foo` 가 `/admin/foo` 로 접히고, 서비스 계정 베어러를 실은 채
|
|
16
|
+
# 의도하지 않은 엔드포인트로 간다.
|
|
17
|
+
# (2) Keycloak 이 허용하는 **공백 든 role/group 이름**이 `URI::InvalidURIError` 를 올린다.
|
|
18
|
+
# 그것은 `Faraday::Error` 가 아니라 stdlib 예외라 `request` 의 rescue 를 통과해
|
|
19
|
+
# §4「하위 오류는 경계에서 SDK 타입으로 변환된다」까지 함께 깬다.
|
|
20
|
+
#
|
|
21
|
+
# `ERB::Util.url_encode` 는 unreserved(A-Za-z0-9\-_.~) 밖을 전부 퍼센트 인코딩하고
|
|
22
|
+
# 공백을 `+` 가 아니라 `%20` 으로 낸다 — 경로 세그먼트에 맞는 유일한 stdlib 선택지다
|
|
23
|
+
# (`CGI.escape` 는 공백을 `+` 로 내므로 경로에서 틀린다). Go 의 `url.PathEscape` 와
|
|
24
|
+
# 같은 자리를 채우며, 그 참조 구현이 `go/admin_realms.go` 에 있다.
|
|
25
|
+
def esc(segment)
|
|
26
|
+
ERB::Util.url_encode(segment.to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
9
29
|
def request
|
|
10
30
|
resp = yield
|
|
11
31
|
return resp if resp.success?
|
|
@@ -11,25 +11,25 @@ module KeycloakSdk
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def create(representation)
|
|
14
|
-
id_from_location(request { @conn.post("admin/realms/#{@realm}/clients", representation) })
|
|
14
|
+
id_from_location(request { @conn.post("admin/realms/#{esc(@realm)}/clients", representation) })
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get(id)
|
|
18
|
-
request { @conn.get("admin/realms/#{@realm}/clients/#{id}") }.body
|
|
18
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/clients/#{esc(id)}") }.body
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# client_id로 조회: 목록 + 필터(admin은 내부 uuid 키).
|
|
22
22
|
def list(**params)
|
|
23
|
-
request { @conn.get("admin/realms/#{@realm}/clients", params) }.body
|
|
23
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/clients", params) }.body
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def update(id, representation)
|
|
27
|
-
request { @conn.put("admin/realms/#{@realm}/clients/#{id}", representation) }
|
|
27
|
+
request { @conn.put("admin/realms/#{esc(@realm)}/clients/#{esc(id)}", representation) }
|
|
28
28
|
nil
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def delete(id)
|
|
32
|
-
request { @conn.delete("admin/realms/#{@realm}/clients/#{id}") }
|
|
32
|
+
request { @conn.delete("admin/realms/#{esc(@realm)}/clients/#{esc(id)}") }
|
|
33
33
|
nil
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -11,24 +11,24 @@ module KeycloakSdk
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def create(representation)
|
|
14
|
-
id_from_location(request { @conn.post("admin/realms/#{@realm}/groups", representation) })
|
|
14
|
+
id_from_location(request { @conn.post("admin/realms/#{esc(@realm)}/groups", representation) })
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get(id)
|
|
18
|
-
request { @conn.get("admin/realms/#{@realm}/groups/#{id}") }.body
|
|
18
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/groups/#{esc(id)}") }.body
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def list(**params)
|
|
22
|
-
request { @conn.get("admin/realms/#{@realm}/groups", params) }.body
|
|
22
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/groups", params) }.body
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def update(id, representation)
|
|
26
|
-
request { @conn.put("admin/realms/#{@realm}/groups/#{id}", representation) }
|
|
26
|
+
request { @conn.put("admin/realms/#{esc(@realm)}/groups/#{esc(id)}", representation) }
|
|
27
27
|
nil
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def delete(id)
|
|
31
|
-
request { @conn.delete("admin/realms/#{@realm}/groups/#{id}") }
|
|
31
|
+
request { @conn.delete("admin/realms/#{esc(@realm)}/groups/#{esc(id)}") }
|
|
32
32
|
nil
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -17,7 +17,7 @@ module KeycloakSdk
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def get(realm)
|
|
20
|
-
request { @conn.get("admin/realms/#{realm}") }.body
|
|
20
|
+
request { @conn.get("admin/realms/#{esc(realm)}") }.body
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def list
|
|
@@ -25,12 +25,12 @@ module KeycloakSdk
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def update(realm, representation)
|
|
28
|
-
request { @conn.put("admin/realms/#{realm}", representation) }
|
|
28
|
+
request { @conn.put("admin/realms/#{esc(realm)}", representation) }
|
|
29
29
|
nil
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def delete(realm)
|
|
33
|
-
request { @conn.delete("admin/realms/#{realm}") }
|
|
33
|
+
request { @conn.delete("admin/realms/#{esc(realm)}") }
|
|
34
34
|
nil
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -11,24 +11,24 @@ module KeycloakSdk
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def create(representation)
|
|
14
|
-
id_from_location(request { @conn.post("admin/realms/#{@realm}/roles", representation) })
|
|
14
|
+
id_from_location(request { @conn.post("admin/realms/#{esc(@realm)}/roles", representation) })
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get(name)
|
|
18
|
-
request { @conn.get("admin/realms/#{@realm}/roles/#{name}") }.body
|
|
18
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/roles/#{esc(name)}") }.body
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def list(**params)
|
|
22
|
-
request { @conn.get("admin/realms/#{@realm}/roles", params) }.body
|
|
22
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/roles", params) }.body
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def update(name, representation)
|
|
26
|
-
request { @conn.put("admin/realms/#{@realm}/roles/#{name}", representation) }
|
|
26
|
+
request { @conn.put("admin/realms/#{esc(@realm)}/roles/#{esc(name)}", representation) }
|
|
27
27
|
nil
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def delete(name)
|
|
31
|
-
request { @conn.delete("admin/realms/#{@realm}/roles/#{name}") }
|
|
31
|
+
request { @conn.delete("admin/realms/#{esc(@realm)}/roles/#{esc(name)}") }
|
|
32
32
|
nil
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -11,24 +11,24 @@ module KeycloakSdk
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def create(representation)
|
|
14
|
-
id_from_location(request { @conn.post("admin/realms/#{@realm}/users", representation) })
|
|
14
|
+
id_from_location(request { @conn.post("admin/realms/#{esc(@realm)}/users", representation) })
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get(id)
|
|
18
|
-
request { @conn.get("admin/realms/#{@realm}/users/#{id}") }.body
|
|
18
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/users/#{esc(id)}") }.body
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def list(**params)
|
|
22
|
-
request { @conn.get("admin/realms/#{@realm}/users", params) }.body
|
|
22
|
+
request { @conn.get("admin/realms/#{esc(@realm)}/users", params) }.body
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def update(id, representation)
|
|
26
|
-
request { @conn.put("admin/realms/#{@realm}/users/#{id}", representation) }
|
|
26
|
+
request { @conn.put("admin/realms/#{esc(@realm)}/users/#{esc(id)}", representation) }
|
|
27
27
|
nil
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def delete(id)
|
|
31
|
-
request { @conn.delete("admin/realms/#{@realm}/users/#{id}") }
|
|
31
|
+
request { @conn.delete("admin/realms/#{esc(@realm)}/users/#{esc(id)}") }
|
|
32
32
|
nil
|
|
33
33
|
end
|
|
34
34
|
end
|
data/lib/keycloak_sdk/config.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module KeycloakSdk
|
|
4
6
|
# 불변 설정. 생성 시 검증하고 freeze한다. client_secret은 inspect에서 마스킹.
|
|
5
7
|
class Config
|
|
@@ -19,6 +21,10 @@ module KeycloakSdk
|
|
|
19
21
|
# 않았으나 JWKS가 10.0/30.0으로 갈린 것과 똑같은 모양이다). 그 자리는 이제 이 상수를 참조한다.
|
|
20
22
|
DEFAULT_CLOCK_SKEW = 30
|
|
21
23
|
|
|
24
|
+
# 깨진 server_url의 ConfigError 접두사. 뒤에 짧은 사유만 붙인다(입력·파서 메시지 금지).
|
|
25
|
+
SERVER_URL_ERROR_PREFIX = "server_url must be an absolute http(s) URL: "
|
|
26
|
+
private_constant :SERVER_URL_ERROR_PREFIX
|
|
27
|
+
|
|
22
28
|
attr_reader :server_url, :realm, :client_id, :client_secret,
|
|
23
29
|
:scopes, :signature_algorithms, :connect_timeout, :read_timeout, :clock_skew,
|
|
24
30
|
:jwks_min_refetch, :expected_audience
|
|
@@ -28,6 +34,11 @@ module KeycloakSdk
|
|
|
28
34
|
connect_timeout: 10, read_timeout: 10, clock_skew: DEFAULT_CLOCK_SKEW,
|
|
29
35
|
jwks_min_refetch: DEFAULT_JWKS_MIN_REFETCH, expected_audience: nil)
|
|
30
36
|
@server_url = strip_trailing_slashes(normalize_required("server_url", server_url))
|
|
37
|
+
# 정규화 직후. 상대 URL("kc.example.com")은 첫 토큰 요청에서 RuntimeError "No Host Info"로,
|
|
38
|
+
# 공백이 섞인 URL("http://kc example.com")은 URI::InvalidURIError로 나간다. 둘 다
|
|
39
|
+
# ConfigError가 아니라 공개 API 밖으로 새는 예외라 생성 시점에 닫는다.
|
|
40
|
+
# InvalidURIError#message는 입력을 그대로 에코하므로 사유만 남긴다.
|
|
41
|
+
require_absolute_http_url!(@server_url)
|
|
31
42
|
@realm = normalize_required("realm", realm)
|
|
32
43
|
@client_id = normalize_required("client_id", client_id)
|
|
33
44
|
@client_secret = client_secret
|
|
@@ -67,6 +78,19 @@ module KeycloakSdk
|
|
|
67
78
|
str[0, i]
|
|
68
79
|
end
|
|
69
80
|
|
|
81
|
+
# URI::HTTP(HTTPS는 하위 클래스)이고 host가 비어 있지 않으며 port <= 65535.
|
|
82
|
+
# 스킴 대소문자는 URI.parse가 가린다("HTTP://" → URI::HTTP). 언더스코어 host
|
|
83
|
+
# ("keycloak_server")도 이 파서가 받으므로 따로 거부하지 않는다.
|
|
84
|
+
def require_absolute_http_url!(url)
|
|
85
|
+
uri = URI.parse(url)
|
|
86
|
+
raise ConfigError, "#{SERVER_URL_ERROR_PREFIX}scheme must be http or https" unless uri.is_a?(URI::HTTP)
|
|
87
|
+
raise ConfigError, "#{SERVER_URL_ERROR_PREFIX}missing host" if uri.host.to_s.empty?
|
|
88
|
+
raise ConfigError, "#{SERVER_URL_ERROR_PREFIX}port out of range" if uri.port > 65_535
|
|
89
|
+
rescue URI::InvalidURIError
|
|
90
|
+
# cause를 끊지 않으면 InvalidURIError#message(입력 에코)가 예외 사슬에 남는다.
|
|
91
|
+
raise ConfigError, "#{SERVER_URL_ERROR_PREFIX}unparseable", cause: nil
|
|
92
|
+
end
|
|
93
|
+
|
|
70
94
|
def normalize_required(name, value)
|
|
71
95
|
raise ConfigError, "#{name} is required" if value.nil?
|
|
72
96
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "json" # ⚠️ `on_data` 를 쓰면 json 미들웨어가 돌지 않아 여기서 직접 파싱한다
|
|
4
5
|
|
|
5
6
|
module KeycloakSdk
|
|
6
7
|
# DoS-safe JWKS 스토어. Mutex 캐시 + rate-limit + single-flight.
|
|
@@ -8,6 +9,27 @@ module KeycloakSdk
|
|
|
8
9
|
# 미해결 kid(force:true)만 재조회하며, rate-limit gate는 재조회 결정 시점에 stamp한다
|
|
9
10
|
# (성공 아님 — IdP 장애창에서 위조 kid 폭주에도 재조회를 상한한다). Go/Rust/Python 동형.
|
|
10
11
|
class JwksStore
|
|
12
|
+
# ⚠️ **실패한 fetch 의 백오프 — `min_refetch` 와 다른 축이다.**
|
|
13
|
+
# `min_refetch`(30초)는 *캐시가 찬 뒤* 미해결 kid 홍수를 막는다. 아래 둘은 **캐시가 비어
|
|
14
|
+
# 있고 fetch 가 계속 실패할 때**를 막는다. 그 자리에는 게이트가 없어서, 측정상 20회 검증이
|
|
15
|
+
# IdP 요청 20건을 그대로 냈다(2026-09-04 · 7개 언어 동일).
|
|
16
|
+
#
|
|
17
|
+
# ⚠️ **여기에 `min_refetch`(30초)를 재사용하면 안 된다** — 일시적 503 한 번이 「30초간 어떤
|
|
18
|
+
# 토큰도 검증 불가」가 된다. 그래서 짧게 시작해 지수적으로 늘리고 상한을 둔다.
|
|
19
|
+
#
|
|
20
|
+
# ⚠️ **sleep 하지 않는다.** 라이브러리가 호출자의 스레드를 붙잡으면 안 되므로, 백오프 창
|
|
21
|
+
# 안에서는 **IdP 를 때리지 않고 즉시 실패**시킨다(negative cache). 재시도 정책은 소비자 몫이다.
|
|
22
|
+
FAILURE_BACKOFF_BASE = 0.2 # 초 — 첫 실패 후 대기
|
|
23
|
+
FAILURE_BACKOFF_CAP = 5.0 # 초 — 지수 증가의 상한
|
|
24
|
+
|
|
25
|
+
# ⚠️ **본문을 통째로 읽기 전에 상한을 건다.** 예전에는 `resp.body`(json 미들웨어가 이미
|
|
26
|
+
# 파싱한 값)를 그대로 받아 서버가 보내는 만큼 다 받았다 — 손상된 IdP 하나가 검증 경로를
|
|
27
|
+
# 메모리로 죽일 수 있었다. 51200 은 Nimbus `RemoteJWKSet.DEFAULT_HTTP_SIZE_LIMIT` 이고
|
|
28
|
+
# go(`jwksMaxBytes`)·rust(`JWKS_MAX_BYTES`)·java·kotlin 이 같은 수를 쓴다.
|
|
29
|
+
# ⚠️ `Content-Length` 로만 판정하면 그 헤더가 없거나 거짓인 응답을 놓친다 — 청크를 받으며
|
|
30
|
+
# 누적치가 상한을 넘는 순간 읽기를 끊는다(`on_data` 안에서 raise 하면 어댑터가 중단한다).
|
|
31
|
+
JWKS_MAX_BYTES = 51_200
|
|
32
|
+
|
|
11
33
|
# ⚠️ 기본값을 여기 숫자로 적지 말 것 — `Config`가 유일한 정의 자리다. 이 클래스는 평범한
|
|
12
34
|
# public 클래스라 소비자가 파사드를 거치지 않고 직접 생성할 수 있고, 예전에는 그 경로가
|
|
13
35
|
# 문서의 30초가 아니라 10초를 받아 IdP를 3배 자주 때렸다(2026-08-13 Task D1).
|
|
@@ -18,6 +40,8 @@ module KeycloakSdk
|
|
|
18
40
|
@mutex = Mutex.new
|
|
19
41
|
@cache = nil # {"keys" => [...]}
|
|
20
42
|
@last_refetch = nil # monotonic seconds
|
|
43
|
+
@last_failure = nil # monotonic seconds — 마지막 fetch 실패 시각
|
|
44
|
+
@failures = 0 # 연속 실패 횟수(성공 시 0으로 되돌린다)
|
|
21
45
|
end
|
|
22
46
|
|
|
23
47
|
# ruby-jwt jwks: 로더가 호출. force=true는 미해결 kid 재조회 요청.
|
|
@@ -26,8 +50,16 @@ module KeycloakSdk
|
|
|
26
50
|
return @cache if @cache && !force
|
|
27
51
|
return @cache if force && !refetch_allowed?
|
|
28
52
|
|
|
53
|
+
# ⚠️ 백오프 검사는 fetch **직전**이자 30초 게이트 **이후**다. 콜드 캐시에서는 위 두 return
|
|
54
|
+
# 이 모두 통과하므로, 이 줄이 없으면 매 검증이 IdP 로 나간다(그게 원래 결함이다).
|
|
55
|
+
if backing_off?
|
|
56
|
+
raise TransportError,
|
|
57
|
+
"JWKS fetch backing off after #{@failures} consecutive failures " \
|
|
58
|
+
"(retry in #{backoff_remaining.round(2)}s)"
|
|
59
|
+
end
|
|
60
|
+
|
|
29
61
|
@last_refetch = monotonic if force # 결정 시점 stamp(cold load는 예산 미소모)
|
|
30
|
-
@cache =
|
|
62
|
+
@cache = fetch_recording_failure
|
|
31
63
|
end
|
|
32
64
|
end
|
|
33
65
|
|
|
@@ -37,16 +69,80 @@ module KeycloakSdk
|
|
|
37
69
|
@last_refetch.nil? || (monotonic - @last_refetch) >= @min_refetch
|
|
38
70
|
end
|
|
39
71
|
|
|
72
|
+
def backing_off?
|
|
73
|
+
backoff_remaining.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def backoff_remaining
|
|
77
|
+
return 0.0 if @last_failure.nil?
|
|
78
|
+
|
|
79
|
+
backoff_delay - (monotonic - @last_failure)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 지수 백오프 + jitter. jitter 는 [0.5, 1.0) 배수 — 여러 인스턴스가 같은 순간에 복구를
|
|
83
|
+
# 시도해 IdP 를 다시 무너뜨리는 것(thundering herd)을 흩는다.
|
|
84
|
+
#
|
|
85
|
+
# ⚠️ **PRNG API 를 쓰지 않고 나노초 시계에서 뽑는다.** 이 값은 비밀이 아니지만, 보안 민감
|
|
86
|
+
# 코드에서 약한 PRNG 를 호출하면 정적분석이 정당하게 막는다(실측: sonar S2245 · gosec G404).
|
|
87
|
+
# 일곱 언어가 **같은 관용**을 쓴다 — 하나만 `rand` 로 남으면 그 자체가 드리프트다.
|
|
88
|
+
def backoff_delay
|
|
89
|
+
raw = FAILURE_BACKOFF_BASE * (2**([@failures, 1].max - 1))
|
|
90
|
+
[raw, FAILURE_BACKOFF_CAP].min * jitter
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def jitter
|
|
94
|
+
0.5 + ((Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) % 1_000_000) / 2_000_000.0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch_recording_failure
|
|
98
|
+
body = fetch
|
|
99
|
+
@failures = 0
|
|
100
|
+
@last_failure = nil
|
|
101
|
+
body
|
|
102
|
+
rescue StandardError
|
|
103
|
+
@failures += 1
|
|
104
|
+
@last_failure = monotonic
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
|
|
40
108
|
def fetch
|
|
41
|
-
|
|
109
|
+
# ⚠️ 상한은 **상태와 무관하게** 건다. 200 만 겨누면 오류 응답의 거대 본문이 그대로
|
|
110
|
+
# 들어온다 — php 가 정확히 그 순서였다(상태 검사 전에 `json_decode`).
|
|
111
|
+
buf = +""
|
|
112
|
+
resp = @http.get(@jwks_url) do |req|
|
|
113
|
+
req.options.on_data = proc do |chunk, _received|
|
|
114
|
+
buf << chunk
|
|
115
|
+
raise TransportError, "JWKS response exceeds #{JWKS_MAX_BYTES} bytes" if buf.bytesize > JWKS_MAX_BYTES
|
|
116
|
+
end
|
|
117
|
+
end
|
|
42
118
|
raise TransportError, "JWKS fetch failed: HTTP #{resp.status}" unless resp.success?
|
|
43
119
|
|
|
44
|
-
|
|
120
|
+
# ⚠️ `on_data` 를 쓰면 Faraday 는 본문을 누적하지 않으므로 json 미들웨어가 돌지 않는다 —
|
|
121
|
+
# 파싱은 여기서 한다. 파싱 실패도 경계에서 SDK 타입이 되어야 한다(§4).
|
|
122
|
+
body = begin
|
|
123
|
+
JSON.parse(buf)
|
|
124
|
+
rescue JSON::ParserError => e
|
|
125
|
+
raise TransportError, "JWKS response unparsable: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
validate_key_set!(body)
|
|
128
|
+
rescue Faraday::Error => e
|
|
129
|
+
raise TransportError, "JWKS transport error: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# 파싱된 본문이 **캐시에 올려도 되는 집합인가**. 통과하면 그 본문을 돌려준다.
|
|
133
|
+
#
|
|
134
|
+
# ⚠️ **빈 집합은 정상 답이 아니고, 설치하면 검증기가 눈이 먼다**(go/jwt.go 와 동형 — 그쪽
|
|
135
|
+
# #380 픽스의 나머지 절반이다). `[]` 는 Array 라 malformed 검사를 그대로 통과한다.
|
|
136
|
+
# 200 + `{"keys":[]}` 를 주는 것은 키를 전부 회수한 IdP 가 아니라 프록시·WAF·반쯤 뜬
|
|
137
|
+
# realm 이고, 좋은 캐시를 그것으로 덮으면 방금 검증되던 토큰이 거부되며 refetch 게이트가
|
|
138
|
+
# 복구까지 막는다. 그래서 쓰기 **전에** 거부한다.
|
|
139
|
+
#
|
|
140
|
+
# ⚠️ 여기서 멈춘다 — `kty`·`n`·`e` 같은 필드 검증으로 번지면 JWKS 스키마 검사가 된다.
|
|
141
|
+
def validate_key_set!(body)
|
|
45
142
|
raise TransportError, "JWKS response malformed" unless body.is_a?(Hash) && body["keys"].is_a?(Array)
|
|
143
|
+
raise TransportError, "JWKS response contains no keys" if body["keys"].empty?
|
|
46
144
|
|
|
47
145
|
body
|
|
48
|
-
rescue Faraday::Error => e
|
|
49
|
-
raise TransportError, "JWKS transport error: #{e.message}"
|
|
50
146
|
end
|
|
51
147
|
|
|
52
148
|
def monotonic
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
3
5
|
module KeycloakSdk
|
|
4
6
|
# Keycloak realm의 OIDC 엔드포인트를 규약대로 조립한다(네트워크 없음).
|
|
5
7
|
class OidcEndpoints
|
|
6
8
|
attr_reader :issuer, :authorization, :token, :introspection, :end_session, :jwks
|
|
7
9
|
|
|
8
10
|
def initialize(server_url, realm)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
# issuer는 토큰 iss 클레임과 그대로 비교하므로 realm을 인코딩하지 않는다.
|
|
12
|
+
@issuer = "#{server_url}/realms/#{realm}"
|
|
13
|
+
# 엔드포인트의 realm은 경로 세그먼트 하나다. "my realm"을 그대로 두면
|
|
14
|
+
# client_credentials_token / create_authorization_request가 URI::InvalidURIError를
|
|
15
|
+
# SDK 밖으로 흘린다(자매 SDK는 이 세그먼트를 퍼센트 인코딩한다).
|
|
16
|
+
oidc = "#{server_url}/realms/#{escape_realm_segment(realm)}/protocol/openid-connect"
|
|
12
17
|
@authorization = "#{oidc}/auth"
|
|
13
18
|
@token = "#{oidc}/token"
|
|
14
19
|
@introspection = "#{oidc}/token/introspect"
|
|
@@ -20,5 +25,13 @@ module KeycloakSdk
|
|
|
20
25
|
def self.from_config(config)
|
|
21
26
|
new(config.server_url, config.realm)
|
|
22
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# admin `esc`와 같은 stdlib 인코더. unreserved(A-Za-z0-9\-_.~)만 남기고 나머지
|
|
32
|
+
# UTF-8 바이트는 %XX(대문자). CGI.escape는 공백을 `+`로 내므로 경로 세그먼트에 못 쓴다.
|
|
33
|
+
def escape_realm_segment(realm)
|
|
34
|
+
ERB::Util.url_encode(realm.to_s)
|
|
35
|
+
end
|
|
23
36
|
end
|
|
24
37
|
end
|
|
@@ -20,6 +20,17 @@ module KeycloakSdk
|
|
|
20
20
|
@expires_at = 0.0
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# ⚠️ 기본 `inspect` 는 인스턴스 변수를 전부 찍는데, 이 클래스는 **원시 액세스 토큰 문자열**을
|
|
24
|
+
# `@cached` 에 들고 있다(`ts.access_token` — TokenSet 이 아니라서 `TokenSet#inspect` 가 안 탄다).
|
|
25
|
+
# 실측(2026-09-12): 재정의 전 `p provider` 가 `@cached="AT-CENSUS-TOKEN-…"` 를 원문으로 찍었다.
|
|
26
|
+
# `Config`·`TokenSet` 은 재정의가 있는데 이 타입만 없어 생긴 **형제 간 불일치**였다.
|
|
27
|
+
# ⚠️ 캐시 유무는 남긴다 — 빈 캐시에 `***` 를 찍으면 「토큰이 있다」는 거짓 신호가 된다.
|
|
28
|
+
def inspect
|
|
29
|
+
"#<KeycloakSdk::ClientCredentialsTokenProvider config=#{@config.inspect} " \
|
|
30
|
+
"cached=#{@cached ? '"***"' : 'nil'} expires_at=#{@expires_at.inspect}>"
|
|
31
|
+
end
|
|
32
|
+
alias to_s inspect
|
|
33
|
+
|
|
23
34
|
def access_token
|
|
24
35
|
@mutex.synchronize do
|
|
25
36
|
now = Time.now.to_f
|
data/lib/keycloak_sdk/tokens.rb
CHANGED
|
@@ -4,10 +4,30 @@ module KeycloakSdk
|
|
|
4
4
|
# OAuth 토큰 응답의 불변 값타입. access/refresh/id 토큰은 inspect에서 마스킹.
|
|
5
5
|
TokenSet = Data.define(:access_token, :token_type, :expires_in, :refresh_token,
|
|
6
6
|
:id_token, :scope, :expires_at) do
|
|
7
|
+
# ⚠️ **검증은 팩토리가 아니라 생성자에 있다.** `from_response` 에만 두면 `AuthClient`
|
|
8
|
+
# 가 `TokenSet.new` 을 직접 부르는 경로(`auth_client.rb` 의 `to_token_set`)가 그것을
|
|
9
|
+
# 통째로 우회한다 — 독립 레그가 그 구멍을 지목했다. 생성자는 어떤 경로도 지나므로
|
|
10
|
+
# 여기 두면 우회가 불가능하다.
|
|
11
|
+
def initialize(access_token:, **rest)
|
|
12
|
+
unless access_token.is_a?(String) && !access_token.empty?
|
|
13
|
+
raise AuthError, "token response has no usable access_token"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
7
19
|
def self.from_response(body, received_at: Time.now.to_f)
|
|
20
|
+
# ⚠️ **존재 검사는 타입 검사가 아니다.** 예전에는 값을 그대로 담아 숫자·해시·nil 이
|
|
21
|
+
# `access_token` 이 됐고, 소비자는 그것을 Bearer 로 실어 보내 매번 401 을 받았다
|
|
22
|
+
# (조용한 반복 실패). `expires_in` 의 문자열 허용은 **의도된 것**이라 건드리지 않는다.
|
|
23
|
+
access_token = body["access_token"]
|
|
24
|
+
unless access_token.is_a?(String) && !access_token.empty?
|
|
25
|
+
raise AuthError, "token response has no usable access_token"
|
|
26
|
+
end
|
|
27
|
+
|
|
8
28
|
expires_in = body["expires_in"] && Integer(body["expires_in"])
|
|
9
29
|
new(
|
|
10
|
-
access_token:
|
|
30
|
+
access_token: access_token,
|
|
11
31
|
token_type: body["token_type"],
|
|
12
32
|
expires_in: expires_in,
|
|
13
33
|
refresh_token: body["refresh_token"],
|
|
@@ -17,8 +37,12 @@ module KeycloakSdk
|
|
|
17
37
|
)
|
|
18
38
|
end
|
|
19
39
|
|
|
40
|
+
# ⚠️ **만료 시각을 모르면 "만료됨"이다**(fail-safe — 자매 여덟과 동형, Java 의 M.6).
|
|
41
|
+
# `false`(=아직 살아있다)를 돌려주면 만료 시각 미상인 토큰이 **영원히 유효**해진다.
|
|
42
|
+
# `expires_at` 이 nil 인 경우는 서버가 `expires_in` 을 안 보냈을 때뿐이라 정상 경로가
|
|
43
|
+
# 아니고, 그때 취할 안전한 쪽은 "재발급"이다.
|
|
20
44
|
def expired?(skew: 0, now: Time.now.to_f)
|
|
21
|
-
return
|
|
45
|
+
return true if expires_at.nil?
|
|
22
46
|
|
|
23
47
|
now >= (expires_at - skew)
|
|
24
48
|
end
|
|
@@ -29,6 +53,13 @@ module KeycloakSdk
|
|
|
29
53
|
"id_token=#{id_token ? '"***"' : 'nil'} scope=#{scope.inspect} expires_at=#{expires_at.inspect}>"
|
|
30
54
|
end
|
|
31
55
|
alias_method :to_s, :inspect
|
|
56
|
+
|
|
57
|
+
# ⚠️ `pp`/`pretty_inspect` 는 `inspect` 와 같은 계급의 **표시 경로**인데, `Data` 타입은 PP 가
|
|
58
|
+
# 멤버를 직접 찍어 위 `inspect` 를 **건너뛴다**(실측: access_token 원문이 나왔다). 일반
|
|
59
|
+
# 클래스인 `Config` 는 PP 가 `inspect` 로 폴백해 안전하다 — 그래서 `Data` 만 이 훅이 필요하다.
|
|
60
|
+
def pretty_print(pp)
|
|
61
|
+
pp.text(inspect)
|
|
62
|
+
end
|
|
32
63
|
end
|
|
33
64
|
|
|
34
65
|
# 검증된 access token의 관심 클레임.
|
|
@@ -54,5 +85,12 @@ module KeycloakSdk
|
|
|
54
85
|
"nonce=#{nonce.inspect} code_verifier=\"***\">"
|
|
55
86
|
end
|
|
56
87
|
alias_method :to_s, :inspect
|
|
88
|
+
|
|
89
|
+
# ⚠️ `pp`/`pretty_inspect` 는 `inspect` 와 같은 계급의 **표시 경로**인데, `Data` 타입은 PP 가
|
|
90
|
+
# 멤버를 직접 찍어 위 `inspect` 를 **건너뛴다**(실측: access_token 원문이 나왔다). 일반
|
|
91
|
+
# 클래스인 `Config` 는 PP 가 `inspect` 로 폴백해 안전하다 — 그래서 `Data` 만 이 훅이 필요하다.
|
|
92
|
+
def pretty_print(pp)
|
|
93
|
+
pp.text(inspect)
|
|
94
|
+
end
|
|
57
95
|
end
|
|
58
96
|
end
|
data/lib/keycloak_sdk/version.rb
CHANGED