keycloak-sdk 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c092fe05aa83b05dbdeff11100f82093a4e757085d503c9e7f0bf2c25686823
4
- data.tar.gz: ca6060f7b57451442cb91d57df7d2d711d7ce5568dd8e60dcd2a120aa9df9a64
3
+ metadata.gz: ced74e5c197f31bcea44f678715d2c2b30d8cfe6b597393a200bda451057f582
4
+ data.tar.gz: ff1f1781cfc00acfcca781217eedb6be8f76c7efc72f93b49940f9a85882f1b2
5
5
  SHA512:
6
- metadata.gz: f0acfdff469b5e3b7962e93219394ec0cf982e0bfa9cd779f5cf57696c832bd36102904338fb8e94aa19e54e8aed5f1a5a975bfeadd8c54742c97fecbbb3756a
7
- data.tar.gz: 61043f7b7e7912fb3c3fbb5d29a006483dbc16e768f0b1678e60ccfb1725901573bd4538762ea57dda26a8beca1f0a03d7ca94d996e1c656ef676692da68f09a
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
- > **Pre-release** — the first release candidate (`0.1.0.rc1`) is on RubyGems; there is no stable release yet. ⚠️ **RubyGems does not install a pre-release by default**: a bare `gem install keycloak-sdk` finds nothing until a stable version exists. Ask for it explicitly — `gem install keycloak-sdk --pre`, or pin the exact version as shown below.
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
 
@@ -14,16 +14,16 @@ Part of a **nine-language polyglot SDK** (Java · Python · Node · Go · C# ·
14
14
  ## Install
15
15
 
16
16
  ```bash
17
- gem install keycloak-sdk -v 0.1.0.rc1
17
+ gem install keycloak-sdk
18
18
  ```
19
19
 
20
20
  Or in a `Gemfile`:
21
21
 
22
22
  ```ruby
23
- gem "keycloak-sdk", "0.1.0.rc1"
23
+ gem "keycloak-sdk", "~> 1.0"
24
24
  ```
25
25
 
26
- While `0.1.0.rc1` is the only release, the version must be explicit — `gem install keycloak-sdk` and a bare `gem "keycloak-sdk"` both skip pre-releases and resolve nothing. Drop the pin once a stable release exists.
26
+ `~> 1.0` accepts every `1.x` release and stops before `2.0` — the boundary a breaking change has to cross under the promise below.
27
27
 
28
28
  > **Name mismatch, on purpose:** the gem is `keycloak-sdk` (hyphen) but the require path and module are `keycloak_sdk` / `KeycloakSdk` (underscore) — this avoids colliding with the existing `keycloak` gem's `Keycloak` module.
29
29
 
@@ -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`, 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). The gate applies on a cold cache too, so it cannot be sidestepped by hitting the SDK before its first successful fetch — no volume of forged tokens makes the SDK issue more than one JWKS request per interval.
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,18 @@ 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 **pre-1.0**. Under SemVer a `0.x` **minor** bump may carry breaking changes, so read the release notes before upgrading. Only the newest released version of each language SDK receives security fixes — there are no LTS lines, and older `0.x` releases are not backported to. Full policy: [SECURITY.md](https://github.com/xzawed/KeyCloakSDK/blob/main/SECURITY.md).
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
+
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
+
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.
77
81
 
78
82
  ## Documentation
79
83
 
80
- - [Getting started](https://github.com/xzawed/KeyCloakSDK/blob/main/docs/guides/getting-started.md#ruby) — install, quickstart, and the compatibility matrix
84
+ - [Project overview](https://github.com/xzawed/KeyCloakSDK) — all nine languages, what is identical and what is not
85
+ - [Changelog](https://github.com/xzawed/KeyCloakSDK/blob/main/CHANGELOG.md) — **read this before upgrading**; breaking changes are listed per language
86
+ - [Getting started](https://github.com/xzawed/KeyCloakSDK/blob/main/docs/guides/getting-started.md#ruby) — install and quickstart for this language
87
+ - [Compatibility](https://github.com/xzawed/KeyCloakSDK/blob/main/docs/reference/compatibility.md) — which Keycloak server range and base libraries each published version shipped against
81
88
  - [Deploying a Keycloak server](https://github.com/xzawed/KeyCloakSDK/blob/main/docs/guides/deploying-keycloak-server.md) — the server this SDK talks to
82
89
  - [Security policy](https://github.com/xzawed/KeyCloakSDK/blob/main/SECURITY.md)
83
90
  - Full example: [`examples/quickstart.rb`](https://github.com/xzawed/KeyCloakSDK/blob/main/ruby/examples/quickstart.rb)
@@ -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
@@ -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 = fetch
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
- resp = @http.get(@jwks_url)
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
- body = resp.body
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
- base = "#{server_url}/realms/#{realm}"
10
- oidc = "#{base}/protocol/openid-connect"
11
- @issuer = base
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
@@ -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: body["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 false if expires_at.nil?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeycloakSdk
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycloak-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - xzawed