keycloak-sdk 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39cf848bf3f01443707855e1a981e8bcaf4fdbb3a5ac9fbf17a7b6ac010e33f4
4
- data.tar.gz: 489cfcf03654e7da757bfcd6020cf148d3fb9550c89ea9f14c09569977aa47b8
3
+ metadata.gz: dc6cc81e7537b1962108102b160aaeb30e45d1018ac533f834204dd58c69af3f
4
+ data.tar.gz: 172f64d5db58105f0402ecbe1821ed2e2c2f2910139c75144b788c38fa170c5c
5
5
  SHA512:
6
- metadata.gz: a2c51c7d06751f6ae0c6ed76d83083400f679354a011d1dbc720c93fd374cf1047092c4dc8214931ea8f25fd2a6099e5103577c35aee998194e0c56b7645c9fb
7
- data.tar.gz: 3d112a01d129df0a1bbebb7ef3b0ba75b4947fd5bb1014a7471beef728f2d22be9020d264f98660d887f1820e58e6014caa4bf12cd0dc8787571568b4c320a56
6
+ metadata.gz: 19f5d3264eaec1620a68a509bcfdfe5c7180a1e7bc90867f96ffce31901ce0670e06a80d8925655e4977a13f6e1675936e59e4920cd431087372c06a0725d05c
7
+ data.tar.gz: 1b534ae18c8a9e1cb7bbf492ccf9713bb9fee474afd0348d05b43b6c9cfd3e22fe773654c615f6e568609c3644603822a70330184d42fc97fb2133658fcaef89
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.0` is on RubyGems** — the first release carrying the stability guarantee. A bare `gem install keycloak-sdk` resolves it.
7
+ > **`1.1.0` is on RubyGems** — a minor release of security fixes on top of `1.0.1`; `1.0.0` was the first release carrying the stability guarantee. A bare `gem install keycloak-sdk` resolves it. An error raised on a malformed or hostile IdP response no longer prints that response or the secrets the SDK sent (present in `1.0.1`): `full_message` — what a logger prints — showed a non-JSON body's head, an echoed `error_description` and a broken status line, a lower `Faraday::ParsingError`'s `inspect` showed the request's client secret, refresh token and admin bearer, and on Ruby 3.2 an unwrapped `NoMethodError` quoted the response body. OAuth failures now carry only the error code (when it is shaped like one) and the HTTP status in their message, and a malformed `200` token response is an `AuthError`. **Added**: `KeycloakSdk::RedactedCause`. ⚠️ **An SDK error's `cause` is now a `KeycloakSdk::RedactedCause` — the lower class names (`lower_classes`) and the backtrace, no message — instead of the original Faraday, rack-oauth2 or JSON exception**, so a `rescue` or `is_a?` on `err.cause` against those types no longer matches. ⚠️ **A token or introspection response that is not a JSON object now raises `AuthError`** — an introspection body that was a string used to come back as a result.
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`, 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,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 or an incompatible change 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.
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` on the same day because they earned the same guarantee at the same time — they do **not** move in lockstep afterwards.
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,18 +1,39 @@
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?
12
32
 
13
33
  raise AdminError.from_status(resp.status, "admin request failed: HTTP #{resp.status}")
14
34
  rescue Faraday::Error => e
15
- raise TransportError, "admin transport error: #{e.message}"
35
+ # 원본을 달면 `Faraday::ParsingError#inspect` 가 요청의 베어러 토큰을, 메시지가 응답 앞부분을 찍는다(실측).
36
+ raise TransportError, "admin transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
16
37
  end
17
38
 
18
39
  def id_from_location(resp)
@@ -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
@@ -11,6 +11,10 @@ module KeycloakSdk
11
11
  class AuthClient
12
12
  include TokenProvider
13
13
 
14
+ # 메시지에 실어도 되는 OAuth 오류 코드 모양 — RFC 6749·OIDC 등록 코드와 Keycloak 코드는 전부 이 모양이고,
15
+ # 토큰·시크릿(JWT·base64·hex·UUID)은 대문자·숫자·점·하이픈 때문에 걸러진다.
16
+ OAUTH_CODE = /\A[a-z_]{1,64}\z/
17
+
14
18
  def initialize(config:, http:, jwt_validator:)
