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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Rails integration" do
4
+ include_context "with mocked keycloak"
5
+
6
+ before :context do
7
+ skip "Rails isn't tested in this environment" unless defined?(Rails)
8
+ end
9
+
10
+ describe "request specs", type: :request do
11
+ it "is unauthorized with header" do
12
+ expect do
13
+ get root_path
14
+ end.not_to raise_error
15
+
16
+ expect(response).to be_unauthorized
17
+ end
18
+
19
+ context "when a valid token is provided", type: :request do
20
+ let!(:token) { token_helper.build_token }
21
+
22
+ let!(:headers) do
23
+ {
24
+ "Authorization" => "Bearer #{token}",
25
+ }
26
+ end
27
+
28
+ def make_request!
29
+ get root_path, headers: headers
30
+ end
31
+
32
+ it "authenticates as expected" do
33
+ expect do
34
+ make_request!
35
+ end.not_to raise_error
36
+
37
+ expect(response).to be_ok
38
+
39
+ expect(response.body).to include_json keycloak_id: a_kind_of(String)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe KeycloakRack::Session do
4
+ let(:session) { FactoryBot.create :session }
5
+
6
+ subject { session }
7
+
8
+ context "with a session built from a valid token" do
9
+ it { is_expected.to be_authenticated }
10
+
11
+ it { is_expected.not_to be_anonymous }
12
+
13
+ it "can authorize realm roles" do
14
+ expect(session.authorize_realm!("foo")).to be_a_success
15
+ end
16
+
17
+ it "can authorize resource roles" do
18
+ expect(session.authorize_resource!("widgets", "bar")).to be_a_success
19
+ end
20
+ end
21
+
22
+ context "with an anonymous session" do
23
+ let(:session) { FactoryBot.create :session, :anonymous }
24
+
25
+ it { is_expected.to be_anonymous }
26
+
27
+ it { is_expected.not_to be_authenticated }
28
+
29
+ it "can authorize realm roles" do
30
+ expect(session.authorize_realm!("foo")).to be_a_failure
31
+ end
32
+
33
+ it "can authorize resource roles" do
34
+ expect(session.authorize_resource!("widgets", "bar")).to be_a_failure
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe KeycloakRack::SkipAuthentication do
4
+ include Rack::Test::Methods
5
+ include_context "with mocked keycloak"
6
+
7
+ before do
8
+ KeycloakRack.configure do |c|
9
+ c.skip_paths = {
10
+ get: ["/ping"],
11
+ post: [%r{\A/foo.+bar}]
12
+ }
13
+ end
14
+ end
15
+
16
+ let(:skipper) { described_class.new }
17
+
18
+ let(:app) do
19
+ ->(env) do
20
+ result = skipper.call env
21
+
22
+ # We treat "skipped" as no content, allowed as ok, and anything else as server error
23
+
24
+ status = result.fmap { |x| x ? 204 : 200 }.value_or(500)
25
+
26
+ [status, {}, ["HTTP #{status}"]]
27
+ end
28
+ end
29
+
30
+ it "skips GET /ping" do
31
+ get "/ping"
32
+
33
+ expect(last_response).to be_no_content
34
+ end
35
+
36
+ it "skips POST /foo/baz/bar (with a regular expression)" do
37
+ post "/foo/baz/bar"
38
+
39
+ expect(last_response).to be_no_content
40
+ end
41
+
42
+ it "does not skip anything else" do
43
+ get "/anywhere/else"
44
+
45
+ expect(last_response).to be_ok
46
+ end
47
+
48
+ it "skips CORS preflight requests" do
49
+ header "Access-Control-Request-Method", "GET"
50
+
51
+ options "/anywhere/else"
52
+
53
+ expect(last_response).to be_no_content
54
+ end
55
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simplecov"
4
+
5
+ SimpleCov.start do
6
+ enable_coverage :branch
7
+
8
+ add_filter "spec"
9
+ end
10
+
11
+ require "anyway/testing/helpers"
12
+ require "dry/container/stub"
13
+ require "factory_bot"
14
+ require "faker"
15
+ require "pry"
16
+ require "rack/test"
17
+ require "rspec/json_expectations"
18
+ require "timecop"
19
+ require "webmock/rspec"
20
+
21
+ begin
22
+ require_relative "./dummy/config/environment"
23
+ require "rspec/rails"
24
+ rescue LoadError
25
+ # For appraisals with rails
26
+ end
27
+
28
+ require "keycloak_rack"
29
+
30
+ Dir[File.join(__dir__, "support/**/*.rb")].sort.each { |f| require f }
31
+
32
+ RSpec.configure do |config|
33
+ config.include Anyway::Testing::Helpers
34
+ config.include FactoryBot::Syntax::Methods
35
+
36
+ config.before(:suite) do
37
+ FactoryBot.find_definitions
38
+ KeycloakRack::Container.enable_stubs!
39
+ WebMock.disable_net_connect!
40
+ end
41
+
42
+ config.expect_with :rspec do |expectations|
43
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
44
+ end
45
+
46
+ config.mock_with :rspec do |mocks|
47
+ mocks.verify_partial_doubles = true
48
+ end
49
+
50
+ config.shared_context_metadata_behavior = :apply_to_host_groups
51
+
52
+ # This allows you to limit a spec run to individual examples or groups
53
+ # you care about by tagging them with `:focus` metadata. When nothing
54
+ # is tagged with `:focus`, all examples get run. RSpec also provides
55
+ # aliases for `it`, `describe`, and `context` that include `:focus`
56
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
57
+ config.filter_run_when_matching :focus
58
+
59
+ # Allows RSpec to persist some state between runs in order to support
60
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
61
+ # you configure your source control system to ignore this file.
62
+ config.example_status_persistence_file_path = "spec/examples.txt"
63
+
64
+ # Limits the available syntax to the non-monkey patched syntax that is
65
+ # recommended. For more details, see:
66
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
67
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
68
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
69
+ config.disable_monkey_patching!
70
+
71
+ # This setting enables warnings. It's recommended, but in some cases may
72
+ # be too noisy due to issues in dependencies.
73
+ config.warnings = true
74
+
75
+ # Many RSpec users commonly either run the entire suite or an individual
76
+ # file, and it's useful to allow more verbose output when running an
77
+ # individual spec file.
78
+ if config.files_to_run.one?
79
+ # Use the documentation formatter for detailed output,
80
+ # unless a formatter has already been configured
81
+ # (e.g. via a command-line flag).
82
+ config.default_formatter = "doc"
83
+ end
84
+
85
+ # Print the 10 slowest examples and example groups at the
86
+ # end of the spec run, to help surface which specs are running
87
+ # particularly slow.
88
+ # config.profile_examples = 10
89
+
90
+ # Run specs in random order to surface order dependencies. If you find an
91
+ # order dependency and want to debug it, you can fix the order by providing
92
+ # the seed, which is printed after each run.
93
+ # --seed 1234
94
+ config.order = :random
95
+
96
+ # Seed global randomization in this process using the `--seed` CLI option.
97
+ # Setting this allows you to use `--seed` to deterministically reproduce
98
+ # test failures related to randomization by passing the same `--seed` value
99
+ # as the one that triggered the failure.
100
+ Kernel.srand config.seed
101
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../token_helper"
4
+
5
+ RSpec.shared_context "with mocked keycloak" do
6
+ let(:config_server_url) { "http://keycloak.example.com/auth" }
7
+ let(:config_realm_id) { "Test" }
8
+ let(:config_allow_anonymous) { false }
9
+ let(:config_halt_on_auth_failure) { true }
10
+ let(:config_cache_ttl) { 86_400 }
11
+ let(:config_skip_paths) { {} }
12
+ let(:mocked_config_env) do
13
+ {
14
+ "KEYCLOAK_SERVER_URL" => config_server_url,
15
+ "KEYCLOAK_REALM_ID" => config_realm_id,
16
+ "KEYCLOAK_CACHE_TTL" => config_cache_ttl,
17
+ "KEYCLOAK_ALLOW_ANONYMOUS" => config_allow_anonymous,
18
+ "KEYCLOAK_HALT_ON_AUTH_FAILURE" => config_halt_on_auth_failure
19
+ }
20
+ end
21
+
22
+ let(:token_helper) { TokenHelper.new }
23
+ let(:jwks_response) { token_helper.jwks.as_json }
24
+
25
+ let(:public_key_url) do
26
+ "#{config_server_url}/realms/#{config_realm_id}/protocol/openid-connect/certs"
27
+ end
28
+
29
+ let(:mocked_public_key_response) do
30
+ {
31
+ body: jwks_response.to_json,
32
+ status: 200
33
+ }
34
+ end
35
+
36
+ around do |example|
37
+ with_env(mocked_config_env.transform_values(&:to_s)) do
38
+ mocked_config = KeycloakRack::Config.new realm_id: config_realm_id
39
+
40
+ mocked_config.skip_paths = config_skip_paths
41
+
42
+ KeycloakRack::Container.stub("keycloak-rack.config", mocked_config)
43
+
44
+ resolver = KeycloakRack::KeyResolver.new
45
+
46
+ KeycloakRack::Container.stub("keycloak-rack.key_resolver", resolver)
47
+
48
+ example.run
49
+ end
50
+ ensure
51
+ KeycloakRack::Container.unstub("keycloak-rack.config")
52
+ end
53
+
54
+ before do
55
+ stub_request(:get, public_key_url).
56
+ to_return(mocked_public_key_response)
57
+ end
58
+
59
+ # @return [void]
60
+ def refresh_public_keys!
61
+ KeycloakRack::Container["keycloak-rack.key_resolver"].refresh!
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context "with mocked rack application" do
4
+ attr_reader :last_rack_environment
5
+
6
+ let(:base_app_implementation) do
7
+ ->(env) do
8
+ @last_rack_environment = env
9
+
10
+ state = env["keycloak:session"].authenticate! do |m|
11
+ m.success(:authenticated) do
12
+ "authenticated"
13
+ end
14
+
15
+ m.success do
16
+ "anonymous"
17
+ end
18
+
19
+ m.failure do
20
+ "failed"
21
+ end
22
+ end
23
+
24
+ response = {
25
+ "auth_state" => state
26
+ }
27
+
28
+ [
29
+ 200,
30
+ { "Content-Type" => "application/json" },
31
+ [response.to_json]
32
+ ]
33
+ end
34
+ end
35
+
36
+ let(:rack_application) do
37
+ base_app_implementation.tap do |app|
38
+ allow(app).to receive(:call).and_call_original
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,27 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIEpAIBAAKCAQEAxFU3CRb04a2yy1rpACuj9+2HQu1RCw3MKmu2WZe3jicvth/r
3
+ fOYFBi7xoi0SPvur0oyVFffHhphGpNE0InL0EWIT9a1oSpwZdc+e3KqaZkO3c3b9
4
+ sHXPfJ8Sjhf/N50RHpb2sjz0qJ6mV4SyYgyBvnF1/iXFerFIgyTwmIXno2suIS7S
5
+ gu5EYxE/jR99Q8E1bvDXqhrfkAv0G1Ae5c2Wxg4qK7qu8ny+OXRQA921WNxBpToS
6
+ VHBCAhc4ga5tSxQ8Ll26ShLwD9SdKuYDZIndV1ubk01l7vrf6JY2tMJqFFu3Cqh3
7
+ Q19WptxcyIaEp/J4CDyTGpyNq9OuThIKWTveqQIDAQABAoIBAGIJPysNyIfsaUw7
8
+ //7yy7SgahtUT1Satik0keCY7rJQBPYHaFp8rWOSC1x07xh+KSVAx60phftCjHv+
9
+ bu8IwbDwbZEO3vXqjpgSbXw4wFJyW+ePMkxr94h+EhDcELffeU3yCgukfnK4jc1D
10
+ 2KM3JY5IL6gRilOitNevmWg/7RPfLugcxFM3a2dfAJ0kO9xUTr5Box/us8dqgk2G
11
+ rJNXqGYg5RpGsBcD08U2xMQCfNoz5OhTKCKkiXFi7bmqqnVVx7O20+afLd980T4J
12
+ AVPF0C/1NW/K7gjztorEP0uMGmmiWVfJ/tCW5okThMo37ZXT+PdLT5K0e20MLZXc
13
+ 7trqNGUCgYEA+pOUPW/HpVQaA0yGfH4AjbUPRbunXhb959imWS9qRX2mySOxkXa+
14
+ u+I0MqKDY75ksrvkrfWfdAe9Bb0aY2Y8wERKk0U1B4UJgupQjSGRU90wu8oHnpMR
15
+ GWZYR54WEBUUG2N7MiMd8eOChE0a4M4m27nn12pIxKLYmBSQT6BMSscCgYEAyJUT
16
+ vuc8YSCUVw9QwH8Ij7yxwRso3vo63eEDA30bm7XVjZSoxmMT457ZFs+W2DZOat7J
17
+ uId9H7H1Ipf0Q/hZj4mttbXnOK5hrWBNn2c8cF4wrvLdBIKxToz79s2tCE5cpsSi
18
+ fUv0pLri5MoUoQNUcq/Q7a0WljbZSZqvVpubmw8CgYEA6FPE8mGdnjCoHb7qQqsh
19
+ IEJr8p/WwmpW6Iv7UF2iDuQ9q+ioTtLmbZWCCCCd6fExtHZ5xMEkIpS6MYPv35F/
20
+ alTnQDy+ukYjV3qhTPl+oV9IPBVJk0GQbRhzaZOtqSOiDPLj2sysiwYCkWBcN2ts
21
+ o/VufFBTP94tLHSEiQ97LSkCgYEAk9IZrTTosIO8DrUAw/xaqONc9H05j6pFu8LZ
22
+ 37ZRpF1LNn36K8pUnAky37a46jqLbAMoEk/3jGYvzADESVs7VacXV7To5ELPRWCV
23
+ lAYW6pDfu+7Lp0lRthv8jJRjEp39dgGv5jsV3ljEYevza/3yPFsJ1D8dSDK/y5it
24
+ 41vmP00CgYBFuydMDA2s9kLS5L8S0sLcinHJobjrCc4VsX8RApMHnrltl7O698Zt
25
+ 6H72ovCXgsL07JztfWyIVPH1Z2BDzJE8tjc6FrZP/EvmOsdHcW97uBkap41mNGDR
26
+ /THOPW/BFimtXDch2f2Ja5sDf6nDNBmee8908cSrnObOaAzgQoDdeg==
27
+ -----END RSA PRIVATE KEY-----
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api private
4
+ class TokenHelper
5
+ extend Dry::Initializer
6
+
7
+ ALGORITHM = "RS256"
8
+
9
+ option :pem_path, Dry::Types["coercible.string"], default: proc { "#{__dir__}/test_key.pem" }
10
+
11
+ def build_token(data: {}, issued_at: Time.current, expires_at: 3.hours.from_now, jti: SecureRandom.uuid, with_random_jwk: false)
12
+ enriched_data = FactoryBot.attributes_for(:token_payload, data)
13
+
14
+ payload = enriched_data.symbolize_keys.merge(
15
+ aud: "keycloak",
16
+ auth_time: issued_at.to_i,
17
+ exp: expires_at.to_i,
18
+ iat: issued_at.to_i,
19
+ jti: jti,
20
+ typ: "JWT",
21
+ azp: "keycloak",
22
+ )
23
+
24
+ jwk = with_random_jwk ? random_jwk : self.jwk
25
+
26
+ headers = {
27
+ kid: jwk.kid
28
+ }
29
+
30
+ JWT.encode payload, jwk.keypair, ALGORITHM, headers
31
+ end
32
+
33
+ # @return [(Hash, Hash)]
34
+ def decode_token(token, leeway: resolve_config(:token_leeway))
35
+ options = {
36
+ algorithms: [ALGORITHM],
37
+ leeway: leeway,
38
+ jwks: { keys: [jwk.export] },
39
+ }
40
+
41
+ JWT.decode token, nil, true, options
42
+ end
43
+
44
+ # @return [KeycloakRack::DecodedToken]
45
+ def build_decoded_token(**options)
46
+ leeway = options.delete(:leeway)
47
+
48
+ decode_options = { leeway: leeway }
49
+
50
+ token = build_token(**options)
51
+
52
+ payload, headers = decode_token token, **decode_options
53
+
54
+ KeycloakRack::DecodedToken.new payload.merge(original_payload: payload, headers: headers)
55
+ end
56
+
57
+ def jwk
58
+ @jwk ||= JWT::JWK.new rsa_key
59
+ end
60
+
61
+ def random_jwk
62
+ JWT::JWK.new OpenSSL::PKey::RSA.generate 2048
63
+ end
64
+
65
+ def jwks
66
+ @jwks ||= { keys: [jwk.export.reverse_merge(alg: ALGORITHM)] }
67
+ end
68
+
69
+ def resolve_config(value)
70
+ KeycloakRack::Container["keycloak-rack.config"].public_send(value)
71
+ end
72
+
73
+ def rsa_key
74
+ @rsa_key ||= OpenSSL::PKey::RSA.new File.read pem_path
75
+ end
76
+ end