keycloak_ruby 0.1.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 +7 -0
- data/.rspec_status +68 -0
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +674 -0
- data/README.md +157 -0
- data/Rakefile +14 -0
- data/lib/assets/.keep +0 -0
- data/lib/generators/keycloak_ruby/install_generator.rb +47 -0
- data/lib/keycloak_ruby/authentication.rb +25 -0
- data/lib/keycloak_ruby/client.rb +249 -0
- data/lib/keycloak_ruby/config.rb +118 -0
- data/lib/keycloak_ruby/errors.rb +73 -0
- data/lib/keycloak_ruby/request_params.rb +16 -0
- data/lib/keycloak_ruby/request_performer.rb +65 -0
- data/lib/keycloak_ruby/response_validator.rb +115 -0
- data/lib/keycloak_ruby/testing/keycloak_helpers.rb +125 -0
- data/lib/keycloak_ruby/testing.rb +13 -0
- data/lib/keycloak_ruby/token_refresher.rb +103 -0
- data/lib/keycloak_ruby/token_service.rb +108 -0
- data/lib/keycloak_ruby/user.rb +55 -0
- data/lib/keycloak_ruby/version.rb +62 -0
- data/lib/keycloak_ruby.rb +65 -0
- data/lib/templates/omniauth.rb +29 -0
- data/sig/keycloak_ruby.rbs +656 -0
- metadata +129 -0
data/README.md
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# Description
|
2
|
+
|
3
|
+
This library is designed to use Keycloak identification in Rails application
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add to Gemfile
|
8
|
+
|
9
|
+
```bash
|
10
|
+
gem "keycloak_ruby", git: "https://github.com/sergey-arkhipov/keycloak_ruby.git"
|
11
|
+
|
12
|
+
```
|
13
|
+
|
14
|
+
Create initializer for omniauth manually or use generator (config/initializers/omniauth.rb)
|
15
|
+
|
16
|
+
```bash
|
17
|
+
rails generate keycloak_ruby:install
|
18
|
+
```
|
19
|
+
|
20
|
+
Now under active development, so you need create manually:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
# ApplicationController
|
24
|
+
|
25
|
+
include KeycloakRuby::Authentication
|
26
|
+
|
27
|
+
# SeesionController
|
28
|
+
def login
|
29
|
+
render :login, layout: "login"
|
30
|
+
end
|
31
|
+
|
32
|
+
def create
|
33
|
+
auth_info = request.env["omniauth.auth"]
|
34
|
+
keycloak_jwt_service.store_tokens(auth_info[:credentials])
|
35
|
+
user = User.find_by(email: auth_info.dig(:info, :email))
|
36
|
+
return destroy unless user&.active?
|
37
|
+
|
38
|
+
redirect_to root_path, notice: I18n.t("user.auth_success")
|
39
|
+
end
|
40
|
+
|
41
|
+
def destroy
|
42
|
+
id_token = session[:id_token]
|
43
|
+
keycloak_jwt_service.clear_tokens
|
44
|
+
logout_url = "#{KeycloakRuby.config.logout_url}?post_logout_redirect_uri=#{CGI.escape(root_url)}&" \
|
45
|
+
"id_token_hint=#{id_token}"
|
46
|
+
|
47
|
+
redirect_to logout_url, allow_other_host: true
|
48
|
+
end
|
49
|
+
|
50
|
+
# config/routes.rb
|
51
|
+
get '/login', to: 'sessions#login', as: :login
|
52
|
+
get '/auth/:provider/callback', to: 'sessions#create'
|
53
|
+
delete '/logout', to: 'sessions#destroy', as: :logout
|
54
|
+
|
55
|
+
```
|
56
|
+
|
57
|
+
It is assumed that you have a User model in Rails app
|
58
|
+
|
59
|
+
## Architecture Overview
|
60
|
+
|
61
|
+
### Component Diagram
|
62
|
+
|
63
|
+
```mermaid
|
64
|
+
flowchart TD
|
65
|
+
subgraph Rails_Application["Rails Application"]
|
66
|
+
A[Controller] --> B[TokenService]
|
67
|
+
B --> C[TokenRefresher]
|
68
|
+
C --> D[Keycloak Server]
|
69
|
+
D -->|Refresh Token| C
|
70
|
+
C -->|New Tokens| B
|
71
|
+
B --> E[User Model]
|
72
|
+
B --> F[ResponseValidator]
|
73
|
+
C --> F
|
74
|
+
F --> G[Errors]
|
75
|
+
B --> H[JWT Decoder]
|
76
|
+
H --> I[JWKS Cache]
|
77
|
+
end
|
78
|
+
|
79
|
+
style A fill:#ff66ff,stroke:#000000,color:#000000
|
80
|
+
style B fill:#66b3ff,stroke:#000000,color:#000000
|
81
|
+
style C fill:#66b3ff,stroke:#000000,color:#000000
|
82
|
+
style D fill:#ff9933,stroke:#000000,color:#000000
|
83
|
+
style E fill:#66cc66,stroke:#000000,color:#000000
|
84
|
+
style F fill:#66b3ff,stroke:#000000,color:#000000
|
85
|
+
style G fill:#ff6666,stroke:#000000,color:#000000
|
86
|
+
style H fill:#66b3ff,stroke:#000000,color:#000000
|
87
|
+
style I fill:#99ccff,stroke:#000000,color:#000000
|
88
|
+
|
89
|
+
%% Subgraph styling - transparent with black border only
|
90
|
+
style Rails_Application fill:none,stroke:#000000,color:#000000
|
91
|
+
|
92
|
+
```
|
93
|
+
|
94
|
+
### Authentication Sequence
|
95
|
+
|
96
|
+
```mermaid
|
97
|
+
sequenceDiagram
|
98
|
+
participant C as Controller
|
99
|
+
participant TS as TokenService
|
100
|
+
participant TR as TokenRefresher
|
101
|
+
participant KS as Keycloak Server
|
102
|
+
participant RV as ResponseValidator
|
103
|
+
participant JD as JWT Decoder
|
104
|
+
|
105
|
+
C->>TS: authenticate(user_credentials)
|
106
|
+
TS->>TR: call()
|
107
|
+
TR->>KS: POST /token
|
108
|
+
KS-->>TR: token_response
|
109
|
+
TR->>RV: validate(response)
|
110
|
+
RV-->>TR: validation_result
|
111
|
+
alt Valid
|
112
|
+
TR-->>TS: new_tokens
|
113
|
+
TS->>JD: decode(access_token)
|
114
|
+
JD->>TS: claims
|
115
|
+
TS-->>C: user
|
116
|
+
else Invalid
|
117
|
+
RV->>TR: raise error
|
118
|
+
TR->>TS: propagate error
|
119
|
+
TS-->>C: nil
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
### Key Flows
|
124
|
+
|
125
|
+
1. **Initial Authentication**:
|
126
|
+
|
127
|
+
- Controller → TokenService → Keycloak Server
|
128
|
+
- Stores tokens in session
|
129
|
+
|
130
|
+
2. **Token Refresh**:
|
131
|
+
|
132
|
+
- TokenService → TokenRefresher → Keycloak Server
|
133
|
+
- Automatic when token expires
|
134
|
+
|
135
|
+
3. **Access Validation**:
|
136
|
+
|
137
|
+
- Verifies token signature and claims
|
138
|
+
- Checks user existence in local DB
|
139
|
+
|
140
|
+
4. **Error Handling**:
|
141
|
+
- Clear sessions on invalid tokens
|
142
|
+
- Propagates meaningful errors
|
143
|
+
|
144
|
+
```
|
145
|
+
|
146
|
+
```
|
147
|
+
|
148
|
+
## Test
|
149
|
+
|
150
|
+
For test purpose there mock helper sign_in(user)
|
151
|
+
|
152
|
+
- add `require "keycloak_ruby/testing/keycloak_helpers"` to keycloak_helper.rb
|
153
|
+
- add sign_in in tests `config.include KeycloakRuby::Testing::KeycloakHelpers`
|
154
|
+
|
155
|
+
```
|
156
|
+
|
157
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec) do |task|
|
7
|
+
task.verbose = false
|
8
|
+
end
|
9
|
+
|
10
|
+
require "rubocop/rake_task"
|
11
|
+
|
12
|
+
RuboCop::RakeTask.new
|
13
|
+
|
14
|
+
task default: %i[spec rubocop]
|
data/lib/assets/.keep
ADDED
File without changes
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/generators/keycloak_ruby/install_generator.rb
|
4
|
+
require "rails/generators"
|
5
|
+
|
6
|
+
##
|
7
|
+
# Generates the OmniAuth initializer for Keycloak integration
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
# rails generate keycloak_ruby:install
|
11
|
+
#
|
12
|
+
module KeycloakRuby
|
13
|
+
##
|
14
|
+
# Rails generator that creates the OmniAuth initializer configuration
|
15
|
+
# for Keycloak authentication
|
16
|
+
class InstallGenerator < Rails::Generators::Base
|
17
|
+
source_root File.expand_path("../../templates", __dir__)
|
18
|
+
desc "Creates Keycloak Ruby initializer for OmniAuth configuration"
|
19
|
+
|
20
|
+
##
|
21
|
+
# Copies the OmniAuth initializer template to the Rails application
|
22
|
+
# unless it already exists
|
23
|
+
def copy_initializer
|
24
|
+
if omniauth_initializer_exists?
|
25
|
+
say_status("skipped", "OmniAuth initializer already exists at #{omniauth_initializer_path}", :yellow)
|
26
|
+
else
|
27
|
+
template "omniauth.rb", "config/initializers/omniauth.rb"
|
28
|
+
say_status("created", "OmniAuth initializer at config/initializers/omniauth.rb", :green)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
##
|
35
|
+
# @return [Pathname] full path to the omniauth initializer
|
36
|
+
def omniauth_initializer_path
|
37
|
+
@omniauth_initializer_path ||= Rails.root.join("config/initializers/omniauth.rb")
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Checks if the omniauth initializer already exists
|
42
|
+
# @return [Boolean] true if file exists
|
43
|
+
def omniauth_initializer_exists?
|
44
|
+
File.exist?(omniauth_initializer_path)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/keycloak_ruby/authentication.rb
|
4
|
+
module KeycloakRuby
|
5
|
+
# Concern to add methods to ApplicationController
|
6
|
+
module Authentication
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
before_action :authenticate_user!
|
11
|
+
end
|
12
|
+
|
13
|
+
def keycloak_jwt_service
|
14
|
+
@keycloak_jwt_service ||= KeycloakRuby::TokenService.new(session)
|
15
|
+
end
|
16
|
+
|
17
|
+
def authenticate_user!
|
18
|
+
redirect_to login_path unless current_user&.active?
|
19
|
+
end
|
20
|
+
|
21
|
+
def current_user
|
22
|
+
@current_user ||= keycloak_jwt_service.find_user
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,249 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/keycloak_ruby/client.rb
|
4
|
+
module KeycloakRuby
|
5
|
+
# Client for interacting with Keycloak (create, delete, and find users, etc.).
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
7
|
+
# rubocop:disable Metrics/MethodLength
|
8
|
+
# :reek:TooManyMethods
|
9
|
+
class Client
|
10
|
+
def initialize(config = KeycloakRuby.config)
|
11
|
+
@config = config
|
12
|
+
@request_performer = RequestPerformer.new(@config)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Authenticates a user with Keycloak and returns token data upon success.
|
16
|
+
#
|
17
|
+
# @param username [String] The user's username or email.
|
18
|
+
# @param password [String] The user's password.
|
19
|
+
# @return [Hash] The token data (access_token, refresh_token, id_token, etc.).
|
20
|
+
# @raise [KeycloakRuby::Errors::InvalidCredentials] If authentication fails.
|
21
|
+
def authenticate_user(username:, password:)
|
22
|
+
body = build_auth_body(username, password)
|
23
|
+
response = http_request(
|
24
|
+
http_method: :post,
|
25
|
+
url: @config.token_url,
|
26
|
+
body: URI.encode_www_form(body),
|
27
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
28
|
+
success_codes: [200],
|
29
|
+
error_class: KeycloakRuby::Errors::InvalidCredentials,
|
30
|
+
error_message: "Failed to authenticate with Keycloak"
|
31
|
+
)
|
32
|
+
response.parsed_response
|
33
|
+
end
|
34
|
+
|
35
|
+
# Creates a user in Keycloak and returns the newly created user's data.
|
36
|
+
#
|
37
|
+
# @param user_attrs [Hash] A hash of user attributes. Must contain:
|
38
|
+
# :username, :email, :password, :temporary
|
39
|
+
# @option user_attrs [String] :username The username for the new user.
|
40
|
+
# @option user_attrs [String] :email The user's email.
|
41
|
+
# @option user_attrs [String] :password The initial password.
|
42
|
+
# @option user_attrs [Boolean] :temporary Whether to force a password update on first login.
|
43
|
+
#
|
44
|
+
# @raise [KeycloakRuby::Errors::UserCreationError] If user creation fails.
|
45
|
+
# @return [Hash] The newly created user's data.
|
46
|
+
def create_user(user_attrs = {})
|
47
|
+
user_data = build_user_data(user_attrs)
|
48
|
+
|
49
|
+
response = http_request(
|
50
|
+
http_method: :post,
|
51
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/users",
|
52
|
+
headers: default_headers,
|
53
|
+
body: user_data.to_json,
|
54
|
+
success_codes: [201],
|
55
|
+
error_class: KeycloakRuby::Errors::UserCreationError,
|
56
|
+
error_message: "Failed to create Keycloak user"
|
57
|
+
)
|
58
|
+
|
59
|
+
# Extract user ID from the "Location" header, then fetch user details
|
60
|
+
user_id = response.headers["Location"].split("/").last
|
61
|
+
fetch_user(user_id)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Deletes all users that match the provided search string (e.g., username, email).
|
65
|
+
#
|
66
|
+
# @param search_string [String] The search criteria for finding users in Keycloak.
|
67
|
+
# @raise [KeycloakRuby::Errors::UserDeletionError] If any user deletion fails.
|
68
|
+
def delete_users(search_string)
|
69
|
+
user_ids = find_users(search_string).pluck("id")
|
70
|
+
user_ids.each { |id| delete_user_by_id(id) }
|
71
|
+
end
|
72
|
+
|
73
|
+
# Deletes a single user by ID in Keycloak.
|
74
|
+
#
|
75
|
+
# @param user_id [String] The ID of the user to delete.
|
76
|
+
# @raise [KeycloakRuby::Errors::UserDeletionError] If the deletion fails.
|
77
|
+
def delete_user_by_id(user_id)
|
78
|
+
http_request(
|
79
|
+
http_method: :delete,
|
80
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/users/#{user_id}",
|
81
|
+
headers: default_headers,
|
82
|
+
success_codes: [204],
|
83
|
+
error_class: KeycloakRuby::Errors::UserDeletionError,
|
84
|
+
error_message: "Failed to delete Keycloak user with ID #{user_id}"
|
85
|
+
)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Finds all users in Keycloak that match the given search string.
|
89
|
+
#
|
90
|
+
# @param search [String] The search query (e.g., part of username or email).
|
91
|
+
# @return [Array<Hash>] An array of user objects.
|
92
|
+
# @raise [KeycloakRuby::Errors::APIError] If the request fails.
|
93
|
+
def find_users(search)
|
94
|
+
response = http_request(
|
95
|
+
http_method: :get,
|
96
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/users/?search=#{search}",
|
97
|
+
headers: default_headers,
|
98
|
+
success_codes: [200],
|
99
|
+
error_class: KeycloakRuby::Errors::APIError,
|
100
|
+
error_message: "Failed to get Keycloak users"
|
101
|
+
)
|
102
|
+
JSON.parse(response.body)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Updates the redirect URIs for a specific client in Keycloak.
|
106
|
+
#
|
107
|
+
# @param client_id [String] The client ID in Keycloak.
|
108
|
+
# @param redirect_uris [Array<String>] A list of valid redirect URIs for this client.
|
109
|
+
# @raise [KeycloakRuby::Errors::ConnectionError] If the update request fails.
|
110
|
+
def update_client_redirect_uris(client_id:, redirect_uris:)
|
111
|
+
client_record = find_client_by_id(client_id)
|
112
|
+
update_redirect_uris_for(client_record["id"], redirect_uris)
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def build_auth_body(username, password)
|
118
|
+
{
|
119
|
+
client_id: @config.oauth_client_id,
|
120
|
+
client_secret: @config.oauth_client_secret,
|
121
|
+
username: username,
|
122
|
+
password: password,
|
123
|
+
grant_type: "password"
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
def build_user_data(attrs)
|
128
|
+
{ username: attrs.fetch(:username), email: attrs.fetch(:email), enabled: true,
|
129
|
+
credentials: [
|
130
|
+
{
|
131
|
+
type: "password",
|
132
|
+
value: attrs.fetch(:password),
|
133
|
+
temporary: attrs.fetch(:temporary, true)
|
134
|
+
}
|
135
|
+
] }
|
136
|
+
end
|
137
|
+
|
138
|
+
# Builds RequestParams and passes them to the RequestPerformer.
|
139
|
+
def http_request(options = {})
|
140
|
+
params = build_request_params(options)
|
141
|
+
@request_performer.call(params)
|
142
|
+
end
|
143
|
+
|
144
|
+
def build_request_params(opts)
|
145
|
+
RequestParams.new(
|
146
|
+
http_method: opts.fetch(:http_method),
|
147
|
+
url: opts.fetch(:url),
|
148
|
+
headers: opts.fetch(:headers, {}),
|
149
|
+
body: opts.fetch(:body, nil),
|
150
|
+
success_codes: opts.fetch(:success_codes, [200]),
|
151
|
+
error_class: opts.fetch(:error_class, KeycloakRuby::Errors::APIError),
|
152
|
+
error_message: opts.fetch(:error_message, "Request failed")
|
153
|
+
)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Retrieves client details by its "clientId".
|
157
|
+
#
|
158
|
+
# @param client_id [String] The "clientId" in Keycloak.
|
159
|
+
# @return [Hash] The client details.
|
160
|
+
# @raise [KeycloakRuby::Errors::ClientError] If no matching client is found or the request fails.
|
161
|
+
def find_client_by_id(client_id)
|
162
|
+
response = http_request(
|
163
|
+
http_method: :get,
|
164
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/clients",
|
165
|
+
headers: default_headers,
|
166
|
+
success_codes: [200],
|
167
|
+
error_class: KeycloakRuby::Errors::ClientError,
|
168
|
+
error_message: "Failed to fetch clients"
|
169
|
+
)
|
170
|
+
|
171
|
+
clients = JSON.parse(response.body)
|
172
|
+
client_record = clients.find { |client| client["clientId"] == client_id }
|
173
|
+
raise KeycloakRuby::Errors::ClientError, "Client #{client_id} not found" unless client_record
|
174
|
+
|
175
|
+
client_record
|
176
|
+
end
|
177
|
+
|
178
|
+
# Performs a PUT request to update the redirect URIs for a given client in Keycloak.
|
179
|
+
#
|
180
|
+
# @param client_id [String] The internal Keycloak client ID.
|
181
|
+
# @param redirect_uris [Array<String>] List of valid redirect URIs for this client.
|
182
|
+
# @raise [KeycloakRuby::Errors::ConnectionError] If the update request fails.
|
183
|
+
def update_redirect_uris_for(client_id, redirect_uris)
|
184
|
+
http_request(
|
185
|
+
http_method: :put,
|
186
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/clients/#{client_id}",
|
187
|
+
headers: default_headers,
|
188
|
+
body: { redirectUris: redirect_uris }.to_json,
|
189
|
+
success_codes: (200..299),
|
190
|
+
error_class: KeycloakRuby::Errors::ConnectionError,
|
191
|
+
error_message: "Failed to update redirectUris for client #{client_id}"
|
192
|
+
)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Fetches a user by ID from Keycloak.
|
196
|
+
#
|
197
|
+
# @param user_id [String] The user's ID in Keycloak.
|
198
|
+
# @return [Hash] The Keycloak user data.
|
199
|
+
# @raise [KeycloakRuby::Errors::UserNotFound] If the user cannot be found or the request fails.
|
200
|
+
def fetch_user(user_id)
|
201
|
+
response = http_request(
|
202
|
+
http_method: :get,
|
203
|
+
url: "#{@config.keycloak_url}/admin/realms/#{@config.realm}/users/#{user_id}",
|
204
|
+
headers: default_headers,
|
205
|
+
success_codes: [200],
|
206
|
+
error_class: KeycloakRuby::Errors::UserNotFound,
|
207
|
+
error_message: "Failed to fetch Keycloak user with ID #{user_id}"
|
208
|
+
)
|
209
|
+
|
210
|
+
response.parsed_response
|
211
|
+
end
|
212
|
+
|
213
|
+
# Retrieves the admin token used to authenticate calls to the Keycloak Admin API.
|
214
|
+
#
|
215
|
+
# @return [String] The admin access token.
|
216
|
+
# @raise [KeycloakRuby::Errors::TokenVerificationFailed] If the token request fails.
|
217
|
+
def admin_token
|
218
|
+
body = {
|
219
|
+
client_id: @config.admin_client_id,
|
220
|
+
client_secret: @config.admin_client_secret,
|
221
|
+
grant_type: "client_credentials"
|
222
|
+
}
|
223
|
+
|
224
|
+
response = http_request(
|
225
|
+
http_method: :post,
|
226
|
+
url: "#{@config.keycloak_url}/realms/#{@config.realm}/protocol/openid-connect/token",
|
227
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
228
|
+
body: URI.encode_www_form(body),
|
229
|
+
success_codes: [200],
|
230
|
+
error_class: KeycloakRuby::Errors::TokenVerificationFailed,
|
231
|
+
error_message: "Failed to get Keycloak admin token"
|
232
|
+
)
|
233
|
+
|
234
|
+
response.parsed_response["access_token"]
|
235
|
+
end
|
236
|
+
|
237
|
+
# Returns a set of default headers for requests requiring the admin token.
|
238
|
+
#
|
239
|
+
# @return [Hash] A headers Hash including Authorization and Content-Type.
|
240
|
+
def default_headers
|
241
|
+
{
|
242
|
+
"Authorization" => "Bearer #{admin_token}",
|
243
|
+
"Content-Type" => "application/json"
|
244
|
+
}
|
245
|
+
end
|
246
|
+
end
|
247
|
+
# rubocop:enable Metrics/ClassLength
|
248
|
+
# rubocop:enable Metrics/MethodLength
|
249
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/keycloak_ruby/config.rb
|
4
|
+
module KeycloakRuby
|
5
|
+
# Configuration class for Keycloak Ruby gem.
|
6
|
+
#
|
7
|
+
# Handles loading and validation of Keycloak configuration from either:
|
8
|
+
# - A YAML file (default: config/keycloak.yml)
|
9
|
+
# - Direct attribute assignment
|
10
|
+
#
|
11
|
+
# == Example YAML Configuration
|
12
|
+
#
|
13
|
+
# development:
|
14
|
+
# keycloak_url: "https://keycloak.example.com"
|
15
|
+
# app_host: "http://localhost:3000"
|
16
|
+
# realm: "my-realm"
|
17
|
+
# oauth_client_id: "my-client"
|
18
|
+
# oauth_client_secret: "secret"
|
19
|
+
#
|
20
|
+
# == Example Programmatic Configuration
|
21
|
+
#
|
22
|
+
# config = KeycloakRuby::Config.new
|
23
|
+
# config.keycloak_url = "https://keycloak.example.com"
|
24
|
+
# config.realm = "my-realm"
|
25
|
+
# # ... etc
|
26
|
+
# :reek:MissingSafeMethod
|
27
|
+
class Config
|
28
|
+
# Default path to configuration file (Rails.root/config/keycloak.yml)
|
29
|
+
DEFAULT_CONFIG_PATH = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
30
|
+
Rails.root.join("config", "keycloak.yml")
|
31
|
+
else
|
32
|
+
# Fallback for non-Rails or when Rails isn't loaded yet
|
33
|
+
File.expand_path("config/keycloak.yml", Dir.pwd)
|
34
|
+
end
|
35
|
+
REQUIRED_ATTRIBUTES = %i[
|
36
|
+
keycloak_url
|
37
|
+
app_host
|
38
|
+
realm
|
39
|
+
oauth_client_id
|
40
|
+
oauth_client_secret
|
41
|
+
].freeze
|
42
|
+
# :reek:Attribute
|
43
|
+
attr_accessor :keycloak_url,
|
44
|
+
:app_host,
|
45
|
+
:realm,
|
46
|
+
:admin_client_id,
|
47
|
+
:admin_client_secret,
|
48
|
+
:oauth_client_id,
|
49
|
+
:oauth_client_secret
|
50
|
+
|
51
|
+
attr_reader :config_path
|
52
|
+
|
53
|
+
# Initialize configuration, optionally loading from YAML file
|
54
|
+
#
|
55
|
+
# @param config_path [String] Path to YAML config file (default: config/keycloak.yml)
|
56
|
+
#
|
57
|
+
#
|
58
|
+
def initialize(config_path = DEFAULT_CONFIG_PATH)
|
59
|
+
@config_path = config_path
|
60
|
+
load_config
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validates that all required configuration attributes are present
|
64
|
+
#
|
65
|
+
# @raise [KeycloakRuby::Errors::ConfigurationError] if any required attribute is missing
|
66
|
+
#
|
67
|
+
|
68
|
+
def validate!
|
69
|
+
REQUIRED_ATTRIBUTES.each do |attr|
|
70
|
+
value = public_send(attr)
|
71
|
+
raise Errors::ConfigurationError, "#{attr} is required" if value.blank?
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def realm_url
|
76
|
+
"#{keycloak_url}/realms/#{realm}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def redirect_url
|
80
|
+
"#{app_host}/auth/keycloak/callback"
|
81
|
+
end
|
82
|
+
|
83
|
+
def logout_url
|
84
|
+
"#{realm_url}/protocol/openid-connect/logout"
|
85
|
+
end
|
86
|
+
|
87
|
+
def token_url
|
88
|
+
"#{realm_url}/protocol/openid-connect/token"
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
# Loads configuration from YAML file if it exists
|
94
|
+
# :reek:ManualDispatch
|
95
|
+
def load_config
|
96
|
+
return unless File.exist?(@config_path)
|
97
|
+
|
98
|
+
yaml_content = load_yaml_file
|
99
|
+
env_config = yaml_content[current_env] || {}
|
100
|
+
apply_config(env_config)
|
101
|
+
end
|
102
|
+
|
103
|
+
def load_yaml_file
|
104
|
+
YAML.safe_load(ERB.new(File.read(@config_path)).result, aliases: true)
|
105
|
+
end
|
106
|
+
|
107
|
+
def current_env
|
108
|
+
defined?(Rails) ? Rails.env : ENV["APP_ENV"] || "development"
|
109
|
+
end
|
110
|
+
|
111
|
+
def apply_config(config_hash)
|
112
|
+
config_hash.each do |key, value|
|
113
|
+
setter = :"#{key}="
|
114
|
+
public_send(setter, value) if respond_to?(setter)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/keycloak_ruby/errors.rb
|
4
|
+
module KeycloakRuby
|
5
|
+
# Namespace for all KeycloakRuby specific errors
|
6
|
+
# Follows a hierarchical structure for better error handling
|
7
|
+
module Errors
|
8
|
+
# Base error class for all KeycloakRuby errors
|
9
|
+
# All custom errors inherit from this class
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
## Configuration Related Errors ##
|
13
|
+
|
14
|
+
# Raised when there's an issue with gem configuration
|
15
|
+
class ConfigurationError < Error; end
|
16
|
+
|
17
|
+
## Authentication Related Errors ##
|
18
|
+
|
19
|
+
# Base class for authentication failures
|
20
|
+
class AuthenticationError < Error; end
|
21
|
+
|
22
|
+
# Raised when user credentials are invalid
|
23
|
+
class InvalidCredentials < AuthenticationError; end
|
24
|
+
|
25
|
+
# Raised when user account is not found
|
26
|
+
class UserNotFound < AuthenticationError; end
|
27
|
+
|
28
|
+
# Raised when account is temporarily locked
|
29
|
+
class AccountLocked < AuthenticationError; end
|
30
|
+
|
31
|
+
## User Management Errors ##
|
32
|
+
|
33
|
+
# Raised when user creation fails
|
34
|
+
class UserCreationError < Error; end
|
35
|
+
|
36
|
+
# Raised when user update fails
|
37
|
+
class UserUpdateError < Error; end
|
38
|
+
|
39
|
+
# Raised when user deletion fails
|
40
|
+
class UserDeletionError < Error; end
|
41
|
+
|
42
|
+
## Token Related Errors ##
|
43
|
+
|
44
|
+
# Base class for all token-related errors
|
45
|
+
class TokenError < Error; end
|
46
|
+
|
47
|
+
# Raised when token has expired +
|
48
|
+
class TokenExpired < TokenError; end
|
49
|
+
|
50
|
+
# Raised when token is invalid (malformed, wrong signature, etc.)
|
51
|
+
class TokenInvalid < TokenError; end
|
52
|
+
|
53
|
+
# Raised when token refresh fails
|
54
|
+
class TokenRefreshFailed < TokenError; end
|
55
|
+
|
56
|
+
# Raised when token verification fails
|
57
|
+
class TokenVerificationFailed < TokenError; end
|
58
|
+
|
59
|
+
## API Communication Errors ##
|
60
|
+
|
61
|
+
# Raised when API request fails
|
62
|
+
class APIError < Error; end
|
63
|
+
|
64
|
+
# Raised when receiving 4xx responses from Keycloak
|
65
|
+
class ClientError < APIError; end
|
66
|
+
|
67
|
+
# Raised when receiving 5xx responses from Keycloak
|
68
|
+
class ServerError < APIError; end
|
69
|
+
|
70
|
+
# Raised when connection to Keycloak fails
|
71
|
+
class ConnectionError < APIError; end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/keycloak_ruby/request_params.rb
|
4
|
+
module KeycloakRuby
|
5
|
+
# A small, typed struct for request parameters
|
6
|
+
RequestParams = Struct.new(
|
7
|
+
:http_method,
|
8
|
+
:url,
|
9
|
+
:headers,
|
10
|
+
:body,
|
11
|
+
:success_codes,
|
12
|
+
:error_class,
|
13
|
+
:error_message,
|
14
|
+
keyword_init: true
|
15
|
+
)
|
16
|
+
end
|