Skip to content

Commit bf9b1da

Browse files
committed
generic HTTP adapter
1 parent 0d95ee3 commit bf9b1da

35 files changed

+1217
-523
lines changed

Gemfile

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ end
1010
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
1111
gem 'rails', '~> 5.2.3'
1212
gem 'pg', '>= 0.20'
13+
gem 'schema_plus_enums'
14+
1315
# Use Puma as the app server
1416
gem 'puma', '~> 3.12'
1517
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder

Gemfile.lock

+17-1
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ GEM
9696
i18n (1.6.0)
9797
concurrent-ruby (~> 1.0)
9898
interception (0.5)
99+
its-it (1.3.0)
99100
json (2.1.0)
100101
jwt (2.1.0)
102+
key_struct (0.4.2)
101103
license_finder (5.8.0)
102104
bundler
103105
rubyzip
@@ -129,6 +131,8 @@ GEM
129131
builder
130132
minitest (>= 5.0)
131133
ruby-progressbar
134+
modware (0.1.3)
135+
key_struct (~> 0.4)
132136
msgpack (1.2.10)
133137
multi_json (1.13.1)
134138
multi_xml (0.6.0)
@@ -208,6 +212,17 @@ GEM
208212
ruby-progressbar (1.10.0)
209213
rubyzip (1.2.2)
210214
safe_yaml (1.0.4)
215+
schema_monkey (2.1.5)
216+
activerecord (>= 4.2)
217+
modware (~> 0.1)
218+
schema_plus_core (2.2.3)
219+
activerecord (~> 5.0)
220+
its-it (~> 1.2)
221+
schema_monkey (~> 2.1)
222+
schema_plus_enums (0.1.8)
223+
activerecord (>= 4.2, < 5.3)
224+
its-it (~> 1.2)
225+
schema_plus_core
211226
simplecov (0.16.1)
212227
docile (~> 1.1)
213228
json (>= 1.8, < 3)
@@ -288,6 +303,7 @@ DEPENDENCIES
288303
que-web
289304
rails (~> 5.2.3)
290305
responders (~> 2.4.1)
306+
schema_plus_enums
291307
spring
292308
tzinfo-data
293309
validate_url
@@ -297,4 +313,4 @@ DEPENDENCIES
297313
yabeda-rails
298314

299315
BUNDLED WITH
300-
1.17.1
316+
2.0.1

app/adapters/abstract_adapter.rb

