keycloak-sdk 0.1.0.rc1
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 +7 -0
- data/LICENSE +201 -0
- data/README.md +88 -0
- data/lib/keycloak_sdk/admin/admin_client.rb +54 -0
- data/lib/keycloak_sdk/admin/bearer_auth.rb +19 -0
- data/lib/keycloak_sdk/admin/call.rb +24 -0
- data/lib/keycloak_sdk/admin/clients.rb +37 -0
- data/lib/keycloak_sdk/admin/groups.rb +36 -0
- data/lib/keycloak_sdk/admin/realms.rb +38 -0
- data/lib/keycloak_sdk/admin/roles.rb +36 -0
- data/lib/keycloak_sdk/admin/users.rb +36 -0
- data/lib/keycloak_sdk/auth_client.rb +152 -0
- data/lib/keycloak_sdk/client.rb +38 -0
- data/lib/keycloak_sdk/config.rb +70 -0
- data/lib/keycloak_sdk/errors.rb +49 -0
- data/lib/keycloak_sdk/http.rb +21 -0
- data/lib/keycloak_sdk/jwks_store.rb +53 -0
- data/lib/keycloak_sdk/jwt_validator.rb +73 -0
- data/lib/keycloak_sdk/masking.rb +12 -0
- data/lib/keycloak_sdk/oidc_endpoints.rb +24 -0
- data/lib/keycloak_sdk/token_provider.rb +54 -0
- data/lib/keycloak_sdk/tokens.rb +56 -0
- data/lib/keycloak_sdk/version.rb +5 -0
- data/lib/keycloak_sdk.rb +33 -0
- metadata +113 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/oauth2"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
module KeycloakSdk
|
|
9
|
+
# 인증 파사드. rack-oauth2를 래핑(그랜트·PKCE)하고 introspection(RFC7662)·logout은 Faraday로 손수 수행한다.
|
|
10
|
+
# TokenProvider를 구현하지만(직접 사용용), admin은 캐싱 ClientCredentialsTokenProvider를 별도로 쓴다(§4).
|
|
11
|
+
class AuthClient
|
|
12
|
+
include TokenProvider
|
|
13
|
+
|
|
14
|
+
def initialize(config:, http:, jwt_validator:)
|
|
15
|
+
@config = config
|
|
16
|
+
@http = http
|
|
17
|
+
@jwt_validator = jwt_validator
|
|
18
|
+
@endpoints = OidcEndpoints.from_config(config)
|
|
19
|
+
configure_rack_oauth2_timeouts(config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_authorization_request(redirect_uri:, scopes: nil, state: SecureRandom.urlsafe_base64(24), nonce: nil)
|
|
23
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
24
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
25
|
+
params = {
|
|
26
|
+
scope: (scopes || @config.scopes).join(" "),
|
|
27
|
+
state: state,
|
|
28
|
+
code_challenge: challenge,
|
|
29
|
+
code_challenge_method: :S256
|
|
30
|
+
}
|
|
31
|
+
params[:nonce] = nonce if nonce
|
|
32
|
+
url = oauth_client(redirect_uri: redirect_uri).authorization_uri(params)
|
|
33
|
+
AuthorizationRequest.new(url: url.to_s, state: state, code_verifier: verifier)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `expected_nonce`가 주어지면(create_authorization_request가 돌려준 nonce) 응답 id_token을
|
|
37
|
+
# realm JWKS로 서명·iss·aud·exp까지 강화 검증한 뒤 nonce 클레임을 대조한다 — OIDC nonce 재생
|
|
38
|
+
# 방지. 불일치·부재·검증실패는 모두 거부(fail-closed). 생략 시 id_token 검증을 건너뛴다(무-nonce 흐름).
|
|
39
|
+
def exchange_code(code:, code_verifier:, redirect_uri:, expected_nonce: nil)
|
|
40
|
+
client = oauth_client(redirect_uri: redirect_uri)
|
|
41
|
+
client.authorization_code = code
|
|
42
|
+
token_set = to_token_set(client.access_token!(code_verifier: code_verifier))
|
|
43
|
+
verify_nonce!(token_set.id_token, expected_nonce) unless expected_nonce.nil?
|
|
44
|
+
token_set
|
|
45
|
+
rescue Rack::OAuth2::Client::Error => e
|
|
46
|
+
raise AuthError.new("authorization_code exchange failed: #{e.message}", oauth_error: e.response[:error].to_s)
|
|
47
|
+
rescue Faraday::Error => e
|
|
48
|
+
raise TransportError, "token endpoint transport error: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refresh(refresh_token:)
|
|
52
|
+
client = oauth_client
|
|
53
|
+
client.refresh_token = refresh_token
|
|
54
|
+
to_token_set(client.access_token!)
|
|
55
|
+
rescue Rack::OAuth2::Client::Error => e
|
|
56
|
+
raise AuthError.new("refresh failed: #{e.message}", oauth_error: e.response[:error].to_s)
|
|
57
|
+
rescue Faraday::Error => e
|
|
58
|
+
raise TransportError, "token endpoint transport error: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def client_credentials_token
|
|
62
|
+
to_token_set(oauth_client.access_token!(scope: @config.scopes.join(" ")))
|
|
63
|
+
rescue Rack::OAuth2::Client::Error => e
|
|
64
|
+
raise AuthError.new("client-credentials failed: #{e.message}", oauth_error: e.response[:error].to_s)
|
|
65
|
+
rescue Faraday::Error => e
|
|
66
|
+
raise TransportError, "token endpoint transport error: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# TokenProvider 계약(직접 사용용). admin은 캐싱 provider를 별도로 쓴다.
|
|
70
|
+
def access_token
|
|
71
|
+
client_credentials_token.access_token
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def introspect(token)
|
|
75
|
+
resp = @http.post(@endpoints.introspection, {
|
|
76
|
+
token: token, client_id: @config.client_id, client_secret: @config.client_secret
|
|
77
|
+
})
|
|
78
|
+
raise AuthError, "introspection failed: HTTP #{resp.status}" unless resp.success?
|
|
79
|
+
|
|
80
|
+
IntrospectionResult.from_response(resp.body)
|
|
81
|
+
rescue Faraday::Error => e
|
|
82
|
+
raise TransportError, "introspection transport error: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def logout(refresh_token:)
|
|
86
|
+
resp = @http.post(@endpoints.end_session, {
|
|
87
|
+
client_id: @config.client_id, client_secret: @config.client_secret,
|
|
88
|
+
refresh_token: refresh_token
|
|
89
|
+
})
|
|
90
|
+
raise AuthError, "logout failed: HTTP #{resp.status}" unless resp.success?
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
rescue Faraday::Error => e
|
|
94
|
+
raise TransportError, "logout transport error: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate(token)
|
|
98
|
+
@jwt_validator.validate(token)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# rack-oauth2의 프로세스 전역 HTTP 타임아웃을 Config로 설정한다(require 시점 하드코딩 대신).
|
|
104
|
+
# 타임아웃은 Faraday::Connection이 아니라 그 #options(Faraday::RequestOptions)에 있다
|
|
105
|
+
# (Connection에 open_timeout=/timeout= 세터가 없어 NoMethodError — 게차 참조).
|
|
106
|
+
def configure_rack_oauth2_timeouts(config)
|
|
107
|
+
Rack::OAuth2.http_config do |conn|
|
|
108
|
+
conn.options.open_timeout = config.connect_timeout
|
|
109
|
+
conn.options.timeout = config.read_timeout
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# id_token의 nonce 클레임을 대조하기 전에 강화 JwtValidator로 서명·iss·aud·exp까지 검증한다
|
|
114
|
+
# (액세스 토큰과 id_token 모두 aud=client_id이므로 검증기를 공유해도 안전 — Kotlin/.NET 동형).
|
|
115
|
+
# ⚠️ `config.expected_audience`를 설정하면 이 공유 검증기가 id_token에도 그 값을 요구한다 —
|
|
116
|
+
# 이 흐름을 쓴다면 해당 오디언스를 id_token에도 매핑해야 한다(audience 매퍼의 "Add to ID token").
|
|
117
|
+
def verify_nonce!(id_token, expected_nonce)
|
|
118
|
+
raise AuthError, "authorization_code exchange failed: missing id_token for nonce validation" if id_token.nil?
|
|
119
|
+
|
|
120
|
+
validated = @jwt_validator.validate(id_token)
|
|
121
|
+
return if validated.claims["nonce"] == expected_nonce
|
|
122
|
+
|
|
123
|
+
raise AuthError, "authorization_code exchange failed: unexpected nonce"
|
|
124
|
+
rescue TokenValidationError => e
|
|
125
|
+
raise AuthError, "authorization_code exchange failed: invalid id_token: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def oauth_client(redirect_uri: nil)
|
|
129
|
+
Rack::OAuth2::Client.new(
|
|
130
|
+
identifier: @config.client_id,
|
|
131
|
+
secret: @config.client_secret,
|
|
132
|
+
authorization_endpoint: @endpoints.authorization,
|
|
133
|
+
token_endpoint: @endpoints.token,
|
|
134
|
+
redirect_uri: redirect_uri
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_token_set(token)
|
|
139
|
+
raw = token.raw_attributes || {}
|
|
140
|
+
scope = raw[:scope] || raw["scope"]
|
|
141
|
+
TokenSet.new(
|
|
142
|
+
access_token: token.access_token,
|
|
143
|
+
token_type: "Bearer",
|
|
144
|
+
expires_in: token.expires_in,
|
|
145
|
+
refresh_token: token.refresh_token,
|
|
146
|
+
id_token: raw[:id_token] || raw["id_token"],
|
|
147
|
+
scope: scope,
|
|
148
|
+
expires_at: token.expires_in ? Time.now.to_f + token.expires_in : nil
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# 통합 진입점. auth는 즉시 조립, admin은 지연 조립(전용 캐싱 TokenProvider 주입 — §4).
|
|
5
|
+
class KeycloakClient
|
|
6
|
+
attr_reader :auth
|
|
7
|
+
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
endpoints = OidcEndpoints.from_config(config)
|
|
11
|
+
@form_http = Http.build(config) do |f|
|
|
12
|
+
f.request :url_encoded
|
|
13
|
+
f.response :json, content_type: /\bjson$/
|
|
14
|
+
end
|
|
15
|
+
@jwks_http = Http.build(config) { |f| f.response :json, content_type: /\bjson$/ }
|
|
16
|
+
jwks_store = JwksStore.new(jwks_url: endpoints.jwks, http: @jwks_http, min_refetch: config.jwks_min_refetch)
|
|
17
|
+
jwt_validator = JwtValidator.from_config(config: config, jwks_store: jwks_store)
|
|
18
|
+
@auth = AuthClient.new(config: config, http: @form_http, jwt_validator: jwt_validator)
|
|
19
|
+
@admin = nil
|
|
20
|
+
@admin_mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def admin
|
|
24
|
+
@admin_mutex.synchronize do
|
|
25
|
+
@admin ||= Admin::AdminClient.new(
|
|
26
|
+
config: @config,
|
|
27
|
+
token_provider: ClientCredentialsTokenProvider.new(config: @config, http: @form_http)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close
|
|
33
|
+
@admin&.close # 지연 생성된 admin의 Faraday 커넥션도 정리(§4 close 계약 — 이전엔 누락)
|
|
34
|
+
[@form_http, @jwks_http].each { |h| h.close if h.respond_to?(:close) }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# 불변 설정. 생성 시 검증하고 freeze한다. client_secret은 inspect에서 마스킹.
|
|
5
|
+
class Config
|
|
6
|
+
attr_reader :server_url, :realm, :client_id, :client_secret,
|
|
7
|
+
:scopes, :signature_algorithms, :connect_timeout, :read_timeout, :clock_skew,
|
|
8
|
+
:jwks_min_refetch, :expected_audience
|
|
9
|
+
|
|
10
|
+
def initialize(server_url:, realm:, client_id:, client_secret: nil,
|
|
11
|
+
scopes: ["openid"], signature_algorithms: ["RS256"],
|
|
12
|
+
connect_timeout: 10, read_timeout: 10, clock_skew: 30,
|
|
13
|
+
jwks_min_refetch: 30.0, expected_audience: nil)
|
|
14
|
+
@server_url = normalize_required("server_url", server_url).sub(%r{/+\z}, "")
|
|
15
|
+
@realm = normalize_required("realm", realm)
|
|
16
|
+
@client_id = normalize_required("client_id", client_id)
|
|
17
|
+
@client_secret = client_secret
|
|
18
|
+
@scopes = Array(scopes).freeze
|
|
19
|
+
# JWT 서명 검증 허용 알고리즘 핀(기본 RS256). ES256/PS256 realm을 위해 설정 가능하되
|
|
20
|
+
# 빈 집합은 alg 핀을 무력화하므로 거부한다.
|
|
21
|
+
@signature_algorithms = non_empty_array("signature_algorithms", signature_algorithms).freeze
|
|
22
|
+
@connect_timeout = positive("connect_timeout", connect_timeout)
|
|
23
|
+
@read_timeout = positive("read_timeout", read_timeout)
|
|
24
|
+
@clock_skew = non_negative("clock_skew", clock_skew)
|
|
25
|
+
# 미해결 kid로 인한 JWKS 재조회의 최소 간격(초, 기본 10.0) — DoS 증폭 상한.
|
|
26
|
+
@jwks_min_refetch = non_negative("jwks_min_refetch", jwks_min_refetch)
|
|
27
|
+
# 토큰 aud에 들어있어야 할 값(기본 nil = client_id). 기본 realm은 client-credentials 토큰의
|
|
28
|
+
# aud에 client_id를 넣지 않으므로, realm이 실제로 발급하는 리소스/오디언스를 지정한다.
|
|
29
|
+
@expected_audience = expected_audience
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inspect
|
|
34
|
+
"#<KeycloakSdk::Config server_url=#{@server_url.inspect} realm=#{@realm.inspect} " \
|
|
35
|
+
"client_id=#{@client_id.inspect} client_secret=#{Masking.mask(@client_secret).inspect} " \
|
|
36
|
+
"scopes=#{@scopes.inspect}>"
|
|
37
|
+
end
|
|
38
|
+
alias to_s inspect
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize_required(name, value)
|
|
43
|
+
raise ConfigError, "#{name} is required" if value.nil?
|
|
44
|
+
|
|
45
|
+
str = value.to_s
|
|
46
|
+
raise ConfigError, "#{name} must not be blank" if str.strip.empty?
|
|
47
|
+
|
|
48
|
+
str
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def positive(name, value)
|
|
52
|
+
raise ConfigError, "#{name} must be > 0" unless value.is_a?(Numeric) && value.positive?
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def non_negative(name, value)
|
|
58
|
+
raise ConfigError, "#{name} must be >= 0" unless value.is_a?(Numeric) && value >= 0
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def non_empty_array(name, value)
|
|
64
|
+
arr = Array(value)
|
|
65
|
+
raise ConfigError, "#{name} must be non-empty" if arr.empty?
|
|
66
|
+
|
|
67
|
+
arr
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# 모든 SDK 오류의 루트.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# 설정 검증 실패.
|
|
8
|
+
class ConfigError < Error; end
|
|
9
|
+
|
|
10
|
+
# 인증/토큰 발급 실패(OAuth 오류 코드 보존).
|
|
11
|
+
class AuthError < Error
|
|
12
|
+
attr_reader :oauth_error
|
|
13
|
+
|
|
14
|
+
def initialize(message = nil, oauth_error: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@oauth_error = oauth_error
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 네트워크 전송 실패(타임아웃/연결거부/DNS).
|
|
21
|
+
class TransportError < Error; end
|
|
22
|
+
|
|
23
|
+
# JWT 검증 실패.
|
|
24
|
+
class TokenValidationError < Error; end
|
|
25
|
+
|
|
26
|
+
# Admin REST 오류(HTTP status 보존).
|
|
27
|
+
class AdminError < Error
|
|
28
|
+
attr_reader :status
|
|
29
|
+
|
|
30
|
+
def initialize(message = nil, status: nil)
|
|
31
|
+
super(message)
|
|
32
|
+
@status = status
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# status → 적절한 하위 예외 인스턴스.
|
|
36
|
+
def self.from_status(status, message)
|
|
37
|
+
case status
|
|
38
|
+
when 404 then NotFoundError.new(message, status: status)
|
|
39
|
+
when 409 then ConflictError.new(message, status: status)
|
|
40
|
+
when 403 then ForbiddenError.new(message, status: status)
|
|
41
|
+
else AdminError.new(message, status: status)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class NotFoundError < AdminError; end
|
|
47
|
+
class ConflictError < AdminError; end
|
|
48
|
+
class ForbiddenError < AdminError; end
|
|
49
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module KeycloakSdk
|
|
6
|
+
# 공유 Faraday 커넥션 팩토리. 타임아웃을 config에서 주입하고,
|
|
7
|
+
# follow_redirects 미들웨어를 절대 장착하지 않는다(SSRF 하드닝 — Faraday는 기본 미추종).
|
|
8
|
+
module Http
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(config, base_url: nil)
|
|
12
|
+
Faraday.new(
|
|
13
|
+
url: base_url,
|
|
14
|
+
request: { timeout: config.read_timeout, open_timeout: config.connect_timeout }
|
|
15
|
+
) do |f|
|
|
16
|
+
yield f if block_given?
|
|
17
|
+
f.adapter :net_http
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
module KeycloakSdk
|
|
6
|
+
# DoS-safe JWKS 스토어. Mutex 캐시 + rate-limit + single-flight.
|
|
7
|
+
# 위조 서명(알려진 kid)은 캐시 반환만 하고 재조회를 유발하지 않는다.
|
|
8
|
+
# 미해결 kid(force:true)만 재조회하며, rate-limit gate는 재조회 결정 시점에 stamp한다
|
|
9
|
+
# (성공 아님 — IdP 장애창에서 위조 kid 폭주에도 재조회를 상한한다). Go/Rust/Python 동형.
|
|
10
|
+
class JwksStore
|
|
11
|
+
def initialize(jwks_url:, http:, min_refetch: 10.0)
|
|
12
|
+
@jwks_url = jwks_url
|
|
13
|
+
@http = http
|
|
14
|
+
@min_refetch = min_refetch
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@cache = nil # {"keys" => [...]}
|
|
17
|
+
@last_refetch = nil # monotonic seconds
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ruby-jwt jwks: 로더가 호출. force=true는 미해결 kid 재조회 요청.
|
|
21
|
+
def key_set(force: false)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return @cache if @cache && !force
|
|
24
|
+
return @cache if force && !refetch_allowed?
|
|
25
|
+
|
|
26
|
+
@last_refetch = monotonic if force # 결정 시점 stamp(cold load는 예산 미소모)
|
|
27
|
+
@cache = fetch
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def refetch_allowed?
|
|
34
|
+
@last_refetch.nil? || (monotonic - @last_refetch) >= @min_refetch
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch
|
|
38
|
+
resp = @http.get(@jwks_url)
|
|
39
|
+
raise TransportError, "JWKS fetch failed: HTTP #{resp.status}" unless resp.success?
|
|
40
|
+
|
|
41
|
+
body = resp.body
|
|
42
|
+
raise TransportError, "JWKS response malformed" unless body.is_a?(Hash) && body["keys"].is_a?(Array)
|
|
43
|
+
|
|
44
|
+
body
|
|
45
|
+
rescue Faraday::Error => e
|
|
46
|
+
raise TransportError, "JWKS transport error: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def monotonic
|
|
50
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module KeycloakSdk
|
|
6
|
+
# 자체강화 JWT 검증기. ruby-jwt의 안전하지 않은 기본값을 전부 오버라이드한다:
|
|
7
|
+
# RS256 핀(none/confusion 구조적 거부)·iss 정확·aud 포함·exp 필수·nbf·클록스큐.
|
|
8
|
+
# 키는 DoS-safe JwksStore로 조회한다(위조 서명은 재조회 미유발). 헤더 alg는 검증 알고리즘 선택에 미사용.
|
|
9
|
+
class JwtValidator
|
|
10
|
+
def initialize(issuer:, audience:, jwks_store:, algorithms: ["RS256"], clock_skew: 30)
|
|
11
|
+
# ruby-jwt의 verify_iss/verify_aud 빌더는 값이 nil이면 조용히 no-op이 되어
|
|
12
|
+
# verify_iss:true/verify_aud:true를 켜도 검사를 건너뛴다 — fail-closed로 방어.
|
|
13
|
+
raise ConfigError, "issuer is required" if issuer.nil? || issuer.to_s.strip.empty?
|
|
14
|
+
raise ConfigError, "audience is required" if audience.nil? || audience.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
@issuer = issuer
|
|
17
|
+
@audience = audience
|
|
18
|
+
@jwks_store = jwks_store
|
|
19
|
+
@algorithms = algorithms
|
|
20
|
+
@clock_skew = clock_skew
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 기대 aud는 config.expected_audience(설정 시) — 미설정이면 종전대로 client_id.
|
|
24
|
+
def self.from_config(config:, jwks_store:)
|
|
25
|
+
new(issuer: OidcEndpoints.from_config(config).issuer,
|
|
26
|
+
audience: config.expected_audience || config.client_id, jwks_store: jwks_store,
|
|
27
|
+
algorithms: config.signature_algorithms, clock_skew: config.clock_skew)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate(token)
|
|
31
|
+
payload, = JWT.decode(token, nil, true, decode_options)
|
|
32
|
+
to_validated(payload)
|
|
33
|
+
rescue JWT::DecodeError => e
|
|
34
|
+
raise TokenValidationError, "JWT validation failed: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def decode_options
|
|
40
|
+
{
|
|
41
|
+
algorithms: @algorithms,
|
|
42
|
+
jwks: jwks_loader,
|
|
43
|
+
verify_iss: true, iss: @issuer,
|
|
44
|
+
verify_aud: true, aud: @audience,
|
|
45
|
+
verify_expiration: true,
|
|
46
|
+
verify_not_before: true,
|
|
47
|
+
required_claims: %w[exp iss aud],
|
|
48
|
+
leeway: @clock_skew
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 초기 호출 {kid:}=캐시, 미해결 시 {kid_not_found:true, invalidate:true, kid:}=재조회.
|
|
53
|
+
# JwksStore#key_set(force:)는 cold-cache 재조회가 rate-limit되면 nil을 반환할 수 있으므로
|
|
54
|
+
# 빈 key set으로 폴백한다 — ruby-jwt에 nil을 넘기면 raw NoMethodError가 나므로 반드시 가드한다.
|
|
55
|
+
def jwks_loader
|
|
56
|
+
lambda do |opts|
|
|
57
|
+
force = opts[:kid_not_found] || opts[:invalidate] || false
|
|
58
|
+
@jwks_store.key_set(force: force) || { "keys" => [] }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_validated(payload)
|
|
63
|
+
ValidatedToken.new(
|
|
64
|
+
subject: payload["sub"],
|
|
65
|
+
audience: Array(payload["aud"]),
|
|
66
|
+
issuer: payload["iss"],
|
|
67
|
+
expires_at: payload["exp"],
|
|
68
|
+
issued_at: payload["iat"],
|
|
69
|
+
claims: payload
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# Keycloak realm의 OIDC 엔드포인트를 규약대로 조립한다(네트워크 없음).
|
|
5
|
+
class OidcEndpoints
|
|
6
|
+
attr_reader :issuer, :authorization, :token, :introspection, :end_session, :jwks
|
|
7
|
+
|
|
8
|
+
def initialize(server_url, realm)
|
|
9
|
+
base = "#{server_url}/realms/#{realm}"
|
|
10
|
+
oidc = "#{base}/protocol/openid-connect"
|
|
11
|
+
@issuer = base
|
|
12
|
+
@authorization = "#{oidc}/auth"
|
|
13
|
+
@token = "#{oidc}/token"
|
|
14
|
+
@introspection = "#{oidc}/token/introspect"
|
|
15
|
+
@end_session = "#{oidc}/logout"
|
|
16
|
+
@jwks = "#{oidc}/certs"
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_config(config)
|
|
21
|
+
new(config.server_url, config.realm)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# 덕 인터페이스: 구현체는 #access_token → String 을 응답한다.
|
|
5
|
+
# admin은 이 인터페이스로만 토큰을 받는다(auth 비의존, §4 결합 규칙).
|
|
6
|
+
module TokenProvider
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# client-credentials 그랜트로 토큰을 발급하고 만료 전까지 캐시한다(Mutex single-flight).
|
|
10
|
+
# admin 파사드가 소비하는 캐싱 provider(무캐시 AuthClient 직접 주입 금지 — §4 캐시 불변식).
|
|
11
|
+
class ClientCredentialsTokenProvider
|
|
12
|
+
include TokenProvider
|
|
13
|
+
|
|
14
|
+
def initialize(config:, http:)
|
|
15
|
+
@config = config
|
|
16
|
+
@http = http
|
|
17
|
+
@token_url = OidcEndpoints.from_config(config).token
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@cached = nil
|
|
20
|
+
@expires_at = 0.0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def access_token
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
now = Time.now.to_f
|
|
26
|
+
return @cached if @cached && now < @expires_at
|
|
27
|
+
|
|
28
|
+
ts = request_token
|
|
29
|
+
@cached = ts.access_token
|
|
30
|
+
@expires_at = ts.expires_at ? (ts.expires_at - @config.clock_skew) : (now + 60)
|
|
31
|
+
@cached
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def request_token
|
|
38
|
+
resp = @http.post(@token_url, {
|
|
39
|
+
grant_type: "client_credentials",
|
|
40
|
+
client_id: @config.client_id,
|
|
41
|
+
client_secret: @config.client_secret,
|
|
42
|
+
scope: @config.scopes.join(" ")
|
|
43
|
+
})
|
|
44
|
+
unless resp.success?
|
|
45
|
+
oauth = resp.body.is_a?(Hash) ? resp.body["error"] : nil
|
|
46
|
+
raise AuthError.new("client-credentials token request failed: HTTP #{resp.status}", oauth_error: oauth)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
TokenSet.from_response(resp.body, received_at: Time.now.to_f)
|
|
50
|
+
rescue Faraday::Error => e
|
|
51
|
+
raise TransportError, "token endpoint transport error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeycloakSdk
|
|
4
|
+
# OAuth 토큰 응답의 불변 값타입. access/refresh/id 토큰은 inspect에서 마스킹.
|
|
5
|
+
TokenSet = Data.define(:access_token, :token_type, :expires_in, :refresh_token,
|
|
6
|
+
:id_token, :scope, :expires_at) do
|
|
7
|
+
def self.from_response(body, received_at: Time.now.to_f)
|
|
8
|
+
expires_in = body["expires_in"] && Integer(body["expires_in"])
|
|
9
|
+
new(
|
|
10
|
+
access_token: body["access_token"],
|
|
11
|
+
token_type: body["token_type"],
|
|
12
|
+
expires_in: expires_in,
|
|
13
|
+
refresh_token: body["refresh_token"],
|
|
14
|
+
id_token: body["id_token"],
|
|
15
|
+
scope: body["scope"],
|
|
16
|
+
expires_at: expires_in ? received_at + expires_in : nil
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def expired?(skew: 0, now: Time.now.to_f)
|
|
21
|
+
return false if expires_at.nil?
|
|
22
|
+
|
|
23
|
+
now >= (expires_at - skew)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<KeycloakSdk::TokenSet access_token=\"***\" token_type=#{token_type.inspect} " \
|
|
28
|
+
"expires_in=#{expires_in.inspect} refresh_token=#{refresh_token ? '"***"' : 'nil'} " \
|
|
29
|
+
"id_token=#{id_token ? '"***"' : 'nil'} scope=#{scope.inspect} expires_at=#{expires_at.inspect}>"
|
|
30
|
+
end
|
|
31
|
+
alias_method :to_s, :inspect
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 검증된 access token의 관심 클레임.
|
|
35
|
+
ValidatedToken = Data.define(:subject, :audience, :issuer, :expires_at, :issued_at, :claims)
|
|
36
|
+
|
|
37
|
+
# RFC 7662 introspection 결과.
|
|
38
|
+
IntrospectionResult = Data.define(:active, :username, :client_id, :claims) do
|
|
39
|
+
def self.from_response(body)
|
|
40
|
+
new(active: body["active"] == true, username: body["username"],
|
|
41
|
+
client_id: body["client_id"], claims: body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def active?
|
|
45
|
+
active == true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# authorization-code 흐름 시작 값(PKCE code_verifier 포함·inspect 마스킹).
|
|
50
|
+
AuthorizationRequest = Data.define(:url, :state, :code_verifier) do
|
|
51
|
+
def inspect
|
|
52
|
+
"#<KeycloakSdk::AuthorizationRequest url=#{url.inspect} state=#{state.inspect} code_verifier=\"***\">"
|
|
53
|
+
end
|
|
54
|
+
alias_method :to_s, :inspect
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/keycloak_sdk.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "keycloak_sdk/version"
|
|
4
|
+
require_relative "keycloak_sdk/masking"
|
|
5
|
+
require_relative "keycloak_sdk/errors"
|
|
6
|
+
require_relative "keycloak_sdk/config"
|
|
7
|
+
require_relative "keycloak_sdk/tokens"
|
|
8
|
+
require_relative "keycloak_sdk/oidc_endpoints"
|
|
9
|
+
require_relative "keycloak_sdk/http"
|
|
10
|
+
require_relative "keycloak_sdk/token_provider"
|
|
11
|
+
require_relative "keycloak_sdk/jwks_store"
|
|
12
|
+
require_relative "keycloak_sdk/jwt_validator"
|
|
13
|
+
require_relative "keycloak_sdk/auth_client"
|
|
14
|
+
require_relative "keycloak_sdk/admin/call"
|
|
15
|
+
require_relative "keycloak_sdk/admin/bearer_auth"
|
|
16
|
+
require_relative "keycloak_sdk/admin/users"
|
|
17
|
+
require_relative "keycloak_sdk/admin/clients"
|
|
18
|
+
require_relative "keycloak_sdk/admin/realms"
|
|
19
|
+
require_relative "keycloak_sdk/admin/roles"
|
|
20
|
+
require_relative "keycloak_sdk/admin/groups"
|
|
21
|
+
require_relative "keycloak_sdk/admin/admin_client"
|
|
22
|
+
require_relative "keycloak_sdk/client"
|
|
23
|
+
|
|
24
|
+
# Polyglot Keycloak SDK for Ruby.
|
|
25
|
+
module KeycloakSdk
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ⚠️ rack-oauth2 HTTP 타임아웃은 프로세스 전역(Rack::OAuth2.http_config)이라 per-client 미세제어가
|
|
29
|
+
# 불가하다. 과거엔 require 시점에 하드코딩 10초로 박아 (a) 단순 require만으로 전역 상태를 변조하고
|
|
30
|
+
# (b) Config 타임아웃을 무시했다. 이제는 AuthClient#initialize에서 Config의 connect/read 타임아웃으로
|
|
31
|
+
# 설정한다(auth_client.rb) — require 부작용 제거 + config 반영. 전역이라는 근본 한계는 남지만
|
|
32
|
+
# "SDK auth를 실제로 쓸 때"로 스코프가 좁혀진다(require 시점 아님).
|
|
33
|
+
require "rack/oauth2"
|