Commit 73da5c4410a8a9dafc25fc0bda3eaf4e6beed2a0
Committed by
GitHub
Exists in
master
Merge pull request #15 from lessonly/azemoh/ch35118/add-bearer-token-auth-to-scim-rails-gem
Add support for OAuth Bearer Authentication
Showing
14 changed files
with
215 additions
and
9 deletions
Show diff stats
Gemfile.lock
1 | PATH | 1 | PATH |
2 | remote: . | 2 | remote: . |
3 | specs: | 3 | specs: |
4 | - scim_rails (0.2.1) | 4 | + scim_rails (0.3.0) |
5 | + jwt (~> 2.2) | ||
5 | rails (>= 5.0, < 6.1) | 6 | rails (>= 5.0, < 6.1) |
6 | 7 | ||
7 | GEM | 8 | GEM |
@@ -77,6 +78,7 @@ GEM | @@ -77,6 +78,7 @@ GEM | ||
77 | activesupport (>= 4.2.0) | 78 | activesupport (>= 4.2.0) |
78 | i18n (1.6.0) | 79 | i18n (1.6.0) |
79 | concurrent-ruby (~> 1.0) | 80 | concurrent-ruby (~> 1.0) |
81 | + jwt (2.2.1) | ||
80 | loofah (2.3.1) | 82 | loofah (2.3.1) |
81 | crass (~> 1.0.2) | 83 | crass (~> 1.0.2) |
82 | nokogiri (>= 1.5.9) | 84 | nokogiri (>= 1.5.9) |
@@ -175,4 +177,4 @@ RUBY VERSION | @@ -175,4 +177,4 @@ RUBY VERSION | ||
175 | ruby 2.4.4p296 | 177 | ruby 2.4.4p296 |
176 | 178 | ||
177 | BUNDLED WITH | 179 | BUNDLED WITH |
178 | - 2.0.1 | 180 | + 2.0.2 |
README.md
@@ -78,6 +78,57 @@ When sending requests to the server the `Content-Type` should be set to `applica | @@ -78,6 +78,57 @@ When sending requests to the server the `Content-Type` should be set to `applica | ||
78 | 78 | ||
79 | All responses will be sent with a `Content-Type` of `application/scim+json`. | 79 | All responses will be sent with a `Content-Type` of `application/scim+json`. |
80 | 80 | ||
81 | +#### Authentication | ||
82 | + | ||
83 | +This gem supports both basic and OAuth bearer authentication. | ||
84 | + | ||
85 | +##### Basic Auth | ||
86 | +###### Username | ||
87 | +The config setting `basic_auth_model_searchable_attribute` is the model attribute used to authenticate as the `username`. It defaults to `:subdomain`. | ||
88 | + | ||
89 | +Ensure it is unique to the model records. | ||
90 | + | ||
91 | +###### Password | ||
92 | +The config setting `basic_auth_model_authenticatable_attribute` is the model attribute used to authenticate as `password`. Defaults to `:api_token`. | ||
93 | + | ||
94 | +Assuming the attribute is `:api_token`, generate the password using: | ||
95 | +```ruby | ||
96 | +token = ScimRails::Encoder.encode(company) | ||
97 | +# use the token as password for requests | ||
98 | +company.api_token = token # required | ||
99 | +company.save! # don't forget to persist the company record | ||
100 | +``` | ||
101 | + | ||
102 | +This is necessary irrespective of your authentication choice(s) - basic auth, oauth bearer or both. | ||
103 | + | ||
104 | +###### Sample Request | ||
105 | + | ||
106 | +```bash | ||
107 | +$ curl -X GET 'http://username:password@localhost:3000/scim/v2/Users' | ||
108 | +``` | ||
109 | + | ||
110 | +##### OAuth Bearer | ||
111 | + | ||
112 | +###### Signing Algorithm | ||
113 | +In the config settings, ensure you set `signing_algorithm` to a valid JWT signing algorithm, e.g "HS256". Defaults to `"none"` when not set. | ||
114 | + | ||
115 | +###### Signing Secret | ||
116 | +In the config settings, ensure you set `signing_secret` to a secret key that will be used to encode and decode tokens. Defaults to `nil` when not set. | ||
117 | + | ||
118 | +If you have already generated the `api_token` in the "Basic Auth" section, then use that as your bearer token and ignore the steps below: | ||
119 | +```ruby | ||
120 | +token = ScimRails::Encoder.encode(company) | ||
121 | +# use the token as bearer token for requests | ||
122 | +company.api_token = token #required | ||
123 | +company.save! # don't forget to persist the company record | ||
124 | +``` | ||
125 | + | ||
126 | +##### Sample Request | ||
127 | + | ||
128 | +```bash | ||
129 | +$ curl -H 'Authorization: Bearer xxxxxxx.xxxxxx' -X GET 'http://localhost:3000/scim/v2/Users' | ||
130 | +``` | ||
131 | + | ||
81 | ### List | 132 | ### List |
82 | 133 | ||
83 | ##### All | 134 | ##### All |
app/controllers/scim_rails/application_controller.rb
@@ -9,14 +9,30 @@ module ScimRails | @@ -9,14 +9,30 @@ module ScimRails | ||
9 | private | 9 | private |
10 | 10 | ||
11 | def authorize_request | 11 | def authorize_request |
12 | - authenticate_with_http_basic do |username, password| | 12 | + send(authentication_strategy) do |searchable_attribute, authentication_attribute| |
13 | authorization = AuthorizeApiRequest.new( | 13 | authorization = AuthorizeApiRequest.new( |
14 | - searchable_attribute: username, | ||
15 | - authentication_attribute: password | 14 | + searchable_attribute: searchable_attribute, |
15 | + authentication_attribute: authentication_attribute | ||
16 | ) | 16 | ) |
17 | @company = authorization.company | 17 | @company = authorization.company |
18 | end | 18 | end |
19 | - raise ScimRails::ExceptionHandler::InvalidCredentials if @company.blank? | 19 | + raise ScimRails::ExceptionHandler::InvalidCredentials if @company.blank? |
20 | + end | ||
21 | + | ||
22 | + def authentication_strategy | ||
23 | + if request.headers["Authorization"]&.include?("Bearer") | ||
24 | + :authenticate_with_oauth_bearer | ||
25 | + else | ||
26 | + :authenticate_with_http_basic | ||
27 | + end | ||
28 | + end | ||
29 | + | ||
30 | + def authenticate_with_oauth_bearer | ||
31 | + authentication_attribute = request.headers["Authorization"].split(" ").last | ||
32 | + payload = ScimRails::Encoder.decode(authentication_attribute).with_indifferent_access | ||
33 | + searchable_attribute = payload[ScimRails.config.basic_auth_model_searchable_attribute] | ||
34 | + | ||
35 | + yield searchable_attribute, authentication_attribute | ||
20 | end | 36 | end |
21 | end | 37 | end |
22 | end | 38 | end |
lib/generators/scim_rails/templates/initializer.rb
@@ -22,6 +22,18 @@ ScimRails.configure do |config| | @@ -22,6 +22,18 @@ ScimRails.configure do |config| | ||
22 | # or throws an error (returning 409 Conflict in accordance with SCIM spec) | 22 | # or throws an error (returning 409 Conflict in accordance with SCIM spec) |
23 | config.scim_user_prevent_update_on_create = false | 23 | config.scim_user_prevent_update_on_create = false |
24 | 24 | ||
25 | + # Cryptographic algorithm used for signing the auth tokens. | ||
26 | + # It supports all algorithms supported by the jwt gem. | ||
27 | + # See https://github.com/jwt/ruby-jwt#algorithms-and-usage for supported algorithms | ||
28 | + # It is "none" by default, hence generated tokens are unsigned | ||
29 | + # The tokens do not need to be signed if you only need basic authentication. | ||
30 | + # config.signing_algorithm = "HS256" | ||
31 | + | ||
32 | + # Secret token used to sign authorization tokens | ||
33 | + # It is `nil` by default, hence generated tokens are unsigned | ||
34 | + # The tokens do not need to be signed if you only need basic authentication. | ||
35 | + # config.signing_secret = SECRET_TOKEN | ||
36 | + | ||
25 | # Default sort order for pagination is by id. If you | 37 | # Default sort order for pagination is by id. If you |
26 | # use non sequential ids for user records, uncomment | 38 | # use non sequential ids for user records, uncomment |
27 | # the below line and configure a determinate order. | 39 | # the below line and configure a determinate order. |
lib/scim_rails.rb
lib/scim_rails/config.rb
@@ -10,6 +10,8 @@ module ScimRails | @@ -10,6 +10,8 @@ module ScimRails | ||
10 | end | 10 | end |
11 | 11 | ||
12 | class Config | 12 | class Config |
13 | + ALGO_NONE = "none".freeze | ||
14 | + | ||
13 | attr_accessor \ | 15 | attr_accessor \ |
14 | :basic_auth_model, | 16 | :basic_auth_model, |
15 | :basic_auth_model_authenticatable_attribute, | 17 | :basic_auth_model_authenticatable_attribute, |
@@ -21,6 +23,8 @@ module ScimRails | @@ -21,6 +23,8 @@ module ScimRails | ||
21 | :scim_users_model, | 23 | :scim_users_model, |
22 | :scim_users_scope, | 24 | :scim_users_scope, |
23 | :scim_user_prevent_update_on_create, | 25 | :scim_user_prevent_update_on_create, |
26 | + :signing_secret, | ||
27 | + :signing_algorithm, | ||
24 | :user_attributes, | 28 | :user_attributes, |
25 | :user_deprovision_method, | 29 | :user_deprovision_method, |
26 | :user_reprovision_method, | 30 | :user_reprovision_method, |
@@ -30,6 +34,7 @@ module ScimRails | @@ -30,6 +34,7 @@ module ScimRails | ||
30 | @basic_auth_model = "Company" | 34 | @basic_auth_model = "Company" |
31 | @scim_users_list_order = :id | 35 | @scim_users_list_order = :id |
32 | @scim_users_model = "User" | 36 | @scim_users_model = "User" |
37 | + @signing_algorithm = ALGO_NONE | ||
33 | @user_schema = {} | 38 | @user_schema = {} |
34 | @user_attributes = [] | 39 | @user_attributes = [] |
35 | end | 40 | end |
@@ -0,0 +1,25 @@ | @@ -0,0 +1,25 @@ | ||
1 | +require "jwt" | ||
2 | + | ||
3 | +module ScimRails | ||
4 | + module Encoder | ||
5 | + extend self | ||
6 | + | ||
7 | + def encode(company) | ||
8 | + payload = { | ||
9 | + iat: Time.current.to_i, | ||
10 | + ScimRails.config.basic_auth_model_searchable_attribute => | ||
11 | + company.public_send(ScimRails.config.basic_auth_model_searchable_attribute) | ||
12 | + } | ||
13 | + | ||
14 | + JWT.encode(payload, ScimRails.config.signing_secret, ScimRails.config.signing_algorithm) | ||
15 | + end | ||
16 | + | ||
17 | + def decode(token) | ||
18 | + verify = ScimRails.config.signing_algorithm != ScimRails::Config::ALGO_NONE | ||
19 | + | ||
20 | + JWT.decode(token, ScimRails.config.signing_secret, verify, algorithm: ScimRails.config.signing_algorithm).first | ||
21 | + rescue JWT::VerificationError, JWT::DecodeError | ||
22 | + raise ScimRails::ExceptionHandler::InvalidCredentials | ||
23 | + end | ||
24 | + end | ||
25 | +end |
lib/scim_rails/version.rb
scim_rails.gemspec
@@ -18,6 +18,7 @@ Gem::Specification.new do |s| | @@ -18,6 +18,7 @@ Gem::Specification.new do |s| | ||
18 | 18 | ||
19 | s.required_ruby_version = "~> 2.4" | 19 | s.required_ruby_version = "~> 2.4" |
20 | s.add_dependency "rails", ">= 5.0", "< 6.1" | 20 | s.add_dependency "rails", ">= 5.0", "< 6.1" |
21 | + s.add_runtime_dependency "jwt", "~> 1.5.1" | ||
21 | s.test_files = Dir["spec/**/*"] | 22 | s.test_files = Dir["spec/**/*"] |
22 | 23 | ||
23 | s.add_development_dependency "bundler", "~> 2.0" | 24 | s.add_development_dependency "bundler", "~> 2.0" |
spec/controllers/scim_rails/scim_users_request_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe ScimRails::ScimUsersController, type: :request do | @@ -5,7 +5,7 @@ RSpec.describe ScimRails::ScimUsersController, type: :request do | ||
5 | let(:credentials) { Base64::encode64("#{company.subdomain}:#{company.api_token}") } | 5 | let(:credentials) { Base64::encode64("#{company.subdomain}:#{company.api_token}") } |
6 | let(:authorization) { "Basic #{credentials}" } | 6 | let(:authorization) { "Basic #{credentials}" } |
7 | 7 | ||
8 | - def post_request(content_type) | 8 | + def post_request(content_type = "application/scim+json") |
9 | # params need to be transformed into a string to test if they are being parsed by Rack | 9 | # params need to be transformed into a string to test if they are being parsed by Rack |
10 | 10 | ||
11 | post "/scim_rails/scim/v2/Users", | 11 | post "/scim_rails/scim/v2/Users", |
@@ -48,4 +48,26 @@ RSpec.describe ScimRails::ScimUsersController, type: :request do | @@ -48,4 +48,26 @@ RSpec.describe ScimRails::ScimUsersController, type: :request do | ||
48 | expect(company.users.count).to eq 0 | 48 | expect(company.users.count).to eq 0 |
49 | end | 49 | end |
50 | end | 50 | end |
51 | + | ||
52 | + context "OAuth Bearer Authorization" do | ||
53 | + context "with valid token" do | ||
54 | + let(:authorization) { "Bearer #{company.api_token}" } | ||
55 | + | ||
56 | + it "supports OAuth bearer authorization and succeeds" do | ||
57 | + expect { post_request }.to change(company.users, :count).from(0).to(1) | ||
58 | + | ||
59 | + expect(response.status).to eq 201 | ||
60 | + end | ||
61 | + end | ||
62 | + | ||
63 | + context "with invalid token" do | ||
64 | + let(:authorization) { "Bearer #{SecureRandom.hex}" } | ||
65 | + | ||
66 | + it "The request fails" do | ||
67 | + expect { post_request }.not_to change(company.users, :count) | ||
68 | + | ||
69 | + expect(response.status).to eq 401 | ||
70 | + end | ||
71 | + end | ||
72 | + end | ||
51 | end | 73 | end |
spec/dummy/config/initializers/scim_rails_config.rb
@@ -7,6 +7,9 @@ ScimRails.configure do |config| | @@ -7,6 +7,9 @@ ScimRails.configure do |config| | ||
7 | config.scim_users_scope = :users | 7 | config.scim_users_scope = :users |
8 | config.scim_users_list_order = :id | 8 | config.scim_users_list_order = :id |
9 | 9 | ||
10 | + config.signing_algorithm = "HS256" | ||
11 | + config.signing_secret = "2d6806dd11c2fece2e81b8ca76dcb0062f5b08e28e3264e8ba1c44bbd3578b70" | ||
12 | + | ||
10 | config.user_deprovision_method = :archive! | 13 | config.user_deprovision_method = :archive! |
11 | config.user_reprovision_method = :unarchive! | 14 | config.user_reprovision_method = :unarchive! |
12 | 15 |
spec/factories/company.rb
@@ -2,6 +2,9 @@ FactoryBot.define do | @@ -2,6 +2,9 @@ FactoryBot.define do | ||
2 | factory :company do | 2 | factory :company do |
3 | name { "Test Company" } | 3 | name { "Test Company" } |
4 | subdomain { "test" } | 4 | subdomain { "test" } |
5 | - api_token { "1" } | 5 | + |
6 | + after(:build) do |company| | ||
7 | + company.api_token = ScimRails::Encoder.encode(company) | ||
8 | + end | ||
6 | end | 9 | end |
7 | end | 10 | end |
@@ -0,0 +1,62 @@ | @@ -0,0 +1,62 @@ | ||
1 | +require "spec_helper" | ||
2 | + | ||
3 | +describe ScimRails::Encoder do | ||
4 | + let(:company) { Company.new(subdomain: "test") } | ||
5 | + | ||
6 | + describe "::encode" do | ||
7 | + context "with signing configuration" do | ||
8 | + it "generates a signed token with the company attribute" do | ||
9 | + token = ScimRails::Encoder.encode(company) | ||
10 | + payload = ScimRails::Encoder.decode(token) | ||
11 | + | ||
12 | + expect(token).to match /[a-z|A-Z|0-9.]{16,}\.[a-z|A-Z|0-9.]{16,}/ | ||
13 | + expect(payload).to contain_exactly(["iat", Integer], ["subdomain", "test"]) | ||
14 | + end | ||
15 | + end | ||
16 | + | ||
17 | + context "without signing configuration" do | ||
18 | + before do | ||
19 | + allow(ScimRails.config).to receive(:signing_secret).and_return(nil) | ||
20 | + allow(ScimRails.config).to receive(:signing_algorithm).and_return(ScimRails::Config::ALGO_NONE) | ||
21 | + end | ||
22 | + | ||
23 | + it "generates an unsigned token with the company attribute" do | ||
24 | + token = ScimRails::Encoder.encode(company) | ||
25 | + payload = ScimRails::Encoder.decode(token) | ||
26 | + | ||
27 | + expect(token).to match /[a-z|A-Z|0-9.]{16,}/ | ||
28 | + expect(payload).to contain_exactly(["iat", Integer], ["subdomain", "test"]) | ||
29 | + end | ||
30 | + end | ||
31 | + end | ||
32 | + | ||
33 | + describe "::decode" do | ||
34 | + let(:token) { ScimRails::Encoder.encode(company) } | ||
35 | + | ||
36 | + it "raises InvalidCredentials error for an invalid token" do | ||
37 | + token = "f487bf84bfub4f74fj4894fnh483f4h4u8f" | ||
38 | + expect { ScimRails::Encoder.decode(token) }.to raise_error ScimRails::ExceptionHandler::InvalidCredentials | ||
39 | + end | ||
40 | + | ||
41 | + context "with signing configuration" do | ||
42 | + it "decodes a signed token, returning the company attributes" do | ||
43 | + payload = ScimRails::Encoder.decode(token) | ||
44 | + | ||
45 | + expect(payload).to contain_exactly(["iat", Integer], ["subdomain", "test"]) | ||
46 | + end | ||
47 | + end | ||
48 | + | ||
49 | + context "without signing configuration" do | ||
50 | + before do | ||
51 | + allow(ScimRails.config).to receive(:signing_secret).and_return(nil) | ||
52 | + allow(ScimRails.config).to receive(:signing_algorithm).and_return(ScimRails::Config::ALGO_NONE) | ||
53 | + end | ||
54 | + | ||
55 | + it "decodes an unsigned token, returning the company attributes" do | ||
56 | + payload = ScimRails::Encoder.decode(token) | ||
57 | + | ||
58 | + expect(payload).to contain_exactly(["iat", Integer], ["subdomain", "test"]) | ||
59 | + end | ||
60 | + end | ||
61 | + end | ||
62 | +end |
spec/support/scim_rails_config.rb
@@ -10,6 +10,9 @@ ScimRails.configure do |config| | @@ -10,6 +10,9 @@ ScimRails.configure do |config| | ||
10 | config.scim_users_scope = :users | 10 | config.scim_users_scope = :users |
11 | config.scim_users_list_order = :id | 11 | config.scim_users_list_order = :id |
12 | 12 | ||
13 | + config.signing_algorithm = "HS256" | ||
14 | + config.signing_secret = "2d6806dd11c2fece2e81b8ca76dcb0062f5b08e28e3264e8ba1c44bbd3578b70" | ||
15 | + | ||
13 | config.user_deprovision_method = :archive! | 16 | config.user_deprovision_method = :archive! |
14 | config.user_reprovision_method = :unarchive! | 17 | config.user_reprovision_method = :unarchive! |
15 | 18 |