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.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/.rspec +2 -0
- data/Dockerfile +11 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +145 -0
- data/MIT-LICENSE +20 -0
- data/README.md +165 -0
- data/keycloak-api-rails.gemspec +23 -0
- data/lib/keycloak-api-rails.rb +49 -0
- data/lib/keycloak-api-rails/configuration.rb +11 -0
- data/lib/keycloak-api-rails/helper.rb +43 -0
- data/lib/keycloak-api-rails/middleware.rb +45 -0
- data/lib/keycloak-api-rails/public_key_cached_resolver.rb +30 -0
- data/lib/keycloak-api-rails/public_key_resolver.rb +21 -0
- data/lib/keycloak-api-rails/railtie.rb +9 -0
- data/lib/keycloak-api-rails/service.rb +59 -0
- data/lib/keycloak-api-rails/token_error.rb +30 -0
- data/lib/keycloak-api-rails/version.rb +3 -0
- data/spec/keycloak-api-rails/helper_spec.rb +33 -0
- data/spec/keycloak-api-rails/public_key_cached_resolver_spec.rb +80 -0
- data/spec/keycloak-api-rails/service_spec.rb +234 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/support/public_key_cached_resolver_stub.rb +11 -0
- data/spec/support/public_key_resolver_stub.rb +7 -0
- data/spec/support/rails_helper.rb +4 -0
- metadata +145 -0
checksums.yaml
ADDED
|
@@ -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
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Dockerfile
ADDED
|
@@ -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
data/Gemfile.lock
ADDED
|
@@ -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
|
data/MIT-LICENSE
ADDED
|
@@ -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.
|
data/README.md
ADDED
|
@@ -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,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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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
|
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: []
|