15
19
  @config = config
16
20
  @http = http
@@ -41,31 +45,19 @@ module KeycloakSdk
41
45
  def exchange_code(code:, code_verifier:, redirect_uri:, expected_nonce: nil)
42
46
  client = oauth_client(redirect_uri: redirect_uri)
43
47
  client.authorization_code = code
44
- token_set = to_token_set(client.access_token!(code_verifier: code_verifier))
48
+ token_set = token_request("authorization_code exchange") { client.access_token!(code_verifier: code_verifier) }
45
49
  verify_nonce!(token_set.id_token, expected_nonce) unless expected_nonce.nil?
46
50
  token_set
47
- rescue Rack::OAuth2::Client::Error => e
48
- raise AuthError.new("authorization_code exchange failed: #{e.message}", oauth_error: e.response[:error].to_s)
49
- rescue Faraday::Error => e
50
- raise TransportError, "token endpoint transport error: #{e.message}"
51
51
  end
52
52
 
53
53
  def refresh(refresh_token:)
54
54
  client = oauth_client
55
55
  client.refresh_token = refresh_token
56
- to_token_set(client.access_token!)
57
- rescue Rack::OAuth2::Client::Error => e
58
- raise AuthError.new("refresh failed: #{e.message}", oauth_error: e.response[:error].to_s)
59
- rescue Faraday::Error => e
60
- raise TransportError, "token endpoint transport error: #{e.message}"
56
+ token_request("refresh") { client.access_token! }
61
57
  end
62
58
 
63
59
  def client_credentials_token
64
- to_token_set(oauth_client.access_token!(scope: @config.scopes.join(" ")))
65
- rescue Rack::OAuth2::Client::Error => e
66
- raise AuthError.new("client-credentials failed: #{e.message}", oauth_error: e.response[:error].to_s)
67
- rescue Faraday::Error => e
68
- raise TransportError, "token endpoint transport error: #{e.message}"
60
+ token_request("client-credentials") { oauth_client.access_token!(scope: @config.scopes.join(" ")) }
69
61
  end
70
62
 
71
63
  # TokenProvider 계약(직접 사용용). admin은 캐싱 provider를 별도로 쓴다.
@@ -81,7 +73,7 @@ module KeycloakSdk
81
73
 
82
74
  IntrospectionResult.from_response(resp.body)
83
75
  rescue Faraday::Error => e
84
- raise TransportError, "introspection transport error: #{e.message}"
76
+ raise TransportError, "introspection transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
85
77
  end
86
78
 
87
79
  def logout(refresh_token:)
@@ -93,7 +85,7 @@ module KeycloakSdk
93
85
 
94
86
  nil
95
87
  rescue Faraday::Error => e
96
- raise TransportError, "logout transport error: #{e.message}"
88
+ raise TransportError, "logout transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
97
89
  end
98
90
 
99
91
  def validate(token)
@@ -127,6 +119,27 @@ module KeycloakSdk
127
119
  raise AuthError, "authorization_code exchange failed: invalid id_token: #{e.message}"
128
120
  end
129
121
 
