keycloak_rack 1.0.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.
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