keycloak-api-rails 1.1.0 → 1.1.2
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 +4 -4
- data/CHANGELOG.md +10 -0
- data/MIT-LICENSE +1 -1
- data/README.md +63 -20
- data/keycloak-api-rails.gemspec +15 -2
- data/lib/keycloak-api-rails/testing.rb +118 -0
- data/lib/keycloak-api-rails/version.rb +1 -1
- data/lib/keycloak-api-rails.rb +8 -0
- metadata +26 -20
- data/.github/workflows/ci.yml +0 -35
- data/.gitignore +0 -8
- data/.rspec +0 -2
- data/Dockerfile +0 -13
- data/Gemfile +0 -3
- data/spec/keycloak-api-rails/activesupport_free_spec.rb +0 -18
- data/spec/keycloak-api-rails/authentication_spec.rb +0 -70
- data/spec/keycloak-api-rails/configuration_spec.rb +0 -101
- data/spec/keycloak-api-rails/helper_spec.rb +0 -94
- data/spec/keycloak-api-rails/public_key_cached_resolver_spec.rb +0 -80
- data/spec/keycloak-api-rails/service_spec.rb +0 -275
- data/spec/spec_helper.rb +0 -14
- data/spec/support/activesupport_free_probe.rb +0 -131
- data/spec/support/public_key_cached_resolver_stub.rb +0 -11
- data/spec/support/public_key_resolver_stub.rb +0 -7
- data/spec/support/rails_helper.rb +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74dec51fa027308c2cf5b57ca67b7316b21677bf1592022f76988f232bbde911
|
|
4
|
+
data.tar.gz: 4836a7951bbf1559ff5eec0dfeb004ff8bcd2955cee7e06ee9e8a553743ad99e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48b3307f4dff316dfa063deddf0d106bbcc409856c20f0af5c441bdc3b1163e9cf93f0c3f7e53fd1a90fcb7946744c2b1b8863fd5a61a0c4012fe3d7df6b5aee
|
|
7
|
+
data.tar.gz: 81aca7224bcae644dd35f6fb715bdb447b0da549bc3da0bdd311a6bea06adfba468936aabcdaeb305a44839c6770a46644abfdf81f2f828246414880e9ab52dd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.2] - 2026-08-01
|
|
9
|
+
|
|
10
|
+
* Gem metadata
|
|
11
|
+
* The published gem no longer ships non-production code
|
|
12
|
+
|
|
13
|
+
## [1.1.1] - 2026-07-31
|
|
14
|
+
|
|
15
|
+
* New test helpers, in `keycloak-api-rails/testing`: `KeycloakApiRails::Testing.stub_public_keys!` validates the tokens forged by `keycloak_token` and `keycloak_auth_headers`, so that controller and request tests can be authenticated without a Keycloak server, and without the boilerplate the README used to describe. This file is not loaded by `keycloak-api-rails` and has to be required explicitly
|
|
16
|
+
* Publish the gem to RubyGems from Github Actions when a `v*` tag is pushed, using RubyGems' Trusted Publishing
|
|
17
|
+
|
|
8
18
|
## [1.1.0] - 2026-07-31
|
|
9
19
|
|
|
10
20
|
* Dependencies: Upgrade test dependency `timecop` to `0.9.11`
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@ the recommended path.
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```ruby
|
|
21
|
-
gem "keycloak-api-rails", "1.1.
|
|
21
|
+
gem "keycloak-api-rails", "1.1.2"
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Token validation
|
|
@@ -191,36 +191,60 @@ end
|
|
|
191
191
|
|
|
192
192
|
## Writing integration tests
|
|
193
193
|
|
|
194
|
-
If you want to write controller tests in your codebase and that Keycloak is configured for these controllers,
|
|
194
|
+
If you want to write controller or request tests in your codebase and that Keycloak is configured for these controllers,
|
|
195
|
+
this gem provides the helpers that forge valid tokens, so that no Keycloak server is required.
|
|
196
|
+
These helpers are not loaded by `keycloak-api-rails`: they have to be required explicitly, so that nothing they define ends up in a production process.
|
|
195
197
|
These lines are based on tests written using `rspec`.
|
|
196
198
|
|
|
197
|
-
* First, create a private key. This key should be created once per test suite for performance matters.
|
|
198
199
|
```ruby
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
# spec/rails_helper.rb
|
|
201
|
+
require "keycloak-api-rails/testing"
|
|
202
|
+
|
|
203
|
+
RSpec.configure do |config|
|
|
204
|
+
config.include KeycloakApiRails::Testing::Helpers
|
|
205
|
+
config.before(:suite) { KeycloakApiRails::Testing.stub_public_keys! }
|
|
201
206
|
end
|
|
202
207
|
```
|
|
203
|
-
|
|
208
|
+
|
|
209
|
+
`stub_public_keys!` makes the library validate the tokens forged by these helpers, instead of fetching public keys from a Keycloak server.
|
|
210
|
+
`server_url` and `realm_id` therefore do not have to be configured in the test environment. Requests can then be authenticated with:
|
|
211
|
+
|
|
204
212
|
```ruby
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
sub: user.keycloak_id,
|
|
210
|
-
}
|
|
211
|
-
token = JSON::JWT.new(claims)
|
|
212
|
-
token.kid = "default"
|
|
213
|
-
token.sign($private_key, :RS256).to_s
|
|
213
|
+
it "returns the current user" do
|
|
214
|
+
get "/me", headers: keycloak_auth_headers(sub: user.keycloak_id)
|
|
215
|
+
|
|
216
|
+
expect(response).to have_http_status(:ok)
|
|
214
217
|
end
|
|
215
218
|
```
|
|
216
|
-
|
|
219
|
+
|
|
220
|
+
`keycloak_auth_headers` returns an `Authorization` header, and `keycloak_token` returns the token alone.
|
|
221
|
+
Both accept the same optional parameters, one for each claim this library reads:
|
|
222
|
+
|
|
223
|
+
| Parameter | Claim | Default |
|
|
224
|
+
| --- | --- | --- |
|
|
225
|
+
| `sub` | `sub` | a random UUID |
|
|
226
|
+
| `email` | `email` | none |
|
|
227
|
+
| `locale` | `locale` | none |
|
|
228
|
+
| `authorized_party` | `azp` | none |
|
|
229
|
+
| `roles` | `realm_access.roles` | none |
|
|
230
|
+
| `resource_roles` | `resource_access` | none |
|
|
231
|
+
| `issued_at` | `iat` | now |
|
|
232
|
+
| `expires_at` | `exp` | one hour after `issued_at` |
|
|
233
|
+
| `claims` | any other claim | none |
|
|
234
|
+
|
|
235
|
+
For instance, to authenticate a request as a user having the realm role `admin`, the role `reader` on the client `a-client`,
|
|
236
|
+
and a `tenant_id` attribute declared in `config.custom_attributes`:
|
|
237
|
+
|
|
217
238
|
```ruby
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
239
|
+
headers = keycloak_auth_headers(sub: user.keycloak_id,
|
|
240
|
+
roles: ["admin"],
|
|
241
|
+
resource_roles: { "a-client" => ["reader"] },
|
|
242
|
+
claims: { "tenant_id" => tenant.id })
|
|
222
243
|
```
|
|
223
244
|
|
|
245
|
+
An expired token is forged by passing an `expires_at` in the past, which is how a `401` can be tested.
|
|
246
|
+
Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular resolver.
|
|
247
|
+
|
|
224
248
|
## How to execute library tests
|
|
225
249
|
|
|
226
250
|
From the `keycloak-rails-api` directory:
|
|
@@ -230,6 +254,25 @@ From the `keycloak-rails-api` directory:
|
|
|
230
254
|
$ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec spec
|
|
231
255
|
```
|
|
232
256
|
|
|
257
|
+
## How to release a new version
|
|
258
|
+
|
|
259
|
+
Releases are published to [RubyGems](https://rubygems.org/gems/keycloak-api-rails) by GitHub Actions
|
|
260
|
+
(`.github/workflows/release.yml`), through [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/):
|
|
261
|
+
no API key is stored in this repository, the workflow exchanges a short-lived GitHub OIDC token for a
|
|
262
|
+
scoped RubyGems credential.
|
|
263
|
+
|
|
264
|
+
1. Update `KeycloakApiRails::VERSION` in `lib/keycloak-api-rails/version.rb` and the `CHANGELOG.md`
|
|
265
|
+
2. Commit and push these changes to `main`
|
|
266
|
+
3. Tag the commit and push the tag:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
$ git tag -a v1.2.0 -m "Version 1.2.0"
|
|
270
|
+
$ git push origin v1.2.0
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
|
|
274
|
+
and pushes it. It only publishes tags starting with `v`.
|
|
275
|
+
|
|
233
276
|
## Next developments
|
|
234
277
|
|
|
235
278
|
* Manage multiple realms
|
data/keycloak-api-rails.gemspec
CHANGED
|
@@ -9,10 +9,21 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["lorent.lempereur.dev@gmail.com"]
|
|
10
10
|
spec.homepage = "https://github.com/looorent/keycloak-api-rails"
|
|
11
11
|
spec.summary = "Rails middleware that validates Authorization token emitted by Keycloak"
|
|
12
|
-
spec.description = "
|
|
12
|
+
spec.description = "Rack middleware that validates the Keycloak JWT access token carried by every " \
|
|
13
|
+
"request of a Ruby on Rails API. It verifies the token signature against the " \
|
|
14
|
+
"realm public keys, exposes the authenticated user, its roles and the custom " \
|
|
15
|
+
"attributes of the token to the controllers, and lets some routes opt out of " \
|
|
16
|
+
"authentication."
|
|
13
17
|
spec.license = "MIT"
|
|
14
18
|
|
|
15
|
-
spec.
|
|
19
|
+
spec.metadata = {
|
|
20
|
+
"source_code_uri" => spec.homepage,
|
|
21
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
22
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
23
|
+
"rubygems_mfa_required" => "true"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
spec.files = Dir.glob(["lib/**/*.rb", "CHANGELOG.md", "MIT-LICENSE", "README.md", "keycloak-api-rails.gemspec"])
|
|
16
27
|
spec.require_paths = ["lib"]
|
|
17
28
|
|
|
18
29
|
spec.required_ruby_version = ">= 2.7"
|
|
@@ -22,6 +33,8 @@ Gem::Specification.new do |spec|
|
|
|
22
33
|
|
|
23
34
|
spec.add_development_dependency "rspec", "3.13.2"
|
|
24
35
|
spec.add_development_dependency "timecop", "0.9.11"
|
|
36
|
+
# Required by 'rake release', which builds and publishes the gem from the CI.
|
|
37
|
+
spec.add_development_dependency "rake", ">= 13.0"
|
|
25
38
|
# Not pinned to an exact version: byebug 12 requires Ruby >= 3.1, byebug 13 requires Ruby >= 3.2.
|
|
26
39
|
# Older Rubies resolve to byebug 11.
|
|
27
40
|
spec.add_development_dependency "byebug", ">= 11.1.3"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Test helpers for the applications that authenticate their requests with this library.
|
|
2
|
+
#
|
|
3
|
+
# This file is *not* loaded by "keycloak-api-rails": it has to be required explicitly, so that
|
|
4
|
+
# nothing it defines can end up in a production process.
|
|
5
|
+
#
|
|
6
|
+
# # spec/rails_helper.rb
|
|
7
|
+
# require "keycloak-api-rails/testing"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include KeycloakApiRails::Testing::Helpers
|
|
11
|
+
# config.before(:suite) { KeycloakApiRails::Testing.stub_public_keys! }
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Requests can then be authenticated without a running Keycloak, and without configuring
|
|
15
|
+
# 'server_url' or 'realm_id':
|
|
16
|
+
#
|
|
17
|
+
# get "/me", headers: keycloak_auth_headers(sub: user.keycloak_id, roles: ["admin"])
|
|
18
|
+
|
|
19
|
+
require_relative "../keycloak-api-rails"
|
|
20
|
+
require "securerandom"
|
|
21
|
+
|
|
22
|
+
module KeycloakApiRails
|
|
23
|
+
module Testing
|
|
24
|
+
|
|
25
|
+
KEY_SIZE = 2048
|
|
26
|
+
KEY_ID = "keycloak-api-rails-testing"
|
|
27
|
+
ALGORITHM = :RS256
|
|
28
|
+
DEFAULT_VALIDITY_IN_SECONDS = 3600
|
|
29
|
+
|
|
30
|
+
# Returns the public keys forged by this module, instead of fetching them from a Keycloak server.
|
|
31
|
+
class PublicKeyResolver
|
|
32
|
+
def initialize(public_keys)
|
|
33
|
+
@public_keys = public_keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find_public_keys
|
|
37
|
+
@public_keys
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# The RSA key pair used to sign the tokens forged by this module. It is generated once per
|
|
43
|
+
# process: generating a key is by far the slowest operation of a test suite that uses tokens.
|
|
44
|
+
def private_key
|
|
45
|
+
@private_key ||= OpenSSL::PKey::RSA.generate(KEY_SIZE)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def signing_key
|
|
49
|
+
@signing_key ||= JSON::JWK.new(private_key, kid: KEY_ID)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def public_keys
|
|
53
|
+
@public_keys ||= JSON::JWK::Set.new(JSON::JWK.new(private_key.public_key, kid: KEY_ID))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Makes the library validate the tokens forged by this module. Assigning
|
|
57
|
+
# 'KeycloakApiRails.public_key_resolver = nil' restores the regular resolver.
|
|
58
|
+
def stub_public_keys!
|
|
59
|
+
KeycloakApiRails.public_key_resolver = PublicKeyResolver.new(public_keys)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Forges a signed token, similar to the ones emitted by Keycloak. Every claim this library
|
|
63
|
+
# reads has a dedicated parameter; any other claim can be passed through 'claims', which is
|
|
64
|
+
# also how the attributes declared in 'config.custom_attributes' are set.
|
|
65
|
+
def token_for(sub: SecureRandom.uuid,
|
|
66
|
+
email: nil,
|
|
67
|
+
locale: nil,
|
|
68
|
+
authorized_party: nil,
|
|
69
|
+
roles: [],
|
|
70
|
+
resource_roles: {},
|
|
71
|
+
issued_at: Time.now,
|
|
72
|
+
expires_at: nil,
|
|
73
|
+
claims: {})
|
|
74
|
+
payload = {
|
|
75
|
+
"sub" => sub,
|
|
76
|
+
"email" => email,
|
|
77
|
+
"locale" => locale,
|
|
78
|
+
"azp" => authorized_party,
|
|
79
|
+
"iat" => issued_at.to_i,
|
|
80
|
+
"exp" => (expires_at || (issued_at.to_i + DEFAULT_VALIDITY_IN_SECONDS)).to_i
|
|
81
|
+
}.reject { |_, value| value.nil? }
|
|
82
|
+
|
|
83
|
+
payload["realm_access"] = { "roles" => roles.map(&:to_s) } unless roles.nil? || roles.empty?
|
|
84
|
+
payload["resource_access"] = build_resource_access(resource_roles) unless resource_roles.nil? || resource_roles.empty?
|
|
85
|
+
|
|
86
|
+
claims.each { |name, value| payload[name.to_s] = value }
|
|
87
|
+
|
|
88
|
+
JSON::JWT.new(payload).sign(signing_key, ALGORITHM).to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The 'Authorization' header a client would send, e.g. { "Authorization" => "Bearer eyJ0..." }.
|
|
92
|
+
# It accepts the same parameters as '.token_for'.
|
|
93
|
+
def auth_headers_for(**options)
|
|
94
|
+
{ "Authorization" => "Bearer #{token_for(**options)}" }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def build_resource_access(resource_roles)
|
|
100
|
+
resource_roles.inject({}) do |resource_access, (name, roles)|
|
|
101
|
+
resource_access[name.to_s] = { "roles" => Array(roles).map(&:to_s) }
|
|
102
|
+
resource_access
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Meant to be included in a test suite, e.g. 'config.include KeycloakApiRails::Testing::Helpers'.
|
|
108
|
+
module Helpers
|
|
109
|
+
def keycloak_token(**options)
|
|
110
|
+
KeycloakApiRails::Testing.token_for(**options)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def keycloak_auth_headers(**options)
|
|
114
|
+
KeycloakApiRails::Testing.auth_headers_for(**options)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/keycloak-api-rails.rb
CHANGED
|
@@ -33,6 +33,14 @@ module KeycloakApiRails
|
|
|
33
33
|
@public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
|
|
37
|
+
# Assigning nil restores the regular resolver. The memoized service is discarded, since it holds
|
|
38
|
+
# a reference to the resolver that is being replaced.
|
|
39
|
+
def self.public_key_resolver=(resolver)
|
|
40
|
+
@public_key_resolver = resolver
|
|
41
|
+
@service = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
def self.service
|
|
37
45
|
@service ||= KeycloakApiRails::Service.new(public_key_resolver)
|
|
38
46
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keycloak-api-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lorent Lempereur
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - '='
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: 0.9.11
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: byebug
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -79,19 +93,17 @@ dependencies:
|
|
|
79
93
|
- - ">="
|
|
80
94
|
- !ruby/object:Gem::Version
|
|
81
95
|
version: 11.1.3
|
|
82
|
-
description:
|
|
96
|
+
description: Rack middleware that validates the Keycloak JWT access token carried
|
|
97
|
+
by every request of a Ruby on Rails API. It verifies the token signature against
|
|
98
|
+
the realm public keys, exposes the authenticated user, its roles and the custom
|
|
99
|
+
attributes of the token to the controllers, and lets some routes opt out of authentication.
|
|
83
100
|
email:
|
|
84
101
|
- lorent.lempereur.dev@gmail.com
|
|
85
102
|
executables: []
|
|
86
103
|
extensions: []
|
|
87
104
|
extra_rdoc_files: []
|
|
88
105
|
files:
|
|
89
|
-
- ".github/workflows/ci.yml"
|
|
90
|
-
- ".gitignore"
|
|
91
|
-
- ".rspec"
|
|
92
106
|
- CHANGELOG.md
|
|
93
|
-
- Dockerfile
|
|
94
|
-
- Gemfile
|
|
95
107
|
- MIT-LICENSE
|
|
96
108
|
- README.md
|
|
97
109
|
- keycloak-api-rails.gemspec
|
|
@@ -105,23 +117,17 @@ files:
|
|
|
105
117
|
- lib/keycloak-api-rails/public_key_resolver.rb
|
|
106
118
|
- lib/keycloak-api-rails/railtie.rb
|
|
107
119
|
- lib/keycloak-api-rails/service.rb
|
|
120
|
+
- lib/keycloak-api-rails/testing.rb
|
|
108
121
|
- lib/keycloak-api-rails/token_error.rb
|
|
109
122
|
- lib/keycloak-api-rails/version.rb
|
|
110
|
-
- spec/keycloak-api-rails/activesupport_free_spec.rb
|
|
111
|
-
- spec/keycloak-api-rails/authentication_spec.rb
|
|
112
|
-
- spec/keycloak-api-rails/configuration_spec.rb
|
|
113
|
-
- spec/keycloak-api-rails/helper_spec.rb
|
|
114
|
-
- spec/keycloak-api-rails/public_key_cached_resolver_spec.rb
|
|
115
|
-
- spec/keycloak-api-rails/service_spec.rb
|
|
116
|
-
- spec/spec_helper.rb
|
|
117
|
-
- spec/support/activesupport_free_probe.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
123
|
homepage: https://github.com/looorent/keycloak-api-rails
|
|
122
124
|
licenses:
|
|
123
125
|
- MIT
|
|
124
|
-
metadata:
|
|
126
|
+
metadata:
|
|
127
|
+
source_code_uri: https://github.com/looorent/keycloak-api-rails
|
|
128
|
+
changelog_uri: https://github.com/looorent/keycloak-api-rails/blob/main/CHANGELOG.md
|
|
129
|
+
bug_tracker_uri: https://github.com/looorent/keycloak-api-rails/issues
|
|
130
|
+
rubygems_mfa_required: 'true'
|
|
125
131
|
rdoc_options: []
|
|
126
132
|
require_paths:
|
|
127
133
|
- lib
|
|
@@ -136,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
136
142
|
- !ruby/object:Gem::Version
|
|
137
143
|
version: '0'
|
|
138
144
|
requirements: []
|
|
139
|
-
rubygems_version:
|
|
145
|
+
rubygems_version: 3.6.9
|
|
140
146
|
specification_version: 4
|
|
141
147
|
summary: Rails middleware that validates Authorization token emitted by Keycloak
|
|
142
148
|
test_files: []
|
data/.github/workflows/ci.yml
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
name: Ruby
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [ "main" ]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [ "main" ]
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: read
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
test:
|
|
14
|
-
name: Ruby ${{ matrix.ruby-version }}
|
|
15
|
-
# Pinned rather than 'ubuntu-latest': ruby/setup-ruby has no prebuilt Rubies for
|
|
16
|
-
# ubuntu-26.04 yet, so the oldest versions of the matrix would break once the
|
|
17
|
-
# 'latest' alias moves on.
|
|
18
|
-
runs-on: ubuntu-24.04
|
|
19
|
-
|
|
20
|
-
strategy:
|
|
21
|
-
fail-fast: false
|
|
22
|
-
matrix:
|
|
23
|
-
# Each version resolves the newest dependencies it supports: Rails 7.1 on Ruby 2.7
|
|
24
|
-
# and 3.0, Rails 7.2 on Ruby 3.1, Rails 8.1 on Ruby 3.2 and above.
|
|
25
|
-
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0']
|
|
26
|
-
|
|
27
|
-
steps:
|
|
28
|
-
- uses: actions/checkout@v4
|
|
29
|
-
- name: Set up Ruby
|
|
30
|
-
uses: ruby/setup-ruby@v1
|
|
31
|
-
with:
|
|
32
|
-
ruby-version: ${{ matrix.ruby-version }}
|
|
33
|
-
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
34
|
-
- name: Run tests
|
|
35
|
-
run: bundle exec rspec
|
data/.gitignore
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
.bundle/
|
|
2
|
-
log/*.log
|
|
3
|
-
.byebug_history
|
|
4
|
-
*.gem
|
|
5
|
-
# This gem supports several Ruby versions, each of them resolving a different set of
|
|
6
|
-
# dependencies (Ruby 2.7 caps Rails to 7.1, Ruby 3.1 to 7.2, Ruby 3.2+ resolves Rails 8.1).
|
|
7
|
-
# A single committed lockfile cannot satisfy all of them.
|
|
8
|
-
Gemfile.lock
|
data/.rspec
DELETED
data/Dockerfile
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
FROM ruby:3.4.10-slim-bookworm
|
|
2
|
-
|
|
3
|
-
RUN apt-get update -qq && apt-get install -y build-essential git ruby-dev && apt-get clean && \
|
|
4
|
-
mkdir -p /usr/src/app/lib/keycloak-api-rails
|
|
5
|
-
|
|
6
|
-
WORKDIR /usr/src/app
|
|
7
|
-
|
|
8
|
-
COPY Gemfile /usr/src/app/
|
|
9
|
-
COPY keycloak-api-rails.gemspec /usr/src/app/
|
|
10
|
-
COPY lib/keycloak-api-rails/version.rb /usr/src/app/lib/keycloak-api-rails/
|
|
11
|
-
RUN bundle install
|
|
12
|
-
COPY . /usr/src/app
|
|
13
|
-
RUN bundle install
|
data/Gemfile
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
require "rbconfig"
|
|
2
|
-
|
|
3
|
-
RSpec.describe "The library without ActiveSupport" do
|
|
4
|
-
|
|
5
|
-
let(:probe) { File.expand_path("../../support/activesupport_free_probe.rb", __FILE__) }
|
|
6
|
-
|
|
7
|
-
# RUBYOPT is cleared so that nothing set up by bundler is preloaded in the probe process.
|
|
8
|
-
def run_probe
|
|
9
|
-
output = IO.popen({ "RUBYOPT" => nil }, [RbConfig.ruby, probe], err: [:child, :out], &:read)
|
|
10
|
-
[$?.exitstatus, output]
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
it "loads and behaves the same way when ActiveSupport is not available" do
|
|
14
|
-
status, output = run_probe
|
|
15
|
-
|
|
16
|
-
expect(status).to eq(0), "The library relies on ActiveSupport:\n#{output}"
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
require "spec_helper"
|
|
2
|
-
|
|
3
|
-
describe KeycloakApiRails::Authentication do
|
|
4
|
-
class ExampleController < ActionController::Base
|
|
5
|
-
include KeycloakApiRails::Authentication
|
|
6
|
-
# Mark protected methods public so they may be called in tests
|
|
7
|
-
public(*KeycloakApiRails::Authentication.protected_instance_methods)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
let(:controller) { ExampleController.new }
|
|
11
|
-
let(:token) { 'keycloak_valid_token'}
|
|
12
|
-
let(:headers) do
|
|
13
|
-
{
|
|
14
|
-
'REQUEST_METHOD' => :get,
|
|
15
|
-
'REQUEST_URI' => 'http://www.an-url.io',
|
|
16
|
-
'HTTP_AUTHORIZATION' => "Bearer #{token}"
|
|
17
|
-
}
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
describe "when included" do
|
|
21
|
-
it "declares keycloak_authenticate as a helper method when the base class supports helpers" do
|
|
22
|
-
declared_helper_methods = []
|
|
23
|
-
base = Class.new do
|
|
24
|
-
define_singleton_method(:helper_method) { |*names| declared_helper_methods.concat(names) }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
base.include(KeycloakApiRails::Authentication)
|
|
28
|
-
|
|
29
|
-
expect(declared_helper_methods).to eq [:keycloak_authenticate]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
it "declares the helper method on a real controller" do
|
|
33
|
-
expect(ExampleController._helper_methods).to include :keycloak_authenticate
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
it "does not fail when the base class does not support helpers" do
|
|
37
|
-
expect {
|
|
38
|
-
Class.new.include(KeycloakApiRails::Authentication)
|
|
39
|
-
}.to_not raise_error
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
it "exposes its methods as protected instance methods" do
|
|
43
|
-
expect(KeycloakApiRails::Authentication.protected_instance_methods).to match_array %i[
|
|
44
|
-
keycloak_authenticate
|
|
45
|
-
authentication_failed
|
|
46
|
-
authentication_succeeded
|
|
47
|
-
]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it "does not add any public method to the including class" do
|
|
51
|
-
base = Class.new.include(KeycloakApiRails::Authentication)
|
|
52
|
-
|
|
53
|
-
expect(base.public_instance_methods).to_not include :keycloak_authenticate
|
|
54
|
-
expect(base.protected_instance_methods).to include :keycloak_authenticate
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
describe "#keycloak_authenticate" do
|
|
59
|
-
before do
|
|
60
|
-
# Mock request object because we aren't using real request spec
|
|
61
|
-
allow(controller).to receive(:request).and_return(double("request", env: headers ))
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
it "it authenticates with request header" do
|
|
65
|
-
expect_any_instance_of(KeycloakApiRails::Service).to receive(:decode_and_verify).with(token).and_return("A User")
|
|
66
|
-
expect(controller).to receive(:authentication_succeeded)
|
|
67
|
-
controller.keycloak_authenticate
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
RSpec.describe KeycloakApiRails::Configuration do
|
|
2
|
-
|
|
3
|
-
# Every attribute that used to be declared through ActiveSupport's `config_accessor`.
|
|
4
|
-
let(:attribute_names) do
|
|
5
|
-
%i[
|
|
6
|
-
server_url
|
|
7
|
-
realm_id
|
|
8
|
-
skip_paths
|
|
9
|
-
opt_in
|
|
10
|
-
token_expiration_tolerance_in_seconds
|
|
11
|
-
public_key_cache_ttl
|
|
12
|
-
custom_attributes
|
|
13
|
-
logger
|
|
14
|
-
ca_certificate_file
|
|
15
|
-
]
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
describe "attributes" do
|
|
19
|
-
let(:configuration) { KeycloakApiRails::Configuration.new }
|
|
20
|
-
|
|
21
|
-
it "exposes a reader and a writer for each attribute" do
|
|
22
|
-
attribute_names.each do |name|
|
|
23
|
-
expect(configuration).to respond_to name
|
|
24
|
-
expect(configuration).to respond_to "#{name}="
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
it "returns nil for each attribute that has not been assigned" do
|
|
29
|
-
attribute_names.each do |name|
|
|
30
|
-
expect(configuration.public_send(name)).to be_nil
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it "returns the value that has been assigned" do
|
|
35
|
-
attribute_names.each do |name|
|
|
36
|
-
configuration.public_send("#{name}=", "a value for #{name}")
|
|
37
|
-
expect(configuration.public_send(name)).to eq "a value for #{name}"
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
it "does not respond to an unknown attribute" do
|
|
42
|
-
expect(configuration).to_not respond_to :not_a_configuration_attribute
|
|
43
|
-
expect {
|
|
44
|
-
configuration.not_a_configuration_attribute
|
|
45
|
-
}.to raise_error NoMethodError
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
describe "#configure" do
|
|
50
|
-
after(:each) do
|
|
51
|
-
KeycloakApiRails.load_configuration
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
it "yields the configuration" do
|
|
55
|
-
expect { |block| KeycloakApiRails.configure(&block) }.to yield_with_args KeycloakApiRails::Configuration
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
it "keeps the values assigned within the block" do
|
|
59
|
-
KeycloakApiRails.configure do |config|
|
|
60
|
-
config.server_url = "http://keycloak:8080"
|
|
61
|
-
config.realm_id = "a-realm"
|
|
62
|
-
config.ca_certificate_file = "/etc/ssl/a-certificate.pem"
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
expect(KeycloakApiRails.config.server_url).to eq "http://keycloak:8080"
|
|
66
|
-
expect(KeycloakApiRails.config.realm_id).to eq "a-realm"
|
|
67
|
-
expect(KeycloakApiRails.config.ca_certificate_file).to eq "/etc/ssl/a-certificate.pem"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
it "always returns the same configuration instance" do
|
|
71
|
-
expect(KeycloakApiRails.config).to be KeycloakApiRails.config
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
describe "#load_configuration" do
|
|
76
|
-
before(:each) do
|
|
77
|
-
KeycloakApiRails.load_configuration
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
after(:each) do
|
|
81
|
-
KeycloakApiRails.load_configuration
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
it "applies the documented default values" do
|
|
85
|
-
config = KeycloakApiRails.config
|
|
86
|
-
|
|
87
|
-
expect(config.server_url).to be_nil
|
|
88
|
-
expect(config.realm_id).to be_nil
|
|
89
|
-
expect(config.logger).to be_a ::Logger
|
|
90
|
-
expect(config.skip_paths).to eq({})
|
|
91
|
-
expect(config.opt_in).to be false
|
|
92
|
-
expect(config.token_expiration_tolerance_in_seconds).to eq 10
|
|
93
|
-
expect(config.public_key_cache_ttl).to eq 86400
|
|
94
|
-
expect(config.custom_attributes).to eq []
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
it "exposes the logger through KeycloakApiRails.logger" do
|
|
98
|
-
expect(KeycloakApiRails.logger).to be KeycloakApiRails.config.logger
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
RSpec.describe KeycloakApiRails::Helper do
|
|
2
|
-
describe "#read_token_from_query_string" do
|
|
3
|
-
|
|
4
|
-
subject { KeycloakApiRails::Helper.read_token_from_query_string(uri) }
|
|
5
|
-
|
|
6
|
-
context "when the uri is nil" do
|
|
7
|
-
let(:uri) { nil }
|
|
8
|
-
it "returns an empty token" do
|
|
9
|
-
expect(subject).to eq ""
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
context "when the uri is empty" do
|
|
14
|
-
let(:uri) { "" }
|
|
15
|
-
it "returns an empty token" do
|
|
16
|
-
expect(subject).to eq ""
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
context "when the uri only contains whitespaces" do
|
|
21
|
-
let(:uri) { " " }
|
|
22
|
-
it "returns an empty token" do
|
|
23
|
-
expect(subject).to eq ""
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
context "when the uri has no query string" do
|
|
28
|
-
let(:uri) { "http://www.an-url.io/health" }
|
|
29
|
-
it "returns no token" do
|
|
30
|
-
expect(subject).to be_nil
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
context "when the uri has a query string but no token" do
|
|
35
|
-
let(:uri) { "http://www.an-url.io/health?firstName=ouioui" }
|
|
36
|
-
it "returns no token" do
|
|
37
|
-
expect(subject).to be_nil
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
context "when the uri has a token in the query string" do
|
|
42
|
-
let(:uri) { "http://www.an-url.io/health?authorizationToken=aToken" }
|
|
43
|
-
it "returns the token" do
|
|
44
|
-
expect(subject).to eq "aToken"
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
context "when the uri has a token among other query string parameters" do
|
|
49
|
-
let(:uri) { "http://www.an-url.io/health?firstName=ouioui&authorizationToken=aToken&lastName=nonnon" }
|
|
50
|
-
it "returns the token" do
|
|
51
|
-
expect(subject).to eq "aToken"
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
context "when the uri has an empty token in the query string" do
|
|
56
|
-
let(:uri) { "http://www.an-url.io/health?authorizationToken=" }
|
|
57
|
-
it "returns an empty token" do
|
|
58
|
-
expect(subject).to eq ""
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
describe "#create_url_with_token" do
|
|
64
|
-
|
|
65
|
-
let(:uri) { "http://www.an-url.io" }
|
|
66
|
-
let(:token) { "aToken" }
|
|
67
|
-
|
|
68
|
-
before(:each) do
|
|
69
|
-
@url_with_token = KeycloakApiRails::Helper.create_url_with_token(uri, token)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
context "when the uri has no query string yet" do
|
|
73
|
-
it "returns an url with the provided token" do
|
|
74
|
-
expect(@url_with_token).to eq "#{uri}?authorizationToken=#{token}"
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
context "when the uri already has no query strings" do
|
|
79
|
-
context "but no token yet" do
|
|
80
|
-
let(:uri) { "http://www.an-url.io?firstName=ouioui&lastName=nonnon" }
|
|
81
|
-
it "returns an url with all the query string and the token" do
|
|
82
|
-
expect(@url_with_token).to eq "#{uri}&authorizationToken=#{token}"
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
context "including a token" do
|
|
87
|
-
let(:uri) { "http://www.an-url.io?authorizationToken=ouioui&lastName=nonnon" }
|
|
88
|
-
it "returns an url with all the query string and the new token" do
|
|
89
|
-
expect(@url_with_token).to eq "http://www.an-url.io?lastName=nonnon&authorizationToken=#{token}"
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
RSpec.describe KeycloakApiRails::Service do
|
|
2
|
-
|
|
3
|
-
let(:public_key_cache_ttl) { 86400 }
|
|
4
|
-
let(:server_url) { "whatever:8080" }
|
|
5
|
-
let(:realm_id) { "pouet" }
|
|
6
|
-
let!(:resolver) { KeycloakApiRails::PublicKeyCachedResolver.new(server_url, realm_id, public_key_cache_ttl) }
|
|
7
|
-
|
|
8
|
-
before(:each) do
|
|
9
|
-
resolver.instance_variable_set(:@resolver, KeycloakApiRails::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
|
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
RSpec.describe KeycloakApiRails::Service do
|
|
2
|
-
|
|
3
|
-
let!(:private_key) { OpenSSL::PKey::RSA.generate(2048) }
|
|
4
|
-
let!(:public_key) { private_key.public_key }
|
|
5
|
-
let!(:key_resolver) { KeycloakApiRails::PublicKeyCachedResolverStub.new(public_key) }
|
|
6
|
-
let!(:service) { KeycloakApiRails::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: "KeycloakApiRails",
|
|
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
|
-
# The service reads `token_expiration_tolerance_in_seconds` when it is instantiated:
|
|
84
|
-
# a token expiring within that tolerance is already considered as expired.
|
|
85
|
-
context "and token expires within the expiration tolerance" do
|
|
86
|
-
let(:expiration_date) { Time.now + 5 }
|
|
87
|
-
|
|
88
|
-
it "should raise an error :expiration_date" do
|
|
89
|
-
expect(KeycloakApiRails.config.token_expiration_tolerance_in_seconds).to eq 10
|
|
90
|
-
expect {
|
|
91
|
-
service.decode_and_verify(token)
|
|
92
|
-
}.to raise_error(TokenError, "JWT token is expired")
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
context "and token expires just after the expiration tolerance" do
|
|
97
|
-
let(:expiration_date) { Time.now + 30 }
|
|
98
|
-
|
|
99
|
-
it "should return a not-nil decoded token" do
|
|
100
|
-
expect(KeycloakApiRails.config.token_expiration_tolerance_in_seconds).to eq 10
|
|
101
|
-
expect(service.decode_and_verify(token)).to_not be_nil
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
context "and token is not expired" do
|
|
106
|
-
let(:expiration_date) { Time.now + 2.days }
|
|
107
|
-
|
|
108
|
-
context "and token is encrypted using RS256" do
|
|
109
|
-
let(:algorithm) { :RS256 }
|
|
110
|
-
|
|
111
|
-
it "should return a not-nil decoded token" do
|
|
112
|
-
expect(service.decode_and_verify(token)).to_not be_nil
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
context "and token is encrypted using RS512" do
|
|
117
|
-
let(:algorithm) { :RS512 }
|
|
118
|
-
|
|
119
|
-
it "should return a not-nil decoded token" do
|
|
120
|
-
expect(service.decode_and_verify(token)).to_not be_nil
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
describe "#need_middleware_authentication?" do
|
|
129
|
-
|
|
130
|
-
let(:method) { nil }
|
|
131
|
-
let(:path) { nil }
|
|
132
|
-
let(:headers) { {} }
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
before(:each) do
|
|
136
|
-
KeycloakApiRails.config.skip_paths = {
|
|
137
|
-
post: [/^\/skip/],
|
|
138
|
-
get: [/^\/skip/]
|
|
139
|
-
}
|
|
140
|
-
@result = service.need_middleware_authentication?(method, path, headers)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
context "when method is nil" do
|
|
144
|
-
let(:method) { nil }
|
|
145
|
-
let(:path) { "/do-not-skip" }
|
|
146
|
-
it "should return true" do
|
|
147
|
-
expect(@result).to be true
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
context "when path is nil" do
|
|
152
|
-
let(:method) { :get }
|
|
153
|
-
let(:path) { nil }
|
|
154
|
-
it "should return true" do
|
|
155
|
-
expect(@result).to be true
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
context "when method does not match the configuration" do
|
|
160
|
-
let(:method) { :put }
|
|
161
|
-
let(:path) { "/skip" }
|
|
162
|
-
it "should return true" do
|
|
163
|
-
expect(@result).to be true
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
context "when path does not match the configuration" do
|
|
168
|
-
let(:method) { :get }
|
|
169
|
-
let(:path) { "/do-not-skip" }
|
|
170
|
-
it "should return true" do
|
|
171
|
-
expect(@result).to be true
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
context "when method [get] and path do match the configuration" do
|
|
176
|
-
let(:method) { :get }
|
|
177
|
-
let(:path) { "/skip" }
|
|
178
|
-
it "should return false" do
|
|
179
|
-
expect(@result).to be false
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
context "when method [post] and path do match the configuration" do
|
|
185
|
-
let(:method) { :get }
|
|
186
|
-
let(:path) { "/skip" }
|
|
187
|
-
it "should return false" do
|
|
188
|
-
expect(@result).to be false
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
context "when the request is preflight" do
|
|
193
|
-
let(:method) { :options }
|
|
194
|
-
let(:headers) { { "HTTP_ACCESS_CONTROL_REQUEST_METHOD" => ["Authorization"] } }
|
|
195
|
-
let(:path) { "/do-not-skip" }
|
|
196
|
-
it "should return false" do
|
|
197
|
-
expect(@result).to be false
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
context "when configured as opt_in" do
|
|
202
|
-
before do
|
|
203
|
-
KeycloakApiRails.config.opt_in = true
|
|
204
|
-
service2 = KeycloakApiRails::Service.new(key_resolver)
|
|
205
|
-
@result = service2.need_middleware_authentication?(method, path, headers)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
it "should return false" do
|
|
209
|
-
expect(@result).to be false
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
describe "#read_token" do
|
|
215
|
-
let(:query_string) { "" }
|
|
216
|
-
let(:url) { "http://api.service.com/api/health?aParameter=true#{query_string}" }
|
|
217
|
-
let(:headers) { {} }
|
|
218
|
-
let(:header_token) { "header_token" }
|
|
219
|
-
let(:query_string_token) { "query_string_token" }
|
|
220
|
-
|
|
221
|
-
before(:each) do
|
|
222
|
-
@token = service.read_token(url, headers)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
context "when the token is provided in the Authorization headers" do
|
|
226
|
-
let(:headers) do
|
|
227
|
-
{
|
|
228
|
-
"HTTP_AUTHORIZATION" => "Bearer #{header_token}"
|
|
229
|
-
}
|
|
230
|
-
end
|
|
231
|
-
context "and not in the query string" do
|
|
232
|
-
let(:query_string) { "" }
|
|
233
|
-
it "returns the header token" do
|
|
234
|
-
expect(@token).to eq header_token
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
context "and also in the query string" do
|
|
239
|
-
let(:query_string) { "&authorizationToken=#{query_string_token}" }
|
|
240
|
-
it "returns the query string token" do
|
|
241
|
-
expect(@token).to eq query_string_token
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
context "when the token is not provided in the Authorization headers" do
|
|
247
|
-
let(:headers) do
|
|
248
|
-
{
|
|
249
|
-
"ANOTHER_HEADER" => header_token
|
|
250
|
-
}
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
context "and not in the query string" do
|
|
254
|
-
let(:query_string) { "" }
|
|
255
|
-
it "returns an empty token" do
|
|
256
|
-
expect(@token).to eq ""
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
context "and query string is nil" do
|
|
261
|
-
let(:query_string) { nil }
|
|
262
|
-
it "returns an empty token" do
|
|
263
|
-
expect(@token).to eq ""
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
context "but in the query string" do
|
|
268
|
-
let(:query_string) { "&authorizationToken=#{query_string_token}" }
|
|
269
|
-
it "returns the query string token" do
|
|
270
|
-
expect(@token).to eq query_string_token
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
end
|
data/spec/spec_helper.rb
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# Loads the library and exercises every code path that used to rely on ActiveSupport,
|
|
2
|
-
# in a process where ActiveSupport is *not* loaded.
|
|
3
|
-
#
|
|
4
|
-
# The regular test suite loads `rails/all`, which makes ActiveSupport available everywhere and
|
|
5
|
-
# therefore cannot detect that the library depends on it again. This probe is executed in a
|
|
6
|
-
# separate process by spec/keycloak-api-rails/activesupport_free_spec.rb.
|
|
7
|
-
#
|
|
8
|
-
# The library files are required one by one on purpose: `lib/keycloak-api-rails.rb` pulls in
|
|
9
|
-
# `json/jwt`, which depends on ActiveSupport. The point of this probe is that *our* code does not
|
|
10
|
-
# use ActiveSupport, not that it never gets loaded by a third party.
|
|
11
|
-
|
|
12
|
-
require "uri"
|
|
13
|
-
require "date"
|
|
14
|
-
require "logger"
|
|
15
|
-
|
|
16
|
-
LIB = File.expand_path("../../../lib/keycloak-api-rails", __FILE__)
|
|
17
|
-
|
|
18
|
-
# Minimal stand-in for the parts of lib/keycloak-api-rails.rb the required files call at runtime.
|
|
19
|
-
module KeycloakApiRails
|
|
20
|
-
class << self
|
|
21
|
-
attr_accessor :config
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
require "#{LIB}/configuration"
|
|
26
|
-
require "#{LIB}/helper"
|
|
27
|
-
require "#{LIB}/authentication"
|
|
28
|
-
require "#{LIB}/public_key_resolver"
|
|
29
|
-
require "#{LIB}/public_key_cached_resolver"
|
|
30
|
-
require "#{LIB}/service"
|
|
31
|
-
|
|
32
|
-
$failures = []
|
|
33
|
-
|
|
34
|
-
def assert(description)
|
|
35
|
-
$failures << description unless yield
|
|
36
|
-
rescue => e
|
|
37
|
-
$failures << "#{description} (raised #{e.class}: #{e.message})"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
assert("ActiveSupport is not loaded by the library") { !defined?(ActiveSupport) }
|
|
41
|
-
|
|
42
|
-
### Configuration -- used to be ActiveSupport::Configurable
|
|
43
|
-
configuration = KeycloakApiRails::Configuration.new
|
|
44
|
-
KeycloakApiRails.config = configuration
|
|
45
|
-
|
|
46
|
-
%i[
|
|
47
|
-
server_url realm_id skip_paths opt_in token_expiration_tolerance_in_seconds
|
|
48
|
-
public_key_cache_ttl custom_attributes logger ca_certificate_file
|
|
49
|
-
].each do |name|
|
|
50
|
-
assert("Configuration##{name} is nil until it is assigned") { configuration.public_send(name).nil? }
|
|
51
|
-
configuration.public_send("#{name}=", "a value for #{name}")
|
|
52
|
-
assert("Configuration##{name} returns what was assigned") { configuration.public_send(name) == "a value for #{name}" }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
### Authentication -- used to be ActiveSupport::Concern
|
|
56
|
-
declared_helper_methods = []
|
|
57
|
-
controller_with_helpers = Class.new do
|
|
58
|
-
define_singleton_method(:helper_method) { |*names| declared_helper_methods.concat(names) }
|
|
59
|
-
end
|
|
60
|
-
controller_with_helpers.include(KeycloakApiRails::Authentication)
|
|
61
|
-
assert("Authentication declares keycloak_authenticate as a helper method") do
|
|
62
|
-
declared_helper_methods == [:keycloak_authenticate]
|
|
63
|
-
end
|
|
64
|
-
assert("Authentication can be included in a class without helper_method") do
|
|
65
|
-
Class.new.include(KeycloakApiRails::Authentication)
|
|
66
|
-
true
|
|
67
|
-
end
|
|
68
|
-
assert("Authentication keeps its methods protected") do
|
|
69
|
-
KeycloakApiRails::Authentication.protected_instance_methods.sort ==
|
|
70
|
-
%i[authentication_failed authentication_succeeded keycloak_authenticate]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
### Helper#read_token_from_query_string -- used to be `present?` and `second`
|
|
74
|
-
{
|
|
75
|
-
nil => "",
|
|
76
|
-
"" => "",
|
|
77
|
-
" " => "",
|
|
78
|
-
"http://www.an-url.io/health" => nil,
|
|
79
|
-
"http://www.an-url.io/health?firstName=ouioui" => nil,
|
|
80
|
-
"http://www.an-url.io/health?authorizationToken=aToken" => "aToken",
|
|
81
|
-
"http://www.an-url.io/health?a=1&authorizationToken=aToken&b=2" => "aToken",
|
|
82
|
-
}.each do |uri, expected|
|
|
83
|
-
assert("Helper.read_token_from_query_string(#{uri.inspect}) returns #{expected.inspect}") do
|
|
84
|
-
KeycloakApiRails::Helper.read_token_from_query_string(uri) == expected
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
### Service#expired? -- used to be `to_datetime` and `seconds`
|
|
89
|
-
configuration.token_expiration_tolerance_in_seconds = 10
|
|
90
|
-
configuration.skip_paths = {}
|
|
91
|
-
configuration.opt_in = false
|
|
92
|
-
configuration.logger = ::Logger.new(File::NULL)
|
|
93
|
-
service = KeycloakApiRails::Service.new(nil)
|
|
94
|
-
now = Time.now
|
|
95
|
-
|
|
96
|
-
{
|
|
97
|
-
"expiring in one hour" => [(now + 3600).to_i, false],
|
|
98
|
-
"expired two days ago" => [(now - 172800).to_i, true],
|
|
99
|
-
"expiring within the tolerance (5s < 10s)" => [(now + 5).to_i, true],
|
|
100
|
-
"expiring after the tolerance (30s > 10s)" => [(now + 30).to_i, false],
|
|
101
|
-
}.each do |description, (exp, expected)|
|
|
102
|
-
assert("Service#expired? returns #{expected} for a token #{description}") do
|
|
103
|
-
service.send(:expired?, { "exp" => exp }) == expected
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
### PublicKeyCachedResolver -- used to be `seconds`
|
|
108
|
-
ttl = 86400
|
|
109
|
-
resolver = KeycloakApiRails::PublicKeyCachedResolver.new(nil, "a-realm", ttl)
|
|
110
|
-
assert("PublicKeyCachedResolver considers an empty cache as outdated") do
|
|
111
|
-
resolver.send(:public_keys_are_outdated?) == true
|
|
112
|
-
end
|
|
113
|
-
resolver.instance_variable_set(:@cached_public_keys, "a-public-key")
|
|
114
|
-
resolver.instance_variable_set(:@cached_public_key_retrieved_at, Time.now - (ttl - 10))
|
|
115
|
-
assert("PublicKeyCachedResolver keeps a cache that is within its TTL") do
|
|
116
|
-
resolver.send(:public_keys_are_outdated?) == false
|
|
117
|
-
end
|
|
118
|
-
resolver.instance_variable_set(:@cached_public_key_retrieved_at, Time.now - (ttl + 10))
|
|
119
|
-
assert("PublicKeyCachedResolver invalidates a cache that outlived its TTL") do
|
|
120
|
-
resolver.send(:public_keys_are_outdated?) == true
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
assert("ActiveSupport has not been loaded while exercising the library") { !defined?(ActiveSupport) }
|
|
124
|
-
|
|
125
|
-
if $failures.empty?
|
|
126
|
-
puts "The library behaves correctly without ActiveSupport"
|
|
127
|
-
exit 0
|
|
128
|
-
else
|
|
129
|
-
$failures.each { |failure| puts "FAILED: #{failure}" }
|
|
130
|
-
exit 1
|
|
131
|
-
end
|