122
+ # rack-oauth2 토큰 호출의 오류 경계(§4). 하위 예외는 SDK 타입이 되고 `cause` 에는 원본 대신 `RedactedCause` 가 달린다.
123
+ # ⚠️ OAuth 오류는 **코드와 HTTP 상태만** 싣는다 — `error_description` 은 서버가 고른 자유 문장이라 토큰을
124
+ # 되울릴 수 있고, 오류 본문이 JSON 이 아니면 rack-oauth2 가 본문 전체를 거기 넣는다. 코드 자리도 서버 값이라
125
+ # **코드 모양**(`OAUTH_CODE` — 등록 코드는 전부 소문자·밑줄)일 때만 메시지에 싣는다(`oauth_error` 에는 그대로).
126
+ # ⚠️ 마지막 `StandardError` 는 rack-oauth2 가 형식이 틀린 200 을 읽다 내는 것(NoMethodError·AttrMissing·
127
+ # 'Unknown Token Type')과 `to_token_set` 의 형 변환 실패다 — 원본이 새면 Ruby 3.2 의 NoMethodError 가 본문을 인용한다.
128
+ def token_request(operation)
129
+ to_token_set(yield)
130
+ rescue Error
131
+ raise
132
+ rescue Rack::OAuth2::Client::Error => e
133
+ code = e.response[:error].is_a?(String) ? e.response[:error] : ""
134
+ shown = code.match?(OAUTH_CODE) ? code : "non-standard error code"
135
+ raise AuthError.new("#{operation} failed: #{shown} (HTTP #{e.status})", oauth_error: code),
136
+ cause: RedactedCause.new(e)
137
+ rescue Faraday::Error => e
138
+ raise TransportError, "token endpoint transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
139
+ rescue StandardError => e
140
+ raise AuthError, "#{operation} failed: unusable token response (#{e.class})", cause: RedactedCause.new(e)
141
+ end
142
+
130
143
  def oauth_client(redirect_uri: nil)
131
144
  Rack::OAuth2::Client.new(
132
145
  identifier: @config.client_id,
@@ -137,17 +150,19 @@ module KeycloakSdk
137
150
  )
138
151
  end
139
152
 
153
+ # `expires_in` 은 `TokenSet.from_response` 와 같이 정수로 읽는다(문자열 "300" 허용) — 예전에는 문자열이면
154
+ # `Float + String` 의 TypeError 가 경계를 뚫었다. 변환 실패는 `token_request` 가 AuthError 로 바꾼다.
140
155
  def to_token_set(token)
141
156
  raw = token.raw_attributes || {}
142
- scope = raw[:scope] || raw["scope"]
157
+ expires_in = token.expires_in && Integer(token.expires_in)
143
158
  TokenSet.new(
144
159
  access_token: token.access_token,
145
160
  token_type: "Bearer",
146
- expires_in: token.expires_in,
161
+ expires_in: expires_in,
147
162
  refresh_token: token.refresh_token,
148
163
  id_token: raw[:id_token] || raw["id_token"],
149
- scope: scope,
150
- expires_at: token.expires_in ? Time.now.to_f + token.expires_in : nil
164
+ scope: raw[:scope] || raw["scope"],
165
+ expires_at: expires_in ? Time.now.to_f + expires_in : nil
151
166
  )
152
167
  end
153
168
  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,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
4
+ require "socket"
5
+ require "timeout"
6
+
3
7
  module KeycloakSdk
4
8
  # 모든 SDK 오류의 루트.
5
9
  class Error < StandardError; end
@@ -46,4 +50,39 @@ module KeycloakSdk
46
50
  class NotFoundError < AdminError; end
47
51
  class ConflictError < AdminError; end
48
52
  class ForbiddenError < AdminError; end
