keycloak_rack 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|