keycloak-sdk 1.0.1 → 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: ced74e5c197f31bcea44f678715d2c2b30d8cfe6b597393a200bda451057f582
4
- data.tar.gz: ff1f1781cfc00acfcca781217eedb6be8f76c7efc72f93b49940f9a85882f1b2
3
+ metadata.gz: dc6cc81e7537b1962108102b160aaeb30e45d1018ac533f834204dd58c69af3f
4
+ data.tar.gz: 172f64d5db58105f0402ecbe1821ed2e2c2f2910139c75144b788c38fa170c5c
5
5
  SHA512:
6
- metadata.gz: 2e4564e3f092dc1161a80e4bd0bb854dd1c05b4c57dd9552db23c8607f5aace485dc5fd160dddcae0e7c27989ddf4b0f4a889e6f365345dc847504afd61c4dea
7
- data.tar.gz: 2b4df9762901e5b1c7f43372952a298ee82392a6ab95cabbfd974c737213813266d7c206dcea5ee5f3509330f71a6674edc8ae5017c77a6769783cedb5356e16
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.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.
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
 
@@ -32,7 +32,8 @@ module KeycloakSdk
32
32
 
33
33
  raise AdminError.from_status(resp.status, "admin request failed: HTTP #{resp.status}")
34
34
  rescue Faraday::Error => e
35
- raise TransportError, "admin transport error: #{e.message}"
35
+ # 원본을 달면 `Faraday::ParsingError#inspect` 가 요청의 베어러 토큰을, 메시지가 응답 앞부분을 찍는다(실측).
36
+ raise TransportError, "admin transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
36
37
  end
37
38
 
38
39
  def id_from_location(resp)
@@ -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,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
@@ -122,11 +122,11 @@ module KeycloakSdk
122
122
  body = begin
123
123
  JSON.parse(buf)
124
124
  rescue JSON::ParserError => e
125
- raise TransportError, "JWKS response unparsable: #{e.message}"
125
+ raise TransportError, "JWKS response unparsable (JSON::ParserError)", cause: RedactedCause.new(e)
126
126
  end
127
127
  validate_key_set!(body)
128
128
  rescue Faraday::Error => e
129
- raise TransportError, "JWKS transport error: #{e.message}"
129
+ raise TransportError, "JWKS transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
130
130
  end
131
131
 
132
132
  # 파싱된 본문이 **캐시에 올려도 되는 집합인가**. 통과하면 그 본문을 돌려준다.
@@ -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
@@ -59,7 +59,7 @@ module KeycloakSdk
59
59
 
60
60
  TokenSet.from_response(resp.body, received_at: Time.now.to_f)
61
61
  rescue Faraday::Error => e
62
- raise TransportError, "token endpoint transport error: #{e.message}"
62
+ raise TransportError, "token endpoint transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
63
63
  end
64
64
  end
65
65
  end
@@ -17,6 +17,9 @@ module KeycloakSdk
17
17
  end
18
18
 
19
19
  def self.from_response(body, received_at: Time.now.to_f)
20
+ # 배열 본문은 `Array#[]` 의 TypeError 로 경계를 뚫었다 — 객체가 아니면 SDK 오류다.
21
+ raise AuthError, "token response is not a JSON object" unless body.is_a?(Hash)
22
+
20
23
  # ⚠️ **존재 검사는 타입 검사가 아니다.** 예전에는 값을 그대로 담아 숫자·해시·nil 이
21
24
  # `access_token` 이 됐고, 소비자는 그것을 Bearer 로 실어 보내 매번 401 을 받았다
22
25
  # (조용한 반복 실패). `expires_in` 의 문자열 허용은 **의도된 것**이라 건드리지 않는다.
@@ -25,7 +28,12 @@ module KeycloakSdk
25
28
  raise AuthError, "token response has no usable access_token"
26
29
  end
27
30
 
28
- expires_in = body["expires_in"] && Integer(body["expires_in"])
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
29
37
  new(
30
38
  access_token: access_token,
31
39
  token_type: body["token_type"],
@@ -67,7 +75,10 @@ module KeycloakSdk
67
75
 
68
76
  # RFC 7662 introspection 결과.
69
77
  IntrospectionResult = Data.define(:active, :username, :client_id, :claims) do
78
+ # 객체가 아닌 본문은 SDK 오류다 — 배열은 TypeError 로 경계를 뚫었고, 문자열은 `claims` 가 문자열인 결과가 됐다.
70
79
  def self.from_response(body)
80
+ raise AuthError, "introspection response is not a JSON object" unless body.is_a?(Hash)
81
+
71
82
  new(active: body["active"] == true, username: body["username"],
72
83
  client_id: body["client_id"], claims: body)
73
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeycloakSdk
4
- VERSION = "1.0.1"
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.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - xzawed