Skip to content

Commit 8a158ec

Browse files
committed
WIP
1 parent 817fda9 commit 8a158ec

26 files changed

+783
-288
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,10 @@ GEM
9393
i18n (1.6.0)
9494
concurrent-ruby (~> 1.0)
9595
interception (0.5)
96+
its-it (1.3.0)
9697
json (2.1.0)
9798
jwt (2.1.0)
99+
key_struct (0.4.2)
98100
license_finder (5.8.0)
99101
bundler
100102
rubyzip
@@ -126,6 +128,8 @@ GEM
126128
builder
127129
minitest (>= 5.0)
128130
ruby-progressbar
131+
modware (0.1.3)
132+
key_struct (~> 0.4)
129133
msgpack (1.2.10)
130134
multi_json (1.13.1)
131135
multi_xml (0.6.0)
@@ -204,6 +208,17 @@ GEM
204208
ruby-progressbar (1.10.0)
205209
rubyzip (1.2.2)
206210
safe_yaml (1.0.4)
211+
schema_monkey (2.1.5)
212+
activerecord (>= 4.2)
213+
modware (~> 0.1)
214+
schema_plus_core (2.2.3)
215+
activerecord (~> 5.0)
216+
its-it (~> 1.2)
217+
schema_monkey (~> 2.1)
218+
schema_plus_enums (0.1.8)
219+
activerecord (>= 4.2, < 5.3)
220+
its-it (~> 1.2)
221+
schema_plus_core
207222
simplecov (0.16.1)
208223
docile (~> 1.1)
209224
json (>= 1.8, < 3)
@@ -283,6 +298,7 @@ DEPENDENCIES
283298
que-web
284299
rails (~> 5.2.3)
285300
responders (~> 2.4.1)
301+
schema_plus_enums
286302
spring
287303
tzinfo-data
288304
validate_url
@@ -292,4 +308,4 @@ DEPENDENCIES
292308
yabeda-rails
293309

294310
BUNDLED WITH
295-
1.17.1
311+
2.0.1

app/adapters/abstract_adapter.rb

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

0 commit comments

Comments
 (0)