keycloak-api-rails 1.1.2 → 2.0.2
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/CHANGELOG.md +87 -0
- data/README.md +73 -26
- data/keycloak-api-rails.gemspec +3 -4
- data/lib/keycloak-api-rails/authentication.rb +13 -11
- data/lib/keycloak-api-rails/configuration.rb +112 -0
- data/lib/keycloak-api-rails/helper.rb +38 -6
- data/lib/keycloak-api-rails/http_client.rb +56 -19
- data/lib/keycloak-api-rails/middleware.rb +30 -21
- data/lib/keycloak-api-rails/public_key_cached_resolver.rb +75 -15
- data/lib/keycloak-api-rails/public_key_resolver.rb +2 -0
- data/lib/keycloak-api-rails/railtie.rb +18 -1
- data/lib/keycloak-api-rails/service.rb +136 -28
- data/lib/keycloak-api-rails/testing.rb +14 -4
- data/lib/keycloak-api-rails/token_error.rb +51 -23
- data/lib/keycloak-api-rails/version.rb +3 -1
- data/lib/keycloak-api-rails.rb +34 -6
- metadata +30 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
|
|
3
5
|
class Middleware
|
|
@@ -8,36 +10,43 @@ module KeycloakApiRails
|
|
|
8
10
|
def call(env)
|
|
9
11
|
method = env["REQUEST_METHOD"]
|
|
10
12
|
path = env["PATH_INFO"]
|
|
11
|
-
uri = env["REQUEST_URI"]
|
|
12
13
|
|
|
13
14
|
if service.need_middleware_authentication?(method, path, env)
|
|
14
15
|
logger.debug("Start authentication for #{method} : #{path}")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
begin
|
|
17
|
+
authenticate(env)
|
|
18
|
+
rescue TokenError => e
|
|
19
|
+
logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
|
|
20
|
+
return authentication_failed(e.message)
|
|
21
|
+
rescue HTTPError, MissingPublicKeysError => e
|
|
22
|
+
logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
|
|
23
|
+
return authentication_unavailable
|
|
24
|
+
end
|
|
18
25
|
else
|
|
19
26
|
logger.debug("Skip authentication for #{method} : #{path}")
|
|
20
|
-
@app.call(env)
|
|
21
27
|
end
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
|
|
29
|
+
@app.call(env)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def authenticate(env)
|
|
35
|
+
token = service.read_token(Helper.request_uri(env), env)
|
|
36
|
+
decoded_token = service.decode_and_verify(token)
|
|
37
|
+
Helper.assign_token(env, decoded_token, config.custom_attributes)
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
def authentication_failed(message)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Helper.assign_realm_roles(env, decoded_token)
|
|
38
|
-
Helper.assign_resource_roles(env, decoded_token)
|
|
39
|
-
Helper.assign_keycloak_token(env, decoded_token)
|
|
40
|
-
@app.call(env)
|
|
41
|
+
# Rack 3 requires header names to be lowercase.
|
|
42
|
+
[401, { "content-type" => "application/json" }, [{ error: message }.to_json]]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def authentication_unavailable
|
|
46
|
+
[503,
|
|
47
|
+
{ "content-type" => "application/json",
|
|
48
|
+
"retry-after" => PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s },
|
|
49
|
+
[{ error: "Authentication is temporarily unavailable" }.to_json]]
|
|
41
50
|
end
|
|
42
51
|
|
|
43
52
|
def service
|
|
@@ -1,30 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class PublicKeyCachedResolver
|
|
3
|
-
|
|
5
|
+
FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@public_key_cache_ttl = public_key_cache_ttl
|
|
8
|
-
@cached_public_keys = nil
|
|
9
|
-
@cached_public_key_retrieved_at = nil
|
|
10
|
-
end
|
|
7
|
+
class RealmCache
|
|
8
|
+
attr_reader :cached_public_key_retrieved_at
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
def initialize(http_client, realm_id)
|
|
11
|
+
@resolver = PublicKeyResolver.new(http_client, realm_id)
|
|
12
|
+
@cached_public_keys = nil
|
|
13
|
+
@cached_public_key_retrieved_at = nil
|
|
14
|
+
@last_refresh_failure_at = nil
|
|
15
|
+
@last_refresh_error = nil
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_public_keys(public_key_cache_ttl, logger)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
raise @last_refresh_error if refresh_recently_failed_without_any_cache?
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
refresh_public_keys(logger) if public_keys_are_outdated?(public_key_cache_ttl)
|
|
24
|
+
@cached_public_keys
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def refresh_public_keys(logger)
|
|
18
31
|
@cached_public_keys = @resolver.find_public_keys
|
|
19
32
|
@cached_public_key_retrieved_at = Time.now
|
|
33
|
+
@last_refresh_failure_at = nil
|
|
34
|
+
@last_refresh_error = nil
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
@last_refresh_failure_at = Time.now
|
|
37
|
+
@last_refresh_error = e
|
|
38
|
+
raise if @cached_public_keys.nil?
|
|
39
|
+
|
|
40
|
+
logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refresh_recently_failed_without_any_cache?
|
|
44
|
+
@cached_public_keys.nil? &&
|
|
45
|
+
!@last_refresh_failure_at.nil? &&
|
|
46
|
+
Time.now <= @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS
|
|
20
47
|
end
|
|
21
|
-
|
|
48
|
+
|
|
49
|
+
def public_keys_are_outdated?(public_key_cache_ttl)
|
|
50
|
+
@cached_public_keys.nil? ||
|
|
51
|
+
@cached_public_key_retrieved_at.nil? ||
|
|
52
|
+
(Time.now > @cached_public_key_retrieved_at + public_key_cache_ttl &&
|
|
53
|
+
(@last_refresh_failure_at.nil? ||
|
|
54
|
+
Time.now > @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
|
|
59
|
+
@http_client = http_client
|
|
60
|
+
@realm_id = realm_id
|
|
61
|
+
@public_key_cache_ttl = public_key_cache_ttl
|
|
62
|
+
@logger = logger
|
|
63
|
+
@caches = {}
|
|
64
|
+
@caches_mutex = Mutex.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.from_configuration(http_client, configuration)
|
|
68
|
+
new(http_client, configuration.realm_id, configuration.public_key_cache_ttl, configuration.logger)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def find_public_keys(realm_id = nil)
|
|
72
|
+
target_realm = realm_id || @realm_id
|
|
73
|
+
cache_for(target_realm).find_public_keys(@public_key_cache_ttl, @logger)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Keep this method backward-compatible for testing
|
|
77
|
+
def cached_public_key_retrieved_at(realm_id = nil)
|
|
78
|
+
target_realm = realm_id || @realm_id
|
|
79
|
+
cache_for(target_realm).cached_public_key_retrieved_at
|
|
22
80
|
end
|
|
23
81
|
|
|
24
82
|
private
|
|
25
83
|
|
|
26
|
-
def
|
|
27
|
-
@
|
|
84
|
+
def cache_for(realm_id)
|
|
85
|
+
@caches_mutex.synchronize do
|
|
86
|
+
@caches[realm_id] ||= RealmCache.new(@http_client, realm_id)
|
|
87
|
+
end
|
|
28
88
|
end
|
|
29
89
|
end
|
|
30
90
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class Railtie < Rails::Railtie
|
|
3
5
|
railtie_name :keycloak_api_rails
|
|
@@ -5,5 +7,20 @@ module KeycloakApiRails
|
|
|
5
7
|
initializer("keycloak.insert_middleware") do |app|
|
|
6
8
|
app.config.middleware.use(KeycloakApiRails::Middleware)
|
|
7
9
|
end
|
|
10
|
+
|
|
11
|
+
# Runs once every initializer has run, config/initializers/keycloak.rb included, so that a
|
|
12
|
+
# misconfiguration fails at boot instead of on the first request reaching the middleware.
|
|
13
|
+
config.after_initialize do
|
|
14
|
+
keycloak_configuration = KeycloakApiRails.config
|
|
15
|
+
keycloak_configuration.validate!
|
|
16
|
+
|
|
17
|
+
unless keycloak_configuration.server_configured?
|
|
18
|
+
keycloak_configuration.logger.warn(
|
|
19
|
+
"KeycloakApiRails: 'server_url' and 'realm_id' are not both configured. No token can be " \
|
|
20
|
+
"verified until they are, unless the public key resolver is replaced -- as " \
|
|
21
|
+
"\"keycloak-api-rails/testing\" does."
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
8
25
|
end
|
|
9
|
-
end
|
|
26
|
+
end
|
|
@@ -1,38 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
1
4
|
module KeycloakApiRails
|
|
5
|
+
class MissingPublicKeysError < StandardError; end
|
|
6
|
+
|
|
2
7
|
class Service
|
|
3
|
-
|
|
8
|
+
|
|
4
9
|
def initialize(key_resolver)
|
|
10
|
+
configuration = KeycloakApiRails.config
|
|
5
11
|
@key_resolver = key_resolver
|
|
6
|
-
@skip_paths =
|
|
7
|
-
@opt_in =
|
|
8
|
-
@
|
|
9
|
-
@
|
|
12
|
+
@skip_paths = normalize_skip_paths(configuration.skip_paths, configuration.logger)
|
|
13
|
+
@opt_in = configuration.opt_in
|
|
14
|
+
@token_expiration_tolerance_in_seconds = configuration.token_expiration_tolerance_in_seconds
|
|
15
|
+
@expected_audiences = Array(configuration.expected_audience).map(&:to_s)
|
|
16
|
+
@expected_token_type = configuration.expected_token_type
|
|
17
|
+
@verify_not_before = configuration.verify_not_before
|
|
18
|
+
@allow_token_in_query_string = configuration.allow_token_in_query_string
|
|
10
19
|
end
|
|
11
20
|
|
|
12
21
|
def decode_and_verify(token)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
raise TokenError.no_token(token) if token.nil? || token.empty?
|
|
23
|
+
|
|
24
|
+
parts = token.to_s.split('.')
|
|
25
|
+
raise TokenError.invalid_format(token) if parts.length < 3
|
|
26
|
+
|
|
27
|
+
realm_id = extract_realm_from_token(token)
|
|
28
|
+
raise TokenError.invalid_realm(token) unless realm_allowed?(realm_id)
|
|
29
|
+
|
|
30
|
+
public_keys = @key_resolver.find_public_keys(realm_id)
|
|
31
|
+
|
|
32
|
+
if public_keys.nil?
|
|
33
|
+
raise MissingPublicKeysError, "No Keycloak public key is available to verify the token"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
decoded_token = decode(token, public_keys)
|
|
37
|
+
verify_claims!(token, decoded_token, realm_id)
|
|
38
|
+
decoded_token
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_realm_from_token(token)
|
|
42
|
+
payload_segment = token.split('.', 3)[1]
|
|
43
|
+
return nil unless payload_segment
|
|
44
|
+
|
|
45
|
+
decoded_payload = Base64.urlsafe_decode64(payload_segment)
|
|
46
|
+
parsed_payload = JSON.parse(decoded_payload)
|
|
47
|
+
iss = parsed_payload['iss']
|
|
48
|
+
return nil unless iss
|
|
49
|
+
|
|
50
|
+
iss.split('/').last
|
|
51
|
+
rescue JSON::ParserError, ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def realm_allowed?(realm_id)
|
|
56
|
+
config_realm_id = KeycloakApiRails.config.realm_id
|
|
57
|
+
return true if config_realm_id.nil?
|
|
58
|
+
return false if realm_id.nil?
|
|
59
|
+
|
|
60
|
+
if config_realm_id.respond_to?(:call)
|
|
61
|
+
config_realm_id.call(realm_id)
|
|
62
|
+
elsif config_realm_id.is_a?(Array)
|
|
63
|
+
config_realm_id.include?(realm_id)
|
|
23
64
|
else
|
|
24
|
-
|
|
65
|
+
config_realm_id == realm_id
|
|
25
66
|
end
|
|
26
|
-
rescue JSON::JWT::VerificationFailed => e
|
|
27
|
-
raise TokenError.verification_failed(token, e)
|
|
28
|
-
rescue JSON::JWK::Set::KidNotFound => e
|
|
29
|
-
raise TokenError.verification_failed(token, e)
|
|
30
|
-
rescue JSON::JWT::InvalidFormat
|
|
31
|
-
raise TokenError.invalid_format(token, e)
|
|
32
67
|
end
|
|
33
68
|
|
|
34
69
|
def read_token(uri, headers)
|
|
35
|
-
|
|
70
|
+
header_token = Helper.read_token_from_headers(headers)
|
|
71
|
+
if !header_token.empty?
|
|
72
|
+
header_token
|
|
73
|
+
elsif @allow_token_in_query_string
|
|
74
|
+
Helper.read_token_from_query_string(uri).to_s
|
|
75
|
+
else
|
|
76
|
+
""
|
|
77
|
+
end
|
|
36
78
|
end
|
|
37
79
|
|
|
38
80
|
def need_middleware_authentication?(method, path, headers)
|
|
@@ -41,20 +83,86 @@ module KeycloakApiRails
|
|
|
41
83
|
|
|
42
84
|
private
|
|
43
85
|
|
|
86
|
+
def decode(token, public_keys)
|
|
87
|
+
decoded_token = JSON::JWT.decode(token, public_keys)
|
|
88
|
+
decoded_token.verify!(public_keys)
|
|
89
|
+
decoded_token
|
|
90
|
+
rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
|
|
91
|
+
raise TokenError.verification_failed(token, e)
|
|
92
|
+
rescue JSON::JWT::InvalidFormat => e
|
|
93
|
+
raise TokenError.invalid_format(token, e)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
raise TokenError.unknown(token, e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# RFC 7519 requires 'exp' and 'nbf' to be NumericDates.
|
|
99
|
+
def verify_claims!(token, decoded_token, realm_id)
|
|
100
|
+
raise TokenError.missing_claim(token, "exp") unless decoded_token.key?("exp")
|
|
101
|
+
raise TokenError.invalid_claim(token, "exp") unless decoded_token["exp"].is_a?(Numeric)
|
|
102
|
+
raise TokenError.invalid_claim(token, "nbf") if not_before_is_invalid?(decoded_token)
|
|
103
|
+
raise TokenError.expired(token) if expired?(decoded_token)
|
|
104
|
+
raise TokenError.not_yet_valid(token) if not_yet_valid?(decoded_token)
|
|
105
|
+
raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
|
|
106
|
+
raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
|
|
107
|
+
|
|
108
|
+
if KeycloakApiRails.config.server_url
|
|
109
|
+
expected_iss = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id.to_s)
|
|
110
|
+
raise TokenError.invalid_realm(token) unless decoded_token["iss"] == expected_iss
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def not_before_is_invalid?(token)
|
|
115
|
+
@verify_not_before && token.key?("nbf") && !token["nbf"].is_a?(Numeric)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Anything that is not a regexp is discarded rather than matched: 'String#match' compiles its
|
|
119
|
+
# argument into a regexp, so a String would be matched against the path of the request instead of
|
|
120
|
+
# the other way around, and would open every path that is a sub-pattern of it. The railtie rejects
|
|
121
|
+
# such a configuration when the application boots; a Rack application running without Rails never
|
|
122
|
+
# calls 'validate!', so the paths keep being authenticated here.
|
|
123
|
+
def normalize_skip_paths(skip_paths, logger)
|
|
124
|
+
(skip_paths || {}).each_with_object({}) do |(method, paths), normalized|
|
|
125
|
+
regexps, discarded = Array(paths).partition { |path| path.is_a?(Regexp) }
|
|
126
|
+
|
|
127
|
+
unless discarded.empty?
|
|
128
|
+
logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{discarded.map(&:inspect).join(', ')}, which are not regexps. They are ignored, and the paths they were meant to open keep being authenticated.")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
normalized[method.to_s.upcase] = regexps
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
44
135
|
def should_skip?(method, path)
|
|
45
|
-
|
|
46
|
-
skip_paths
|
|
47
|
-
!skip_paths.nil? && !skip_paths.empty? && !skip_paths.find_index { |skip_path| skip_path.match(path) }.nil?
|
|
136
|
+
skip_paths = @skip_paths[method]
|
|
137
|
+
!skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
|
|
48
138
|
end
|
|
49
139
|
|
|
50
140
|
def is_preflight?(method, headers)
|
|
51
|
-
|
|
52
|
-
method_symbol == :options && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
|
|
141
|
+
method == "OPTIONS" && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
|
|
53
142
|
end
|
|
54
143
|
|
|
55
144
|
def expired?(token)
|
|
56
145
|
token_expiration = Time.at(token["exp"])
|
|
57
146
|
token_expiration < Time.now + @token_expiration_tolerance_in_seconds
|
|
58
147
|
end
|
|
148
|
+
|
|
149
|
+
def not_yet_valid?(token)
|
|
150
|
+
return false unless @verify_not_before
|
|
151
|
+
|
|
152
|
+
not_before = token["nbf"]
|
|
153
|
+
!not_before.nil? && Time.at(not_before) > Time.now
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def audience_valid?(token)
|
|
157
|
+
return true if @expected_audiences.empty?
|
|
158
|
+
|
|
159
|
+
Array(token["aud"]).any? { |audience| @expected_audiences.include?(audience.to_s) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def token_type_valid?(token)
|
|
163
|
+
return true if @expected_token_type.nil?
|
|
164
|
+
|
|
165
|
+
token["typ"].to_s.casecmp?(@expected_token_type)
|
|
166
|
+
end
|
|
59
167
|
end
|
|
60
168
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# Test helpers for the applications that authenticate their requests with this library.
|
|
2
4
|
#
|
|
3
5
|
# This file is *not* loaded by "keycloak-api-rails": it has to be required explicitly, so that
|
|
@@ -33,24 +35,26 @@ module KeycloakApiRails
|
|
|
33
35
|
@public_keys = public_keys
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def find_public_keys
|
|
38
|
+
def find_public_keys(realm_id = nil)
|
|
37
39
|
@public_keys
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
MONITOR = Monitor.new
|
|
44
|
+
|
|
41
45
|
class << self
|
|
42
46
|
# The RSA key pair used to sign the tokens forged by this module. It is generated once per
|
|
43
47
|
# process: generating a key is by far the slowest operation of a test suite that uses tokens.
|
|
44
48
|
def private_key
|
|
45
|
-
@private_key ||= OpenSSL::PKey::RSA.generate(KEY_SIZE)
|
|
49
|
+
MONITOR.synchronize { @private_key ||= OpenSSL::PKey::RSA.generate(KEY_SIZE) }
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def signing_key
|
|
49
|
-
@signing_key ||= JSON::JWK.new(private_key, kid: KEY_ID)
|
|
53
|
+
MONITOR.synchronize { @signing_key ||= JSON::JWK.new(private_key, kid: KEY_ID) }
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def public_keys
|
|
53
|
-
@public_keys ||= JSON::JWK::Set.new(JSON::JWK.new(private_key.public_key, kid: KEY_ID))
|
|
57
|
+
MONITOR.synchronize { @public_keys ||= JSON::JWK::Set.new(JSON::JWK.new(private_key.public_key, kid: KEY_ID)) }
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
# Makes the library validate the tokens forged by this module. Assigning
|
|
@@ -83,6 +87,12 @@ module KeycloakApiRails
|
|
|
83
87
|
payload["realm_access"] = { "roles" => roles.map(&:to_s) } unless roles.nil? || roles.empty?
|
|
84
88
|
payload["resource_access"] = build_resource_access(resource_roles) unless resource_roles.nil? || resource_roles.empty?
|
|
85
89
|
|
|
90
|
+
unless claims.key?(:iss) || claims.key?("iss")
|
|
91
|
+
config_realm_id = KeycloakApiRails.config.realm_id
|
|
92
|
+
realm_id = config_realm_id.is_a?(String) ? config_realm_id : "master"
|
|
93
|
+
payload["iss"] = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
86
96
|
claims.each { |name, value| payload[name.to_s] = value }
|
|
87
97
|
|
|
88
98
|
JSON::JWT.new(payload).sign(signing_key, ALGORITHM).to_s
|
|
@@ -1,30 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
attr_reader :token, :reason, :original_error
|
|
3
|
-
|
|
4
|
-
def initialize(token, reason, message, original_error)
|
|
5
|
-
super(message)
|
|
6
|
-
@token = token
|
|
7
|
-
@reason = reason
|
|
8
|
-
@original_error = original_error
|
|
9
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
10
2
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
module KeycloakApiRails
|
|
4
|
+
class TokenError < StandardError
|
|
5
|
+
attr_reader :token, :reason, :original_error
|
|
14
6
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
def initialize(token, reason, message, original_error = nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@token = token
|
|
10
|
+
@reason = reason
|
|
11
|
+
@original_error = original_error
|
|
12
|
+
end
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
def self.verification_failed(token, original_error)
|
|
15
|
+
new(token, :verification_failed, "Failed to verify JWT token", original_error)
|
|
16
|
+
end
|
|
22
17
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
def self.invalid_format(token, original_error = nil)
|
|
19
|
+
new(token, :invalid_format, "Wrong JWT Format", original_error)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.invalid_realm(token)
|
|
23
|
+
new(token, :invalid_realm, "JWT token does not have a valid realm")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.no_token(token)
|
|
27
|
+
new(token, :no_token, "No JWT token provided")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.expired(token)
|
|
31
|
+
new(token, :expired, "JWT token is expired")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.not_yet_valid(token)
|
|
35
|
+
new(token, :not_yet_valid, "JWT token is not valid yet")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.invalid_audience(token)
|
|
39
|
+
new(token, :invalid_audience, "JWT token has been issued for another audience")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.invalid_token_type(token)
|
|
43
|
+
new(token, :invalid_token_type, "JWT token is not of the expected type")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.missing_claim(token, claim)
|
|
47
|
+
new(token, :missing_claim, "JWT token does not carry the mandatory claim '#{claim}'")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.invalid_claim(token, claim)
|
|
51
|
+
new(token, :invalid_claim, "JWT token carries an invalid '#{claim}' claim: it must be a number of seconds since the Epoch")
|
|
52
|
+
end
|
|
26
53
|
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
def self.unknown(token, original_error)
|
|
55
|
+
new(token, :unknown, "Failed to read JWT token", original_error)
|
|
56
|
+
end
|
|
29
57
|
end
|
|
30
58
|
end
|
data/lib/keycloak-api-rails.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "logger"
|
|
2
4
|
require "json/jwt"
|
|
3
5
|
require "uri"
|
|
4
6
|
require "date"
|
|
7
|
+
require "monitor"
|
|
5
8
|
require "net/http"
|
|
6
9
|
|
|
7
10
|
require_relative "keycloak-api-rails/authentication"
|
|
@@ -17,32 +20,50 @@ require_relative "keycloak-api-rails/railtie" if defined?(Rails)
|
|
|
17
20
|
|
|
18
21
|
module KeycloakApiRails
|
|
19
22
|
|
|
23
|
+
# These objects are memoized lazily, on the first request each process serves -- which several
|
|
24
|
+
# threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
|
|
25
|
+
# memoizations nest, 'service' needing 'public_key_resolver', which needs 'http_client'.
|
|
26
|
+
MONITOR = Monitor.new
|
|
27
|
+
|
|
20
28
|
def self.configure
|
|
21
|
-
|
|
29
|
+
MONITOR.synchronize do
|
|
30
|
+
yield @configuration ||= KeycloakApiRails::Configuration.new
|
|
31
|
+
discard_configured_objects
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.discard_configured_objects
|
|
36
|
+
@http_client = nil
|
|
37
|
+
@public_key_resolver = nil unless @public_key_resolver_assigned
|
|
38
|
+
@service = nil
|
|
22
39
|
end
|
|
40
|
+
private_class_method :discard_configured_objects
|
|
23
41
|
|
|
24
42
|
def self.config
|
|
25
43
|
@configuration
|
|
26
44
|
end
|
|
27
45
|
|
|
28
46
|
def self.http_client
|
|
29
|
-
@http_client ||= KeycloakApiRails::HTTPClient.new(config, logger)
|
|
47
|
+
MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
|
|
30
48
|
end
|
|
31
49
|
|
|
32
50
|
def self.public_key_resolver
|
|
33
|
-
@public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config)
|
|
51
|
+
MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
|
|
34
52
|
end
|
|
35
53
|
|
|
36
54
|
# Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
|
|
37
55
|
# Assigning nil restores the regular resolver. The memoized service is discarded, since it holds
|
|
38
56
|
# a reference to the resolver that is being replaced.
|
|
39
57
|
def self.public_key_resolver=(resolver)
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
MONITOR.synchronize do
|
|
59
|
+
@public_key_resolver = resolver
|
|
60
|
+
@public_key_resolver_assigned = !resolver.nil?
|
|
61
|
+
@service = nil
|
|
62
|
+
end
|
|
42
63
|
end
|
|
43
64
|
|
|
44
65
|
def self.service
|
|
45
|
-
@service ||= KeycloakApiRails::Service.new(public_key_resolver)
|
|
66
|
+
MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
|
|
46
67
|
end
|
|
47
68
|
|
|
48
69
|
def self.logger
|
|
@@ -59,6 +80,13 @@ module KeycloakApiRails
|
|
|
59
80
|
config.token_expiration_tolerance_in_seconds = 10
|
|
60
81
|
config.public_key_cache_ttl = 86400
|
|
61
82
|
config.custom_attributes = []
|
|
83
|
+
config.ca_certificate_file = nil
|
|
84
|
+
config.expected_audience = nil
|
|
85
|
+
config.expected_token_type = nil
|
|
86
|
+
config.verify_not_before = false
|
|
87
|
+
config.allow_token_in_query_string = false
|
|
88
|
+
config.http_open_timeout = 5
|
|
89
|
+
config.http_read_timeout = 5
|
|
62
90
|
end
|
|
63
91
|
end
|
|
64
92
|
|