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 | 1 | PATH |
2 | 2 | remote: . |
3 | 3 | specs: |
4 | - scim_rails (0.2.1) | |
4 | + scim_rails (0.3.0) | |
5 | + jwt (~> 2.2) | |
5 | 6 | rails (>= 5.0, < 6.1) |
6 | 7 | |
7 | 8 | GEM |
... | ... | @@ -77,6 +78,7 @@ GEM |
77 | 78 | activesupport (>= 4.2.0) |
78 | 79 | i18n (1.6.0) |
79 | 80 | concurrent-ruby (~> 1.0) |
81 | + jwt (2.2.1) | |
80 | 82 | loofah (2.3.1) |
81 | 83 | crass (~> 1.0.2) |
82 | 84 | nokogiri (>= 1.5.9) |
... | ... | @@ -175,4 +177,4 @@ RUBY VERSION |
175 | 177 | ruby 2.4.4p296 |
176 | 178 | |
177 | 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 | 78 | |
79 | 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 | 132 | ### List |
82 | 133 | |
83 | 134 | ##### All | ... | ... |
app/controllers/scim_rails/application_controller.rb
... | ... | @@ -9,14 +9,30 @@ module ScimRails |
9 | 9 | private |
10 | 10 | |
11 | 11 | def authorize_request |
12 | - authenticate_with_http_basic do |username, password| | |
12 | + send(authentication_strategy) do |searchable_attribute, authentication_attribute| | |
13 | 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 | 17 | @company = authorization.company |
18 | 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 | 36 | end |
21 | 37 | end |
22 | 38 | end | ... | ... |
lib/generators/scim_rails/templates/initializer.rb
... | ... | @@ -22,6 +22,18 @@ ScimRails.configure do |config| |
22 | 22 | # or throws an error (returning 409 Conflict in accordance with SCIM spec) |
23 | 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 | 37 | # Default sort order for pagination is by id. If you |
26 | 38 | # use non sequential ids for user records, uncomment |
27 | 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 | 10 | end |
11 | 11 | |
12 | 12 | class Config |
13 | + ALGO_NONE = "none".freeze | |
14 | + | |
13 | 15 | attr_accessor \ |
14 | 16 | :basic_auth_model, |
15 | 17 | :basic_auth_model_authenticatable_attribute, |
... | ... | @@ -21,6 +23,8 @@ module ScimRails |
21 | 23 | :scim_users_model, |
22 | 24 | :scim_users_scope, |
23 | 25 | :scim_user_prevent_update_on_create, |
26 | + :signing_secret, | |
27 | + :signing_algorithm, | |
24 | 28 | :user_attributes, |
25 | 29 | :user_deprovision_method, |
26 | 30 | :user_reprovision_method, |
... | ... | @@ -30,6 +34,7 @@ module ScimRails |
30 | 34 | @basic_auth_model = "Company" |
31 | 35 | @scim_users_list_order = :id |
32 | 36 | @scim_users_model = "User" |
37 | + @signing_algorithm = ALGO_NONE | |
33 | 38 | @user_schema = {} |
34 | 39 | @user_attributes = [] |
35 | 40 | end | ... | ... |
... | ... | @@ -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 | 18 | |
19 | 19 | s.required_ruby_version = "~> 2.4" |
20 | 20 | s.add_dependency "rails", ">= 5.0", "< 6.1" |
21 | + s.add_runtime_dependency "jwt", "~> 1.5.1" | |
21 | 22 | s.test_files = Dir["spec/**/*"] |
22 | 23 | |
23 | 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 | 5 | let(:credentials) { Base64::encode64("#{company.subdomain}:#{company.api_token}") } |
6 | 6 | let(:authorization) { "Basic #{credentials}" } |
7 | 7 | |
8 | - def post_request(content_type) | |
8 | + def post_request(content_type = "application/scim+json") | |
9 | 9 | # params need to be transformed into a string to test if they are being parsed by Rack |
10 | 10 | |
11 | 11 | post "/scim_rails/scim/v2/Users", |
... | ... | @@ -48,4 +48,26 @@ RSpec.describe ScimRails::ScimUsersController, type: :request do |
48 | 48 | expect(company.users.count).to eq 0 |
49 | 49 | end |
50 | 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 | 73 | end | ... | ... |
spec/dummy/config/initializers/scim_rails_config.rb
... | ... | @@ -7,6 +7,9 @@ ScimRails.configure do |config| |
7 | 7 | config.scim_users_scope = :users |
8 | 8 | config.scim_users_list_order = :id |
9 | 9 | |
10 | + config.signing_algorithm = "HS256" | |
11 | + config.signing_secret = "2d6806dd11c2fece2e81b8ca76dcb0062f5b08e28e3264e8ba1c44bbd3578b70" | |
12 | + | |
10 | 13 | config.user_deprovision_method = :archive! |
11 | 14 | config.user_reprovision_method = :unarchive! |
12 | 15 | ... | ... |
spec/factories/company.rb
... | ... | @@ -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 | 10 | config.scim_users_scope = :users |
11 | 11 | config.scim_users_list_order = :id |
12 | 12 | |
13 | + config.signing_algorithm = "HS256" | |
14 | + config.signing_secret = "2d6806dd11c2fece2e81b8ca76dcb0062f5b08e28e3264e8ba1c44bbd3578b70" | |
15 | + | |
13 | 16 | config.user_deprovision_method = :archive! |
14 | 17 | config.user_reprovision_method = :unarchive! |
15 | 18 | ... | ... |