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,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # @abstract
5
+ class FlexibleStruct < Dry::Struct
6
+ transform_keys(&:to_sym)
7
+
8
+ transform_types do |type|
9
+ # :nocov:
10
+ if type.default?
11
+ type.constructor do |value|
12
+ value.nil? ? Dry::Types::Undefined : value
13
+ end
14
+ else
15
+ type
16
+ end
17
+ # :nocov:
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # @note Adapted from monadic HTTP client in another project
5
+ # @api private
6
+ class HTTPClient
7
+ include Dry::Monads[:do, :result]
8
+
9
+ include Import[config: "keycloak-rack.config", server_url: "keycloak-rack.server_url", x509_store: "keycloak-rack.x509_store"]
10
+
11
+ # @param [String] realm_id
12
+ # @param [String] path
13
+ # @return [Dry::Monads::Success(Net::HTTPSuccess)] on a successful request
14
+ # @return [Dry::Monads::Failure(Symbol, String, Net::HTTPResponse)] on a failure
15
+ def get(realm_id, path)
16
+ uri = build_uri realm_id, path
17
+
18
+ request = Net::HTTP::Get.new(uri)
19
+
20
+ call request
21
+ end
22
+
23
+ # @param [String] realm_id
24
+ # @param [String] path
25
+ # @return [Dry::Monads::Success({ Symbol => Object })] on a successful request
26
+ # @return [Dry::Monads::Failure(:invalid_response, String, Net::HTTPResponse)] if the JSON fails to parse
27
+ # @return [Dry::Monads::Failure(Symbol, String, Net::HTTPResponse)] on a failure
28
+ def get_json(realm_id, path)
29
+ response = yield get realm_id, path
30
+
31
+ parse_json response
32
+ end
33
+
34
+ # @param [Net::HTTPRequest] request
35
+ # @return [Dry::Monads::Success(Net::HTTPSuccess)] on a successful request
36
+ # @return [Dry::Monads::Failure(Symbol, String, Net::HTTPResponse)] on a failure
37
+ def call(request)
38
+ # :nocov:
39
+ return Failure[:invalid_request, "Not a request: #{request.inspect}", nil] unless request.kind_of?(Net::HTTPRequest)
40
+
41
+ uri = request.uri
42
+
43
+ use_ssl = uri.scheme != "http"
44
+ # :nocov:
45
+
46
+ Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl, cert_store: x509_store) do |http|
47
+ response = http.request request
48
+
49
+ # :nocov:
50
+ case response
51
+ when Net::HTTPSuccess then Success response
52
+ when Net::HTTPBadRequest then Failure[:bad_request, "Bad Request", response]
53
+ when Net::HTTPUnauthorized then Failure[:unauthorized, "Unauthorized", response]
54
+ when Net::HTTPForbidden then Failure[:forbidden, "Forbidden", response]
55
+ when Net::HTTPNotFound then Failure[:not_found, "Not Found: #{uri}", response]
56
+ when Net::HTTPGatewayTimeout then Failure[:gateway_timeout, "Gateway Timeout", response]
57
+ when Net::HTTPClientError then Failure[:client_error, "Client Error: HTTP #{response.code}", response]
58
+ when Net::HTTPServerError then Failure[:server_error, "Server Error: HTTP #{response.code}", response]
59
+ else
60
+ Failure[:unknown_error, "Unknown Error", response]
61
+ end
62
+ # :nocov:
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # @param [String] realm_id
69
+ # @param [String] path
70
+ # @return [URI]
71
+ def build_uri(realm_id, path)
72
+ string_uri = File.join(server_url, "realms", realm_id, path)
73
+
74
+ URI(string_uri)
75
+ end
76
+
77
+ # @param [Net::HTTPResponse] response
78
+ # @return [Dry::Monads::Sucess({ Symbol => Object })] the deserialized JSON, should more or less always be a hash
79
+ # @return [Dry::Monads::Failure(:invalid_response, String, Net::HTTPResponse)] if the JSON fails to parse
80
+ def parse_json(response)
81
+ Success JSON.parse response.body, symbolize_names: true
82
+ rescue JSON::ParserError => e
83
+ Failure[:invalid_response, "Response was not valid JSON: #{e.message}", response]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Dependency injection injector
5
+ #
6
+ # @api private
7
+ # @!visibility private
8
+ Import = Dry::AutoInject(Container)
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Fetches the public key for a keycloak installation.
5
+ #
6
+ # @api private
7
+ class KeyFetcher
8
+ include Import[config: "keycloak-rack.config", http_client: "keycloak-rack.http_client"]
9
+
10
+ delegate :realm_id, to: :config
11
+
12
+ # @return [Dry::Monads::Success({ Symbol => Object })]
13
+ # @return [Dry::Monads::Failure(Symbol, String)]
14
+ def find_public_keys
15
+ http_client.get_json(realm_id, "protocol/openid-connect/certs").or do |(code, reason, response)|
16
+ Dry::Monads::Result::Failure[:invalid_public_keys, "Could not fetch public keys: #{reason.inspect}"]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # A caching resolver that wraps around {KeycloakRack::KeyFetcher} to cache its result
5
+ # for {KeycloakRack::Config#cache_ttl} seconds (default: 1.day)
6
+ #
7
+ # @api private
8
+ class KeyResolver
9
+ include Import[config: "keycloak-rack.config", fetcher: "keycloak-rack.key_fetcher"]
10
+
11
+ delegate :cache_ttl, to: :config
12
+
13
+ # @!attribute [r] cached_public_key_retrieved_at
14
+ # @return [ActiveSupport::TimeWithZone]
15
+ attr_reader :cached_public_key_retrieved_at
16
+
17
+ # @!attribute [r] cached_public_keys
18
+ # @return [Dry::Monads::Success({ Symbol => <{ Symbol => String }> })]
19
+ # @return [Dry::Monads::Failure]
20
+ attr_reader :cached_public_keys
21
+
22
+ def initialize(**)
23
+ super
24
+
25
+ @cached_public_keys = Dry::Monads.Failure("nothing fetched yet")
26
+ @cached_public_key_retrieved_at = 1.year.ago
27
+ end
28
+
29
+ # @see KeycloakRack::PublicKeyResolver#find_public_keys
30
+ # @return [Dry::Monads::Success({ Symbol => Object })]
31
+ # @return [Dry::Monads::Failure(Symbol, String)]
32
+ def find_public_keys
33
+ fetch! if should_refetch?
34
+
35
+ @cached_public_keys
36
+ end
37
+
38
+ def has_failed_fetch?
39
+ @cached_public_keys.failure?
40
+ end
41
+
42
+ def has_outdated_cache?
43
+ Time.current > @cached_public_key_expires_at
44
+ end
45
+
46
+ # @return [void]
47
+ def refresh!
48
+ fetch!
49
+ end
50
+
51
+ def should_refetch?
52
+ has_failed_fetch? || has_outdated_cache?
53
+ end
54
+
55
+ private
56
+
57
+ # @return [void]
58
+ def fetch!
59
+ @cached_public_keys = fetcher.find_public_keys
60
+ @cached_public_key_retrieved_at = Time.current
61
+ @cached_public_key_expires_at = @cached_public_key_retrieved_at + cache_ttl.seconds
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Rack middleware that calls {KeycloakRack::Authenticate} to process a keycloak token.
5
+ #
6
+ # Upon successful processing, it populates the following values into the rack environment
7
+ # for consumption later down the stack:
8
+ #
9
+ # - `keycloak:session`: An instance of {KeycloakRack::Session} that serves as the primary interface
10
+ # - `keycloak:authorize_realm`: An instance of {KeycloakRack::AuthorizeRealm} for authorizing realm-level roles
11
+ # - `keycloak:authorize_resource`: An instance of {KeycloakRack::AuthorizeResource} for authorizing resource-level roles
12
+ class Middleware
13
+ include Dry::Monads[:result]
14
+
15
+ include Import[authenticate: "keycloak-rack.authenticate", config: "keycloak-rack.config"]
16
+
17
+ # @param [#call] app the next component in the rack middleware stack
18
+ def initialize(app, **options)
19
+ super(**options)
20
+
21
+ @app = app
22
+ end
23
+
24
+ # Process the rack environment and inject the gem's interfaces into it.
25
+ #
26
+ # If the authentication is a monadic failure, and {KeycloakRack::Config#halt_on_auth_failure halt_on_auth_failure}
27
+ # is true, then it will short-circuit with {#authentication_failed}.
28
+ #
29
+ # @param [Hash] env the rack environment
30
+ # @return [Object]
31
+ def call(env)
32
+ result = authenticate.call(env)
33
+
34
+ return authentication_failed(env, result) if halt?(result)
35
+
36
+ session_opts = { skipped: false, auth_result: result }
37
+
38
+ case result
39
+ in Success[:authenticated, decoded_token]
40
+ session_opts[:token] = decoded_token
41
+ in Success[:skipped]
42
+ session_opts[:skipped] = true
43
+ else
44
+ # nothing to do
45
+ end
46
+
47
+ env["keycloak:session"] = session = KeycloakRack::Session.new(**session_opts)
48
+ env["keycloak:authorize_realm"] = session.authorize_realm
49
+ env["keycloak:authorize_resource"] = session.authorize_resource
50
+
51
+ @app.call(env)
52
+ end
53
+
54
+ private
55
+
56
+ # Build the authentication failure when short-circuiting.
57
+ #
58
+ # @note See {#build_failure_headers} and {#build_failure_body} for opportunities
59
+ # to override.
60
+ # @param [Hash] env the rack environment
61
+ # @param [Dry::Monads::Result] monad
62
+ # @return [(Integer, { String => String }, <String>)] rack response
63
+ def authentication_failed(env, monad)
64
+ status = build_failure_status env, monad
65
+
66
+ headers = build_failure_headers env, monad
67
+
68
+ body = build_failure_body env, monad
69
+
70
+ # :nocov:
71
+ body = body.to_json unless body.kind_of?(String)
72
+ # :nocov:
73
+
74
+ [
75
+ status,
76
+ headers,
77
+ [ body ]
78
+ ]
79
+ end
80
+
81
+ def build_failure_status(env, monad)
82
+ case monad
83
+ in Failure[:no_token, _]
84
+ 401
85
+ in Failure[:expired, String, String, Exception]
86
+ 403
87
+ in Failure[Symbol, String, String, Exception]
88
+ 400
89
+ else
90
+ 500
91
+ # nothing to do
92
+ end
93
+ end
94
+
95
+ # @todo Make customizable
96
+ # @param [Hash] env the rack environment
97
+ # @param [Dry::Monads::Result] monad
98
+ # @return [{ String => String }]
99
+ def build_failure_headers(env, monad)
100
+ {
101
+ "Content-Type" => "application/json"
102
+ }
103
+ end
104
+
105
+ # @todo Make customizable
106
+ # @note Currently uses GraphQL error format.
107
+ # @param [Hash] env the rack environment
108
+ # @param [Dry::Monads::Result] monad
109
+ # @return [String, #to_json]
110
+ def build_failure_body(env, monad)
111
+ _reason, message, _token, _original_error = monad.failure
112
+
113
+ {
114
+ errors: [
115
+ {
116
+ message: message,
117
+ extensions: {
118
+ code: "UNAUTHENTICATED"
119
+ }
120
+ }
121
+ ]
122
+ }
123
+ end
124
+
125
+ # @param [Dry::Monads::Result] result
126
+ def halt?(result)
127
+ return false unless result.failure?
128
+
129
+ config.halt_on_auth_failure?
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Railtie that gets autoloaded when Rails is detected in the environment.
5
+ #
6
+ # @api private
7
+ class Railtie < Rails::Railtie
8
+ railtie_name :keycloak_rack
9
+
10
+ initializer("keycloak_rack.insert_middleware") do |app|
11
+ app.config.middleware.use(KeycloakRack::Middleware)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # Read the bearer token from the `Authorization` token.
5
+ #
6
+ # @api private
7
+ class ReadToken
8
+ include Dry::Monads[:result]
9
+
10
+ include Import[config: "keycloak-rack.config"]
11
+
12
+ # The pattern to match bearer tokens with.
13
+ BEARER_TOKEN = /\ABearer (?<token>.+)\z/i.freeze
14
+
15
+ # @param [Hash, #[]] env
16
+ # @return [Dry::Monads::Success(String)] when a token is found
17
+ # @return [Dry::Monads::Success(nil)] when a token is not found, but unauthenticated requests are allowed
18
+ # @return [Dry::Monads::Failure(:no_token, String)]
19
+ def call(env)
20
+ found_token = read_from env
21
+
22
+ return Success(found_token) if found_token.present?
23
+
24
+ return Success(nil) if config.allow_anonymous?
25
+
26
+ Failure[:no_token, "No JWT provided"]
27
+ end
28
+
29
+ private
30
+
31
+ # @param [Hash] env the rack environment
32
+ # @option env [String] "HTTP_AUTHORIZATION" the Authorization header
33
+ # @return [String, nil]
34
+ def read_from(env)
35
+ match = BEARER_TOKEN.match env["HTTP_AUTHORIZATION"]
36
+
37
+ match&.[](:token)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # A type to define a map of {RoleMap}s keyed by resource type.
5
+ #
6
+ # @api private
7
+ ResourceRoleMap = Types::Hash.map(Types::String, RoleMap).default { { "account" => {} } }
8
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # PORO to interface with Keycloak roles.
5
+ class RoleMap < KeycloakRack::FlexibleStruct
6
+ # @!attribute [r] roles
7
+ # @return [<String>]
8
+ attribute :roles, Types::StringList
9
+
10
+ # @param [#to_s] name
11
+ def has_role?(name)
12
+ name.to_s.in? roles
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRack
4
+ # This serves as the primary interface for interacting with Rack and Rails applications,
5
+ # and an instance gets mounted into `keycloak:session` when the middleware processes.
6
+ class Session
7
+ extend Dry::Initializer
8
+
9
+ include Dry::Matcher.for(:authenticate!, with: Dry::Matcher::ResultMatcher)
10
+
11
+ option :auth_result, Types.Instance(Dry::Monads::Result)
12
+ option :skipped, Types::Bool, default: proc { false }
13
+ option :token, Types.Instance(KeycloakRack::DecodedToken).optional, optional: true
14
+ option :authorize_realm, Types.Interface(:call), default: proc { KeycloakRack::AuthorizeRealm.new self }
15
+ option :authorize_resource, Types.Interface(:call), default: proc { KeycloakRack::AuthorizeResource.new self }
16
+
17
+ delegate :has_realm_role?, :has_resource_role?, to: :token, allow_nil: true
18
+
19
+ alias skipped? skipped
20
+
21
+ # @return [Dry::Monads::Result]
22
+ def authenticate!
23
+ auth_result
24
+ end
25
+
26
+ # @return [Dry::Monads::Result]
27
+ def authorize_realm!(*args)
28
+ authorize_realm.call(*args)
29
+ end
30
+
31
+ # @return [Dry::Monads::Result]
32
+ def authorize_resource!(*args)
33
+ authorize_resource.call(*args)
34
+ end
35
+
36
+ def authenticated?
37
+ auth_result.success? && token.present?
38
+ end
39
+
40
+ def anonymous?
41
+ auth_result.success? && token.blank?
42
+ end
43
+ end
44
+ end