keycloak-sdk 1.0.1 → 1.2.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: 73ecdc82d8bb713a7529ce242c2eacfa8279d9e413ce09aa2e2e468e4fd269c3
4
+ data.tar.gz: c6670b1e3767730f49604d2215a4650d3630e34b5cac979ecc24d63517c423e3
5
5
  SHA512:
6
- metadata.gz: 2e4564e3f092dc1161a80e4bd0bb854dd1c05b4c57dd9552db23c8607f5aace485dc5fd160dddcae0e7c27989ddf4b0f4a889e6f365345dc847504afd61c4dea
7
- data.tar.gz: 2b4df9762901e5b1c7f43372952a298ee82392a6ab95cabbfd974c737213813266d7c206dcea5ee5f3509330f71a6674edc8ae5017c77a6769783cedb5356e16
6
+ metadata.gz: 165d9c457ceb36f8b38e0cf622c15175af2ce8643e6ccfdba818143b4ca85a09d7967e0c51422108ce5b906ae6f805e28f0bc44380695f0a530e96cc9b0e01b6
7
+ data.tar.gz: fcbce56000bd6f42ff3c376b0c8913487792eac3803e7fbbc2a718d58bf1fec6826dc3f99e4d59b61f7cc950116926629766cb408b990347479df87bd2e64f6e
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.2.0` is on RubyGems** — a minor release on top of `1.1.0`; `1.0.0` was the first release carrying the stability guarantee. A bare `gem install keycloak-sdk` resolves it. **Fixed**: with `expected_audience` set to something other than the client_id (a resource server), `exchange_code` with an `expected_nonce:` rejected the server's valid id_token as `invalid id_token` (`Invalid audience`), because the id_token was checked against the same override as access tokens. The id_token's `aud` is now checked for the **client_id** (OIDC Core §2, §3.1.3.7), so that exchange passes, and an id_token without the client_id in `aud` is refused even when it carries the overridden value. `validate` still applies the override to access tokens, and both share one JWKS cache and refetch limit. **Added**: an `audience:` keyword on `JwtValidator#validate(token, audience:)` — omit it and nothing changes; `nil` or a blank value raises `ConfigError`.
8
8
 
9
9
  ## Requirements
10
10
 
@@ -55,7 +55,7 @@ client.admin.users.delete(user_id)
55
55
  client.close
