keycloak_rack 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +68 -0
  3. data/.gitignore +8 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +220 -0
  6. data/.ruby-version +1 -0
  7. data/.yardopts +7 -0
  8. data/Appraisals +16 -0
  9. data/CHANGELOG.md +10 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/Gemfile +5 -0
  12. data/LICENSE +19 -0
  13. data/README.md +288 -0
  14. data/Rakefile +10 -0
  15. data/bin/appraisal +29 -0
  16. data/bin/console +6 -0
  17. data/bin/fix-appraisals +14 -0
  18. data/bin/rake +29 -0
  19. data/bin/rspec +29 -0
  20. data/bin/rubocop +29 -0
  21. data/bin/yard +29 -0
  22. data/bin/yardoc +29 -0
  23. data/bin/yri +29 -0
  24. data/gemfiles/rack_only.gemfile +5 -0
  25. data/gemfiles/rack_only.gemfile.lock +204 -0
  26. data/gemfiles/rails_6_0.gemfile +9 -0
  27. data/gemfiles/rails_6_0.gemfile.lock +323 -0
  28. data/gemfiles/rails_6_1.gemfile +9 -0
  29. data/gemfiles/rails_6_1.gemfile.lock +326 -0
  30. data/keycloak_rack.gemspec +56 -0
  31. data/lib/keycloak_rack.rb +59 -0
  32. data/lib/keycloak_rack/authenticate.rb +115 -0
  33. data/lib/keycloak_rack/authorize_realm.rb +53 -0
  34. data/lib/keycloak_rack/authorize_resource.rb +54 -0
  35. data/lib/keycloak_rack/config.rb +84 -0
  36. data/lib/keycloak_rack/container.rb +53 -0
  37. data/lib/keycloak_rack/decoded_token.rb +191 -0
  38. data/lib/keycloak_rack/flexible_struct.rb +20 -0
  39. data/lib/keycloak_rack/http_client.rb +86 -0
  40. data/lib/keycloak_rack/import.rb +9 -0
  41. data/lib/keycloak_rack/key_fetcher.rb +20 -0
  42. data/lib/keycloak_rack/key_resolver.rb +64 -0
  43. data/lib/keycloak_rack/middleware.rb +132 -0
  44. data/lib/keycloak_rack/railtie.rb +14 -0
  45. data/lib/keycloak_rack/read_token.rb +40 -0
  46. data/lib/keycloak_rack/resource_role_map.rb +8 -0
  47. data/lib/keycloak_rack/role_map.rb +15 -0
  48. data/lib/keycloak_rack/session.rb +44 -0
  49. data/lib/keycloak_rack/skip_authentication.rb +44 -0
  50. data/lib/keycloak_rack/types.rb +42 -0
  51. data/lib/keycloak_rack/version.rb +6 -0
  52. data/lib/keycloak_rack/with_config.rb +15 -0
  53. data/spec/dummy/.ruby-version +1 -0
  54. data/spec/dummy/README.md +24 -0
  55. data/spec/dummy/Rakefile +8 -0
  56. data/spec/dummy/app/controllers/application_controller.rb +22 -0
  57. data/spec/dummy/app/controllers/test_controller.rb +9 -0
  58. data/spec/dummy/config.ru +8 -0
  59. data/spec/dummy/config/application.rb +52 -0
  60. data/spec/dummy/config/boot.rb +3 -0
  61. data/spec/dummy/config/environment.rb +7 -0
  62. data/spec/dummy/config/environments/development.rb +51 -0
  63. data/spec/dummy/config/environments/test.rb +51 -0
  64. data/spec/dummy/config/initializers/application_controller_renderer.rb +9 -0
  65. data/spec/dummy/config/initializers/backtrace_silencers.rb +10 -0
  66. data/spec/dummy/config/initializers/cors.rb +17 -0
  67. data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  68. data/spec/dummy/config/initializers/inflections.rb +17 -0
  69. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  70. data/spec/dummy/config/initializers/wrap_parameters.rb +11 -0
  71. data/spec/dummy/config/keycloak.yml +12 -0
  72. data/spec/dummy/config/locales/en.yml +33 -0
  73. data/spec/dummy/config/routes.rb +5 -0
  74. data/spec/dummy/public/robots.txt +1 -0
  75. data/spec/dummy/tmp/development_secret.txt +1 -0
  76. data/spec/factories/decoded_token.rb +18 -0
  77. data/spec/factories/session.rb +21 -0
  78. data/spec/factories/token_payload.rb +40 -0
  79. data/spec/keycloak_rack/authorize_realm_spec.rb +15 -0
  80. data/spec/keycloak_rack/authorize_resource_spec.rb +19 -0
  81. data/spec/keycloak_rack/decoded_token_spec.rb +31 -0
  82. data/spec/keycloak_rack/key_resolver_spec.rb +95 -0
  83. data/spec/keycloak_rack/middleware_spec.rb +172 -0
  84. data/spec/keycloak_rack/rails_integration_spec.rb +43 -0
  85. data/spec/keycloak_rack/session_spec.rb +37 -0
  86. data/spec/keycloak_rack/skip_authentication_spec.rb +55 -0
  87. data/spec/spec_helper.rb +101 -0
  88. data/spec/support/contexts/mocked_keycloak.rb +63 -0
  89. data/spec/support/contexts/mocked_rack_application.rb +41 -0
  90. data/spec/support/test_key.pem +27 -0
  91. data/spec/support/token_helper.rb +76 -0
  92. 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,12 @@
1
+ default: &default
2
+ server_url: "http://keycloak.example.com/auth"
3
+ realm_id: Test
4
+
5
+ development:
6
+ <<: *default
7
+
8
+ test:
9
+ <<: *default
10
+
11
+ production:
12
+ <<: *default
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ root to: "test#root"
5
+ end
@@ -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