53
+
54
+ # SDK 오류의 `cause` 에 원본 하위 예외 대신 다는 사본 — **클래스 이름 사슬과 백트레이스만** 옮긴다.
55
+ # 경계는 `raise …, cause: RedactedCause.new(e)` 로 단다(`KeycloakSdk::Error` 가 아니다 — 원인 자리에만 있다).
56
+ #
57
+ # ⚠️ Ruby 는 `rescue` 안의 `raise` 가 처리 중인 예외를 `cause` 에 **자동으로** 달고, `full_message`(잡히지 않은
58
+ # 예외·로거)가 그 사슬의 메시지를 찍는다. 하위 예외는 응답을 싣는다 — JSON 파서는 본문 앞 32 바이트를,
59
+ # rack-oauth2 는 `error_description`(JSON 이 아닌 오류면 본문 전체)을, Net::HTTP 는 깨진 상태 줄을 메시지에 싣고,
60
+ # `Faraday::ParsingError#inspect` 는 **요청** 본문·헤더(클라이언트 시크릿·refresh 토큰·베어러)까지 찍었다. Ruby 3.2 의
61
+ # `NoMethodError` 는 수신자(응답 본문)를 인용했다(실측 2026-09-26 · `spec/unit/hostile_token_response_spec.rb`).
62
+ # 원본 객체를 공개 API 로 내보내지 않는 것은 §4 도 요구한다.
63
+ class RedactedCause < StandardError
64
+ # 메시지를 SDK 메시지로 옮겨도 되는 하위 원인 — 응답을 인용할 수 없는 소켓·타임아웃·TLS 실패뿐이다.
65
+ # ⚠️ `Faraday::ConnectionFailed` 를 통째로 넣지 않는다 — `Net::HTTPBadResponse`(깨진 상태 줄 dump)도 그것으로 온다.
66
+ QUOTE_FREE = [SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError].freeze
67
+
68
+ # 원본과 그 `cause` 들의 클래스 이름(바깥부터).
69
+ attr_reader :lower_classes
70
+
71
+ def initialize(error)
72
+ @lower_classes = []
73
+ link = error
74
+ while link && @lower_classes.size < 8
75
+ @lower_classes << link.class.name
76
+ link = link.cause
77
+ end
78
+ super("#{@lower_classes.join(' <- ')} (message withheld: it can quote the response)")
79
+ set_backtrace(error.backtrace) if error.backtrace
80
+ end
81
+
82
+ # SDK 메시지에 실을 하위 오류 설명 — 인용할 수 없는 연결 실패면 그 메시지, 아니면 클래스 이름뿐.
83
+ def self.describe(error)
84
+ origin = error.respond_to?(:wrapped_exception) && error.wrapped_exception ? error.wrapped_exception : error
85
+ QUOTE_FREE.any? { |k| origin.is_a?(k) } ? error.message : error.class.name
86
+ end
87
+ end
49
88
  end
@@ -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 (JSON::ParserError)", cause: RedactedCause.new(e)
126
+ end
127
+ validate_key_set!(body)
128
+ rescue Faraday::Error => e
129
+ raise TransportError, "JWKS transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
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
@@ -34,7 +34,9 @@ module KeycloakSdk
34
34
  payload, = JWT.decode(token, nil, true, decode_options)
35
35
  to_validated(payload)
36
36
  rescue JWT::DecodeError => e
37
- raise TokenValidationError, "JWT validation failed: #{e.message}"
37
+ # ruby-jwt 메시지는 입력을 인용하지 않는다. 그 **원인**은 인용한다 — 조각이 JSON 이 아니면 JSON::ParserError 가
38
+ # 디코드한 토큰 조각을 싣는다(실측) — 그래서 사슬은 `RedactedCause` 로 끊는다.
39
+ raise TokenValidationError, "JWT validation failed: #{e.message}", cause: RedactedCause.new(e)
38
40
  end
39
41
 
40
42
  private
@@ -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
@@ -48,7 +59,7 @@ module KeycloakSdk
48
59
 
49
60
  TokenSet.from_response(resp.body, received_at: Time.now.to_f)
50
61
  rescue Faraday::Error => e
51
- raise TransportError, "token endpoint transport error: #{e.message}"
62
+ raise TransportError, "token endpoint transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
52
63
  end
53
64
  end
54
65
  end
@@ -4,10 +4,38 @@ 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)
8
- expires_in = body["expires_in"] && Integer(body["expires_in"])
20
+ # 배열 본문은 `Array#[]` 의 TypeError 로 경계를 뚫었다 — 객체가 아니면 SDK 오류다.
21
+ raise AuthError, "token response is not a JSON object" unless body.is_a?(Hash)
22
+
23
+ # ⚠️ **존재 검사는 타입 검사가 아니다.** 예전에는 값을 그대로 담아 숫자·해시·nil 이
24
+ # `access_token` 이 됐고, 소비자는 그것을 Bearer 로 실어 보내 매번 401 을 받았다
25
+ # (조용한 반복 실패). `expires_in` 의 문자열 허용은 **의도된 것**이라 건드리지 않는다.
26
+ access_token = body["access_token"]
27
+ unless access_token.is_a?(String) && !access_token.empty?
28
+ raise AuthError, "token response has no usable access_token"
29
+ end
30
+
31
+ expires_in = begin
32
+ body["expires_in"] && Integer(body["expires_in"])
33
+ rescue ArgumentError, TypeError, RangeError # RangeError: JSON 1e400 = Infinity → FloatDomainError
34
+ # `cause: nil` — ArgumentError 는 그 값을 인용한다(`invalid value for Integer(): "…"`).
35
+ raise AuthError, "token response has an unusable expires_in", cause: nil
36
+ end
9
37
  new(
10
- access_token: body["access_token"],
38
+ access_token: access_token,
11
39
  token_type: body["token_type"],
12
40
  expires_in: expires_in,
13
41
  refresh_token: body["refresh_token"],
@@ -17,8 +45,12 @@ module KeycloakSdk
17
45
  )
18
46
  end
19
47
 
48
+ # ⚠️ **만료 시각을 모르면 "만료됨"이다**(fail-safe — 자매 여덟과 동형, Java 의 M.6).
49
+ # `false`(=아직 살아있다)를 돌려주면 만료 시각 미상인 토큰이 **영원히 유효**해진다.
50
+ # `expires_at` 이 nil 인 경우는 서버가 `expires_in` 을 안 보냈을 때뿐이라 정상 경로가
51
+ # 아니고, 그때 취할 안전한 쪽은 "재발급"이다.
20
52
  def expired?(skew: 0, now: Time.now.to_f)
21
- return false if expires_at.nil?
53
+ return true if expires_at.nil?
22
54
 
23
55
  now >= (expires_at - skew)
24
56
  end
@@ -29,6 +61,13 @@ module KeycloakSdk
29
61
  "id_token=#{id_token ? '"***"' : 'nil'} scope=#{scope.inspect} expires_at=#{expires_at.inspect}>"
30
62
  end
31
63
  alias_method :to_s, :inspect
64
+
65
+ # ⚠️ `pp`/`pretty_inspect` 는 `inspect` 와 같은 계급의 **표시 경로**인데, `Data` 타입은 PP 가
66
+ # 멤버를 직접 찍어 위 `inspect` 를 **건너뛴다**(실측: access_token 원문이 나왔다). 일반
67
+ # 클래스인 `Config` 는 PP 가 `inspect` 로 폴백해 안전하다 — 그래서 `Data` 만 이 훅이 필요하다.
68
+ def pretty_print(pp)
69
+ pp.text(inspect)
70
+ end
32
71
  end
33
72
 
34
73
  # 검증된 access token의 관심 클레임.
@@ -36,7 +75,10 @@ module KeycloakSdk
36
75
 
37
76
  # RFC 7662 introspection 결과.
38
77
  IntrospectionResult = Data.define(:active, :username, :client_id, :claims) do
78
+ # 객체가 아닌 본문은 SDK 오류다 — 배열은 TypeError 로 경계를 뚫었고, 문자열은 `claims` 가 문자열인 결과가 됐다.
39
79
  def self.from_response(body)
80
+ raise AuthError, "introspection response is not a JSON object" unless body.is_a?(Hash)
81
+
40
82
  new(active: body["active"] == true, username: body["username"],
41
83
  client_id: body["client_id"], claims: body)
42
84
  end
@@ -54,5 +96,12 @@ module KeycloakSdk
54
96
  "nonce=#{nonce.inspect} code_verifier=\"***\">"
55
97
  end
56
98
  alias_method :to_s, :inspect
99
+
100
+ # ⚠️ `pp`/`pretty_inspect` 는 `inspect` 와 같은 계급의 **표시 경로**인데, `Data` 타입은 PP 가
101
+ # 멤버를 직접 찍어 위 `inspect` 를 **건너뛴다**(실측: access_token 원문이 나왔다). 일반
102
+ # 클래스인 `Config` 는 PP 가 `inspect` 로 폴백해 안전하다 — 그래서 `Data` 만 이 훅이 필요하다.
103
+ def pretty_print(pp)
104
+ pp.text(inspect)
105
+ end
57
106
  end
58
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeycloakSdk
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - xzawed