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,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