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.
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