gds-sso 20.0.0 → 21.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ce653302bc22f4fd60d83307eff612eac4a491eff439eed0c602c288d798ef9
4
- data.tar.gz: de16391f9abe70acb77dbb6b00ca2b2f3e5da82965777bf281099e03e8420204
3
+ metadata.gz: 72938e39cf2466b8a2dc2d22aca03d64ff33ab2a8c5d11c0c57f3710a2935205
4
+ data.tar.gz: 042b5c966919ab345d9ea3dc9e47197947fdd75e51ad263b0f0f038397c09fa9
5
5
  SHA512:
6
- metadata.gz: 51ad269ab4ba83b3b21c8fe9cdcc8b713c3b14c98e2068a4f6c06b198f75e7641ad087b15422bffabfcb5313e43ba255dce86f5ed6ea7ab6c0c8b92b151656ec
7
- data.tar.gz: 261cae8502648293bdd629d09379df37efd5ca632ffb0360463807d609174c9f532ebdce31993a48e55f6fd3576a09c792209a907c7647daa549b62623f044c3
6
+ metadata.gz: fdca3de72981e79aa420da0b3d6c4d689fba034d3d3cda4874082efe58bcf056128242ab09a4266b8d11f91da500d47b665e434ccb805a3f6a799d6d29f765f4
7
+ data.tar.gz: ad252eb2aeaf3986b68b54a15f7f4622f4d56e730d509690e1e80c77be6411201ebf618ba68efebaff825fe731a111de186b439b6834a4c1de91782745658335
data/README.md CHANGED
@@ -90,7 +90,7 @@ Authorization: Bearer your-token-here
90
90
 
