keycloak_rack 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +68 -0
  3. data/.gitignore +8 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +220 -0
  6. data/.ruby-version +1 -0
  7. data/.yardopts +7 -0
  8. data/Appraisals +16 -0
  9. data/CHANGELOG.md +10 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/Gemfile +5 -0
  12. data/LICENSE +19 -0
  13. data/README.md +288 -0
  14. data/Rakefile +10 -0
  15. data/bin/appraisal +29 -0
  16. data/bin/console +6 -0
  17. data/bin/fix-appraisals +14 -0
  18. data/bin/rake +29 -0
  19. data/bin/rspec +29 -0
  20. data/bin/rubocop +29 -0
  21. data/bin/yard +29 -0
  22. data/bin/yardoc +29 -0
  23. data/bin/yri +29 -0
  24. data/gemfiles/rack_only.gemfile +5 -0
  25. data/gemfiles/rack_only.gemfile.lock +204 -0
  26. data/gemfiles/rails_6_0.gemfile +9 -0
  27. data/gemfiles/rails_6_0.gemfile.lock +323 -0
  28. data/gemfiles/rails_6_1.gemfile +9 -0
  29. data/gemfiles/rails_6_1.gemfile.lock +326 -0
  30. data/keycloak_rack.gemspec +56 -0
  31. data/lib/keycloak_rack.rb +59 -0
  32. data/lib/keycloak_rack/authenticate.rb +115 -0
  33. data/lib/keycloak_rack/authorize_realm.rb +53 -0
  34. data/lib/keycloak_rack/authorize_resource.rb +54 -0
  35. data/lib/keycloak_rack/config.rb +84 -0
  36. data/lib/keycloak_rack/container.rb +53 -0
  37. data/lib/keycloak_rack/decoded_token.rb +191 -0
  38. data/lib/keycloak_rack/flexible_struct.rb +20 -0
  39. data/lib/keycloak_rack/http_client.rb +86 -0
  40. data/lib/keycloak_rack/import.rb +9 -0
  41. data/lib/keycloak_rack/key_fetcher.rb +20 -0
  42. data/lib/keycloak_rack/key_resolver.rb +64 -0
  43. data/lib/keycloak_rack/middleware.rb +132 -0
  44. data/lib/keycloak_rack/railtie.rb +14 -0
  45. data/lib/keycloak_rack/read_token.rb +40 -0
  46. data/lib/keycloak_rack/resource_role_map.rb +8 -0
  47. data/lib/keycloak_rack/role_map.rb +15 -0
  48. data/lib/keycloak_rack/session.rb +44 -0
  49. data/lib/keycloak_rack/skip_authentication.rb +44 -0
  50. data/lib/keycloak_rack/types.rb +42 -0
  51. data/lib/keycloak_rack/version.rb +6 -0
  52. data/lib/keycloak_rack/with_config.rb +15 -0
  53. data/spec/dummy/.ruby-version +1 -0
  54. data/spec/dummy/README.md +24 -0
  55. data/spec/dummy/Rakefile +8 -0
  56. data/spec/dummy/app/controllers/application_controller.rb +22 -0
  57. data/spec/dummy/app/controllers/test_controller.rb +9 -0
  58. data/spec/dummy/config.ru +8 -0
  59. data/spec/dummy/config/application.rb +52 -0
  60. data/spec/dummy/config/boot.rb +3 -0
  61. data/spec/dummy/config/environment.rb +7 -0
  62. data/spec/dummy/config/environments/development.rb +51 -0
  63. data/spec/dummy/config/environments/test.rb +51 -0
  64. data/spec/dummy/config/initializers/application_controller_renderer.rb +9 -0
  65. data/spec/dummy/config/initializers/backtrace_silencers.rb +10 -0
  66. data/spec/dummy/config/initializers/cors.rb +17 -0
  67. data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  68. data/spec/dummy/config/initializers/inflections.rb +17 -0
  69. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  70. data/spec/dummy/config/initializers/wrap_parameters.rb +11 -0
  71. data/spec/dummy/config/keycloak.yml +12 -0
  72. data/spec/dummy/config/locales/en.yml +33 -0
  73. data/spec/dummy/config/routes.rb +5 -0
  74. data/spec/dummy/public/robots.txt +1 -0
  75. data/spec/dummy/tmp/development_secret.txt +1 -0
  76. data/spec/factories/decoded_token.rb +18 -0
  77. data/spec/factories/session.rb +21 -0
  78. data/spec/factories/token_payload.rb +40 -0
  79. data/spec/keycloak_rack/authorize_realm_spec.rb +15 -0
  80. data/spec/keycloak_rack/authorize_resource_spec.rb +19 -0
  81. data/spec/keycloak_rack/decoded_token_spec.rb +31 -0
  82. data/spec/keycloak_rack/key_resolver_spec.rb +95 -0
  83. data/spec/keycloak_rack/middleware_spec.rb +172 -0
  84. data/spec/keycloak_rack/rails_integration_spec.rb +43 -0
  85. data/spec/keycloak_rack/session_spec.rb +37 -0
  86. data/spec/keycloak_rack/skip_authentication_spec.rb +55 -0
  87. data/spec/spec_helper.rb +101 -0
  88. data/spec/support/contexts/mocked_keycloak.rb +63 -0
  89. data/spec/support/contexts/mocked_rack_application.rb +41 -0
  90. data/spec/support/test_key.pem +27 -0
  91. data/spec/support/token_helper.rb +76 -0
  92. metadata +616 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # The core service that handles authenticating a request from Keycloak.
