keycloak-sdk 1.1.0 → 1.3.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 +2 -2
- data/lib/keycloak_sdk/admin/bearer_auth.rb +31 -1
- data/lib/keycloak_sdk/auth_client.rb +81 -27
- data/lib/keycloak_sdk/http.rb +72 -0
- data/lib/keycloak_sdk/jwks_store.rb +8 -9
- data/lib/keycloak_sdk/jwt_validator.rb +13 -4
- data/lib/keycloak_sdk/token_provider.rb +7 -6
- data/lib/keycloak_sdk/version.rb +1 -1
- data/lib/keycloak_sdk.rb +2 -0
- 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: b1a2f1983231445d40411139a273115f4680aced0fe873b06a26bf0965e050cb
|
|
4
|
+
data.tar.gz: 5c5b50cdfca4f6839d2bcb94a9a107f0772bc3a4dad386a43b77ccc8b44a37b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dcde89692c25c6ab41a45363018c7b47d8d629d45ce5660f74f80f1025394438314dee56e70b4e2e91d3fa63155589867ddbded27a91d79e744115ca0880a8de
|
|
7
|
+
data.tar.gz: 4c37d217507bdee67f59b96b2e130cc057cab28a19c7ddf1686d517c0ea18c4dccd208bfb04cb2916673dff7216ffee9e029c49d309f20fe26a5575f5bef7114
|
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.
|
|
7
|
+
> **`1.3.0` is on RubyGems** — a minor release on top of `1.2.0`; `1.0.0` was the first release carrying the stability guarantee. A bare `gem install keycloak-sdk` resolves it. **Security**: token, introspection and logout responses — on the grants, `introspect`, `logout` and the admin client's `ClientCredentialsTokenProvider` — are now read only up to 1,048,576 bytes; a usable token padded with 32 MiB of JSON whitespace used to be read whole, and `logout` read any body to the end. The cap is 16 times the longest bearer a default Keycloak 26.6 accepts, so no token a server accepts is refused; a larger response raises `KeycloakSdk::TransportError` (`token response exceeds 1048576 bytes` and the like) whatever its status, and the admin client sends no admin request. A response that decodes to invalid UTF-8 and an access token that a header cannot carry now raise SDK errors instead of a raw `ArgumentError` or `Encoding::CompatibilityError` — the admin client's quoted the whole bearer token. An invalid-UTF-8 input to `validate`, `introspect`, `logout` or a grant now raises an SDK error too (`TokenValidationError`, or `AuthError` without a request). ⚠️ **Token, introspection, logout and JWKS requests now send `Accept-Encoding: identity`**, so a server that answers them with gzip anyway fails. ⚠️ **The grants now go out on the SDK's own connection** rather than rack-oauth2's process-wide one: `Config` timeouts apply per client and the `User-Agent` becomes Faraday's default, while client authentication, parameters and error handling are unchanged; an empty `code` or `refresh_token` raises `AuthError` without a request. **Added**: `KeycloakSdk::Http::TOKEN_RESPONSE_MAX_BYTES` and the helpers `Http.read_capped`, `Http.decode_json` and `Http.utf8?`.
|
|
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
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "net/http"
|
|
4
5
|
|
|
5
6
|
module KeycloakSdk
|
|
6
7
|
module Admin
|
|
@@ -12,7 +13,36 @@ module KeycloakSdk
|
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def on_request(env)
|
|
15
|
-
|
|
16
|
+
token = @token_provider.access_token
|
|
17
|
+
sendable!(token) if token.is_a?(String)
|
|
18
|
+
env.request_headers["Authorization"] = "Bearer #{token}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# ⚠️ **net-http 가 헤더에 실을 수 없는 Bearer 는 보내기 전에 SDK 오류로 거부한다.** net-http 는 요청 헤더를 만들
|
|
24
|
+
# 때(`initialize_http_header`) CR·LF 면 헤더 값을 **통째로 인용한** raw ArgumentError(= Bearer 누출)를,
|
|
25
|
+
# `MAX_FIELD_LENGTH`(65,536)를 넘으면 raw ArgumentError 를, 잘못된 UTF-8 이면 `strip` 의 raw
|
|
26
|
+
# Encoding::CompatibilityError 를 낸다 — 셋 다 admin 경계(`rescue Faraday::Error`)를 지나 샜다(실측).
|
|
27
|
+
# ⚠️ 고쳐 쓰지 않는다(잘라내거나 바꿔 보내면 다른 토큰이다). NUL·U+00FF 위 문자는 net-http 가 막지 않으므로
|
|
28
|
+
# 그대로 보내고 판정은 서버에 맡긴다. 메시지에는 토큰을 싣지 않는다(길이만).
|
|
29
|
+
def sendable!(token)
|
|
30
|
+
raise AuthError, "admin request not sent: the access token is not valid UTF-8" unless token.valid_encoding?
|
|
31
|
+
|
|
32
|
+
if token.match?(/[\r\n]/)
|
|
33
|
+
raise AuthError, "admin request not sent: the access token holds CR or LF, which an HTTP header cannot carry"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
limit = field_limit
|
|
37
|
+
return if limit.nil? || "Bearer ".bytesize + token.bytesize <= limit
|
|
38
|
+
|
|
39
|
+
raise AuthError, "admin request not sent: the access token (#{token.bytesize} bytes) does not fit an " \
|
|
40
|
+
"HTTP header field (#{limit} bytes with \"Bearer \")"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 이 SDK 의 어댑터는 net-http 로 고정돼 있다(`Http.build`). 그 상한이 없는 net-http 판은 길이로 막지 않는다.
|
|
44
|
+
def field_limit
|
|
45
|
+
Net::HTTPHeader::MAX_FIELD_LENGTH if defined?(Net::HTTPHeader::MAX_FIELD_LENGTH)
|
|
16
46
|
end
|
|
17
47
|
end
|
|
18
48
|
end
|
|
@@ -4,9 +4,11 @@ require "rack/oauth2"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "digest"
|
|
6
6
|
require "base64"
|
|
7
|
+
require "uri"
|
|
7
8
|
|
|
8
9
|
module KeycloakSdk
|
|
9
|
-
# 인증 파사드. rack-oauth2
|
|
10
|
+
# 인증 파사드. 인가 URL(PKCE)은 rack-oauth2 로 만들고, 세 그랜트·introspection(RFC7662)·logout 은 SDK 의 Faraday
|
|
11
|
+
# 커넥션으로 직접 보낸다 — 토큰 응답 본문을 상한까지만 읽어야 하는데 rack-oauth2 는 자기 커넥션으로 통째로 읽는다.
|
|
10
12
|
# TokenProvider를 구현하지만(직접 사용용), admin은 캐싱 ClientCredentialsTokenProvider를 별도로 쓴다(§4).
|
|
11
13
|
class AuthClient
|
|
12
14
|
include TokenProvider
|
|
@@ -39,25 +41,23 @@ module KeycloakSdk
|
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# `expected_nonce`가 주어지면(create_authorization_request가 항상 돌려주는 nonce) 응답 id_token을
|
|
42
|
-
# realm JWKS로 서명·iss·aud·exp까지 강화 검증한 뒤 nonce 클레임을 대조한다 — OIDC nonce 재생
|
|
44
|
+
# realm JWKS로 서명·iss·aud(client_id)·exp까지 강화 검증한 뒤 nonce 클레임을 대조한다 — OIDC nonce 재생
|
|
43
45
|
# 방지. 불일치·부재·검증실패는 모두 거부(fail-closed). 생략 시 id_token 검증을 건너뛴다
|
|
44
46
|
# (여덟 언어 공통 — exchange에서 nonce를 필수로 만들지 않는다).
|
|
45
47
|
def exchange_code(code:, code_verifier:, redirect_uri:, expected_nonce: nil)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
token_set = token_request("authorization_code exchange",
|
|
49
|
+
{ grant_type: "authorization_code", code: code, redirect_uri: redirect_uri,
|
|
50
|
+
code_verifier: code_verifier }, required: :code)
|
|
49
51
|
verify_nonce!(token_set.id_token, expected_nonce) unless expected_nonce.nil?
|
|
50
52
|
token_set
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def refresh(refresh_token:)
|
|
54
|
-
|
|
55
|
-
client.refresh_token = refresh_token
|
|
56
|
-
token_request("refresh") { client.access_token! }
|
|
56
|
+
token_request("refresh", { grant_type: "refresh_token", refresh_token: refresh_token }, required: :refresh_token)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def client_credentials_token
|
|
60
|
-
token_request("client-credentials"
|
|
60
|
+
token_request("client-credentials", { grant_type: "client_credentials", scope: @config.scopes.join(" ") })
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# TokenProvider 계약(직접 사용용). admin은 캐싱 provider를 별도로 쓴다.
|
|
@@ -66,9 +66,10 @@ module KeycloakSdk
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def introspect(token)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
form = form!("introspection", token: token, client_id: @config.client_id, client_secret: @config.client_secret)
|
|
70
|
+
resp = Http.decode_json(Http.read_capped(@http, :post, @endpoints.introspection,
|
|
71
|
+
body: form, max_bytes: Http::TOKEN_RESPONSE_MAX_BYTES,
|
|
72
|
+
what: "introspection"))
|
|
72
73
|
raise AuthError, "introspection failed: HTTP #{resp.status}" unless resp.success?
|
|
73
74
|
|
|
74
75
|
IntrospectionResult.from_response(resp.body)
|
|
@@ -76,12 +77,16 @@ module KeycloakSdk
|
|
|
76
77
|
raise TransportError, "introspection transport error: #{RedactedCause.describe(e)}", cause: RedactedCause.new(e)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# 본문은 쓰지 않지만 읽는다 — `TOKEN_RESPONSE_MAX_BYTES` 까지만(넘으면 상태와 무관하게 TransportError).
|
|
81
|
+
# 해석은 커넥션의 json 미들웨어가 하던 그대로 남긴다: JSON 이라면서 JSON 이 아닌 본문은 지금처럼 TransportError 다.
|
|
82
|
+
# UTF-8 검사는 걸지 않는다(`check_utf8: false`) — 값을 쓰지 않아 지킬 것이 없고, 걸면 400 이 오류 본문의 바이트에
|
|
83
|
+
# 따라 AuthError 에서 TransportError 로 바뀐다(hostile_token_response_spec 의 u2). 해석은 상태 검사보다 먼저다.
|
|
79
84
|
def logout(refresh_token:)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
raise AuthError, "logout failed: HTTP #{resp.status}" unless resp.success?
|
|
85
|
+
form = form!("logout", client_id: @config.client_id, client_secret: @config.client_secret,
|
|
86
|
+
refresh_token: refresh_token)
|
|
87
|
+
resp = Http.read_capped(@http, :post, @endpoints.end_session,
|
|
88
|
+
body: form, max_bytes: Http::TOKEN_RESPONSE_MAX_BYTES, what: "logout")
|
|
89
|
+
raise AuthError, "logout failed: HTTP #{resp.status}" unless Http.decode_json(resp, check_utf8: false).success?
|
|
85
90
|
|
|
86
91
|
nil
|
|
87
92
|
rescue Faraday::Error => e
|
|
@@ -97,6 +102,8 @@ module KeycloakSdk
|
|
|
97
102
|
# rack-oauth2의 프로세스 전역 HTTP 타임아웃을 Config로 설정한다(require 시점 하드코딩 대신).
|
|
98
103
|
# 타임아웃은 Faraday::Connection이 아니라 그 #options(Faraday::RequestOptions)에 있다
|
|
99
104
|
# (Connection에 open_timeout=/timeout= 세터가 없어 NoMethodError — 게차 참조).
|
|
105
|
+
# ⚠️ SDK 의 토큰 요청은 이제 rack-oauth2 의 연결을 타지 않는다(그랜트는 `token_request` 가 직접 보낸다) — 이 등록이
|
|
106
|
+
# 닿는 것은 같은 프로세스의 다른 rack-oauth2 사용자뿐이다. 등록은 first-wins(`@@http_config ||= block`)다.
|
|
100
107
|
def configure_rack_oauth2_timeouts(config)
|
|
101
108
|
Rack::OAuth2.http_config do |conn|
|
|
102
109
|
conn.options.open_timeout = config.connect_timeout
|
|
@@ -104,14 +111,13 @@ module KeycloakSdk
|
|
|
104
111
|
end
|
|
105
112
|
end
|
|
106
113
|
|
|
107
|
-
# id_token의 nonce 클레임을 대조하기 전에 강화 JwtValidator로 서명·iss·aud·exp까지
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# 이 흐름을 쓴다면 해당 오디언스를 id_token에도 매핑해야 한다(audience 매퍼의 "Add to ID token").
|
|
114
|
+
# id_token의 nonce 클레임을 대조하기 전에 강화 JwtValidator로 서명·iss·aud·exp까지 검증한다.
|
|
115
|
+
# aud 는 `expected_audience` 재정의가 아니라 **client_id** 다(OIDC Core §2·§3.1.3.7) — 재정의는 액세스 토큰의 것.
|
|
116
|
+
# 검증기(=키 저장소)는 `validate` 와 같은 하나를 쓴다.
|
|
111
117
|
def verify_nonce!(id_token, expected_nonce)
|
|
112
118
|
raise AuthError, "authorization_code exchange failed: missing id_token for nonce validation" if id_token.nil?
|
|
113
119
|
|
|
114
|
-
validated = @jwt_validator.validate(id_token)
|
|
120
|
+
validated = @jwt_validator.validate(id_token, audience: @config.client_id)
|
|
115
121
|
return if validated.claims["nonce"] == expected_nonce
|
|
116
122
|
|
|
117
123
|
raise AuthError, "authorization_code exchange failed: unexpected nonce"
|
|
@@ -119,14 +125,18 @@ module KeycloakSdk
|
|
|
119
125
|
raise AuthError, "authorization_code exchange failed: invalid id_token: #{e.message}"
|
|
120
126
|
end
|
|
121
127
|
|
|
122
|
-
#
|
|
128
|
+
# 토큰 그랜트 하나와 그 오류 경계(§4). 하위 예외는 SDK 타입이 되고 `cause` 에는 원본 대신 `RedactedCause` 가 달린다.
|
|
123
129
|
# ⚠️ OAuth 오류는 **코드와 HTTP 상태만** 싣는다 — `error_description` 은 서버가 고른 자유 문장이라 토큰을
|
|
124
130
|
# 되울릴 수 있고, 오류 본문이 JSON 이 아니면 rack-oauth2 가 본문 전체를 거기 넣는다. 코드 자리도 서버 값이라
|
|
125
131
|
# **코드 모양**(`OAUTH_CODE` — 등록 코드는 전부 소문자·밑줄)일 때만 메시지에 싣는다(`oauth_error` 에는 그대로).
|
|
126
|
-
# ⚠️ 마지막 `StandardError` 는
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
# ⚠️ 마지막 `StandardError` 는 형식이 틀린 200 을 읽다 나는 것(NoMethodError·AttrMissing·'Unknown Token Type')과
|
|
133
|
+
# `to_token_set` 의 형 변환 실패다 — 원본이 새면 Ruby 3.2 의 NoMethodError 가 본문을 인용한다.
|
|
134
|
+
# ⚠️ 필수 값(`code`·`refresh_token`)이 비면 보내지 않는다 — rack-oauth2 의 그랜트가 하던 검사이고, 그때는 이 경계
|
|
135
|
+
# 밖에서 raw `AttrRequired::AttrMissing` 으로 샜다.
|
|
136
|
+
def token_request(operation, params, required: nil)
|
|
137
|
+
raise AuthError, "#{operation} failed: #{required} is required" if required && missing?(params[required])
|
|
138
|
+
|
|
139
|
+
to_token_set(oauth_token(post_grant(operation, params)))
|
|
130
140
|
rescue Error
|
|
131
141
|
raise
|
|
132
142
|
rescue Rack::OAuth2::Client::Error => e
|
|
@@ -140,6 +150,50 @@ module KeycloakSdk
|
|
|
140
150
|
raise AuthError, "#{operation} failed: unusable token response (#{e.class})", cause: RedactedCause.new(e)
|
|
141
151
|
end
|
|
142
152
|
|
|
153
|
+
# 그랜트를 SDK 커넥션으로 보낸다 — 클라이언트 인증은 rack-oauth2 의 기본(`:basic`)과 같은 모양(id·secret 을
|
|
154
|
+
# 각각 form-url-encode 한 뒤 base64)이고, 빈 값은 rack-oauth2 의 `Util.compact_hash` 처럼 보내지 않는다.
|
|
155
|
+
# 본문은 `TOKEN_RESPONSE_MAX_BYTES` 까지만 읽는다.
|
|
156
|
+
def post_grant(operation, params)
|
|
157
|
+
form = form!(operation, **params).reject { |_, v| v.nil? || v.to_s.match?(/\A[[:space:]]*\z/) }
|
|
158
|
+
pair = [@config.client_id, @config.client_secret].map { |v| URI.encode_www_form_component(v) }.join(":")
|
|
159
|
+
basic = { "Authorization" => "Basic #{Base64.strict_encode64(pair)}" }
|
|
160
|
+
Http.decode_json(Http.read_capped(@http, :post, @endpoints.token,
|
|
161
|
+
body: form, headers: basic,
|
|
162
|
+
max_bytes: Http::TOKEN_RESPONSE_MAX_BYTES, what: "token"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# rack-oauth2 의 응답 규칙(`Client#handle_response` — 2.3.0 client.rb:205-240)을 SDK 가 읽은 본문에 그대로 건다:
|
|
166
|
+
# 200·201 은 token_type 이 bearer 인 `AccessToken::Bearer`, 그 밖은 `Client::Error`(본문이 객체가 아니면 'Unknown').
|
|
167
|
+
# 그 메서드는 private 이라 규칙만 옮겼다 — 결과·오류는 rack-oauth2 의 공개 타입이라 위 오류 대응이 그대로다.
|
|
168
|
+
# 객체가 아닌 200 본문은 rack-oauth2 처럼 NoMethodError 로 떨어져 `token_request` 가 AuthError 로 바꾼다.
|
|
169
|
+
def oauth_token(resp)
|
|
170
|
+
body = resp.body
|
|
171
|
+
unless (200..201).cover?(resp.status)
|
|
172
|
+
error = body.is_a?(Hash) ? body.transform_keys(&:to_sym) : { error: "Unknown", error_description: body }
|
|
173
|
+
raise Rack::OAuth2::Client::Error.new(resp.status, error)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
token_hash = body.transform_keys(&:to_sym)
|
|
177
|
+
raise "Unknown Token Type" unless token_hash[:token_type]&.downcase == "bearer"
|
|
178
|
+
|
|
179
|
+
Rack::OAuth2::AccessToken::Bearer.new(token_hash)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# rack-oauth2 의 `attr_required` 와 같은 「비었다」 — nil 이거나 빈 값(공백뿐인 문자열은 비지 않았다).
|
|
183
|
+
def missing?(value)
|
|
184
|
+
value.respond_to?(:empty?) ? value.empty? : value.nil?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 폼 값이 잘못된 UTF-8 이면 요청 없이 거부하고(어느 자리인지만 말한다) 아니면 폼을 돌려준다. Faraday 의 폼
|
|
188
|
+
# 인코더(`Faraday::Utils.escape` 의 `gsub`)가 그것에서 raw ArgumentError 를 냈다(실측: logout·introspect 는 요청 0 건에
|
|
189
|
+
# raw, 그랜트는 「unusable token response (ArgumentError)」라는 틀린 말). 고쳐 쓰지 않는다.
|
|
190
|
+
def form!(operation, **form)
|
|
191
|
+
bad = form.find { |_, v| v.is_a?(String) && !v.valid_encoding? }
|
|
192
|
+
raise AuthError, "#{operation} failed: #{bad.first} is not valid UTF-8" if bad
|
|
193
|
+
|
|
194
|
+
form
|
|
195
|
+
end
|
|
196
|
+
|
|
143
197
|
def oauth_client(redirect_uri: nil)
|
|
144
198
|
Rack::OAuth2::Client.new(
|
|
145
199
|
identifier: @config.client_id,
|
data/lib/keycloak_sdk/http.rb
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module KeycloakSdk
|
|
6
7
|
# 공유 Faraday 커넥션 팩토리. 타임아웃을 config에서 주입하고,
|
|
7
8
|
# follow_redirects 미들웨어를 절대 장착하지 않는다(SSRF 하드닝 — Faraday는 기본 미추종).
|
|
9
|
+
# 상한을 건 본문 읽기(`read_capped`)도 여기 있다 — 토큰·introspection·logout·JWKS 레인이 같은 기제를 쓴다.
|
|
8
10
|
module Http
|
|
11
|
+
# 토큰 엔드포인트(세 그랜트 · admin 레인의 자기 토큰)·introspection·logout 응답 본문의 상한(바이트).
|
|
12
|
+
# 1 MiB 는 Keycloak 26.6 이 기본 설정으로 받는 가장 긴 Bearer(65,459 바이트 — 하나 더 길면 HTTP 431)의
|
|
13
|
+
# 16 배라 서버가 받는 토큰은 거부하지 않고, 끝없는 본문은 여기서 멈춘다.
|
|
14
|
+
# ⚠️ JWKS 의 51,200 을 빌려 쓰지 말 것 — 서비스 계정 토큰은 관리하는 realm 수만큼 자란다.
|
|
15
|
+
# ⚠️ 맨 십진 리터럴이어야 한다 — 아홉 SDK 가 같은 값을 쓰고 교차언어 가드가 이 줄을 읽는다.
|
|
16
|
+
TOKEN_RESPONSE_MAX_BYTES = 1_048_576
|
|
17
|
+
|
|
18
|
+
# ⚠️ **SDK 가 직접 읽는 요청은 인코딩 없는 본문을 청한다.** Accept-Encoding 이 없으면 net-http 가 gzip 을 청하고
|
|
19
|
+
# 스스로 푸는데, 그 inflater 는 상한에서 읽기를 끊어도 ensure 에서 `Inflater#finish` 로 **지금 읽은 압축 조각을
|
|
20
|
+
# 끝까지** 푼다 — 16 KiB 조각 하나가 공백이면 ~16 MiB 다(실측: JWKS 상한이 지켜진 채 피크 +24 MB).
|
|
21
|
+
# 요청에 이 헤더가 있으면 net-http 는 자기 것을 붙이지 않고 풀지도 않는다. 그래도 gzip 을 보내는 서버는
|
|
22
|
+
# 압축된 바이트가 그대로 와 JSON 으로 읽히지 않으므로 SDK 오류로 닫힌다.
|
|
23
|
+
IDENTITY = { "Accept-Encoding" => "identity" }.freeze
|
|
24
|
+
|
|
25
|
+
# Faraday 의 JSON 응답 규칙 그대로(JSON 콘텐츠 타입만 · 빈 본문은 nil · 해석 실패는 Faraday::ParsingError).
|
|
26
|
+
# 스트리밍으로 읽은 본문은 커넥션의 json 미들웨어가 보지 못하므로(그때 본문은 비어 있다) 여기서 같은 규칙을 건다.
|
|
27
|
+
JSON_RESPONSE = Faraday::Response::Json.new(nil, content_type: /\bjson$/)
|
|
28
|
+
UNDECODABLE = "JSON text decodes to invalid UTF-8"
|
|
29
|
+
private_constant :IDENTITY, :JSON_RESPONSE, :UNDECODABLE
|
|
30
|
+
|
|
9
31
|
module_function
|
|
10
32
|
|
|
11
33
|
def build(config, base_url: nil)
|
|
@@ -17,5 +39,55 @@ module KeycloakSdk
|
|
|
17
39
|
f.adapter :net_http
|
|
18
40
|
end
|
|
19
41
|
end
|
|
42
|
+
|
|
43
|
+
# 요청 하나를 보내고 본문을 `max_bytes` 까지만 읽는다 — **상태와 무관하게**(오류 응답의 거대 본문도 같다).
|
|
44
|
+
# 돌려주는 응답의 `body` 는 읽은 바이트(BINARY 문자열)다. 넘으면 그 즉시 `TransportError` 이고 연결은 닫힌다.
|
|
45
|
+
#
|
|
46
|
+
# ⚠️ 판정은 청크를 **붙이기 전에** 한다 — 버퍼는 상한을 넘지 않고, 넘는 청크는 복사하지 않는다. 읽는 크기는
|
|
47
|
+
# SDK 가 정하지 못한다: net-http 가 소켓에서 16 KiB(`Net::BufferedIO::BUFSIZE`)씩 읽어 넘기므로 판정 순간
|
|
48
|
+
# 손에 든 것은 많아야 상한 + 청크 하나(16,384 바이트)다. 버퍼는 읽은 만큼만 자란다(미리 상한만큼 잡지 않는다).
|
|
49
|
+
# ⚠️ `Content-Length` 만 보면 그 헤더가 없거나 거짓인 응답을 놓친다 — 누적치로 판정한다.
|
|
50
|
+
def read_capped(conn, method, url, max_bytes:, what:, body: nil, headers: {})
|
|
51
|
+
buf = String.new(encoding: Encoding::BINARY)
|
|
52
|
+
resp = conn.run_request(method, url, body, IDENTITY.merge(headers)) do |req|
|
|
53
|
+
req.options.on_data = proc do |chunk, _received|
|
|
54
|
+
over = buf.bytesize + chunk.bytesize > max_bytes
|
|
55
|
+
raise TransportError, "#{what} response exceeds #{max_bytes} bytes" if over
|
|
56
|
+
|
|
57
|
+
buf << chunk
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
resp.env.body = buf
|
|
61
|
+
resp
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `read_capped` 의 본문을 커넥션의 json 미들웨어가 하던 그대로 해석한다(응답을 돌려준다).
|
|
65
|
+
# ⚠️ 해석한 값의 문자열이 잘못된 UTF-8 이면 해석 실패다(Faraday::ParsingError ← JSON::ParserError — 짝 없는
|
|
66
|
+
# 서로게이트 이스케이프에서 json 3 이 내는 것과 같은 모양). 고쳐 쓰지 않는다 — 그 값은 SDK 를 지나며 raw 예외가 된다.
|
|
67
|
+
# `check_utf8: false` 는 해석한 값을 하나도 쓰지 않는 호출(logout)의 것이다 — 미들웨어의 판정만 남는다.
|
|
68
|
+
def decode_json(resp, check_utf8: true)
|
|
69
|
+
JSON_RESPONSE.on_complete(resp.env)
|
|
70
|
+
return resp if !check_utf8 || utf8?(resp.body)
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
raise JSON::ParserError, UNDECODABLE
|
|
74
|
+
rescue JSON::ParserError => e
|
|
75
|
+
raise Faraday::ParsingError.new(e, resp) # 원인 사슬도 json 3 의 실패와 같다(ParsingError <- ParserError)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 해석한 JSON 값(키 포함)의 문자열이 전부 올바른 인코딩인가. 해석하지 않은 본문(BINARY)은 언제나 참이다.
|
|
80
|
+
# ⚠️ **json 버전으로 막을 수 없다** — 잠긴 json 3.0.2 도 날 잘못된 UTF-8 바이트(0xFF)는 그대로 받아 잘못된
|
|
81
|
+
# 문자열을 내고, json 2.9–2.21 은 짝 없는 **낮은** 서로게이트 이스케이프(\udc00)까지 그렇게 푼다(둘 다 실측).
|
|
82
|
+
# 그 문자열이 지나는 자리마다 raw 예외다 — OAuth 코드 대조(`match?`)의 ArgumentError, admin 헤더의
|
|
83
|
+
# Encoding::CompatibilityError(net-http `strip`), ruby-jwt base64 의 ArgumentError.
|
|
84
|
+
def utf8?(value)
|
|
85
|
+
case value
|
|
86
|
+
when String then value.valid_encoding?
|
|
87
|
+
when Hash then value.all? { |k, v| utf8?(k) && utf8?(v) }
|
|
88
|
+
when Array then value.all? { |v| utf8?(v) }
|
|
89
|
+
else true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
20
92
|
end
|
|
21
93
|
end
|
|
@@ -107,20 +107,19 @@ module KeycloakSdk
|
|
|
107
107
|
|
|
108
108
|
def fetch
|
|
109
109
|
# ⚠️ 상한은 **상태와 무관하게** 건다. 200 만 겨누면 오류 응답의 거대 본문이 그대로
|
|
110
|
-
# 들어온다 — php 가 정확히 그 순서였다(상태 검사 전에 `json_decode`).
|
|
111
|
-
|
|
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
|
|
110
|
+
# 들어온다 — php 가 정확히 그 순서였다(상태 검사 전에 `json_decode`). 기제는 토큰 레인과 같은 `Http.read_capped`.
|
|
111
|
+
resp = Http.read_capped(@http, :get, @jwks_url, max_bytes: JWKS_MAX_BYTES, what: "JWKS")
|
|
118
112
|
raise TransportError, "JWKS fetch failed: HTTP #{resp.status}" unless resp.success?
|
|
119
113
|
|
|
120
114
|
# ⚠️ `on_data` 를 쓰면 Faraday 는 본문을 누적하지 않으므로 json 미들웨어가 돌지 않는다 —
|
|
121
115
|
# 파싱은 여기서 한다. 파싱 실패도 경계에서 SDK 타입이 되어야 한다(§4).
|
|
116
|
+
# ⚠️ 잘못된 UTF-8 로 풀리는 본문도 해석 실패다(`Http.utf8?`) — n·e 가 그러면 ruby-jwt 의 base64 가 검증 중에
|
|
117
|
+
# raw ArgumentError 를 냈다(실측, 잠긴 json 3.0.2 에서도).
|
|
122
118
|
body = begin
|
|
123
|
-
JSON.parse(
|
|
119
|
+
parsed = JSON.parse(resp.body)
|
|
120
|
+
raise JSON::ParserError, "JSON text decodes to invalid UTF-8" unless Http.utf8?(parsed)
|
|
121
|
+
|
|
122
|
+
parsed
|
|
124
123
|
rescue JSON::ParserError => e
|
|
125
124
|
raise TransportError, "JWKS response unparsable (JSON::ParserError)", cause: RedactedCause.new(e)
|
|
126
125
|
end
|
|
@@ -30,8 +30,17 @@ module KeycloakSdk
|
|
|
30
30
|
algorithms: config.signature_algorithms, clock_skew: config.clock_skew)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
# ⚠️ ruby-jwt 는 맨 먼저 토큰을 `split` 하고, 잘못된 UTF-8 이면 그것이 raw ArgumentError 다(실측).
|
|
39
|
+
if token.is_a?(String) && !token.valid_encoding?
|
|
40
|
+
raise TokenValidationError, "JWT validation failed: token is not valid UTF-8"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
payload, = JWT.decode(token, nil, true, decode_options(audience))
|
|
35
44
|
to_validated(payload)
|
|
36
45
|
rescue JWT::DecodeError => e
|
|
37
46
|
# ruby-jwt 메시지는 입력을 인용하지 않는다. 그 **원인**은 인용한다 — 조각이 JSON 이 아니면 JSON::ParserError 가
|
|
@@ -41,12 +50,12 @@ module KeycloakSdk
|
|
|
41
50
|
|
|
42
51
|
private
|
|
43
52
|
|
|
44
|
-
def decode_options
|
|
53
|
+
def decode_options(audience)
|
|
45
54
|
{
|
|
46
55
|
algorithms: @algorithms,
|
|
47
56
|
jwks: jwks_loader,
|
|
48
57
|
verify_iss: true, iss: @issuer,
|
|
49
|
-
verify_aud: true, aud:
|
|
58
|
+
verify_aud: true, aud: audience,
|
|
50
59
|
verify_expiration: true,
|
|
51
60
|
verify_not_before: true,
|
|
52
61
|
required_claims: %w[exp iss aud],
|
|
@@ -45,13 +45,14 @@ module KeycloakSdk
|
|
|
45
45
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
|
+
# 본문은 `Http::TOKEN_RESPONSE_MAX_BYTES` 까지만 읽는다 — 넘으면 `TransportError` 이고 admin 요청은 나가지 않는다.
|
|
48
49
|
def request_token
|
|
49
|
-
resp = @http
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
resp = Http.decode_json(Http.read_capped(@http, :post, @token_url,
|
|
51
|
+
body: { grant_type: "client_credentials",
|
|
52
|
+
client_id: @config.client_id,
|
|
53
|
+
client_secret: @config.client_secret,
|
|
54
|
+
scope: @config.scopes.join(" ") },
|
|
55
|
+
max_bytes: Http::TOKEN_RESPONSE_MAX_BYTES, what: "token"))
|
|
55
56
|
unless resp.success?
|
|
56
57
|
oauth = resp.body.is_a?(Hash) ? resp.body["error"] : nil
|
|
57
58
|
raise AuthError.new("client-credentials token request failed: HTTP #{resp.status}", oauth_error: oauth)
|
data/lib/keycloak_sdk/version.rb
CHANGED
data/lib/keycloak_sdk.rb
CHANGED
|
@@ -30,4 +30,6 @@ end
|
|
|
30
30
|
# (b) Config 타임아웃을 무시했다. 이제는 AuthClient#initialize에서 Config의 connect/read 타임아웃으로
|
|
31
31
|
# 설정한다(auth_client.rb) — require 부작용 제거 + config 반영. 전역이라는 근본 한계는 남지만
|
|
32
32
|
# "SDK auth를 실제로 쓸 때"로 스코프가 좁혀진다(require 시점 아님).
|
|
33
|
+
# ⚠️ SDK 자신의 토큰 요청은 이 전역 연결을 타지 않는다 — 그랜트는 SDK 커넥션(`Http.build`, 클라이언트마다 Config
|
|
34
|
+
# 타임아웃)으로 직접 보낸다(응답 상한 때문). rack-oauth2 의 HTTP 연결을 쓰는 SDK 경로는 이제 없다(인가 URL 조립은 네트워크가 없다).
|
|
33
35
|
require "rack/oauth2"
|