+294
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
# frozen_string_literal: true
2+
3+
require 'uri'
4+
require 'httpclient'
5+
require 'mutex_m'
6+
7+
# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API.
8+
class AbstractAdapter
9+
def self.build_client(*)
10+
raise NoMethodError, __method__
11+
end
12+
13+
attr_reader :endpoint
14+
15+
def initialize(endpoint, authentication: nil)
16+
endpoint = EndpointConfiguration.new(endpoint)
17+
@http_client = build_http_client(endpoint)
18+
@oidc = OIDC.new(endpoint, http_client)
19+
@oidc.access_token = authentication if authentication
20+
@endpoint = endpoint.issuer
21+
end
22+
23+
def authentication=(value)
24+
oidc.access_token = value
25+
end
26+
27+
def authentication
28+
oidc.access_token.token
29+
end
30+
31+
def create_client(_client)
32+
raise NoMethodError, __method__
33+
end
34+
35+
def read_client(_client)
36+
raise NoMethodError, __method__
37+
end
38+
39+
def update_client(_client)
40+
raise NoMethodError, __method__
41+
end
42+
43+
def delete_client(_client)
44+
raise NoMethodError, __method__
45+
end
46+
47+
def test
48+
raise NoMethodError, __method__
49+
end
50+
51+
protected
52+
53+
attr_reader :oidc
54+
55+
def headers
56+
oidc.headers
57+
end
58+
59+
JSON_TYPE = Mime[:json]
60+
private_constant :JSON_TYPE
61+
62+
NULL_TYPE = Mime::Type.lookup(nil)
63+
64+
attr_reader :http_client
65+
66+
def build_http_client(endpoint)
67+
HTTPClient.new do
68+
self.debug_dev = $stderr if ENV.fetch('DEBUG', '0') == '1'
69+
70+
self.set_auth endpoint, *endpoint.auth
71+
72+
Rails.application.config.x.http_client.deep_symbolize_keys
73+
.slice(:connect_timeout, :send_timeout, :receive_timeout).each do |key, value|
74+
self.public_send("#{key}=", value)
75+
end
76+
end
77+
end
78+
79+
def parse(response)
80+
body = self.class.parse_response(response)
81+
82+
raise InvalidResponseError, { response: response, message: body } unless response.ok?
83+
84+
params = body.try(:to_h) or return # no need to create client if there are no attributes
85+
86+
parse_client(params)
87+
end
88+
89+
def parse_client(_)
90+
raise NoMethodError, __method__
91+
end
92+
93+
# TODO: Extract this into Response object to fix :reek:FeatureEnvy
94+
def self.parse_response(response)
95+
body = response.body
96+
97+
case Mime::Type.lookup(response.content_type)
98+
when JSON_TYPE then JSON.parse(body)
99+
when NULL_TYPE then body
100+
else raise InvalidResponseError, { response: response, message: 'Unknown Content-Type' }
101+
end
102+
end
103+
104+
# Extracts credentials from the endpoint URL.
105+
class EndpointConfiguration
106+
attr_reader :uri, :client_id, :client_secret
107+
108+
alias_method :issuer, :uri
109+
110+
def initialize(endpoint)
111+
uri, client_id, client_secret = split_uri(endpoint)
112+
113+
@uri = normalize_uri(uri).freeze
114+
@client_id = client_id.freeze
115+
@client_secret = client_secret.freeze
116+
end
117+
118+
def auth
119+
[client_id, client_secret]
120+
end
121+
122+
delegate :normalize_uri, :split_uri, to: :class
123+
124+
def self.normalize_uri(uri)
125+
uri.normalize.merge("#{uri.path}/".tr_s('/', '/'))
126+
end
127+
128+
def self.split_uri(endpoint)
129+
uri = URI(endpoint)
130+
client_id = uri.user
131+
client_secret = uri.password
132+
133+
uri.userinfo = ''
134+
135+
[ uri, client_id, client_secret ]
136+
end
137+
end
138+
139+
# Implements OpenID connect discovery and getting access token.
140+
class OIDC
141+
include Mutex_m
142+
143+
def initialize(endpoint, http_client)
144+
super()
145+
146+
@endpoint = endpoint
147+
@http_client = http_client
148+
@config = nil
149+
150+
@access_token = AccessToken.new(method(:oauth_client))
151+
end
152+
153+
def well_known_url
154+
URI.join(@endpoint.issuer, '.well-known/openid-configuration')
155+
end
156+
157+
def config
158+
mu_synchronize do
159+
@config ||= fetch_oidc_discovery
160+
end
161+
end
162+
163+
# Raised when there is no Access Token to authenticate with.
164+
class AuthenticationError < StandardError
165+
include Bugsnag::MetaData
166+
167+
def initialize(error: , endpoint: )
168+
self.bugsnag_meta_data = {
169+
faraday: { uri: endpoint.to_s }
170+
}
171+
super(error)
172+
end
173+
end
174+
175+
def access_token=(value)
176+
@access_token.value = value
177+
end
178+
179+
def token_endpoint
180+
config['token_endpoint']
181+
end
182+
183+
def headers
184+
{ 'Authorization' => "#{authentication_type} #{access_token.token}" }
185+
end
186+
187+
def access_token
188+
@access_token.value!
189+
rescue => error
190+
raise AuthenticationError, error: error, endpoint: @endpoint.issuer
191+
end
192+
193+
protected
194+
195+
def oauth_client
196+
OAuth2::Client.new(@endpoint.client_id, @endpoint.client_secret,
197+
site: @endpoint.uri.dup, token_url: token_endpoint) do |builder|
198+
builder.adapter(:httpclient).last.instance_variable_set(:@client, http_client)
199+
end
200+
end
201+
202+
attr_reader :http_client
203+
204+
def fetch_oidc_discovery
205+
response = http_client.get(well_known_url)
206+
config = AbstractAdapter.parse_response(response)
207+
208+
case config
209+
when ->(obj) { obj.respond_to?(:[]) } then config
210+
else raise InvalidOIDCDiscoveryError, response
211+
end
212+
end
213+
214+
# Raised when OIDC Discovery is not correct.
215+
class InvalidOIDCDiscoveryError < StandardError; end
216+
217+
# Handles getting and refreshing Access Token for the API access.
218+
class AccessToken
219+
220+
# Breaking :reek:NestedIterators because that is how Faraday expects it.
221+
def initialize(oauth_client)
222+
@oauth_client = oauth_client
223+
@value = Concurrent::IVar.new
224+
freeze
225+
end
226+
227+
def value
228+
ref = reference or return
229+
ref.try_update(&method(:fresh_token))
230+
231+
ref.value
232+
end
233+
234+
def value=(value)
235+
@value.try_set { Concurrent::AtomicReference.new(OAuth2::AccessToken.new(nil, value)) }
236+
@value.value
237+
end
238+
239+
def value!
240+
value or error
241+
end
242+
243+
def error
244+
raise reason
245+
end
246+
247+
protected
248+
249+
def oauth_client
250+
@oauth_client.call
251+
end
252+
253+
delegate :reason, to: :@value
254+
255+
def reference
256+
@value.try_set { Concurrent::AtomicReference.new(get_token) }
257+
@value.value
258+
end
259+
260+
def get_token
261+
oauth_client.client_credentials.get_token.freeze
262+
end
263+
264+
def fresh_token(access_token)
265+
access_token && !access_token.expired? ? access_token : get_token
266+
end
267+
end
268+
private_constant :AccessToken
269+
270+
def authentication_type
271+
'Bearer'
272+
end
273+
end
274+
275+
# Raised when unexpected response is returned by the KeycloakAdapter API.
276+
class InvalidResponseError < StandardError
277+
attr_reader :response
278+
include Bugsnag::MetaData
279+
280+
def initialize(response: , message: )
281+
@response = response
282+
self.bugsnag_meta_data = {
283+
response: {
284+
status: status = response.status,
285+
reason: reason = response.reason,
286+
content_type: response.content_type,
287+
body: response.body,
288+
},
289+
headers: response.headers
290+
}
291+
super(message.presence || '%s %s' % [ status, reason ])
292+
end
293+
end
294+
end

0 commit comments

Comments
 (0)