56
56
  ```
57
57
 
58
- > **Audience:** validation requires the token's `aud` to contain `client_id`. A stock realm does *not* put the client id in a client-credentials token's `aud`, so on a default realm either pass `expected_audience: "my-api"` (the audience your realm actually issues), or add an *Audience* protocol mapper to the client in Keycloak.
58
+ > **Audience:** validation requires the token's `aud` to contain `client_id`. A stock realm does *not* put the client id in a client-credentials token's `aud`, so on a default realm either pass `expected_audience: "my-api"` (the audience your realm actually issues), or add an *Audience* protocol mapper to the client in Keycloak. `expected_audience` applies to access tokens (`validate`) only: the `id_token` that `exchange_code(expected_nonce:)` validates must always carry `client_id` in `aud` (OIDC Core §3.1.3.7).
59
59
 
60
60
  The five admin resources — `users` / `clients` / `roles` / `groups` / `realms` — offer symmetric CRUD, and `client.admin.raw` is the escape hatch to the underlying bearer-authenticated `Faraday::Connection`.
61
61
 
@@ -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
@@ -35,37 +39,25 @@ module KeycloakSdk
35
39
  end
36
40
 
37
41
  # `expected_nonce`가 주어지면(create_authorization_request가 항상 돌려주는 nonce) 응답 id_token을
38
- # realm JWKS로 서명·iss·aud·exp까지 강화 검증한 뒤 nonce 클레임을 대조한다 — OIDC nonce 재생
42
+ # realm JWKS로 서명·iss·aud(client_id)·exp까지 강화 검증한 뒤 nonce 클레임을 대조한다 — OIDC nonce 재생
39
43
  # 방지. 불일치·부재·검증실패는 모두 거부(fail-closed). 생략 시 id_token 검증을 건너뛴다
40
44
  # (여덟 언어 공통 — exchange에서 nonce를 필수로 만들지 않는다).
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)
@@ -112,14 +104,13 @@ module KeycloakSdk
112
104
  end
113
105
  end
114
106
 
115
- # id_token의 nonce 클레임을 대조하기 전에 강화 JwtValidator로 서명·iss·aud·exp까지 검증한다
116
- # (액세스 토큰과 id_token 모두 aud=client_id이므로 검증기를 공유해도 안전 — Kotlin/.NET 동형).
117
- # ⚠️ `config.expected_audience`를 설정하면 이 공유 검증기가 id_token에도 그 값을 요구한다 —
118
- # 이 흐름을 쓴다면 해당 오디언스를 id_token에도 매핑해야 한다(audience 매퍼의 "Add to ID token").
107
+ # id_token의 nonce 클레임을 대조하기 전에 강화 JwtValidator로 서명·iss·aud·exp까지 검증한다.
108
+ # aud 는 `expected_audience` 재정의가 아니라 **client_id** 다(OIDC Core §2·§3.1.3.7) — 재정의는 액세스 토큰의 것.
109
+ # 검증기(=키 저장소)는 `validate` 와 같은 하나를 쓴다.
119
110
  def verify_nonce!(id_token, expected_nonce)
120
111
  raise AuthError, "authorization_code exchange failed: missing id_token for nonce validation" if id_token.nil?
121
112
 
122
- validated = @jwt_validator.validate(id_token)
113
+ validated = @jwt_validator.validate(id_token, audience: @config.client_id)
123
114
  return if validated.claims["nonce"] == expected_nonce
124
115
 
125
116
  raise AuthError, "authorization_code exchange failed: unexpected nonce"
@@ -127,6 +118,27 @@ module KeycloakSdk
127
118
  raise AuthError, "authorization_code exchange failed: invalid id_token: #{e.message}"
128
119
  end
129
120
 
121
+ # rack-oauth2 토큰 호출의 오류 경계(§4). 하위 예외는 SDK 타입이 되고 `cause` 에는 원본 대신 `RedactedCause` 가 달린다.
122
+ # ⚠️ OAuth 오류는 **코드와 HTTP 상태만** 싣는다 — `error_description` 은 서버가 고른 자유 문장이라 토큰을
123
+ # 되울릴 수 있고, 오류 본문이 JSON 이 아니면 rack-oauth2 가 본문 전체를 거기 넣는다. 코드 자리도 서버 값이라
124
+ # **코드 모양**(`OAUTH_CODE` — 등록 코드는 전부 소문자·밑줄)일 때만 메시지에 싣는다(`oauth_error` 에는 그대로).
125
+ # ⚠️ 마지막 `StandardError` 는 rack-oauth2 가 형식이 틀린 200 을 읽다 내는 것(NoMethodError·AttrMissing·
126
+ # 'Unknown Token Type')과 `to_token_set` 의 형 변환 실패다 — 원본이 새면 Ruby 3.2 의 NoMethodError 가 본문을 인용한다.
127
+ def token_request(operation)
128
+ to_token_set(yield)
129
+ rescue Error
130
+ raise
131
+ rescue Rack::OAuth2::Client::Error => e
132
+ code = e.response[:error].is_a?(String) ? e.response[:error] : ""
133
+ shown = code.match?(OAUTH_CODE) ? code : "non-standard error code"
134
+ raise AuthError.new("#{operation} failed: #{shown} (HTTP #{e.status})", oauth_error: code),
135
+ cause: RedactedCause.new(e)
136
+ rescue Faraday::Error => e
137
+ raise TransportError, "token endpoint transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
138
+ rescue StandardError => e
139
+ raise AuthError, "#{operation} failed: unusable token response (#{e.class})", cause: RedactedCause.new(e)
140
+ end
141
+
130
142
  def oauth_client(redirect_uri: nil)
131
143
  Rack::OAuth2::Client.new(
132
144
  identifier: @config.client_id,
@@ -137,17 +149,19 @@ module KeycloakSdk
137
149
  )
138
150
  end
139
151
 
152
+ # `expires_in` 은 `TokenSet.from_response` 와 같이 정수로 읽는다(문자열 "300" 허용) — 예전에는 문자열이면
153
+ # `Float + String` 의 TypeError 가 경계를 뚫었다. 변환 실패는 `token_request` 가 AuthError 로 바꾼다.
140
154
  def to_token_set(token)
141
155
  raw = token.raw_attributes || {}
142
- scope = raw[:scope] || raw["scope"]
156
+ expires_in = token.expires_in && Integer(token.expires_in)
143
157
  TokenSet.new(
144
158
  access_token: token.access_token,
145
159
  token_type: "Bearer",
146
- expires_in: token.expires_in,
160
+ expires_in: expires_in,
147
161
  refresh_token: token.refresh_token,
148
162
  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
163
+ scope: raw[:scope] || raw["scope"],
164
+ expires_at: expires_in ? Time.now.to_f + expires_in : nil
151
165
  )
152
166
  end
153
167
  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
  # 파싱된 본문이 **캐시에 올려도 되는 집합인가**. 통과하면 그 본문을 돌려준다.
@@ -30,21 +30,28 @@ module KeycloakSdk
30
30
  algorithms: config.signature_algorithms, clock_skew: config.clock_skew)
31
31
  end
32
32
 
33
- def validate(token)
34
- payload, = JWT.decode(token, nil, true, decode_options)
33
+ # `audience:` 는 이 호출만의 기대 aud 다(기본 = 생성 시 값). 코드 교환이 id_token 을 client_id 로 검사할 때
34
+ # 쓴다 — 키 저장소(캐시 · 재조회 제한 · 백오프)는 그대로 이 인스턴스의 것 하나다.
35
+ # ⚠️ nil·공백은 생성자와 같이 ConfigError — ruby-jwt 는 `aud: nil` 이면 aud 검사를 건너뛴다.
36
+ def validate(token, audience: @audience)
37
+ raise ConfigError, "audience is required" if audience.nil? || audience.to_s.strip.empty?
38
+
39
+ payload, = JWT.decode(token, nil, true, decode_options(audience))
35
40
  to_validated(payload)
36
41
  rescue JWT::DecodeError => e
37
- raise TokenValidationError, "JWT validation failed: #{e.message}"
42
+ # ruby-jwt 메시지는 입력을 인용하지 않는다. 그 **원인**은 인용한다 — 조각이 JSON 이 아니면 JSON::ParserError 가
43
+ # 디코드한 토큰 조각을 싣는다(실측) — 그래서 사슬은 `RedactedCause` 로 끊는다.
44
+ raise TokenValidationError, "JWT validation failed: #{e.message}", cause: RedactedCause.new(e)
38
45
  end
39
46
 
40
47
  private
41
48
 
42
- def decode_options
49
+ def decode_options(audience)
43
50
  {
44
51
  algorithms: @algorithms,
45
52
  jwks: jwks_loader,
46
53
  verify_iss: true, iss: @issuer,
47
- verify_aud: true, aud: @audience,
54
+ verify_aud: true, aud: audience,
48
55
  verify_expiration: true,
49
56
  verify_not_before: true,
50
57
  required_claims: %w[exp iss aud],
@@ -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.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - xzawed