91
91
  To avoid making these requests for each incoming request, this gem will [automatically cache a successful response](https://github.com/alphagov/gds-sso/blob/master/lib/gds-sso/bearer_token.rb), using the [Rails cache](https://github.com/alphagov/gds-sso/blob/master/lib/gds-sso/railtie.rb).
92
92
 
93
- If you are using a Rails 5 app in
93
+ If you are using a Rails app in
94
94
  [api_only](http://guides.rubyonrails.org/api_app.html) mode this gem will
95
95
  automatically disable the oauth layers which use session persistence. You can
96
96
  configure this gem to be in api_only mode (or not) with:
@@ -103,6 +103,19 @@ GDS::SSO.config do |config|
103
103
  end
104
104
  ```
105
105
 
106
+ For apps that have both usage of web and API you can configure a lambda to
107
+ match your API endpoints to ensure they don't support session auth and return
108
+ JSON error messages:
109
+
110
+
111
+ ```ruby
112
+ GDS::SSO.config do |config|
113
+ # ...
114
+ #
115
+ config.api_request_matcher = ->(request) { request.path.start_with?("/api/") }
116
+ end
117
+ ```
118
+
106
119
  ### Use in production mode
107
120
 
108
121
  To use gds-sso in production you will need to setup the following environment variables, which we look for in [the config](https://github.com/alphagov/gds-sso/blob/master/lib/gds-sso/config.rb). You will need to have admin access to Signon to get these.
data/config/routes.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  Rails.application.routes.draw do
2
+ put "/auth/gds/api/users/:uid", to: "api/user#update"
3
+ post "/auth/gds/api/users/:uid/reauth", to: "api/user#reauth"
4
+
2
5
  next if GDS::SSO::Config.api_only
3
6
 
4
7
  get "/auth/gds/callback", to: "authentications#callback", as: :gds_sign_in
5
8
  get "/auth/gds/sign_out", to: "authentications#sign_out", as: :gds_sign_out
6
- get "/auth/failure", to: "authentications#failure", as: :auth_failure
7
- put "/auth/gds/api/users/:uid", to: "api/user#update"
8
- post "/auth/gds/api/users/:uid/reauth", to: "api/user#reauth"
9
+ get "/auth/failure", to: "authentications#failure", as: :auth_failure
9
10
  end
@@ -1,8 +1,34 @@
1
+ require "rack/request"
2
+
1
3
  module GDS
2
4
  module SSO
3
5
  class ApiAccess
4
6
  def self.api_call?(env)
5
- env["HTTP_AUTHORIZATION"].to_s =~ /\ABearer /
7
+ return env["gds_sso.api_call"] unless env["gds_sso.api_call"].nil?
8
+ return true if GDS::SSO::Config.api_only
9
+
10
+ if GDS::SSO::Config.api_request_matcher
11
+ request = Rack::Request.new(env)
12
+
13
+ gds_sso_api_request_matcher = GDS::SSO::Config.gds_sso_api_request_matcher
14
+ return true if gds_sso_api_request_matcher&.call(request)
15
+
16
+ return GDS::SSO::Config.api_request_matcher.call(request)
17
+ end
18
+
19
+ !bearer_token(env).nil?
20
+ end
21
+
22
+ def self.bearer_token(env)
23
+ Rack::Auth::AbstractRequest::AUTHORIZATION_KEYS.each do |key|
24
+ next unless env.key?(key)
25
+
26
+ if (match = env[key].match(/\ABearer (.+)/))
27
+ return match[1]
28
+ end
29
+ end
30
+
31
+ nil
6
32
  end
7
33
  end
8
34
  end
@@ -6,10 +6,9 @@ module GDS
6
6
  end
7
7
 
8
8
  def matches?(request)
9
- warden = request.env["warden"]
10
- warden.authenticate! if !warden.authenticated? || warden.user.remotely_signed_out?
9
+ user = GDS::SSO.authenticate_user!(request.env["warden"])
11
10
 
12
- GDS::SSO::AuthoriseUser.call(warden.user, permissions)
11
+ GDS::SSO::AuthoriseUser.call(user, permissions)
13
12
  true
14
13
  end
15
14
 
@@ -6,6 +6,8 @@ module GDS
6
6
  module SSO
7
7
  module BearerToken
8
8
  def self.locate(token_string)
9
+ return if token_string.nil? || token_string.empty?
10
+
9
11
  user_details = GDS::SSO::Config.cache.fetch(["api-user-cache", token_string], expires_in: 5.minutes) do
10
12
  access_token = OAuth2::AccessToken.new(oauth_client, token_string)
11
13
  response_body = access_token.get("/user.json?client_id=#{CGI.escape(GDS::SSO::Config.oauth_id)}").body
@@ -56,7 +58,10 @@ module GDS
56
58
 
57
59
  module MockBearerToken
58
60
  def self.locate(_token_string)
59
- dummy_api_user = GDS::SSO.test_user || GDS::SSO::Config.user_klass.where(email: "dummyapiuser@domain.com").first
61
+ return unless ENV["GDS_SSO_MOCK_INVALID"].to_s.empty?
62
+ return GDS::SSO.test_user if GDS::SSO.test_user
63
+
64
+ dummy_api_user = GDS::SSO::Config.user_klass.where(email: "dummyapiuser@domain.com").first
60
65
  if dummy_api_user.nil?
61
66
  dummy_api_user = GDS::SSO::Config.user_klass.new
62
67
  dummy_api_user.email = "dummyapiuser@domain.com"
@@ -29,6 +29,13 @@ module GDS
29
29
 
30
30
  mattr_accessor :api_only
31
31
 
32
+ mattr_accessor :api_request_matcher
33
+
34
+ # A matcher for GDS SSO's own API for updating user details. Shouldn't
35
+ # need configuring unless you mount the API at a different location.
36
+ mattr_accessor :gds_sso_api_request_matcher
37
+ @@gds_sso_api_request_matcher = ->(request) { request.path.start_with?("/auth/gds/api/") }
38
+
32
39
  mattr_accessor :intercept_401_responses
33
40
  @@intercept_401_responses = true
34
41
 
@@ -4,17 +4,9 @@ module GDS
4
4
  end
5
5
 
6
6
  module ControllerMethods
7
- # TODO: remove this for the next major release
8
- class PermissionDeniedException < PermissionDeniedError
9
- def initialize(...)
10
- warn "GDS::SSO::ControllerMethods::PermissionDeniedException is deprecated, please replace with GDS::SSO::PermissionDeniedError"
11
- super(...)
12
- end
13
- end
14
-
15
7
  def self.included(base)
16
8
  base.rescue_from PermissionDeniedError do |e|
17
- if GDS::SSO::Config.api_only
9
+ if GDS::SSO::ApiAccess.api_call?(request.env)
18
10
  render json: { message: e.message }, status: :forbidden
19
11
  else
20
12
  render "authorisations/unauthorised", layout: "unauthorised", status: :forbidden, locals: { message: e.message }
@@ -6,20 +6,19 @@ require "rails"
6
6
  module GDS
7
7
  module SSO
8
8
  class FailureApp < ActionController::Metal
9
- include ActionController::UrlFor
10
9
  include ActionController::Redirecting
11
10
  include AbstractController::Rendering
12
11
  include ActionController::Rendering
13
12
  include ActionController::Renderers
14
13
  use_renderers :json
15
14
 
16
- include Rails.application.routes.url_helpers
17
-
18
15
  def self.call(env)
19
- if GDS::SSO::ApiAccess.api_call?(env)
20
- action(:api_invalid_token).call(env)
21
- elsif GDS::SSO::Config.api_only
22
- action(:api_missing_token).call(env)
16
+ if env["gds_sso.api_call"]
17
+ if env["gds_sso.api_bearer_token_present"]
18
+ action(:api_invalid_token).call(env)
19
+ else
20
+ action(:api_missing_token).call(env)
21
+ end
23
22
  else
24
23
  action(:redirect).call(env)
25
24
  end
@@ -1,5 +1,5 @@
1
1
  module GDS
2
2
  module SSO
3
- VERSION = "20.0.0".freeze
3
+ VERSION = "21.1.0".freeze
4
4
  end
5
5
  end
@@ -6,6 +6,12 @@ def logger
6
6
  Rails.logger || env["rack.logger"]
7
7
  end
8
8
 
9
+ Warden::Manager.on_request do |proxy|
10
+ proxy.env["gds_sso.api_call"] ||= ::GDS::SSO::ApiAccess.api_call?(proxy.env)
11
+ proxy.env["gds_sso.api_bearer_token_present"] ||=
12
+ proxy.env["gds_sso.api_call"] && !::GDS::SSO::ApiAccess.bearer_token(proxy.env).nil?
13
+ end
14
+
9
15
  Warden::Manager.after_authentication do |user, _auth, _opts|
10
16
  # We've successfully signed in.
11
17
  # If they were remotely signed out, clear the flag as they're no longer suspended
@@ -35,7 +41,7 @@ end
35
41
 
36
42
  Warden::Strategies.add(:gds_sso) do
37
43
  def valid?
38
- !::GDS::SSO::ApiAccess.api_call?(env)
44
+ !env["gds_sso.api_call"]
39
45
  end
40
46
 
41
47
  def authenticate!
@@ -61,11 +67,32 @@ end
61
67
  Warden::OAuth2.configure do |config|
62
68
  config.token_model = GDS::SSO::Config.use_mock_strategies? ? GDS::SSO::MockBearerToken : GDS::SSO::BearerToken
63
69
  end
64
- Warden::Strategies.add(:gds_bearer_token, Warden::OAuth2::Strategies::Bearer)
70
+
71
+ # We're using our own bearer token strategy rather than the one in Warden::OAuth2
72
+ # so that we can have all requests match either a bearer token or a session
73
+ # strategy. It also allows us to avoid multiple DB queries to locate a user.
74
+ Warden::Strategies.add(:gds_bearer_token, Class.new(Warden::OAuth2::Strategies::Token)) do
75
+ def valid?
76
+ env["gds_sso.api_call"]
77
+ end
78
+
79
+ def token_string
80
+ @token_string ||= GDS::SSO::ApiAccess.bearer_token(env)
81
+ end
82
+
83
+ def token
84
+ # Using a defined? based memo approach over @token ||= so that we can set
85
+ # @token to nil and not re-evaluate the assignment.
86
+ return @token if defined? @token
87
+
88
+ @token = Warden::OAuth2.config.token_model.locate(token_string)
89
+ @token
90
+ end
91
+ end
65
92
 
66
93
  Warden::Strategies.add(:mock_gds_sso) do
67
94
  def valid?
68
- !::GDS::SSO::ApiAccess.api_call?(env)
95
+ !env["gds_sso.api_call"]
69
96
  end
70
97
 
71
98
  def authenticate!
data/lib/gds-sso.rb CHANGED
@@ -25,6 +25,12 @@ module GDS
25
25
  yield GDS::SSO::Config
26
26
  end
27
27
 
28
+ def self.authenticate_user!(warden)
29
+ warden.authenticate! if !warden.authenticated? || warden.user.remotely_signed_out?
30
+
31
+ warden.user
32
+ end
33
+
28
34
  class Engine < ::Rails::Engine
29
35
  # Force routes to be loaded if we are doing any eager load.
30
36
  # TODO - check this one - Stolen from Devise because it looked sensible...
@@ -5,7 +5,7 @@ Rails.application.routes.draw do
5
5
  get "/restricted" => "example#restricted"
6
6
  get "/this-requires-execute-permission" => "example#this_requires_execute_permission"
7
7
 
8
- constraints(GDS::SSO::AuthorisedUserConstraint.new("execute")) do
8
+ constraints(GDS::SSO::AuthorisedUserConstraint.new("constraint")) do
9
9
  get "/constraint-restricted" => "example#constraint_restricted"
10
10
  end
11
11
  end
@@ -0,0 +1,144 @@
1
+ require "spec_helper"
2
+
3
+ describe "Api::UserController", type: :request do
4
+ shared_examples "rejects a request from an unauthenticated user" do |method, path|
5
+ it "rejects the request when a user is unauthenticated" do
6
+ stub_failed_signon_user_request
7
+ public_send(method, path, headers: { "Authorization" => "Bearer anything" })
8
+ expect(response).to have_http_status(:unauthorized)
9
+ end
10
+ end
11
+
12
+ shared_examples "rejects a request from an authenticated user lacking permission" do |method, path|
13
+ it "rejects the request when a user is authenticated but lacking permission" do
14
+ stub_successful_signon_user_request(permissions: [])
15
+ public_send(method, path, headers: { "Authorization" => "Bearer anything" })
16
+ expect(response).to have_http_status(:forbidden)
17
+ end
18
+ end
19
+
20
+ shared_examples "operates as an API endpoint if api_request_matcher doesn't match it" do |method, path|
21
+ it "doesn't redirect to /auth/gds because it's recognised as an API request" do
22
+ allow(GDS::SSO::Config)
23
+ .to receive(:api_request_matcher)
24
+ .and_return(->(_request) { false })
25
+
26
+ stub_failed_signon_user_request
27
+ public_send(method, path, headers: { "Authorization" => "Bearer anything" })
28
+ expect(response.media_type).to eq("application/json")
29
+ end
30
+ end
31
+
32
+ shared_examples "redirects to /auth/gds if gds_sso_api_request_matcher is configured to not match" do |method, path|
33
+ it "fails if gds_sso_api_request_matcher is configured to not match" do
34
+ allow(GDS::SSO::Config)
35
+ .to receive(:api_request_matcher)
36
+ .and_return(->(_request) { false })
37
+
38
+ allow(GDS::SSO::Config)
39
+ .to receive(:gds_sso_api_request_matcher)
40
+ .and_return(nil)
41
+
42
+ public_send(method, path, headers: { "Authorization" => "Bearer anything" })
43
+ expect(response).to redirect_to("/auth/gds")
44
+ end
45
+ end
46
+
47
+ describe "PUT /auth/gds/api/users/:uid" do
48
+ shared_params = [:put, "/auth/gds/api/users/#{SecureRandom.uuid}"]
49
+ it_behaves_like "rejects a request from an unauthenticated user", *shared_params
50
+ it_behaves_like "rejects a request from an authenticated user lacking permission", *shared_params
51
+ it_behaves_like "operates as an API endpoint if api_request_matcher doesn't match it", *shared_params
52
+ it_behaves_like "redirects to /auth/gds if gds_sso_api_request_matcher is configured to not match", *shared_params
53
+
54
+ it "updates an existing user" do
55
+ stub_successful_signon_user_request(permissions: %w[user_update_permission])
56
+
57
+ user = User.create!(
58
+ uid: SecureRandom.uuid,
59
+ email: "user@example.com",
60
+ name: "Example User",
61
+ permissions: [],
62
+ )
63
+
64
+ put "/auth/gds/api/users/#{user.uid}",
65
+ headers: { "Authorization" => "Bearer anything" },
66
+ params: user_update_params(user, { "name" => "John Matrix" }),
67
+ as: :json
68
+
69
+ expect(response).to have_http_status(:success)
70
+ expect(response.body).to eq("")
71
+ expect(user.reload.name).to eq("John Matrix")
72
+ end
73
+
74
+ it "creates a new user if a user does not exist" do
75
+ stub_successful_signon_user_request(permissions: %w[user_update_permission])
76
+
77
+ user = User.new(
78
+ uid: SecureRandom.uuid,
79
+ email: "user@example.com",
80
+ name: "Example User",
81
+ permissions: [],
82
+ )
83
+
84
+ put "/auth/gds/api/users/#{user.uid}",
85
+ headers: { "Authorization" => "Bearer anything" },
86
+ params: user_update_params(user),
87
+ as: :json
88
+
89
+ expect(response).to have_http_status(:success)
90
+ expect(response.body).to eq("")
91
+ expect(User.last.uid).to eq(user.uid)
92
+ end
93
+ end
94
+
95
+ describe "POST /auth/gds/api/users/:uid/reauth" do
96
+ shared_params = [:post, "/auth/gds/api/users/#{SecureRandom.uuid}/reauth"]
97
+ it_behaves_like "rejects a request from an unauthenticated user", *shared_params
98
+ it_behaves_like "rejects a request from an authenticated user lacking permission", *shared_params
99
+ it_behaves_like "operates as an API endpoint if api_request_matcher doesn't match it", *shared_params
100
+ it_behaves_like "redirects to /auth/gds if gds_sso_api_request_matcher is configured to not match", *shared_params
101
+
102
+ it "flags a user that exists as remotely signed out" do
103
+ stub_successful_signon_user_request(permissions: %w[user_update_permission])
104
+
105
+ user = User.create!(
106
+ uid: SecureRandom.uuid,
107
+ email: "user@example.com",
108
+ name: "Example User",
109
+ permissions: [],
110
+ )
111
+
112
+ expect {
113
+ post "/auth/gds/api/users/#{user.uid}/reauth",
114
+ headers: { "Authorization" => "Bearer anything" }
115
+ }.to change { user.reload.remotely_signed_out }.to(true)
116
+
117
+ expect(response).to have_http_status(:success)
118
+ expect(response.body).to eq("")
119
+ end
120
+
121
+ it "responds successfully even if the user doesn't exist" do
122
+ stub_successful_signon_user_request(permissions: %w[user_update_permission])
123
+
124
+ user = User.new(
125
+ uid: SecureRandom.uuid,
126
+ email: "user@example.com",
127
+ name: "Example User",
128
+ permissions: [],
129
+ )
130
+
131
+ post "/auth/gds/api/users/#{user.uid}/reauth",
132
+ headers: { "Authorization" => "Bearer anything" }
133
+
134
+ expect(response).to have_http_status(:success)
135
+ expect(response.body).to eq("")
136
+ end
137
+ end
138
+
139
+ def user_update_params(user, modifications = {})
140
+ fields = %i[uid name email permissions organisation_slug organisation_content_id disabled]
141
+ user_details = user.as_json(only: fields).merge(modifications)
142
+ { "user" => user_details }
143
+ end
144
+ end
@@ -0,0 +1,77 @@
1
+ require "spec_helper"
2
+
3
+ describe "AuthenticationController", type: :request do
4
+ describe "GET /auth/gds/callback" do
5
+ it "fails without a valid state param" do
6
+ get "/auth/gds/callback"
7
+
8
+ expect(response).to redirect_to("/auth/failure?message=csrf_detected&strategy=gds")
9
+ end
10
+
11
+ it "redirects to the attempted url if a user was restricted earlier in the session" do
12
+ get "/restricted"
13
+
14
+ state = request_to_establish_oauth_state
15
+
16
+ stub_signon_oauth_token_request
17
+ stub_successful_signon_user_request
18
+
19
+ get "/auth/gds/callback?state=#{state}"
20
+ expect(response).to redirect_to("/restricted")
21
+ end
22
+
23
+ it "redirects to the root path if the user hadn't tried to access a restricted url" do
24
+ state = request_to_establish_oauth_state
25
+
26
+ stub_signon_oauth_token_request
27
+ stub_successful_signon_user_request
28
+
29
+ get "/auth/gds/callback?state=#{state}"
30
+ expect(response).to redirect_to("/")
31
+ end
32
+
33
+ it "uses the OAuth2 proof key for code exchange feature for increased security" do
34
+ get "/auth/gds"
35
+
36
+ expect(response).to have_http_status(:redirect)
37
+ location = URI.parse(response.location)
38
+ query = Rack::Utils.parse_query(location.query)
39
+
40
+ expect(location.path).to eq("/oauth/authorize")
41
+ expect(query).to include("code_challenge", "code_challenge_method")
42
+
43
+ token_request = stub_request(:post, "http://signon/oauth/access_token")
44
+ .with(body: hash_including("code_verifier"))
45
+
46
+ get "/auth/gds/callback?state=#{query['state']}"
47
+
48
+ expect(token_request).to have_been_made
49
+ end
50
+ end
51
+
52
+ describe "GET /auth/failure" do
53
+ it "responds successfully" do
54
+ get "/auth/failure"
55
+
56
+ expect(response).to have_http_status(:success)
57
+ end
58
+ end
59
+
60
+ describe "GET /auth/gds/sign_out" do
61
+ it "redirects to signon sign out" do
62
+ get "/auth/gds/sign_out"
63
+
64
+ expect(response).to redirect_to("http://signon/users/sign_out")
65
+ end
66
+
67
+ it "logs an authenticated user out" do
68
+ authenticate_with_stub_signon
69
+
70
+ get "/auth/gds/sign_out"
71
+
72
+ # access a restricted route to assert we're logged out
73
+ get "/restricted"
74
+ expect(response).to redirect_to("/auth/gds")
75
+ end
76
+ end
77
+ end