keycloak-api-rails 0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []