keycloak_oauth 0.1.3 → 0.1.8
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 +26 -0
- data/app/controllers/keycloak_oauth/callbacks_controller.rb +12 -2
- data/app/services/keycloak_oauth/authentication_service.rb +12 -36
- data/app/services/keycloak_oauth/authorizable_service.rb +50 -0
- data/app/services/keycloak_oauth/logout_service.rb +44 -0
- data/app/services/keycloak_oauth/post_token_service.rb +40 -0
- data/app/services/keycloak_oauth/post_users_service.rb +49 -0
- data/app/services/keycloak_oauth/user_info_retrieval_service.rb +8 -23
- data/lib/keycloak_oauth/connection.rb +12 -4
- data/lib/keycloak_oauth/endpoints.rb +10 -0
- data/lib/keycloak_oauth/version.rb +1 -1
- metadata +11 -8
- data/app/controllers/keycloak_oauth/application_controller.rb +0 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5b84ab145e8572eebc11e31faff2080a82ec5f157fe5579c7076f32149f4b2b7
|
4
|
+
data.tar.gz: '0308c1a7e95dbd136e1cb1c659d203b355d78faecfefa4bd56d31fc222040245'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a9c847ab0c59e5d6c4d28c51bbc5ad8ae515874cd680b312b31e264edc97748e813a319eb83f51c75ee463edaa02a9eba65ad6a444b82c59bc805b1a6eb1cfbf
|
7
|
+
data.tar.gz: fab61c415c9a09a4cc66a6e9986998dfc1db3c126205698938ef4f76c59a3571d1078f74456ab97201f7fa578b323430767fedd5687315a6233404f3610fb01d
|
data/README.md
CHANGED
@@ -44,6 +44,16 @@ e.g.
|
|
44
44
|
|
45
45
|
Once authentication is performed, the access and refresh tokens are stored in the session and can be used in your app as wished.
|
46
46
|
|
47
|
+
***Customising redirect URIs***
|
48
|
+
There are situations where you would want to customise the oauth2 route (e.g. to use a localised version of the callback URL).
|
49
|
+
In this case, you can do the following:
|
50
|
+
- add a controller to your app: e.g. `CallbackOverrides`
|
51
|
+
- add the following to your routes.rb file: `get 'oauth2', to: 'callback_overrides#oauth2'`
|
52
|
+
- add whatever logic you need in the controller, e.g. a `skip_before_action`; it can also be blank
|
53
|
+
- add redirect URI to the authorization link:
|
54
|
+
e.g.
|
55
|
+
`<%= link_to 'Login with Keycloak', KeycloakOauth.connection.authorization_endpoint(options: {redirect_uri: 'http://myapp.com/en/oauth2'}) %>`
|
56
|
+
|
47
57
|
**Keycloak callback URL**
|
48
58
|
Keycloak needs a callback URL to send the authorization code to once a user logs in.
|
49
59
|
By default, once authentication is performed, we redirect to the `/` path (i.e. whatever the root path is set to in the host app).
|
@@ -78,6 +88,22 @@ def map_authenticatable(_request)
|
|
78
88
|
end
|
79
89
|
```
|
80
90
|
|
91
|
+
**Logging out**
|
92
|
+
In order to log out, you can use the following API call:
|
93
|
+
`KeycloakOauth.connection.logout(session: session)`
|
94
|
+
|
95
|
+
Note that you need to pass in the session, as the gem needs to remove the Keycloak tokens from there.
|
96
|
+
|
97
|
+
e.g.
|
98
|
+
```ruby
|
99
|
+
class SessionsController < ApplicationController
|
100
|
+
def destroy
|
101
|
+
KeycloakOauth.connection.logout(session: session)
|
102
|
+
redirect_to new_session_path
|
103
|
+
end
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
81
107
|
## Development
|
82
108
|
|
83
109
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
@@ -1,5 +1,5 @@
|
|
1
1
|
module KeycloakOauth
|
2
|
-
class CallbacksController < ApplicationController
|
2
|
+
class CallbacksController < ::ApplicationController
|
3
3
|
if KeycloakOauth.connection.callback_module.present?
|
4
4
|
include KeycloakOauth.connection.callback_module
|
5
5
|
end
|
@@ -18,7 +18,7 @@ module KeycloakOauth
|
|
18
18
|
private
|
19
19
|
|
20
20
|
def authentication_params
|
21
|
-
params.permit(:code)
|
21
|
+
params.permit(:code).merge({ redirect_uri: current_uri_without_params })
|
22
22
|
end
|
23
23
|
|
24
24
|
def map_authenticatable_if_implemented(request)
|
@@ -28,5 +28,15 @@ module KeycloakOauth
|
|
28
28
|
raise NotImplementedError.new('User mapping must be handled by the host app. See README for more information.')
|
29
29
|
end
|
30
30
|
end
|
31
|
+
|
32
|
+
def current_uri_without_params
|
33
|
+
# If the host app has overwritten the route (e.g. to enable localised
|
34
|
+
# callbacks), this ensures we are using the path coming from the host app
|
35
|
+
# instead of the one coming from the engine.
|
36
|
+
main_app.url_for(only_path: false, overwrite_params: nil)
|
37
|
+
rescue ActionController::UrlGenerationError
|
38
|
+
# If the host app does not override the oauth2 path, use the engine's path.
|
39
|
+
oauth2_path
|
40
|
+
end
|
31
41
|
end
|
32
42
|
end
|
@@ -1,55 +1,31 @@
|
|
1
|
-
require 'net/http'
|
2
|
-
|
3
1
|
module KeycloakOauth
|
4
|
-
class AuthenticationError < StandardError; end
|
5
|
-
|
6
2
|
class AuthenticationService
|
7
|
-
GRANT_TYPE = 'authorization_code'.freeze
|
8
|
-
CONTENT_TYPE = 'application/x-www-form-urlencoded'.freeze
|
9
3
|
ACCESS_TOKEN_KEY = 'access_token'.freeze
|
10
4
|
REFRESH_TOKEN_KEY = 'refresh_token'.freeze
|
11
5
|
|
12
|
-
attr_reader :
|
6
|
+
attr_reader :session, :authentication_params
|
13
7
|
|
14
8
|
def initialize(authentication_params:, session:)
|
15
|
-
@
|
9
|
+
@authentication_params = authentication_params
|
16
10
|
@session = session
|
17
11
|
end
|
18
12
|
|
19
13
|
def authenticate
|
20
|
-
|
14
|
+
post_token_service = KeycloakOauth::PostTokenService.new(
|
15
|
+
connection: KeycloakOauth.connection,
|
16
|
+
request_params: authentication_params
|
17
|
+
)
|
18
|
+
post_token_service.perform
|
19
|
+
store_credentials(post_token_service)
|
21
20
|
end
|
22
21
|
|
23
22
|
private
|
24
23
|
|
25
|
-
def
|
26
|
-
|
27
|
-
Net::HTTP.start(uri.host, uri.port) do |http|
|
28
|
-
request = Net::HTTP::Post.new(uri)
|
29
|
-
request.set_content_type(CONTENT_TYPE)
|
30
|
-
request.set_form_data(token_request_params)
|
31
|
-
http.request(request)
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
def token_request_params
|
36
|
-
{
|
37
|
-
client_id: KeycloakOauth.connection.client_id,
|
38
|
-
client_secret: KeycloakOauth.connection.client_secret,
|
39
|
-
grant_type: GRANT_TYPE,
|
40
|
-
code: code
|
41
|
-
}
|
42
|
-
end
|
43
|
-
|
44
|
-
def store_credentials(http_response)
|
45
|
-
response_hash = JSON.parse(http_response.body)
|
24
|
+
def store_credentials(post_token_service)
|
25
|
+
response_hash = post_token_service.parsed_response_body
|
46
26
|
|
47
|
-
|
48
|
-
|
49
|
-
session[:refresh_token] = response_hash[REFRESH_TOKEN_KEY]
|
50
|
-
else
|
51
|
-
raise KeycloakOauth::AuthenticationError.new(response_hash)
|
52
|
-
end
|
27
|
+
session[:access_token] = response_hash[ACCESS_TOKEN_KEY]
|
28
|
+
session[:refresh_token] = response_hash[REFRESH_TOKEN_KEY]
|
53
29
|
end
|
54
30
|
end
|
55
31
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module KeycloakOauth
|
4
|
+
class AuthorizableError < StandardError; end
|
5
|
+
|
6
|
+
class AuthorizableService
|
7
|
+
HTTP_SUCCESS_CODES = [Net::HTTPOK, Net::HTTPNoContent, Net::HTTPCreated]
|
8
|
+
DEFAULT_CONTENT_TYPE = 'application/x-www-form-urlencoded'.freeze
|
9
|
+
AUTHORIZATION_HEADER = 'Authorization'.freeze
|
10
|
+
|
11
|
+
attr_reader :http_response, :parsed_response_body
|
12
|
+
|
13
|
+
def perform
|
14
|
+
@http_response = send_request
|
15
|
+
@parsed_response_body ||= parse_response_body(http_response)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def parse_response_body(http_response)
|
21
|
+
response_body = http_response.body.present? ? JSON.parse(http_response.body) : http_response.body
|
22
|
+
|
23
|
+
return response_body if HTTP_SUCCESS_CODES.include?(http_response.code_type)
|
24
|
+
|
25
|
+
# TODO: For now, we assume that the access token is always valid.
|
26
|
+
# We do not yet handle the case where a refresh token is passed in and
|
27
|
+
# used if the access token has expired.
|
28
|
+
raise KeycloakOauth::AuthorizableError.new(error_message_from(response_body))
|
29
|
+
end
|
30
|
+
|
31
|
+
def error_message_from(response)
|
32
|
+
# Keycloak sometimes sends back a hash containing the "errorMessage" key,
|
33
|
+
# other times it returns a hash containing the "error_description key" key
|
34
|
+
# and other times it returns an empty string. There could be more cases,
|
35
|
+
# but for the moment we are only handling these three.
|
36
|
+
case response.class.to_s
|
37
|
+
when 'Hash'
|
38
|
+
if response.has_key?('errorMessage')
|
39
|
+
return response['errorMessage']
|
40
|
+
elsif response.has_key?('error_description')
|
41
|
+
return response['error_description']
|
42
|
+
end
|
43
|
+
when 'String'
|
44
|
+
return response
|
45
|
+
else
|
46
|
+
'Unexpected Keycloak error'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module KeycloakOauth
|
4
|
+
class LogoutService < KeycloakOauth::AuthorizableService
|
5
|
+
def initialize(session)
|
6
|
+
@session = session
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_request
|
10
|
+
post_logout
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_accessor :session
|
16
|
+
|
17
|
+
def post_logout
|
18
|
+
uri = URI.parse(KeycloakOauth.connection.logout_endpoint)
|
19
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
21
|
+
request.set_content_type(DEFAULT_CONTENT_TYPE)
|
22
|
+
request.set_form_data(logout_request_params)
|
23
|
+
request[AUTHORIZATION_HEADER] = "Bearer #{access_token}"
|
24
|
+
http.request(request)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def logout_request_params
|
29
|
+
{
|
30
|
+
client_id: KeycloakOauth.connection.client_id,
|
31
|
+
client_secret: KeycloakOauth.connection.client_secret,
|
32
|
+
refresh_token: refresh_token
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def access_token
|
37
|
+
session[:access_token]
|
38
|
+
end
|
39
|
+
|
40
|
+
def refresh_token
|
41
|
+
session[:refresh_token]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module KeycloakOauth
|
4
|
+
class PostTokenService < KeycloakOauth::AuthorizableService
|
5
|
+
DEFAULT_GRANT_TYPE = 'authorization_code'.freeze
|
6
|
+
|
7
|
+
attr_reader :request_params, :connection
|
8
|
+
|
9
|
+
def initialize(connection:, request_params:)
|
10
|
+
@connection = connection
|
11
|
+
@request_params = request_params
|
12
|
+
end
|
13
|
+
|
14
|
+
def send_request
|
15
|
+
post_token
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :code, :redirect_uri
|
21
|
+
|
22
|
+
def post_token
|
23
|
+
uri = URI.parse(connection.authentication_endpoint)
|
24
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
25
|
+
request = Net::HTTP::Post.new(uri)
|
26
|
+
request.set_content_type(DEFAULT_CONTENT_TYPE)
|
27
|
+
request.set_form_data(token_request_params)
|
28
|
+
http.request(request)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def token_request_params
|
33
|
+
{
|
34
|
+
client_id: connection.client_id,
|
35
|
+
client_secret: connection.client_secret,
|
36
|
+
grant_type: DEFAULT_GRANT_TYPE
|
37
|
+
}.merge(request_params)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module KeycloakOauth
|
4
|
+
class DuplicationError < StandardError; end
|
5
|
+
|
6
|
+
class PostUsersService < KeycloakOauth::AuthorizableService
|
7
|
+
CONTENT_TYPE = 'application/json'.freeze
|
8
|
+
|
9
|
+
attr_reader :request_params, :connection, :user_params
|
10
|
+
|
11
|
+
def initialize(connection:, access_token:, refresh_token:, user_params:)
|
12
|
+
@connection = connection
|
13
|
+
@access_token = access_token
|
14
|
+
@refresh_token = refresh_token
|
15
|
+
@user_params = user_params
|
16
|
+
end
|
17
|
+
|
18
|
+
def send_request
|
19
|
+
post_users
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_accessor :access_token, :refresh_token
|
25
|
+
|
26
|
+
def post_users
|
27
|
+
uri = URI.parse(connection.post_users_endpoint)
|
28
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
29
|
+
request = Net::HTTP::Post.new(uri)
|
30
|
+
request.set_content_type(CONTENT_TYPE)
|
31
|
+
request[AUTHORIZATION_HEADER] = "Bearer #{access_token}"
|
32
|
+
request.body = user_params.to_json
|
33
|
+
http.request(request)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_response_body(http_response)
|
38
|
+
super
|
39
|
+
rescue KeycloakOauth::AuthorizableError => exception
|
40
|
+
raise exception unless is_exception_a_duplication?(exception)
|
41
|
+
raise KeycloakOauth::DuplicationError.new(exception)
|
42
|
+
end
|
43
|
+
|
44
|
+
def is_exception_a_duplication?(exception)
|
45
|
+
exception.message == "User exists with same email" ||
|
46
|
+
exception.message == "User exists with same username"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -1,45 +1,30 @@
|
|
1
1
|
require 'net/http'
|
2
2
|
|
3
3
|
module KeycloakOauth
|
4
|
-
class
|
5
|
-
|
6
|
-
class UserInfoRetrievalService
|
7
|
-
AUTHORIZATION_HEADER = 'Authorization'.freeze
|
8
|
-
CONTENT_TYPE = 'application/x-www-form-urlencoded'.freeze
|
9
|
-
|
4
|
+
class UserInfoRetrievalService < KeycloakOauth::AuthorizableService
|
10
5
|
attr_reader :user_information
|
11
6
|
|
12
|
-
def initialize(access_token:)
|
7
|
+
def initialize(access_token:, refresh_token:)
|
13
8
|
@access_token = access_token
|
9
|
+
@refresh_token = refresh_token
|
14
10
|
end
|
15
11
|
|
16
|
-
def
|
17
|
-
|
12
|
+
def send_request
|
13
|
+
get_user
|
18
14
|
end
|
19
15
|
|
20
16
|
private
|
21
17
|
|
22
|
-
attr_accessor :access_token
|
18
|
+
attr_accessor :access_token, :refresh_token
|
23
19
|
|
24
20
|
def get_user
|
25
21
|
uri = URI.parse(KeycloakOauth.connection.user_info_endpoint)
|
26
|
-
Net::HTTP.start(uri.host, uri.port) do |http|
|
22
|
+
Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
|
27
23
|
request = Net::HTTP::Get.new(uri)
|
28
|
-
request.set_content_type(
|
24
|
+
request.set_content_type(DEFAULT_CONTENT_TYPE)
|
29
25
|
request[AUTHORIZATION_HEADER] = "Bearer #{access_token}"
|
30
26
|
http.request(request)
|
31
27
|
end
|
32
28
|
end
|
33
|
-
|
34
|
-
def parsed_user_information(http_response)
|
35
|
-
response_hash = JSON.parse(http_response.body)
|
36
|
-
|
37
|
-
return response_hash if http_response.code_type == Net::HTTPOK
|
38
|
-
|
39
|
-
# TODO: For now, we assume that the access token is always valid.
|
40
|
-
# We do not yet handle the case where a refresh token is passed in and
|
41
|
-
# used if the access token has expired.
|
42
|
-
raise KeycloakOauth::UserInfoRetrievalError.new(response_hash)
|
43
|
-
end
|
44
29
|
end
|
45
30
|
end
|
@@ -14,10 +14,18 @@ module KeycloakOauth
|
|
14
14
|
@callback_module = callback_module
|
15
15
|
end
|
16
16
|
|
17
|
-
def get_user_information(access_token:)
|
18
|
-
service = KeycloakOauth::UserInfoRetrievalService.new(
|
19
|
-
|
20
|
-
|
17
|
+
def get_user_information(access_token:, refresh_token:)
|
18
|
+
service = KeycloakOauth::UserInfoRetrievalService.new(
|
19
|
+
access_token: access_token,
|
20
|
+
refresh_token: refresh_token
|
21
|
+
)
|
22
|
+
service.perform
|
23
|
+
service.parsed_response_body
|
24
|
+
end
|
25
|
+
|
26
|
+
def logout(session:)
|
27
|
+
service = KeycloakOauth::LogoutService.new(session)
|
28
|
+
service.perform
|
21
29
|
end
|
22
30
|
end
|
23
31
|
end
|
@@ -5,6 +5,8 @@ module KeycloakOauth
|
|
5
5
|
def authorization_endpoint(options: {})
|
6
6
|
endpoint = "#{auth_url}/realms/#{realm}/protocol/openid-connect/auth?client_id=#{client_id}"
|
7
7
|
endpoint += "&response_type=#{options[:response_type] || DEFAULT_RESPONSE_TYPE}"
|
8
|
+
endpoint += "&redirect_uri=#{options[:redirect_uri]}" if options[:redirect_uri].present?
|
9
|
+
endpoint
|
8
10
|
end
|
9
11
|
|
10
12
|
def authentication_endpoint
|
@@ -14,5 +16,13 @@ module KeycloakOauth
|
|
14
16
|
def user_info_endpoint
|
15
17
|
"#{auth_url}/realms/#{realm}/protocol/openid-connect/userinfo"
|
16
18
|
end
|
19
|
+
|
20
|
+
def logout_endpoint
|
21
|
+
"#{auth_url}/realms/#{realm}/protocol/openid-connect/logout"
|
22
|
+
end
|
23
|
+
|
24
|
+
def post_users_endpoint
|
25
|
+
"#{auth_url}/admin/realms/#{realm}/users"
|
26
|
+
end
|
17
27
|
end
|
18
28
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: keycloak_oauth
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- simplificator
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-11-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -58,7 +58,7 @@ dependencies:
|
|
58
58
|
- - ">="
|
59
59
|
- !ruby/object:Gem::Version
|
60
60
|
version: '0'
|
61
|
-
description:
|
61
|
+
description:
|
62
62
|
email:
|
63
63
|
- dev@simplificator.com
|
64
64
|
executables: []
|
@@ -67,9 +67,12 @@ extra_rdoc_files: []
|
|
67
67
|
files:
|
68
68
|
- README.md
|
69
69
|
- Rakefile
|
70
|
-
- app/controllers/keycloak_oauth/application_controller.rb
|
71
70
|
- app/controllers/keycloak_oauth/callbacks_controller.rb
|
72
71
|
- app/services/keycloak_oauth/authentication_service.rb
|
72
|
+
- app/services/keycloak_oauth/authorizable_service.rb
|
73
|
+
- app/services/keycloak_oauth/logout_service.rb
|
74
|
+
- app/services/keycloak_oauth/post_token_service.rb
|
75
|
+
- app/services/keycloak_oauth/post_users_service.rb
|
73
76
|
- app/services/keycloak_oauth/user_info_retrieval_service.rb
|
74
77
|
- config/routes.rb
|
75
78
|
- lib/keycloak_oauth.rb
|
@@ -85,7 +88,7 @@ metadata:
|
|
85
88
|
homepage_uri: https://rubygems.org/gems/keycloak_oauth
|
86
89
|
source_code_uri: https://github.com/simplificator/keycloak_oauth
|
87
90
|
changelog_uri: https://github.com/simplificator/keycloak_oauth
|
88
|
-
post_install_message:
|
91
|
+
post_install_message:
|
89
92
|
rdoc_options: []
|
90
93
|
require_paths:
|
91
94
|
- lib
|
@@ -100,8 +103,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
103
|
- !ruby/object:Gem::Version
|
101
104
|
version: '0'
|
102
105
|
requirements: []
|
103
|
-
rubygems_version: 3.
|
104
|
-
signing_key:
|
106
|
+
rubygems_version: 3.1.4
|
107
|
+
signing_key:
|
105
108
|
specification_version: 4
|
106
109
|
summary: Implementing OAuth with Keycloak in Ruby
|
107
110
|
test_files: []
|