keycloak_rack 1.0.0
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/.github/workflows/main.yml +68 -0
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.rubocop.yml +220 -0
- data/.ruby-version +1 -0
- data/.yardopts +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +10 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +5 -0
- data/LICENSE +19 -0
- data/README.md +288 -0
- data/Rakefile +10 -0
- data/bin/appraisal +29 -0
- data/bin/console +6 -0
- data/bin/fix-appraisals +14 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/yard +29 -0
- data/bin/yardoc +29 -0
- data/bin/yri +29 -0
- data/gemfiles/rack_only.gemfile +5 -0
- data/gemfiles/rack_only.gemfile.lock +204 -0
- data/gemfiles/rails_6_0.gemfile +9 -0
- data/gemfiles/rails_6_0.gemfile.lock +323 -0
- data/gemfiles/rails_6_1.gemfile +9 -0
- data/gemfiles/rails_6_1.gemfile.lock +326 -0
- data/keycloak_rack.gemspec +56 -0
- data/lib/keycloak_rack.rb +59 -0
- data/lib/keycloak_rack/authenticate.rb +115 -0
- data/lib/keycloak_rack/authorize_realm.rb +53 -0
- data/lib/keycloak_rack/authorize_resource.rb +54 -0
- data/lib/keycloak_rack/config.rb +84 -0
- data/lib/keycloak_rack/container.rb +53 -0
- data/lib/keycloak_rack/decoded_token.rb +191 -0
- data/lib/keycloak_rack/flexible_struct.rb +20 -0
- data/lib/keycloak_rack/http_client.rb +86 -0
- data/lib/keycloak_rack/import.rb +9 -0
- data/lib/keycloak_rack/key_fetcher.rb +20 -0
- data/lib/keycloak_rack/key_resolver.rb +64 -0
- data/lib/keycloak_rack/middleware.rb +132 -0
- data/lib/keycloak_rack/railtie.rb +14 -0
- data/lib/keycloak_rack/read_token.rb +40 -0
- data/lib/keycloak_rack/resource_role_map.rb +8 -0
- data/lib/keycloak_rack/role_map.rb +15 -0
- data/lib/keycloak_rack/session.rb +44 -0
- data/lib/keycloak_rack/skip_authentication.rb +44 -0
- data/lib/keycloak_rack/types.rb +42 -0
- data/lib/keycloak_rack/version.rb +6 -0
- data/lib/keycloak_rack/with_config.rb +15 -0
- data/spec/dummy/.ruby-version +1 -0
- data/spec/dummy/README.md +24 -0
- data/spec/dummy/Rakefile +8 -0
- data/spec/dummy/app/controllers/application_controller.rb +22 -0
- data/spec/dummy/app/controllers/test_controller.rb +9 -0
- data/spec/dummy/config.ru +8 -0
- data/spec/dummy/config/application.rb +52 -0
- data/spec/dummy/config/boot.rb +3 -0
- data/spec/dummy/config/environment.rb +7 -0
- data/spec/dummy/config/environments/development.rb +51 -0
- data/spec/dummy/config/environments/test.rb +51 -0
- data/spec/dummy/config/initializers/application_controller_renderer.rb +9 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +10 -0
- data/spec/dummy/config/initializers/cors.rb +17 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/spec/dummy/config/initializers/inflections.rb +17 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +11 -0
- data/spec/dummy/config/keycloak.yml +12 -0
- data/spec/dummy/config/locales/en.yml +33 -0
- data/spec/dummy/config/routes.rb +5 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/dummy/tmp/development_secret.txt +1 -0
- data/spec/factories/decoded_token.rb +18 -0
- data/spec/factories/session.rb +21 -0
- data/spec/factories/token_payload.rb +40 -0
- data/spec/keycloak_rack/authorize_realm_spec.rb +15 -0
- data/spec/keycloak_rack/authorize_resource_spec.rb +19 -0
- data/spec/keycloak_rack/decoded_token_spec.rb +31 -0
- data/spec/keycloak_rack/key_resolver_spec.rb +95 -0
- data/spec/keycloak_rack/middleware_spec.rb +172 -0
- data/spec/keycloak_rack/rails_integration_spec.rb +43 -0
- data/spec/keycloak_rack/session_spec.rb +37 -0
- data/spec/keycloak_rack/skip_authentication_spec.rb +55 -0
- data/spec/spec_helper.rb +101 -0
- data/spec/support/contexts/mocked_keycloak.rb +63 -0
- data/spec/support/contexts/mocked_rack_application.rb +41 -0
- data/spec/support/test_key.pem +27 -0
- data/spec/support/token_helper.rb +76 -0
- metadata +616 -0
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Be sure to restart your server when you modify this file.
|
4
|
+
|
5
|
+
# This file contains settings for ActionController::ParamsWrapper which
|
6
|
+
# is enabled by default.
|
7
|
+
|
8
|
+
# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
|
9
|
+
ActiveSupport.on_load(:action_controller) do
|
10
|
+
wrap_parameters format: [:json]
|
11
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Files in the config/locales directory are used for internationalization
|
2
|
+
# and are automatically loaded by Rails. If you want to use locales other
|
3
|
+
# than English, add the necessary files in this directory.
|
4
|
+
#
|
5
|
+
# To use the locales, use `I18n.t`:
|
6
|
+
#
|
7
|
+
# I18n.t 'hello'
|
8
|
+
#
|
9
|
+
# In views, this is aliased to just `t`:
|
10
|
+
#
|
11
|
+
# <%= t('hello') %>
|
12
|
+
#
|
13
|
+
# To use a different locale, set it with `I18n.locale`:
|
14
|
+
#
|
15
|
+
# I18n.locale = :es
|
16
|
+
#
|
17
|
+
# This would use the information in config/locales/es.yml.
|
18
|
+
#
|
19
|
+
# The following keys must be escaped otherwise they will not be retrieved by
|
20
|
+
# the default I18n backend:
|
21
|
+
#
|
22
|
+
# true, false, on, off, yes, no
|
23
|
+
#
|
24
|
+
# Instead, surround them with single quotes.
|
25
|
+
#
|
26
|
+
# en:
|
27
|
+
# 'true': 'foo'
|
28
|
+
#
|
29
|
+
# To learn more, please read the Rails Internationalization guide
|
30
|
+
# available at https://guides.rubyonrails.org/i18n.html.
|
31
|
+
|
32
|
+
en:
|
33
|
+
hello: "Hello world"
|
@@ -0,0 +1 @@
|
|
1
|
+
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
@@ -0,0 +1 @@
|
|
1
|
+
e4c5defcf0e8aedd26fd7bfe2e676e81546b2979f29b2a19a37be9c8428533b3102527517380707731dea750778c92fdab1cd4707c6c616ff37fc5bedd535831
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
FactoryBot.define do
|
4
|
+
factory :decoded_token, class: "KeycloakRack::DecodedToken" do
|
5
|
+
data { {} }
|
6
|
+
expires_at { 3.hours.from_now }
|
7
|
+
issued_at { Time.current }
|
8
|
+
leeway { 10 }
|
9
|
+
|
10
|
+
initialize_with do
|
11
|
+
TokenHelper.new.build_decoded_token(**attributes)
|
12
|
+
end
|
13
|
+
|
14
|
+
to_create do |instance|
|
15
|
+
instance
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
FactoryBot.define do
|
4
|
+
factory :session, class: "KeycloakRack::Session" do
|
5
|
+
token { FactoryBot.create :decoded_token }
|
6
|
+
skipped { false }
|
7
|
+
auth_result { Dry::Monads.Success token }
|
8
|
+
|
9
|
+
initialize_with do
|
10
|
+
KeycloakRack::Session.new(**attributes)
|
11
|
+
end
|
12
|
+
|
13
|
+
to_create do |instance|
|
14
|
+
instance
|
15
|
+
end
|
16
|
+
|
17
|
+
trait :anonymous do
|
18
|
+
token { nil }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
FactoryBot.define do
|
4
|
+
factory :token_payload, class: Hash do
|
5
|
+
transient do
|
6
|
+
realm_roles { ["foo"] }
|
7
|
+
widget_roles { ["bar"] }
|
8
|
+
end
|
9
|
+
|
10
|
+
aud { "keycloak" }
|
11
|
+
azp { "keycloak" }
|
12
|
+
nonce { SecureRandom.uuid }
|
13
|
+
session_state { SecureRandom.uuid }
|
14
|
+
allowed_origins { ["http://example.com"] }
|
15
|
+
|
16
|
+
sub { SecureRandom.uuid }
|
17
|
+
realm_access { { "roles" => Array(realm_roles) } }
|
18
|
+
resource_access do
|
19
|
+
{ "widgets" => { "roles" => Array(widget_roles) } }
|
20
|
+
end
|
21
|
+
scope { "openid" }
|
22
|
+
|
23
|
+
email_verified { true }
|
24
|
+
given_name { Faker::Name.first_name }
|
25
|
+
family_name { Faker::Name.last_name }
|
26
|
+
name { "#{given_name} #{family_name}" }
|
27
|
+
preferred_username do
|
28
|
+
Faker::Internet.username specifier: "#{given_name}.#{family_name}"
|
29
|
+
end
|
30
|
+
email { Faker::Internet.safe_email name: name }
|
31
|
+
|
32
|
+
custom_attribute { "custom_value" }
|
33
|
+
|
34
|
+
initialize_with do
|
35
|
+
Hash(attributes).tap do |h|
|
36
|
+
h["allowed-origins"] = Array(h.delete("allowed_origins"))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe KeycloakRack::AuthorizeRealm do
|
4
|
+
let(:session) { FactoryBot.create :session }
|
5
|
+
|
6
|
+
let(:instance) { session.authorize_realm }
|
7
|
+
|
8
|
+
it "works with an authorized realm role" do
|
9
|
+
expect(instance.call("foo")).to be_a_success
|
10
|
+
end
|
11
|
+
|
12
|
+
it "fails with an unauthorized realm role" do
|
13
|
+
expect(instance.call("bar")).to be_a_failure
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe KeycloakRack::AuthorizeResource do
|
4
|
+
let(:session) { FactoryBot.create :session }
|
5
|
+
|
6
|
+
let(:instance) { session.authorize_resource }
|
7
|
+
|
8
|
+
it "works with an authorized widget role" do
|
9
|
+
expect(instance.call("widgets", "bar")).to be_a_success
|
10
|
+
end
|
11
|
+
|
12
|
+
it "fails with an unauthorized widget role" do
|
13
|
+
expect(instance.call("widgets", "baz")).to be_a_failure
|
14
|
+
end
|
15
|
+
|
16
|
+
it "fails with an unknown resource" do
|
17
|
+
expect(instance.call("unknown", "bar")).to be_a_failure
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe KeycloakRack::DecodedToken do
|
4
|
+
let(:instance) { FactoryBot.create :decoded_token }
|
5
|
+
|
6
|
+
describe "#fetch" do
|
7
|
+
it "can fetch aliases" do
|
8
|
+
expect(instance.fetch(:keycloak_id)).to eq instance.sub
|
9
|
+
end
|
10
|
+
|
11
|
+
it "can be used to get custom attributes" do
|
12
|
+
expect(instance.fetch(:custom_attribute)).to eq "custom_value"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe "#slice" do
|
17
|
+
it "can slice attributes and aliases" do
|
18
|
+
expect(instance.slice(:email, :first_name)).to include_json(email: instance.email, first_name: instance.given_name)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "can slice custom attributes" do
|
22
|
+
expect(instance.slice(:custom_attribute)).to include_json(custom_attribute: a_kind_of(String))
|
23
|
+
end
|
24
|
+
|
25
|
+
it "raises an error when trying to slice an unknown attribute" do
|
26
|
+
expect do
|
27
|
+
instance.slice(:heck)
|
28
|
+
end.to raise_error KeycloakRack::DecodedToken::UnknownAttribute
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe KeycloakRack::KeyResolver do
|
4
|
+
include_context "with mocked keycloak"
|
5
|
+
|
6
|
+
let(:cache_ttl) { config_cache_ttl }
|
7
|
+
|
8
|
+
let!(:resolver) { described_class.new }
|
9
|
+
|
10
|
+
let(:start_time) do
|
11
|
+
Time.local(2021, 4, 3, 0, 0).in_time_zone
|
12
|
+
end
|
13
|
+
|
14
|
+
subject { resolver }
|
15
|
+
|
16
|
+
def fetch_keys(at:)
|
17
|
+
Timecop.freeze at do
|
18
|
+
{
|
19
|
+
public_key: resolver.find_public_keys,
|
20
|
+
retrieved_at: resolver.cached_public_key_retrieved_at,
|
21
|
+
}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "#find_public_key" do
|
26
|
+
context "when there is no public key in cache yet" do
|
27
|
+
let!(:public_key) do
|
28
|
+
Timecop.freeze start_time do
|
29
|
+
resolver.find_public_keys
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
it "returns a valid public key" do
|
34
|
+
expect(public_key).to be_a_success
|
35
|
+
end
|
36
|
+
|
37
|
+
it "sets the current time to the resolver" do
|
38
|
+
expect(resolver.cached_public_key_retrieved_at).to eq start_time
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "when there is already a public key in cache" do
|
43
|
+
let!(:first_fetch) do
|
44
|
+
fetch_keys at: start_time
|
45
|
+
end
|
46
|
+
|
47
|
+
let!(:first_public_key) { first_fetch[:public_key] }
|
48
|
+
|
49
|
+
let!(:first_retrieved_at) { first_fetch[:retrieved_at] }
|
50
|
+
|
51
|
+
context "with no need to refresh it" do
|
52
|
+
let(:almost_a_day_later) { start_time + cache_ttl.seconds - 10.seconds }
|
53
|
+
|
54
|
+
let!(:second_fetch) do
|
55
|
+
fetch_keys at: almost_a_day_later
|
56
|
+
end
|
57
|
+
|
58
|
+
let(:second_public_key) { second_fetch[:public_key] }
|
59
|
+
let(:second_retrieved_at) { second_fetch[:retrieved_at] }
|
60
|
+
|
61
|
+
it "returns a valid public key" do
|
62
|
+
expect(second_public_key).to be_a_success
|
63
|
+
end
|
64
|
+
|
65
|
+
it "does not refresh the public key" do
|
66
|
+
expect(second_public_key.value!).to be first_public_key.value!
|
67
|
+
end
|
68
|
+
|
69
|
+
it "does not refresh the public key retrieval time" do
|
70
|
+
expect(first_retrieved_at).to eq second_retrieved_at
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
context "when its TTL has expired" do
|
75
|
+
let(:over_a_day_later) { start_time + cache_ttl.seconds + 10.seconds }
|
76
|
+
|
77
|
+
let!(:second_fetch) { fetch_keys at: over_a_day_later }
|
78
|
+
let(:second_public_key) { second_fetch[:public_key] }
|
79
|
+
let(:second_retrieved_at) { second_fetch[:retrieved_at] }
|
80
|
+
|
81
|
+
it "returns a valid public key" do
|
82
|
+
expect(second_public_key).to be_a_success
|
83
|
+
end
|
84
|
+
|
85
|
+
it "refreshes the public key" do
|
86
|
+
expect(second_public_key.value!).not_to be first_public_key.value!
|
87
|
+
end
|
88
|
+
|
89
|
+
it "refreshes the public key retrieval time" do
|
90
|
+
expect(first_retrieved_at).not_to eq second_retrieved_at
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe KeycloakRack::Middleware do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
include_context "with mocked keycloak"
|
7
|
+
include_context "with mocked rack application"
|
8
|
+
|
9
|
+
before do
|
10
|
+
refresh_public_keys!
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:app) do
|
14
|
+
ra = rack_application
|
15
|
+
|
16
|
+
Rack::Builder.app do
|
17
|
+
use KeycloakRack::Middleware
|
18
|
+
|
19
|
+
run ra
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
it "fails with an invalid bearer token" do
|
24
|
+
header "Authorization", "Bearer whoops"
|
25
|
+
|
26
|
+
get ?/
|
27
|
+
|
28
|
+
expect(last_response).to be_bad_request
|
29
|
+
end
|
30
|
+
|
31
|
+
context "with an anonymous request" do
|
32
|
+
context "when unauthenticated requests are allowed" do
|
33
|
+
let(:config_allow_anonymous) { true }
|
34
|
+
|
35
|
+
it "works" do
|
36
|
+
get ?/
|
37
|
+
|
38
|
+
expect(last_response).to be_ok
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "when unauthenticated requests are forbidden" do
|
43
|
+
it "is unauthorized" do
|
44
|
+
get ?/
|
45
|
+
|
46
|
+
expect(last_response).to be_unauthorized
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context "when the token's key id doesn't match expectations" do
|
52
|
+
let(:token) { token_helper.build_token with_random_jwk: true }
|
53
|
+
|
54
|
+
before do
|
55
|
+
header "Authorization", "Bearer #{token}"
|
56
|
+
end
|
57
|
+
|
58
|
+
it "fails" do
|
59
|
+
get ?/
|
60
|
+
|
61
|
+
expect(last_response).to be_bad_request
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
context "with a valid token" do
|
66
|
+
let(:expires_at) { 1.hour.from_now }
|
67
|
+
|
68
|
+
let(:token) { token_helper.build_token expires_at: expires_at }
|
69
|
+
|
70
|
+
let(:leeway_expires) { expires_at + 10 - 1.second }
|
71
|
+
|
72
|
+
let(:expected_partial_rack_environment) do
|
73
|
+
{
|
74
|
+
"keycloak:session" => a_kind_of(KeycloakRack::Session),
|
75
|
+
"keycloak:authorize_realm" => a_kind_of(KeycloakRack::AuthorizeRealm),
|
76
|
+
"keycloak:authorize_resource" => a_kind_of(KeycloakRack::AuthorizeResource),
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
before do
|
81
|
+
header "Authorization", "Bearer #{token}"
|
82
|
+
end
|
83
|
+
|
84
|
+
it "sets the expected keys in the rack environment" do
|
85
|
+
get ?/
|
86
|
+
|
87
|
+
expect(last_response).to be_ok
|
88
|
+
|
89
|
+
expect(last_rack_environment).to include_json(expected_partial_rack_environment)
|
90
|
+
end
|
91
|
+
|
92
|
+
it "fails when expired" do
|
93
|
+
Timecop.freeze(expires_at + 1.hour) do
|
94
|
+
get ?/
|
95
|
+
end
|
96
|
+
|
97
|
+
expect(last_response).to be_forbidden
|
98
|
+
end
|
99
|
+
|
100
|
+
it "honors leeway" do
|
101
|
+
Timecop.freeze(leeway_expires) do
|
102
|
+
get ?/
|
103
|
+
end
|
104
|
+
|
105
|
+
expect(last_response).to be_ok
|
106
|
+
end
|
107
|
+
|
108
|
+
context "when the keycloak server fails to provide a valid public key" do
|
109
|
+
context "when the server returns an error" do
|
110
|
+
let(:mocked_public_key_response) do
|
111
|
+
{
|
112
|
+
body: "Whoops",
|
113
|
+
status: 500
|
114
|
+
}
|
115
|
+
end
|
116
|
+
|
117
|
+
it "fails" do
|
118
|
+
get ?/
|
119
|
+
|
120
|
+
expect(last_response).to be_server_error
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context "when the JSON is invalid" do
|
125
|
+
let(:mocked_public_key_response) do
|
126
|
+
{
|
127
|
+
body: "{ foo",
|
128
|
+
status: 200
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
it "fails" do
|
133
|
+
get ?/
|
134
|
+
|
135
|
+
expect(last_response).to be_server_error
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context "when :alg is missing from the keys" do
|
140
|
+
let(:jwks_response) do
|
141
|
+
super().tap do |h|
|
142
|
+
h["keys"].each do |k|
|
143
|
+
k.delete("alg")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
it "fails" do
|
149
|
+
get ?/
|
150
|
+
|
151
|
+
expect(last_response).to be_server_error
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
context "with a skip configuration" do
|
158
|
+
let(:config_skip_paths) do
|
159
|
+
{
|
160
|
+
get: %w[/ping]
|
161
|
+
}
|
162
|
+
end
|
163
|
+
|
164
|
+
it "skips authentication" do
|
165
|
+
get "/ping"
|
166
|
+
|
167
|
+
expect(last_response).to be_ok
|
168
|
+
|
169
|
+
expect(last_response.body).to match(/anonymous/)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|