keycloak-api-rails 0.6

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1fb2c6f355e3fc27fbb0110609f34fc4d17e34f0
4
+ data.tar.gz: 3f338ecbe4d68ecccf65904434399394b7f51a80
5
+ SHA512:
6
+ metadata.gz: 869df876da34f6cbb957a7c12f88975efc5147ddb4282813b30997aa5e9b229045f5e3fee056429a6990193909b3e864893d69787f67e09ade924816f5696b96
7
+ data.tar.gz: 1a1e5f069ad76e2eb83653bddff7da10b019097f06277f91793b6839e2ccf3fe91e1df3ff25a5a12caf32fdc4668570e853a964a64fee0d85c4c1eb8aabd009e
@@ -0,0 +1,3 @@
1
+ .bundle/
2
+ log/*.log
3
+ .byebug_history
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,11 @@
1
+ FROM ruby:2.3
2
+ RUN mkdir -p /usr/src/app/lib/keycloak-api-rails
3
+ WORKDIR /usr/src/app
4
+
5
+ COPY Gemfile /usr/src/app/
6
+ COPY Gemfile.lock /usr/src/app/
7
+ COPY keycloak-api-rails.gemspec /usr/src/app/
8
+ COPY lib/keycloak-api-rails/version.rb /usr/src/app/lib/keycloak-api-rails/
9
+ RUN bundle install
10
+ COPY . /usr/src/app
11
+ RUN bundle install
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,145 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ keycloak-api-rails (0.6)
5
+ json-jwt (~> 1.8, >= 1.8.3)
6
+ rails (>= 4.2)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ actioncable (5.1.4)
12
+ actionpack (= 5.1.4)
13
+ nio4r (~> 2.0)
14
+ websocket-driver (~> 0.6.1)
15
+ actionmailer (5.1.4)
16
+ actionpack (= 5.1.4)
17
+ actionview (= 5.1.4)
18
+ activejob (= 5.1.4)
19
+ mail (~> 2.5, >= 2.5.4)
20
+ rails-dom-testing (~> 2.0)
21
+ actionpack (5.1.4)
22
+ actionview (= 5.1.4)
23
+ activesupport (= 5.1.4)
24
+ rack (~> 2.0)
25
+ rack-test (>= 0.6.3)
26
+ rails-dom-testing (~> 2.0)
27
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
28
+ actionview (5.1.4)
29
+ activesupport (= 5.1.4)
30
+ builder (~> 3.1)
31
+ erubi (~> 1.4)
32
+ rails-dom-testing (~> 2.0)
33
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
34
+ activejob (5.1.4)
35
+ activesupport (= 5.1.4)
36
+ globalid (>= 0.3.6)
37
+ activemodel (5.1.4)
38
+ activesupport (= 5.1.4)
39
+ activerecord (5.1.4)
40
+ activemodel (= 5.1.4)
41
+ activesupport (= 5.1.4)
42
+ arel (~> 8.0)
43
+ activesupport (5.1.4)
44
+ concurrent-ruby (~> 1.0, >= 1.0.2)
45
+ i18n (~> 0.7)
46
+ minitest (~> 5.1)
47
+ tzinfo (~> 1.1)
48
+ arel (8.0.0)
49
+ bindata (2.4.1)
50
+ builder (3.2.3)
51
+ byebug (9.1.0)
52
+ concurrent-ruby (1.0.5)
53
+ crass (1.0.3)
54
+ diff-lcs (1.3)
55
+ erubi (1.7.0)
56
+ globalid (0.4.1)
57
+ activesupport (>= 4.2.0)
58
+ i18n (0.9.1)
59
+ concurrent-ruby (~> 1.0)
60
+ json-jwt (1.8.3)
61
+ activesupport
62
+ bindata
63
+ securecompare
64
+ url_safe_base64
65
+ loofah (2.1.1)
66
+ crass (~> 1.0.2)
67
+ nokogiri (>= 1.5.9)
68
+ mail (2.7.0)
69
+ mini_mime (>= 0.1.1)
70
+ method_source (0.9.0)
71
+ mini_mime (1.0.0)
72
+ mini_portile2 (2.3.0)
73
+ minitest (5.11.1)
74
+ nio4r (2.2.0)
75
+ nokogiri (1.8.1)
76
+ mini_portile2 (~> 2.3.0)
77
+ rack (2.0.3)
78
+ rack-test (0.8.2)
79
+ rack (>= 1.0, < 3)
80
+ rails (5.1.4)
81
+ actioncable (= 5.1.4)
82
+ actionmailer (= 5.1.4)
83
+ actionpack (= 5.1.4)
84
+ actionview (= 5.1.4)
85
+ activejob (= 5.1.4)
86
+ activemodel (= 5.1.4)
87
+ activerecord (= 5.1.4)
88
+ activesupport (= 5.1.4)
89
+ bundler (>= 1.3.0)
90
+ railties (= 5.1.4)
91
+ sprockets-rails (>= 2.0.0)
92
+ rails-dom-testing (2.0.3)
93
+ activesupport (>= 4.2.0)
94
+ nokogiri (>= 1.6)
95
+ rails-html-sanitizer (1.0.3)
96
+ loofah (~> 2.0)
97
+ railties (5.1.4)
98
+ actionpack (= 5.1.4)
99
+ activesupport (= 5.1.4)
100
+ method_source
101
+ rake (>= 0.8.7)
102
+ thor (>= 0.18.1, < 2.0)
103
+ rake (12.3.0)
104
+ rspec (3.7.0)
105
+ rspec-core (~> 3.7.0)
106
+ rspec-expectations (~> 3.7.0)
107
+ rspec-mocks (~> 3.7.0)
108
+ rspec-core (3.7.1)
109
+ rspec-support (~> 3.7.0)
110
+ rspec-expectations (3.7.0)
111
+ diff-lcs (>= 1.2.0, < 2.0)
112
+ rspec-support (~> 3.7.0)
113
+ rspec-mocks (3.7.0)
114
+ diff-lcs (>= 1.2.0, < 2.0)
115
+ rspec-support (~> 3.7.0)
116
+ rspec-support (3.7.0)
117
+ securecompare (1.0.0)
118
+ sprockets (3.7.1)
119
+ concurrent-ruby (~> 1.0)
120
+ rack (> 1, < 3)
121
+ sprockets-rails (3.2.1)
122
+ actionpack (>= 4.0)
123
+ activesupport (>= 4.0)
124
+ sprockets (>= 3.0.0)
125
+ thor (0.20.0)
126
+ thread_safe (0.3.6)
127
+ timecop (0.9.1)
128
+ tzinfo (1.2.4)
129
+ thread_safe (~> 0.1)
130
+ url_safe_base64 (0.2.2)
131
+ websocket-driver (0.6.5)
132
+ websocket-extensions (>= 0.1.0)
133
+ websocket-extensions (0.1.3)
134
+
135
+ PLATFORMS
136
+ ruby
137
+
138
+ DEPENDENCIES
139
+ byebug (= 9.1.0)
140
+ keycloak-api-rails!
141
+ rspec (= 3.7.0)
142
+ timecop (= 0.9.1)
143
+
144
+ BUNDLED WITH
145
+ 1.16.1
@@ -0,0 +1,20 @@
1
+ Copyright 2018
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,165 @@
1
+ # Keycloak-Rails-Api
2
+
3
+ This gem aims at validates Keycloak JWT token in Ruby On Rails APIs.
4
+
5
+ ## Token validation
6
+
7
+ Tokens send (through query strings or Authorization headers) to this Railtie Middleware are validated against a Keycloak public key. This public key is downloaded every day by default (this interval can be changed through `public_key_cache_ttl`).
8
+
9
+ ## Pass token to the API
10
+
11
+ * Method 1: By adding an `Authorization` HTTP Header with its value set to `Bearer <your token>`.
12
+ _e.g_ using curl: `curl -H "Authorization: Bearer <your-token>" https://api.pouet.io/api/more-pouets`
13
+ * Method 2: By providing the token via query string, especially via the parameter named `authorizationToken`. Keep in mind that this method is less secure (url are kept intact in your browser history, and so on...)
14
+ _e.g._ using curl: `curl https://api.pouet.io/api/more-pouets?authorizationToken<your-token>`
15
+
16
+ _If both method are used at the same time, The query string as a higher priority when reading given tokens._
17
+
18
+ ## When a token is validated
19
+
20
+ In Rails controller, the request `env` variables has two more properties:
21
+ * `keycloak:keycloak_id`
22
+ * `keycloak:roles`
23
+
24
+ They can be accessed using `Keycloak::Helper` methods.
25
+
26
+ ## Overall configuration options
27
+
28
+ All options have a default value. However, all of them can be changed in your initializer file.
29
+
30
+ | Option | Default Value | Type | Required? | Description | Example |
31
+ | ---- | ----- | ------ | ----- | ------ | ----- |
32
+ | `server_url` | `nil`| String | Required | The base url where your Keycloak server is located. This value can be retrieved in your Keycloak client configuration. | `auth:8080` |
33
+ | `realm_id` | `nil`| String | Required | Realm's name (not id, actually) | `master` |
34
+ | `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` | 
35
+ | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose the token must not be validatefd | `{ get: [/^\/health\/.+/] }`| 
36
+ | `token_expiration_tolerance_in_seconds` | `10`| Logger | Optional | Number of seconds a token can expire before being rejected by the API. | `15` | 
37
+ | `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to {project_name} to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. | `Rails.logger` | 
38
+
39
+ ## Configure it
40
+
41
+ Create a `keycloak.rb` file in your Rails `config/initializers` folder. For instance:
42
+
43
+ ```
44
+ Keycloak.configure do |config|
45
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
46
+ config.realm_id = ENV["KEYCLOAK_REALM_ID"]
47
+ config.logger = Rails.logger
48
+ config.skip_paths = {
49
+ post: [/^\/message/],
50
+ get: [/^\/locales/, /^\/health\/.+/]
51
+ }
52
+ end
53
+ ```
54
+
55
+ ## Use cases
56
+
57
+ Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
58
+
59
+ ### Keycloak Id
60
+
61
+ If you identify users using their Keycloak Id, this value can be read from your controllers using `Keycloak::Helper.current_user_id(request.env)`.
62
+
63
+ ```ruby
64
+ class AuthenticatedController < ApplicationController
65
+
66
+ def user
67
+ keycloak_id = Keycloak::Helper.current_user_id(request.env)
68
+ User.active.find_by(keycloak_id: keycloak_id)
69
+ end
70
+ end
71
+ ```
72
+
73
+ ### Roles
74
+
75
+ `Keycloak::Helper.current_user_roles` can be use against a Rails request to read user's roles.
76
+
77
+ For example, a controller can require users to be administrator (considering you defined an `application-admin` role):
78
+
79
+ ```ruby
80
+ class AdminController < ApplicationController
81
+
82
+ before_action :require_to_be_admin!
83
+
84
+ def require_to_be_admin!
85
+ if !current_user_roles.include?("application-admin")
86
+ render(json: { reason: "admin", message: "You have to be an administrator to access that endpoint." }, status: :forbidden)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def current_user_roles
93
+ Keycloak::Helper.current_user_roles(request.env)
94
+ end
95
+ end
96
+ ```
97
+
98
+ ### Create an URL where the token must be passed via query string
99
+
100
+ `Keycloak::Helper.create_url_with_token` method can be used to build an url from another, by adding a token as query string.
101
+
102
+ ```ruby
103
+ def example
104
+ Keycloak::Helper.create_url_with_token("https://api.pouet.io/api/more-pouets", "myToken")
105
+ end
106
+ ```
107
+
108
+ This should output `https://api.pouet.io/api/more-pouets?authorizationToken=myToken`.
109
+
110
+
111
+ ### Accessing Keycloak Service
112
+
113
+ A lazy-loaded service Keycloak::Service can be accessed using `Keycloak.service`.
114
+ For instance, to read a provided token:
115
+ ```ruby
116
+ class RenderTokenController < ApplicationController
117
+ def show
118
+ uri = request.env["REQUEST_URI"]
119
+ headers = request.env
120
+ token = Keycloak.service.read_token(uri, headers)
121
+ render json: { token: token }, status: :ok
122
+ end
123
+ end
124
+ ```
125
+
126
+ ## Writing integration tests
127
+
128
+ If you want to write controller tests in your codebase and that Keycloak is configured for these controllers, here is how to mock it.
129
+ These lines are based on tests written using `rspec`.
130
+
131
+ * First, create a private key. This key should be created once per test suite for performance matters.
132
+ ```ruby
133
+ config.before(:suite) do
134
+ $private_key = OpenSSL::PKey::RSA.generate(1024)
135
+ end
136
+ ```
137
+ * Then, in a `shared_context`, configure a lazy token based on your main user. (here, we assume you have a `user` variable with a `keycloak_id` property)
138
+ ```ruby
139
+ let(:jwt) do
140
+ claims = {
141
+ iat: Time.zone.now.to_i,
142
+ exp: (Time.zone.now + 1.day).to_i,
143
+ sub: user.keycloak_id,
144
+ }
145
+ token = JSON::JWT.new(claims)
146
+ token.kid = "default"
147
+ token.sign($private_key, :RS256).to_s
148
+ end
149
+ ```
150
+ * Finally, in the same `shared_context`, stub `Keycloak.public_key_resolver` to use a valid public key that is able to validate `jwt`:
151
+ ```ruby
152
+ before(:each) do
153
+ public_key_resolver = Keycloak.public_key_resolver
154
+ allow(public_key_resolver).to receive(:find_public_keys) { JSON::JWK::Set.new(JSON::JWK.new($private_key, kid: "default")) }
155
+ end
156
+ ```
157
+
158
+ ## How to execute library tests
159
+
160
+ From the `keycloak-rails-api` directory:
161
+
162
+ ```
163
+ $ docker build . -t keycloak-rails-api:test
164
+ $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec spec
165
+ ```
@@ -0,0 +1,23 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
3
+ require "keycloak-api-rails/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "keycloak-api-rails"
7
+ spec.version = Keycloak::VERSION
8
+ spec.authors = ["Lorent Lempereur"]
9
+ spec.email = ["lorent.lempereur.dev@gmail.com"]
10
+ spec.homepage = "https://github.com/looorent/keycloak-api-rails"
11
+ spec.summary = "Rails middleware that validates Authorization token emitted by Keycloak"
12
+ spec.description = "Rails middleware that validates Authorization token emitted by Keycloak"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.require_paths = ["lib"]
17
+
18
+ spec.add_dependency "rails", ">= 4.2"
19
+ spec.add_dependency "json-jwt", "~> 1.8", ">= 1.8.3"
20
+ spec.add_development_dependency "rspec", "3.7.0"
21
+ spec.add_development_dependency "timecop", "0.9.1"
22
+ spec.add_development_dependency "byebug", "9.1.0"
23
+ end
@@ -0,0 +1,49 @@
1
+ require "logger"
2
+ require "json/jwt"
3
+ require "uri"
4
+ require "date"
5
+
6
+ require_relative "keycloak-api-rails/configuration"
7
+ require_relative "keycloak-api-rails/token_error"
8
+ require_relative "keycloak-api-rails/helper"
9
+ require_relative "keycloak-api-rails/public_key_resolver"
10
+ require_relative "keycloak-api-rails/public_key_cached_resolver"
11
+ require_relative "keycloak-api-rails/service"
12
+ require_relative "keycloak-api-rails/middleware"
13
+ require_relative "keycloak-api-rails/railtie" if defined?(Rails)
14
+
15
+ module Keycloak
16
+
17
+ def self.configure
18
+ yield @configuration ||= Keycloak::Configuration.new
19
+ end
20
+
21
+ def self.config
22
+ @configuration
23
+ end
24
+
25
+ def self.public_key_resolver
26
+ @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(config)
27
+ end
28
+
29
+ def self.service
30
+ @service ||= Keycloak::Service.new(public_key_resolver)
31
+ end
32
+
33
+ def self.logger
34
+ config.logger
35
+ end
36
+
37
+ def self.load_configuration
38
+ configure do |config|
39
+ config.server_url = nil
40
+ config.realm_id = nil
41
+ config.logger = ::Logger.new(STDOUT)
42
+ config.skip_paths = {}
43
+ config.token_expiration_tolerance_in_seconds = 10
44
+ config.public_key_cache_ttl = 86400
45
+ end
46
+ end
47
+
48
+ load_configuration
49
+ end
@@ -0,0 +1,11 @@
1
+ module Keycloak
2
+ class Configuration
3
+ include ActiveSupport::Configurable
4
+ config_accessor :server_url
5
+ config_accessor :realm_id
6
+ config_accessor :skip_paths
7
+ config_accessor :token_expiration_tolerance_in_seconds
8
+ config_accessor :public_key_cache_ttl
9
+ config_accessor :logger
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ module Keycloak
2
+ class Helper
3
+
4
+ CURRENT_USER_ID_KEY = "keycloak:keycloak_id"
5
+ ROLES_KEY = "keycloak:roles"
6
+ QUERY_STRING_TOKEN_KEY = "authorizationToken"
7
+
8
+ def self.current_user_id(env)
9
+ env[CURRENT_USER_ID_KEY]
10
+ end
11
+
12
+ def self.assign_current_user_id(env, token)
13
+ env[CURRENT_USER_ID_KEY] = token["sub"]
14
+ end
15
+
16
+ def self.current_user_roles(env)
17
+ env[ROLES_KEY]
18
+ end
19
+
20
+ def self.assign_realm_roles(env, token)
21
+ env[ROLES_KEY] = token.dig("realm_access", "roles")
22
+ end
23
+
24
+ def self.read_token_from_query_string(uri)
25
+ parsed_uri = URI.parse(uri)
26
+ query = URI.decode_www_form(parsed_uri.query || "")
27
+ query_string_token = query.detect { |param| param.first == QUERY_STRING_TOKEN_KEY }
28
+ query_string_token&.second
29
+ end
30
+
31
+ def self.create_url_with_token(uri, token)
32
+ uri = URI(uri)
33
+ params = URI.decode_www_form(uri.query || "").reject { |query_string| query_string.first == QUERY_STRING_TOKEN_KEY }
34
+ params << [QUERY_STRING_TOKEN_KEY, token]
35
+ uri.query = URI.encode_www_form(params)
36
+ uri.to_s
37
+ end
38
+
39
+ def self.read_token_from_headers(headers)
40
+ headers["HTTP_AUTHORIZATION"]&.gsub(/^Bearer /, "") || ""
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,45 @@
1
+ module Keycloak
2
+
3
+ class Middleware
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ method = env["REQUEST_METHOD"]
10
+ path = env["PATH_INFO"]
11
+ uri = env["REQUEST_URI"]
12
+
13
+ if service.need_authentication?(method, path, env)
14
+ logger.debug("Start authentication for #{method} : #{path}")
15
+ token = service.read_token(uri, env)
16
+ decoded_token = service.decode_and_verify(token)
17
+ authentication_succeeded(env, decoded_token)
18
+ else
19
+ logger.debug("Skip authentication for #{method} : #{path}")
20
+ @app.call(env)
21
+ end
22
+ rescue TokenError => e
23
+ authentication_failed(e.message)
24
+ end
25
+
26
+ def authentication_failed(message)
27
+ logger.warn(message)
28
+ [401, {"Content-Type" => "application/json"}, [ { error: message }.to_json]]
29
+ end
30
+
31
+ def authentication_succeeded(env, decoded_token)
32
+ Helper.assign_current_user_id(env, decoded_token)
33
+ Helper.assign_realm_roles(env, decoded_token)
34
+ @app.call(env)
35
+ end
36
+
37
+ def service
38
+ Keycloak.service
39
+ end
40
+
41
+ def logger
42
+ Keycloak.logger
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ module Keycloak
2
+ class PublicKeyCachedResolver
3
+ attr_reader :cached_public_key_retrieved_at
4
+
5
+ def initialize(server_url, realm_id, public_key_cache_ttl)
6
+ @resolver = PublicKeyResolver.new(server_url, realm_id)
7
+ @public_key_cache_ttl = public_key_cache_ttl
8
+ @cached_public_keys = nil
9
+ @cached_public_key_retrieved_at = nil
10
+ end
11
+
12
+ def self.from_configuration(configuration)
13
+ PublicKeyCachedResolver.new(configuration.server_url, configuration.realm_id, configuration.public_key_cache_ttl)
14
+ end
15
+
16
+ def find_public_keys
17
+ if public_keys_are_outdated?
18
+ @cached_public_keys = @resolver.find_public_keys
19
+ @cached_public_key_retrieved_at = Time.now
20
+ end
21
+ @cached_public_keys
22
+ end
23
+
24
+ private
25
+
26
+ def public_keys_are_outdated?
27
+ @cached_public_keys.nil? || @cached_public_key_retrieved_at.nil? || Time.now > (@cached_public_key_retrieved_at + @public_key_cache_ttl.seconds)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ module Keycloak
2
+ class PublicKeyResolver
3
+ def initialize(server_url, realm_id)
4
+ @public_certificate_url = create_public_certificate_url(server_url, realm_id)
5
+ end
6
+
7
+ def find_public_keys
8
+ JSON::JWK::Set.new(JSON.parse(RestClient.get(@public_certificate_url).body)["keys"])
9
+ end
10
+
11
+ private
12
+
13
+ def create_realm_url(server_url, realm_id)
14
+ "#{server_url}/realms/#{realm_id}"
15
+ end
16
+
17
+ def create_public_certificate_url(server_url, realm_id)
18
+ "#{create_realm_url(server_url, realm_id)}/protocol/openid-connect/certs"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ module Keycloak
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :keycloak_api_rails
4
+
5
+ initializer("keycloak.insert_middleware") do |app|
6
+ app.config.middleware.use(Keycloak::Middleware)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ module Keycloak
2
+ class Service
3
+
4
+ def initialize(key_resolver)
5
+ @key_resolver = key_resolver
6
+ @skip_paths = Keycloak.config.skip_paths
7
+ @logger = Keycloak.config.logger
8
+ @token_expiration_tolerance_in_seconds = Keycloak.config.token_expiration_tolerance_in_seconds
9
+ end
10
+
11
+ def decode_and_verify(token)
12
+ unless token.nil? || token&.empty?
13
+ public_key = @key_resolver.find_public_keys
14
+ decoded_token = JSON::JWT.decode(token, public_key)
15
+
16
+ unless expired?(decoded_token)
17
+ decoded_token.verify!(public_key)
18
+ decoded_token
19
+ else
20
+ raise TokenError.expired(token)
21
+ end
22
+ else
23
+ raise TokenError.no_token(token)
24
+ end
25
+ rescue JSON::JWT::VerificationFailed => e
26
+ raise TokenError.verification_failed(token, e)
27
+ rescue JSON::JWK::Set::KidNotFound => e
28
+ raise TokenError.verification_failed(token, e)
29
+ rescue JSON::JWT::InvalidFormat
30
+ raise TokenError.invalid_format(token, e)
31
+ end
32
+
33
+ def read_token(uri, headers)
34
+ Helper.read_token_from_query_string(uri) || Helper.read_token_from_headers(headers)
35
+ end
36
+
37
+ def need_authentication?(method, path, headers)
38
+ !should_skip?(method, path) && !is_preflight?(method, headers)
39
+ end
40
+
41
+ private
42
+
43
+ def should_skip?(method, path)
44
+ method_symbol = method&.downcase&.to_sym
45
+ skip_paths = @skip_paths[method_symbol]
46
+ !skip_paths.nil? && !skip_paths.empty? && !skip_paths.find_index { |skip_path| skip_path.match(path) }.nil?
47
+ end
48
+
49
+ def is_preflight?(method, headers)
50
+ method_symbol = method&.downcase&.to_sym
51
+ method_symbol == :options && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
52
+ end
53
+
54
+ def expired?(token)
55
+ token_expiration = Time.at(token["exp"]).to_datetime
56
+ token_expiration < Time.now + @token_expiration_tolerance_in_seconds.seconds
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,30 @@
1
+ class TokenError < StandardError
2
+ attr_reader :token, :reason, :original_error
3
+
4
+ def initialize(token, reason, message, original_error)
5
+ super(message)
6
+ @token = token
7
+ @reason = reason
8
+ @original_error = original_error
9
+ end
10
+
11
+ def self.verification_failed(token, original_error)
12
+ TokenError.new(token, :verification_failed, "Failed to verify JWT token", original_error)
13
+ end
14
+
15
+ def self.invalid_format(token, original_error)
16
+ TokenError.new(token, :invalid_format, "Wrong JWT Format", original_error)
17
+ end
18
+
19
+ def self.no_token(token)
20
+ TokenError.new(token, :no_token, "No JWT token provided", nil)
21
+ end
22
+
23
+ def self.expired(token)
24
+ TokenError.new(token, :expired, "JWT token is expired", nil)
25
+ end
26
+
27
+ def self.unknown(token)
28
+ TokenError.new
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module Keycloak
2
+ VERSION = "0.6"
3
+ end
@@ -0,0 +1,33 @@
1
+ RSpec.describe Keycloak::Helper do
2
+ describe "#create_url_with_token" do
3
+
4
+ let(:uri) { "http://www.an-url.io" }
5
+ let(:token) { "aToken" }
6
+
7
+ before(:each) do
8
+ @url_with_token = Keycloak::Helper.create_url_with_token(uri, token)
9
+ end
10
+
11
+ context "when the uri has no query string yet" do
12
+ it "returns an url with the provided token" do
13
+ expect(@url_with_token).to eq "#{uri}?authorizationToken=#{token}"
14
+ end
15
+ end
16
+
17
+ context "when the uri already has no query strings" do
18
+ context "but no token yet" do
19
+ let(:uri) { "http://www.an-url.io?firstName=ouioui&lastName=nonnon" }
20
+ it "returns an url with all the query string and the token" do
21
+ expect(@url_with_token).to eq "#{uri}&authorizationToken=#{token}"
22
+ end
23
+ end
24
+
25
+ context "including a token" do
26
+ let(:uri) { "http://www.an-url.io?authorizationToken=ouioui&lastName=nonnon" }
27
+ it "returns an url with all the query string and the new token" do
28
+ expect(@url_with_token).to eq "http://www.an-url.io?lastName=nonnon&authorizationToken=#{token}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,80 @@
1
+ RSpec.describe Keycloak::Service do
2
+
3
+ let(:public_key_cache_ttl) { 86400 }
4
+ let(:server_url) { "whatever:8080" }
5
+ let(:realm_id) { "pouet" }
6
+ let!(:resolver) { Keycloak::PublicKeyCachedResolver.new(server_url, realm_id, public_key_cache_ttl) }
7
+
8
+ before(:each) do
9
+ resolver.instance_variable_set(:@resolver, Keycloak::PublicKeyResolverStub.new)
10
+ now = Time.local(2018, 1, 9, 12, 0, 0)
11
+ Timecop.freeze(now)
12
+ end
13
+
14
+ after(:each) do
15
+ Timecop.return
16
+ end
17
+
18
+ describe "#find_public_key" do
19
+ context "when there is no public key in cache yet" do
20
+ before(:each) do
21
+ @public_key = resolver.find_public_keys
22
+ end
23
+
24
+ it "returns a valid public key" do
25
+ expect(@public_key).to_not be_nil
26
+ end
27
+
28
+ it "sets the current time to the resolver" do
29
+ expect(resolver.cached_public_key_retrieved_at).to eq Time.now
30
+ end
31
+ end
32
+
33
+ context "when there is already a public key in cache" do
34
+ before(:each) do
35
+ @first_public_key = resolver.find_public_keys
36
+ @first_cached_public_key_retrieved_at = resolver.cached_public_key_retrieved_at
37
+ end
38
+
39
+ context "and no need to refresh it" do
40
+ before(:each) do
41
+ Timecop.freeze(Time.now + public_key_cache_ttl.seconds - 10.seconds)
42
+ @second_public_key = resolver.find_public_keys
43
+ @second_cached_public_key_retrieved_at = resolver.cached_public_key_retrieved_at
44
+ end
45
+
46
+ it "returns a valid public key" do
47
+ expect(@second_public_key).to_not be_nil
48
+ end
49
+
50
+ it "does not refresh the public key" do
51
+ expect(@second_public_key).to eq @first_public_key
52
+ end
53
+
54
+ it "does not refresh the public key retrieval time" do
55
+ expect(@first_cached_public_key_retrieved_at).to eq @second_cached_public_key_retrieved_at
56
+ end
57
+ end
58
+
59
+ context "and its TTL has expired" do
60
+ before(:each) do
61
+ Timecop.freeze(Time.now + public_key_cache_ttl.seconds + 10.seconds)
62
+ @second_public_key = resolver.find_public_keys
63
+ @second_cached_public_key_retrieved_at = resolver.cached_public_key_retrieved_at
64
+ end
65
+
66
+ it "returns a valid public key" do
67
+ expect(@second_public_key).to_not be_nil
68
+ end
69
+
70
+ it "refreshes the public key" do
71
+ expect(@second_public_key).to_not eq @first_public_key
72
+ end
73
+
74
+ it "refreshes the public key retrieval time" do
75
+ expect(@first_cached_public_key_retrieved_at).to_not eq @second_cached_public_key_retrieved_at
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,234 @@
1
+ RSpec.describe Keycloak::Service do
2
+
3
+ let!(:private_key) { OpenSSL::PKey::RSA.generate(2048) }
4
+ let!(:public_key) { private_key.public_key }
5
+ let!(:key_resolver) { Keycloak::PublicKeyCachedResolverStub.new(public_key) }
6
+ let!(:service) { Keycloak::Service.new(key_resolver) }
7
+
8
+ before(:each) do
9
+ now = Time.local(2018, 1, 9, 12, 0, 0)
10
+ Timecop.freeze(now)
11
+ end
12
+
13
+ after(:each) do
14
+ Timecop.return
15
+ end
16
+
17
+ describe "#decode_and_verify" do
18
+ def create_token(private_key, expiration_date, algorithm)
19
+ claim = {
20
+ iss: "Keycloak",
21
+ exp: expiration_date,
22
+ nbf: Time.local(2018, 1, 1, 0, 0, 0)
23
+ }
24
+ jws = JSON::JWT.new(claim).sign(private_key, algorithm)
25
+ jws.to_s
26
+ end
27
+
28
+ context "when token is nil" do
29
+ let(:token) { nil }
30
+ it "should raise an error :no_token" do
31
+ expect {
32
+ service.decode_and_verify(token)
33
+ }.to raise_error(TokenError, "No JWT token provided")
34
+ end
35
+ end
36
+
37
+ context "when token is an empty string" do
38
+ let(:token) { "" }
39
+ it "should raise an error :no_token" do
40
+ expect {
41
+ service.decode_and_verify(token)
42
+ }.to raise_error(TokenError, "No JWT token provided")
43
+ end
44
+ end
45
+
46
+ context "when token is in an invalid format" do
47
+ let(:token) { "coucou" }
48
+ it "should raise an error :invalid_format" do
49
+ expect {
50
+ service.decode_and_verify(token)
51
+ }.to raise_error(TokenError, "Wrong JWT Format")
52
+ end
53
+ end
54
+
55
+ context "when token is in a valid format" do
56
+ let(:algorithm) { :RS256 }
57
+ let(:expiration_date) { 1.week.from_now }
58
+
59
+ context "and token is generated by another private key" do
60
+ let(:another_private_key) { OpenSSL::PKey::RSA.generate(1024) }
61
+ let(:token) { create_token(another_private_key, expiration_date, algorithm) }
62
+
63
+ it "should raise an error :verification_failed" do
64
+ expect {
65
+ service.decode_and_verify(token)
66
+ }.to raise_error(TokenError, "Failed to verify JWT token")
67
+ end
68
+ end
69
+
70
+ context "and token is generated by the right private key" do
71
+ let(:token) { create_token(private_key, expiration_date, algorithm) }
72
+
73
+ context "and token is expired" do
74
+ let(:expiration_date) { Time.now - 2.days }
75
+
76
+ it "should raise an error :expiration_date" do
77
+ expect {
78
+ service.decode_and_verify(token)
79
+ }.to raise_error(TokenError, "JWT token is expired")
80
+ end
81
+ end
82
+
83
+ context "and token is not expired" do
84
+ let(:expiration_date) { Time.now + 2.days }
85
+
86
+ context "and token is encrypted using RS256" do
87
+ let(:algorithm) { :RS256 }
88
+
89
+ it "should return a not-nil decoded token" do
90
+ expect(service.decode_and_verify(token)).to_not be_nil
91
+ end
92
+ end
93
+
94
+ context "and token is encrypted using RS512" do
95
+ let(:algorithm) { :RS512 }
96
+
97
+ it "should return a not-nil decoded token" do
98
+ expect(service.decode_and_verify(token)).to_not be_nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ describe "#need_authentication?" do
107
+
108
+ let(:method) { nil }
109
+ let(:path) { nil }
110
+ let(:headers) { {} }
111
+
112
+
113
+ before(:each) do
114
+ Keycloak.config.skip_paths = {
115
+ post: [/^\/skip/],
116
+ get: [/^\/skip/]
117
+ }
118
+ @result = service.need_authentication?(method, path, headers)
119
+ end
120
+
121
+ context "when method is nil" do
122
+ let(:method) { nil }
123
+ let(:path) { "/do-not-skip" }
124
+ it "should return true" do
125
+ expect(@result).to be true
126
+ end
127
+ end
128
+
129
+ context "when path is nil" do
130
+ let(:method) { :get }
131
+ let(:path) { nil }
132
+ it "should return true" do
133
+ expect(@result).to be true
134
+ end
135
+ end
136
+
137
+ context "when method does not match the configuration" do
138
+ let(:method) { :put }
139
+ let(:path) { "/skip" }
140
+ it "should return true" do
141
+ expect(@result).to be true
142
+ end
143
+ end
144
+
145
+ context "when path does not match the configuration" do
146
+ let(:method) { :get }
147
+ let(:path) { "/do-not-skip" }
148
+ it "should return true" do
149
+ expect(@result).to be true
150
+ end
151
+ end
152
+
153
+ context "when method [get] and path do match the configuration" do
154
+ let(:method) { :get }
155
+ let(:path) { "/skip" }
156
+ it "should return false" do
157
+ expect(@result).to be false
158
+ end
159
+ end
160
+
161
+
162
+ context "when method [post] and path do match the configuration" do
163
+ let(:method) { :get }
164
+ let(:path) { "/skip" }
165
+ it "should return false" do
166
+ expect(@result).to be false
167
+ end
168
+ end
169
+
170
+ context "when the request is preflight" do
171
+ let(:method) { :options }
172
+ let(:headers) { { "HTTP_ACCESS_CONTROL_REQUEST_METHOD" => ["Authorization"] } }
173
+ let(:path) { "/do-not-skip" }
174
+ it "should return false" do
175
+ expect(@result).to be false
176
+ end
177
+ end
178
+ end
179
+
180
+ describe "#read_token" do
181
+ let(:query_string) { "" }
182
+ let(:url) { "http://api.service.com/api/health?aParameter=true#{query_string}" }
183
+ let(:headers) { {} }
184
+ let(:header_token) { "header_token" }
185
+ let(:query_string_token) { "query_string_token" }
186
+
187
+ before(:each) do
188
+ @token = service.read_token(url, headers)
189
+ end
190
+
191
+ context "when the token is provided in the Authorization headers" do
192
+ let(:headers) do
193
+ {
194
+ "HTTP_AUTHORIZATION" => "Bearer #{header_token}"
195
+ }
196
+ end
197
+ context "and not in the query string" do
198
+ let(:query_string) { "" }
199
+ it "returns the header token" do
200
+ expect(@token).to eq header_token
201
+ end
202
+ end
203
+
204
+ context "and also in the query string" do
205
+ let(:query_string) { "&authorizationToken=#{query_string_token}" }
206
+ it "returns the query string token" do
207
+ expect(@token).to eq query_string_token
208
+ end
209
+ end
210
+ end
211
+
212
+ context "when the token is not provided in the Authorization headers" do
213
+ let(:headers) do
214
+ {
215
+ "ANOTHER_HEADER" => header_token
216
+ }
217
+ end
218
+
219
+ context "and not in the query string" do
220
+ let(:query_string) { "" }
221
+ it "returns an empty token" do
222
+ expect(@token).to eq ""
223
+ end
224
+ end
225
+
226
+ context "but in the query string" do
227
+ let(:query_string) { "&authorizationToken=#{query_string_token}" }
228
+ it "returns the query string token" do
229
+ expect(@token).to eq query_string_token
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "../lib/keycloak-api-rails"
2
+ require_relative "support/rails_helper"
3
+ require_relative "support/public_key_cached_resolver_stub"
4
+ require_relative "support/public_key_resolver_stub"
5
+ require "timecop"
6
+ require "byebug"
7
+
8
+ RSpec.configure do |config|
9
+ config.include RailsHelper
10
+
11
+ config.expect_with :rspec do |expectations|
12
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ module Keycloak
2
+ class PublicKeyCachedResolverStub
3
+ def initialize(public_key)
4
+ @public_key = public_key
5
+ end
6
+
7
+ def find_public_keys
8
+ @public_key
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Keycloak
2
+ class PublicKeyResolverStub
3
+ def find_public_keys
4
+ OpenSSL::PKey::RSA.generate(1024).public_key
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ require "rails/all"
2
+
3
+ module RailsHelper
4
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keycloak-api-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.6'
5
+ platform: ruby
6
+ authors:
7
+ - Lorent Lempereur
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-01-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json-jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 1.8.3
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '1.8'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.8.3
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 3.7.0
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 3.7.0
61
+ - !ruby/object:Gem::Dependency
62
+ name: timecop
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '='
66
+ - !ruby/object:Gem::Version
67
+ version: 0.9.1
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - '='
73
+ - !ruby/object:Gem::Version
74
+ version: 0.9.1
75
+ - !ruby/object:Gem::Dependency
76
+ name: byebug
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - '='
80
+ - !ruby/object:Gem::Version
81
+ version: 9.1.0
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - '='
87
+ - !ruby/object:Gem::Version
88
+ version: 9.1.0
89
+ description: Rails middleware that validates Authorization token emitted by Keycloak
90
+ email:
91
+ - lorent.lempereur.dev@gmail.com
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - ".gitignore"
97
+ - ".rspec"
98
+ - Dockerfile
99
+ - Gemfile
100
+ - Gemfile.lock
101
+ - MIT-LICENSE
102
+ - README.md
103
+ - keycloak-api-rails.gemspec
104
+ - lib/keycloak-api-rails.rb
105
+ - lib/keycloak-api-rails/configuration.rb
106
+ - lib/keycloak-api-rails/helper.rb
107
+ - lib/keycloak-api-rails/middleware.rb
108
+ - lib/keycloak-api-rails/public_key_cached_resolver.rb
109
+ - lib/keycloak-api-rails/public_key_resolver.rb
110
+ - lib/keycloak-api-rails/railtie.rb
111
+ - lib/keycloak-api-rails/service.rb
112
+ - lib/keycloak-api-rails/token_error.rb
113
+ - lib/keycloak-api-rails/version.rb
114
+ - spec/keycloak-api-rails/helper_spec.rb
115
+ - spec/keycloak-api-rails/public_key_cached_resolver_spec.rb
116
+ - spec/keycloak-api-rails/service_spec.rb
117
+ - spec/spec_helper.rb
118
+ - spec/support/public_key_cached_resolver_stub.rb
119
+ - spec/support/public_key_resolver_stub.rb
120
+ - spec/support/rails_helper.rb
121
+ homepage: https://github.com/looorent/keycloak-api-rails
122
+ licenses:
123
+ - MIT
124
+ metadata: {}
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubyforge_project:
141
+ rubygems_version: 2.6.4
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: Rails middleware that validates Authorization token emitted by Keycloak
145
+ test_files: []