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 +4 -4
- data/README.md +1 -1
- data/lib/keycloak_sdk/admin/call.rb +2 -1
- data/lib/keycloak_sdk/auth_client.rb +36 -21
- data/lib/keycloak_sdk/errors.rb +39 -0
- data/lib/keycloak_sdk/jwks_store.rb +2 -2
- data/lib/keycloak_sdk/jwt_validator.rb +3 -1
- data/lib/keycloak_sdk/token_provider.rb +1 -1
- data/lib/keycloak_sdk/tokens.rb +12 -1
- data/lib/keycloak_sdk/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc6cc81e7537b1962108102b160aaeb30e45d1018ac533f834204dd58c69af3f
|
|
4
|
+
data.tar.gz: 172f64d5db58105f0402ecbe1821ed2e2c2f2910139c75144b788c38fa170c5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
data/lib/keycloak_sdk/errors.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
+
raise TransportError, "token endpoint transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
end
|
data/lib/keycloak_sdk/tokens.rb
CHANGED
|
@@ -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 =
|
|
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
|
data/lib/keycloak_sdk/version.rb
CHANGED