5
+ #
6
+ # @example
7
+ # class ApplicationController < ActionController::API
8
+ # before_action :authenticate_user!
9
+ #
10
+ # # @return [void]
11
+ # def authenticate_user!
12
+ # # KeycloakRack::Session#authenticate! implements a Dry::Matcher::ResultMatcher
13
+ # request.env["keycloak:session"].authenticate! do |m|
14
+ # m.success(:authenticated) do |_, token|
15
+ # # this is the case when a user is successfully authenticated
16
+ #
17
+ # # token will be a KeycloakRack::DecodedToken instance, a
18
+ # # hash-like PORO that maps a number of values from the
19
+ # # decoded JWT that can be used to find or upsert a user
20
+ #
21
+ # attrs = decoded_token.slice(:keycloak_id, :email, :email_verified, :realm_access, :resource_access)
22
+ #
23
+ # result = User.upsert attrs, returning: %i[id], unique_by: %i[keycloak_id]
24
+ #
25
+ # @current_user = User.find result.first["id"]
26
+ # end
27
+ #
28
+ # m.success do
29
+ # # When allow_anonymous is true, or
30
+ # # a URI is skipped because of skip_paths, this
31
+ # # case will be reached. Requests from here on
32
+ # # out should be considered anonymous and treated
33
+ # # accordingly
34
+ #
35
+ # @current_user = AnonymousUser.new
36
+ # end
37
+ #
38
+ # m.failure do |code, reason|
39
+ # # All authentication failures are reached here,
40
+ # # assuming halt_on_auth_failure is set to false
41
+ # # This allows the application to decide how it
42
+ # # wants to respond
43
+ #
44
+ # render json: { errors: [{ message: "Auth Failure" }] }, status: :forbidden
45
+ # end
46
+ # end
47
+ # end
48
+ # end
49
+ class Authenticate
50
+ include Dry::Monads[:do, :result]
51
+
52
+ include Import[
53
+ config: "keycloak-rack.config",
54
+ key_resolver: "keycloak-rack.key_resolver",
55
+ read_token: "keycloak-rack.read_token",
56
+ skip_authentication: "keycloak-rack.skip_authentication"
57
+ ]
58
+
59
+ delegate :token_leeway, to: :config
60
+
61
+ # @param [Hash] env the rack environment
62
+ # @return [Dry::Monads::Success(:authenticated, KeycloakRack::DecodedToken)]
63
+ # @return [Dry::Monads::Success(:skipped, String)]
64
+ # @return [Dry::Monads::Success(:unauthenticated)]
65
+ # @return [Dry::Monads::Failure(:expired, String, String, Exception)]
66
+ # @return [Dry::Monads::Failure(:decoding_failed, String, String, Exception)]
67
+ def call(env)
68
+ return Success[:skipped] if yield skip_authentication.call(env)
69
+
70
+ token = yield read_token.call env
71
+
72
+ return Success[:unauthenticated] if token.blank?
73
+
74
+ decoded_token = yield decode_and_verify token
75
+
76
+ Success[:authenticated, decoded_token]
77
+ end
78
+
79
+ private
80
+
81
+ # @param [String] token
82
+ # @return [Dry::Monads::Success(KeycloakRack::DecodedToken)]
83
+ # @return [Dry::Monads::Failure(:expired, String, String, Exception)]
84
+ # @return [Dry::Monads::Failure(:decoding_failed, String, String, Exception)]
85
+ def decode_and_verify(token)
86
+ jwks = yield key_resolver.find_public_keys
87
+
88
+ algorithms = yield algorithms_for jwks
89
+
90
+ options = {
91
+ algorithms: algorithms,
92
+ leeway: token_leeway,
93
+ jwks: jwks
94
+ }
95
+
96
+ payload, headers = JWT.decode token, nil, true, options
97
+ rescue JWT::ExpiredSignature => e
98
+ Failure[:expired, "JWT is expired", token, e]
99
+ rescue JWT::DecodeError => e
100
+ Failure[:decoding_failed, "Failed to decode JWT", token, e]
101
+ else
102
+ Success DecodedToken.new payload.merge(original_payload: payload, headers: headers)
103
+ end
104
+
105
+ # @param [{ Symbol => <{ Symbol => String }> }] jwks
106
+ # @return [<String>]
107
+ def algorithms_for(jwks)
108
+ jwks.fetch(:keys, []).map do |k|
109
+ k[:alg]
110
+ end.uniq.compact.then do |algs|
111
+ algs.present? ? Success(algs) : Failure[:no_algorithms, "Could not derive algorithms from JWKS"]
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # A service that allows someone to check if the current token has a realm-level role.
5
+ #
6
+ # It is instantiated in `keycloak:authorize_realm` after the middleware runs.
7
+ #
8
+ # This can greatly simplify access control for rack services (for instance, to gate uploading files outside of Rails).
9
+ #
10
+ # @example
11
+ # class UploadProcessor
12
+ # def initialize(app)
13
+ # @app = app
14
+ # end
15
+ #
16
+ # def call(env)
17
+ # env["keycloak.authorize_realm"].call("upload_permission") do |m|
18
+ # m.success do
19
+ # # allow the upload to proceed
20
+ # end
21
+ #
22
+ # m.failure do
23
+ # # fail the response, return 403, etc
24
+ # end
25
+ # end
26
+ # end
27
+ # end
28
+ class AuthorizeRealm
29
+ extend Dry::Initializer
30
+
31
+ include Dry::Monads[:result]
32
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
33
+
34
+ param :session, Types.Instance(KeycloakRack::Session)
35
+
36
+ # Check to see if the current user session has a certain realm-level role.
37
+ #
38
+ # @see KeycloakRack::DecodedToken#has_realm_role?
39
+ # @param [String] role_name
40
+ # @return [Dry::Monads::Success(:authorized, String)]
41
+ # @return [Dry::Monads::Failure(:unauthorized, String)]
42
+ # @return [Dry::Monads::Failure(:unauthenticated, String)]
43
+ def call(role_name)
44
+ if session.has_realm_role?(role_name)
45
+ Success[:authorized, role_name]
46
+ elsif session.authenticated?
47
+ Failure[:unauthorized, "You do not have #{role_name.to_s.inspect} access"]
48
+ else
49
+ Failure[:unauthenticated, "You are not authenticated"]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # A service that allows someone to check if the current token has a resource-level role.
5
+ #
6
+ # It is instantiated in `keycloak:authorize_resource` after the middleware runs.
7
+ #
8
+ # This can greatly simplify access control for rack services (for instance, to gate modifications to a certain type of resource).
9
+ #
10
+ # @example
11
+ # class WidgetCombobulator
12
+ # def initialize(app)
13
+ # @app = app
14
+ # end
15
+ #
16
+ # def call(env)
17
+ # env["keycloak.authorize_resource"].call("widgets", "recombobulate") do |m|
18
+ # m.success do
19
+ # # allow the user to recombobulate the widget
20
+ # end
21
+ #
22
+ # m.failure do
23
+ # # return forbidden, log the attempt, etc
24
+ # end
25
+ # end
26
+ # end
27
+ # end
28
+ class AuthorizeResource
29
+ extend Dry::Initializer
30
+
31
+ include Dry::Monads[:result]
32
+ include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
33
+
34
+ param :session, Types.Instance(KeycloakRack::Session)
35
+
36
+ # Check that the current session has a certain resource role.
37
+ #
38
+ # @see KeycloakRack::DecodedToken#has_resource_role?
39
+ # @param [String] resource_name
40
+ # @param [String] role_name
41
+ # @return [Dry::Monads::Success(:authorized, String)]
42
+ # @return [Dry::Monads::Failure(:unauthorized, String)]
43
+ # @return [Dry::Monads::Failure(:unauthenticated, String)]
44
+ def call(resource_name, role_name)
45
+ if session.has_resource_role?(resource_name, role_name)
46
+ Success[:authorized, resource_name, role_name]
47
+ elsif session.authenticated?
48
+ Failure[:unauthorized, "You do not have #{role_name.to_s.inspect} access on #{resource_name.to_s.inspect}"]
49
+ else
50
+ Failure[:unauthenticated, "You are not authenticated"]
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Configuration model for KeycloakRack.
5
+ #
6
+ # Uses [anyway_config](https://github.com/palkan/anyway_config)
7
+ # to permit flexible approaches to configuration.
8
+ class Config < Anyway::Config
9
+ config_name "keycloak"
10
+
11
+ env_prefix "KEYCLOAK"
12
+
13
+ # @!attribute [rw] server_url
14
+ #
15
+ # The URL of your Keycloak installation. Be sure to include `/auth` if necessary.
16
+ #
17
+ # @note Required config value
18
+ # @return [String]
19
+ attr_config :server_url
20
+
21
+ # @!attribute [rw] realm_id
22
+ #
23
+ # The ID of the realm used to authenticate requests.
24
+ #
25
+ # @note Required config value
26
+ # @return [String]
27
+ attr_config :realm_id
28
+
29
+ # @!attribute [rw] ca_certificate_file
30
+ # The optional path to the CA Certificate to validate connections to a keycloak server.
31
+ # @return [String, nil]
32
+ attr_config :ca_certificate_file
33
+
34
+ # @!attribute [r] skip_paths
35
+ # @return [{ #to_s => <String, Regexp> }]
36
+ attr_config skip_paths: {}
37
+
38
+ # @!attribute [rw] token_leeway
39
+ # The number of seconds to allow for tokens to be expired to allow for clock drift.
40
+ #
41
+ # @see https://github.com/jwt/ruby-jwt#expiration-time-claim
42
+ # @return [Integer]
43
+ attr_config token_leeway: 10
44
+
45
+ # @!attribute [rw] cache_ttl
46
+ # The interval (in seconds) that cached public keys in {KeycloakRack::KeyResolver} should be cached.
47
+ # @return [Integer]
48
+ attr_config cache_ttl: 86_400
49
+
50
+ # @!attribute [rw] halt_on_auth_failure
51
+ # @return [Boolean]
52
+ attr_config halt_on_auth_failure: true
53
+
54
+ # @!attribute [rw] allow_anonymous
55
+ # @return [Boolean]
56
+ attr_config allow_anonymous: false
57
+
58
+ # required :server_url, :realm_id
59
+
60
+ def cache_ttl=(value)
61
+ super Types::Coercible::Integer[value]
62
+ end
63
+
64
+ def skip_paths=(value)
65
+ super Types::SkipPaths[value]
66
+ end
67
+
68
+ def token_leeway=(value)
69
+ super Types::Coercible::Integer[value]
70
+ end
71
+
72
+ # @api private
73
+ # @!visibility private
74
+ # @return [OpenSSL::X509::Store]
75
+ def build_x509_store
76
+ # :nocov:
77
+ OpenSSL::X509::Store.new.tap do |store|
78
+ store.set_default_paths
79
+ store.add_file(ca_certificate_file) if ca_certificate_file.present?
80
+ end
81
+ # :nocov:
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Dependency injection container for various `KeycloakRack` objects
5
+ #
6
+ # @api private
7
+ # @!visibility private
8
+ class Container
9
+ extend Dry::Container::Mixin
10
+
11
+ namespace "keycloak-rack" do
12
+ register :config do
13
+ # :nocov:
14
+ KeycloakRack::Config.new
15
+ # :nocov:
16
+ end
17
+
18
+ register :authenticate do
19
+ KeycloakRack::Authenticate.new
20
+ end
21
+
22
+ register :http_client do
23
+ KeycloakRack::HTTPClient.new
24
+ end
25
+
26
+ register :key_fetcher do
27
+ KeycloakRack::KeyFetcher.new
28
+ end
29
+
30
+ register :key_resolver, memoize: true do
31
+ # :nocov:
32
+ KeycloakRack::KeyResolver.new
33
+ # :nocov:
34
+ end
35
+
36
+ register :read_token do
37
+ KeycloakRack::ReadToken.new
38
+ end
39
+
40
+ register :server_url do
41
+ resolve(:config).server_url
42
+ end
43
+
44
+ register :skip_authentication do
45
+ KeycloakRack::SkipAuthentication.new
46
+ end
47
+
48
+ register :x509_store do
49
+ resolve(:config).build_x509_store
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # PORO that wraps the result of decoding the JWT into something slightly more usable,
5
+ # with some type-safety and role checking features.
6
+ class DecodedToken < KeycloakRack::FlexibleStruct
7
+ # Mapping used to remap keys from a Keycloak JWT payload into something more legible.
8
+ # @api private
9
+ KEY_MAP = {
10
+ "allowed-origins" => :allowed_origins,
11
+ "auth_time" => :authorized_at,
12
+ "aud" => :audience,
13
+ "azp" => :authorized_party,
14
+ "exp" => :expires_at,
15
+ "iat" => :issued_at,
16
+ "typ" => :type,
17
+ }.with_indifferent_access.freeze
18
+
19
+ private_constant :KEY_MAP
20
+
21
+ transform_keys do |k|
22
+ KEY_MAP[k] || k.to_sym
23
+ end
24
+
25
+ # @!attribute [r] sub
26
+ # The user id / subject for the JWT. Corresponds to `user_id` in Keycloak's rest API,
27
+ # and suitable for linking your local user records to Keycloak's.
28
+ # @return [String]
29
+ attribute :sub, Types::String
30
+
31
+ # @!attribute [r] realm_access
32
+ # @return [KeycloakRack::RoleMap]
33
+ attribute :realm_access, RoleMap
34
+
35
+ # @!attribute [r] resource_access
36
+ # @return [{ String => KeycloakRack::RoleMap }]
37
+ attribute :resource_access, ResourceRoleMap
38
+
39
+ # @!attribute [r] email_verified
40
+ # @return [Boolean]
41
+ attribute? :email_verified, Types::Bool
42
+
43
+ # @!attribute [r] name
44
+ # @return [String, nil]
45
+ attribute? :name, Types::String.optional
46
+
47
+ # @!attribute [r] preferred_username
48
+ # @return [String, nil]
49
+ attribute? :preferred_username, Types::String.optional
50
+
51
+ # @!attribute [r] given_name
52
+ # @return [String, nil]
53
+ attribute? :given_name, Types::String.optional
54
+
55
+ # @!attribute [r] family_name
56
+ # @return [String, nil]
57
+ attribute? :family_name, Types::String.optional
58
+
59
+ # @!attribute [r] email
60
+ # @return [String, nil]
61
+ attribute? :email, Types::String.optional
62
+
63
+ # @!group Token Details
64
+
65
+ # @!attribute [r] expires_at
66
+ # The `exp` claim
67
+ # @return [Time]
68
+ attribute :expires_at, Types::Timestamp
69
+
70
+ # @!attribute [r] issued_at
71
+ # The `iat` claim
72
+ # @return [Time]
73
+ attribute :issued_at, Types::Timestamp
74
+
75
+ # @!attribute [r] authorized_at
76
+ # The `auth_time` value from Keycloak.
77
+ # @return [Time]
78
+ attribute :authorized_at, Types::Timestamp
79
+
80
+ # @!attribute [r] jti
81
+ # @return [String]
82
+ attribute :jti, Types::String
83
+
84
+ # @!attribute [r] audience
85
+ # @return [String]
86
+ attribute :audience, Types::String
87
+
88
+ # @!attribute [r] type
89
+ # The `typ` claim in the JWT. Keycloak sets this to `"JWT"`.
90
+ # @return [String]
91
+ attribute :type, Types::String
92
+
93
+ # @!attribute [r] authorized_party
94
+ # The `azp` claim
95
+ # @return [String]
96
+ attribute :authorized_party, Types::String
97
+
98
+ # @!attribute [r] nonce
99
+ # Cryptographic nonce for the token
100
+ # @return [String]
101
+ attribute :nonce, Types::String
102
+
103
+ # @!attribute [r] scope
104
+ # @return [String]
105
+ attribute :scope, Types::String
106
+
107
+ # @!attribute [r] session_state
108
+ # @return [String]
109
+ attribute :session_state, Types::String
110
+
111
+ # @!attribute [r] locale
112
+ # @return [String, nil]
113
+ attribute? :locale, Types::String.optional
114
+
115
+ # @!attribute [r] allowed_origins
116
+ # @return [<String>]
117
+ attribute :allowed_origins, Types::StringList
118
+
119
+ # @!attribute [r] headers
120
+ # The JWT headers, provided for debugging
121
+ # @return [ActiveSupport::HashWithindifferentAccess]
122
+ attribute? :headers, Types::IndifferentHash
123
+
124
+ # @!attribute [r] original_payload
125
+ # The original JWT payload, unmodified, for extracting potential additional attributes.
126
+ # @return [ActiveSupport::HashWithIndifferentAccess]
127
+ attribute? :original_payload, Types::IndifferentHash
128
+
129
+ # @!endgroup
130
+
131
+ alias keycloak_id sub
132
+
133
+ alias first_name given_name
134
+
135
+ alias last_name family_name
136
+
137
+ ALIASES = %i[keycloak_id first_name last_name].freeze
138
+
139
+ private_constant :ALIASES
140
+
141
+ delegate :attribute_names, to: :class
142
+
143
+ # @param [#to_sym] key
144
+ # @raise [KeycloakRack::DecodedToken::UnknownAttribute] if it is an unknown attribute
145
+ # @return [Object]
146
+ def fetch(key)
147
+ key = key.to_sym
148
+
149
+ if key.in?(attribute_names)
150
+ self[key]
151
+ elsif key.in?(ALIASES)
152
+ public_send(key)
153
+ elsif key.in?(original_payload)
154
+ original_payload[key]
155
+ else
156
+ raise UnknownAttribute, "Cannot fetch #{key.inspect}"
157
+ end
158
+ end
159
+
160
+ # Check if the current user has a certain realm role
161
+ #
162
+ # @param [#to_s] name
163
+ def has_realm_role?(name)
164
+ name.to_s.in? realm_access.roles
165
+ end
166
+
167
+ # Check if the user has a certain role on a certain resource.
168
+ #
169
+ # @param [#to_s] resource_name
170
+ # @param [#to_s] role_name
171
+ def has_resource_role?(resource_name, role_name)
172
+ resource_access[resource_name.to_s]&.has_role?(role_name)
173
+ end
174
+
175
+ # Extract keys into something hash-like
176
+ #
177
+ # @param [<String, Symbol>] keys
178
+ # @return [ActiveSupport::HashWithIndifferentAccess]
179
+ def slice(*keys)
180
+ keys.flatten!
181
+
182
+ keys.each_with_object({}.with_indifferent_access) do |key, h|
183
+ h[key] = fetch(key)
184
+ end
185
+ end
186
+
187
+ # An error raised by {KeycloakRack::DecodedToken#fetch} when
188
+ # trying to fetch something the token doesn't know about
189
+ class UnknownAttribute < KeyError; end
190
+ end
191
+ end