keycloak_rack 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +68 -0
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.rubocop.yml +220 -0
- data/.ruby-version +1 -0
- data/.yardopts +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +10 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +5 -0
- data/LICENSE +19 -0
- data/README.md +288 -0
- data/Rakefile +10 -0
- data/bin/appraisal +29 -0
- data/bin/console +6 -0
- data/bin/fix-appraisals +14 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/yard +29 -0
- data/bin/yardoc +29 -0
- data/bin/yri +29 -0
- data/gemfiles/rack_only.gemfile +5 -0
- data/gemfiles/rack_only.gemfile.lock +204 -0
- data/gemfiles/rails_6_0.gemfile +9 -0
- data/gemfiles/rails_6_0.gemfile.lock +323 -0
- data/gemfiles/rails_6_1.gemfile +9 -0
- data/gemfiles/rails_6_1.gemfile.lock +326 -0
- data/keycloak_rack.gemspec +56 -0
- data/lib/keycloak_rack.rb +59 -0
- data/lib/keycloak_rack/authenticate.rb +115 -0
- data/lib/keycloak_rack/authorize_realm.rb +53 -0
- data/lib/keycloak_rack/authorize_resource.rb +54 -0
- data/lib/keycloak_rack/config.rb +84 -0
- data/lib/keycloak_rack/container.rb +53 -0
- data/lib/keycloak_rack/decoded_token.rb +191 -0
- data/lib/keycloak_rack/flexible_struct.rb +20 -0
- data/lib/keycloak_rack/http_client.rb +86 -0
- data/lib/keycloak_rack/import.rb +9 -0
- data/lib/keycloak_rack/key_fetcher.rb +20 -0
- data/lib/keycloak_rack/key_resolver.rb +64 -0
- data/lib/keycloak_rack/middleware.rb +132 -0
- data/lib/keycloak_rack/railtie.rb +14 -0
- data/lib/keycloak_rack/read_token.rb +40 -0
- data/lib/keycloak_rack/resource_role_map.rb +8 -0
- data/lib/keycloak_rack/role_map.rb +15 -0
- data/lib/keycloak_rack/session.rb +44 -0
- data/lib/keycloak_rack/skip_authentication.rb +44 -0
- data/lib/keycloak_rack/types.rb +42 -0
- data/lib/keycloak_rack/version.rb +6 -0
- data/lib/keycloak_rack/with_config.rb +15 -0
- data/spec/dummy/.ruby-version +1 -0
- data/spec/dummy/README.md +24 -0
- data/spec/dummy/Rakefile +8 -0
- data/spec/dummy/app/controllers/application_controller.rb +22 -0
- data/spec/dummy/app/controllers/test_controller.rb +9 -0
- data/spec/dummy/config.ru +8 -0
- data/spec/dummy/config/application.rb +52 -0
- data/spec/dummy/config/boot.rb +3 -0
- data/spec/dummy/config/environment.rb +7 -0
- data/spec/dummy/config/environments/development.rb +51 -0
- data/spec/dummy/config/environments/test.rb +51 -0
- data/spec/dummy/config/initializers/application_controller_renderer.rb +9 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +10 -0
- data/spec/dummy/config/initializers/cors.rb +17 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/spec/dummy/config/initializers/inflections.rb +17 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +11 -0
- data/spec/dummy/config/keycloak.yml +12 -0
- data/spec/dummy/config/locales/en.yml +33 -0
- data/spec/dummy/config/routes.rb +5 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/dummy/tmp/development_secret.txt +1 -0
- data/spec/factories/decoded_token.rb +18 -0
- data/spec/factories/session.rb +21 -0
- data/spec/factories/token_payload.rb +40 -0
- data/spec/keycloak_rack/authorize_realm_spec.rb +15 -0
- data/spec/keycloak_rack/authorize_resource_spec.rb +19 -0
- data/spec/keycloak_rack/decoded_token_spec.rb +31 -0
- data/spec/keycloak_rack/key_resolver_spec.rb +95 -0
- data/spec/keycloak_rack/middleware_spec.rb +172 -0
- data/spec/keycloak_rack/rails_integration_spec.rb +43 -0
- data/spec/keycloak_rack/session_spec.rb +37 -0
- data/spec/keycloak_rack/skip_authentication_spec.rb +55 -0
- data/spec/spec_helper.rb +101 -0
- data/spec/support/contexts/mocked_keycloak.rb +63 -0
- data/spec/support/contexts/mocked_rack_application.rb +41 -0
- data/spec/support/test_key.pem +27 -0
- data/spec/support/token_helper.rb +76 -0
- 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
|