machina-auth 0.3.0 → 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.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/app/controllers/machina/callbacks_controller.rb +51 -0
- data/config/routes.rb +1 -0
- data/lib/machina/configuration.rb +2 -0
- data/lib/machina/controller_helpers.rb +17 -1
- data/lib/machina/middleware/authentication.rb +23 -16
- data/lib/machina/version.rb +1 -1
- data/lib/machina.rb +21 -4
- data/spec/machina/authorize_url_spec.rb +24 -0
- data/spec/machina/controller_helpers_spec.rb +20 -0
- data/spec/machina/middleware/authentication_spec.rb +55 -19
- data/spec/requests/machina/callbacks_spec.rb +117 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64cd50975d04d89620d0ad73d6be045f93256efc4d34c57a563782167e659dc2
|
|
4
|
+
data.tar.gz: 3a962c88b3b58a015fb267de869acc17575cf7377df37b1bfe4439c0bbc66eba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8962c63372568bacb6c6afa2ea47a0530999c0299a39117cbeefee5cec676f81fa25eba413aa3f093e26201878446412a4c9a5fac7f5df7604a13f6078876cf
|
|
7
|
+
data.tar.gz: 5cc1964cc2c24e6f4d3ddd9e45334bd2dec68a59fdeb73c85ccfc604cd4d19c49094ab6decec799c2d71862f88bd37e8310c1e1801d4d0c5d85cbb383f4ef956
|
data/README.md
CHANGED
|
@@ -4,7 +4,8 @@ Rails engine that integrates product apps with the Machina Console identity serv
|
|
|
4
4
|
|
|
5
5
|
## What It Provides
|
|
6
6
|
|
|
7
|
-
- **Authentication middleware** — extracts session tokens from cookies
|
|
7
|
+
- **Authentication middleware** — extracts session tokens from cookies or headers and resolves them against the Console
|
|
8
|
+
- **Auth callback** — the engine serves `GET <mount>/callback`, completing the Console login handshake with CSRF-state verification (see `docs/PRODUCT_CALLBACK_FLOW.md`)
|
|
8
9
|
- **`Machina::Authorized`** — frozen value object with `can?`, `cannot?`, `authorize!`, and permission query methods
|
|
9
10
|
- **`Machina::Current`** — thread-safe current attributes (user, org, workspace, session)
|
|
10
11
|
- **`Machina::ControllerHelpers`** — `require_authorized!` and `authorize!` for controllers
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
# Completes the Console auth handshake. Console redirects here with the
|
|
5
|
+
# minted session token; the state nonce issued by +authenticate!+ must be
|
|
6
|
+
# echoed back or the token is rejected (login-CSRF / session fixation).
|
|
7
|
+
# The engine has no ApplicationController; Base is required for cookies.
|
|
8
|
+
class CallbacksController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
|
9
|
+
def show
|
|
10
|
+
return reject('missing token') if params[:token].blank?
|
|
11
|
+
return reject('state mismatch') unless valid_state?
|
|
12
|
+
|
|
13
|
+
cookies.delete(:machina_state)
|
|
14
|
+
reset_session
|
|
15
|
+
store_session_cookie
|
|
16
|
+
redirect_to safe_return_to
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def valid_state?
|
|
22
|
+
expected = cookies.signed[:machina_state]
|
|
23
|
+
given = params[:state].to_s
|
|
24
|
+
expected.present? && given.present? && ActiveSupport::SecurityUtils.secure_compare(given, expected)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reject(reason)
|
|
28
|
+
cookies.delete(:machina_state)
|
|
29
|
+
Rails.logger.warn("[machina] Auth callback rejected: #{reason}")
|
|
30
|
+
render plain: 'Invalid authentication callback', status: :unprocessable_content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def store_session_cookie
|
|
34
|
+
cookies[:machina_session] = {
|
|
35
|
+
value: params[:token],
|
|
36
|
+
httponly: true,
|
|
37
|
+
same_site: :lax,
|
|
38
|
+
secure: Machina.secure_cookies?
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Relative paths only — an absolute or protocol-relative return_to is an
|
|
43
|
+
# open redirect on a credential-bearing request.
|
|
44
|
+
def safe_return_to
|
|
45
|
+
value = params[:return_to].to_s
|
|
46
|
+
return value if value.start_with?('/') && !value.start_with?('//')
|
|
47
|
+
|
|
48
|
+
'/'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -10,12 +10,14 @@ module Machina
|
|
|
10
10
|
:product_slug,
|
|
11
11
|
:cache_store,
|
|
12
12
|
:cache_ttl,
|
|
13
|
+
:negative_cache_ttl,
|
|
13
14
|
:manifest,
|
|
14
15
|
:skip_paths,
|
|
15
16
|
:identity_callback_uri
|
|
16
17
|
|
|
17
18
|
def initialize
|
|
18
19
|
@cache_ttl = 5.minutes
|
|
20
|
+
@negative_cache_ttl = 30.seconds
|
|
19
21
|
@skip_paths = []
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -25,7 +25,8 @@ module Machina
|
|
|
25
25
|
if request.format.json?
|
|
26
26
|
render json: { error: 'unauthorized' }, status: :unauthorized
|
|
27
27
|
else
|
|
28
|
-
redirect_to Machina.authorize_url(return_to: request.
|
|
28
|
+
redirect_to Machina.authorize_url(return_to: request.fullpath, state: issue_state),
|
|
29
|
+
allow_other_host: true
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -49,6 +50,21 @@ module Machina
|
|
|
49
50
|
|
|
50
51
|
private
|
|
51
52
|
|
|
53
|
+
# Mints the single-use CSRF state for the auth handshake. The value rides
|
|
54
|
+
# on the callback URL through Console; the callback rejects a redirect
|
|
55
|
+
# whose state does not match this cookie.
|
|
56
|
+
def issue_state
|
|
57
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
58
|
+
cookies.signed[:machina_state] = {
|
|
59
|
+
value: state,
|
|
60
|
+
httponly: true,
|
|
61
|
+
same_site: :lax,
|
|
62
|
+
secure: Machina.secure_cookies?,
|
|
63
|
+
expires: 10.minutes
|
|
64
|
+
}
|
|
65
|
+
state
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def respond_unauthorized(error)
|
|
53
69
|
if request.format.json?
|
|
54
70
|
render json: { error: 'forbidden', permission: error.message }, status: :forbidden
|
|
@@ -8,7 +8,11 @@ module Machina
|
|
|
8
8
|
# resolves them against the identity service, and sets Current.authorized.
|
|
9
9
|
class Authentication
|
|
10
10
|
TRANSIENT_FAILURE = :transient_failure
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
# Negative-cache marker for a rejected token. A Hash, so it round-trips through any cache coder.
|
|
13
|
+
INVALID_SESSION = { '__machina_invalid__' => true }.freeze
|
|
14
|
+
|
|
15
|
+
private_constant :TRANSIENT_FAILURE, :INVALID_SESSION
|
|
12
16
|
|
|
13
17
|
def initialize(app)
|
|
14
18
|
@app = app
|
|
@@ -16,7 +20,7 @@ module Machina
|
|
|
16
20
|
|
|
17
21
|
def call(env)
|
|
18
22
|
request = ActionDispatch::Request.new(env)
|
|
19
|
-
return @app.call(env) if skip_path?(request)
|
|
23
|
+
return @app.call(env) if callback_path?(request) || skip_path?(request)
|
|
20
24
|
|
|
21
25
|
token = extract_token(request)
|
|
22
26
|
return @app.call(env) if token.blank?
|
|
@@ -39,7 +43,7 @@ module Machina
|
|
|
39
43
|
if transient_failure?(session_data)
|
|
40
44
|
@app.call(env)
|
|
41
45
|
else
|
|
42
|
-
handle_expired_token(env, request, token
|
|
46
|
+
handle_expired_token(env, request, token)
|
|
43
47
|
end
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -47,6 +51,16 @@ module Machina
|
|
|
47
51
|
result == TRANSIENT_FAILURE
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# The callback sets the session cookie itself; resolving a stale cookie
|
|
55
|
+
# here would answer it with an expired-token deletion that clobbers the
|
|
56
|
+
# fresh cookie.
|
|
57
|
+
def callback_path?(request)
|
|
58
|
+
callback = Machina.config.identity_callback_uri
|
|
59
|
+
callback.present? && request.path == URI.parse(callback).path
|
|
60
|
+
rescue URI::InvalidURIError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
50
64
|
# Strings match as path prefixes; regexes match path or full URL.
|
|
51
65
|
def skip_path?(request)
|
|
52
66
|
return false if Machina.config.skip_paths.blank?
|
|
@@ -62,28 +76,20 @@ module Machina
|
|
|
62
76
|
end
|
|
63
77
|
end
|
|
64
78
|
|
|
65
|
-
# Clears the
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
# Clears the cookie for a rejected token. The cache holds the INVALID_SESSION
|
|
80
|
+
# sentinel (written in fetch_from_identity_service) and must not be cleared here.
|
|
81
|
+
def handle_expired_token(env, request, token)
|
|
69
82
|
status, headers, body = @app.call(env)
|
|
70
83
|
Rack::Utils.delete_cookie_header!(headers, 'machina_session') if request.cookies['machina_session'] == token
|
|
71
84
|
[status, headers, body]
|
|
72
85
|
end
|
|
73
86
|
|
|
74
87
|
def extract_token(request)
|
|
75
|
-
|
|
76
|
-
|| request.cookies['machina_session']
|
|
88
|
+
request.cookies['machina_session']
|
|
77
89
|
|| extract_bearer(request)
|
|
78
90
|
|| request.headers['X-Api-Key']
|
|
79
91
|
end
|
|
80
92
|
|
|
81
|
-
def extract_param_token(request)
|
|
82
|
-
request.params['token']
|
|
83
|
-
rescue ActionDispatch::Http::Parameters::ParseError
|
|
84
|
-
nil
|
|
85
|
-
end
|
|
86
|
-
|
|
87
93
|
def extract_bearer(request)
|
|
88
94
|
auth_header = request.headers['Authorization'].to_s
|
|
89
95
|
match = auth_header.match(/\ABearer\s+(.+)\z/)
|
|
@@ -92,6 +98,7 @@ module Machina
|
|
|
92
98
|
|
|
93
99
|
def resolve_session(token, hints)
|
|
94
100
|
cached = Machina.cache.read(cache_key(token, hints))
|
|
101
|
+
return nil if cached == INVALID_SESSION
|
|
95
102
|
return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
|
|
96
103
|
|
|
97
104
|
fetch_from_identity_service(token, hints)
|
|
@@ -109,7 +116,7 @@ module Machina
|
|
|
109
116
|
response = Machina.identity_client.resolve_session(token, **hints.to_resolve_kwargs)
|
|
110
117
|
|
|
111
118
|
unless response.success?
|
|
112
|
-
Machina.cache.
|
|
119
|
+
Machina.cache.write(cache_key(token, hints), INVALID_SESSION, expires_in: Machina.config.negative_cache_ttl)
|
|
113
120
|
return nil
|
|
114
121
|
end
|
|
115
122
|
|
data/lib/machina/version.rb
CHANGED
data/lib/machina.rb
CHANGED
|
@@ -71,11 +71,14 @@ module Machina
|
|
|
71
71
|
# present the callback URI config is ignored
|
|
72
72
|
# @param return_to [String, nil] user's intended destination, appended to
|
|
73
73
|
# +identity_callback_uri+ so the product app can restore it after auth
|
|
74
|
+
# @param state [String, nil] CSRF nonce appended to the redirect target;
|
|
75
|
+
# Console echoes it back and the callback rejects a mismatch
|
|
74
76
|
# @return [String] the full Console authorize URL
|
|
75
77
|
# @raise [ConfigurationError] when +redirect_to+ is omitted and
|
|
76
78
|
# +identity_callback_uri+ is not configured
|
|
77
|
-
def authorize_url(redirect_to: nil, return_to: nil)
|
|
79
|
+
def authorize_url(redirect_to: nil, return_to: nil, state: nil)
|
|
78
80
|
redirect_target = redirect_to.presence || callback_redirect_target(return_to)
|
|
81
|
+
redirect_target = append_query_param(redirect_target, 'state', state) if state.present?
|
|
79
82
|
|
|
80
83
|
base = config.identity_service_url.to_s.sub(%r{/\z}, '')
|
|
81
84
|
"#{base}/authorize?redirect_to=#{CGI.escape(redirect_target)}"
|
|
@@ -84,9 +87,18 @@ module Machina
|
|
|
84
87
|
# Convenience wrapper that delegates to {authorize_url} with +return_to+.
|
|
85
88
|
#
|
|
86
89
|
# @param return_to [String] the user's intended destination
|
|
90
|
+
# @param state [String, nil] CSRF nonce (see {authorize_url})
|
|
87
91
|
# @return [String] the full authorize URL
|
|
88
|
-
def login_url(return_to:)
|
|
89
|
-
authorize_url(return_to:)
|
|
92
|
+
def login_url(return_to:, state: nil)
|
|
93
|
+
authorize_url(return_to:, state:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when session/state cookies must carry the Secure flag. Forced on
|
|
97
|
+
# outside development/test: +request.ssl?+ is false behind TLS-terminating
|
|
98
|
+
# load balancers unless the app sets assume_ssl, silently downgrading the
|
|
99
|
+
# cookie.
|
|
100
|
+
def secure_cookies?
|
|
101
|
+
!Rails.env.local?
|
|
90
102
|
end
|
|
91
103
|
|
|
92
104
|
private
|
|
@@ -105,7 +117,12 @@ module Machina
|
|
|
105
117
|
'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
|
|
106
118
|
end
|
|
107
119
|
|
|
108
|
-
return_to.present? ?
|
|
120
|
+
return_to.present? ? append_query_param(callback, 'return_to', return_to) : callback
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def append_query_param(url, key, value)
|
|
124
|
+
separator = url.include?('?') ? '&' : '?'
|
|
125
|
+
"#{url}#{separator}#{key}=#{CGI.escape(value)}"
|
|
109
126
|
end
|
|
110
127
|
end
|
|
111
128
|
end
|
|
@@ -29,6 +29,30 @@ RSpec.describe 'Machina.authorize_url' do
|
|
|
29
29
|
callback_with_return = 'http://localhost:3000/auth/machina/callback?return_to=http%3A%2F%2Flocalhost%3A3000%2Finquiries%3Fpage%3D2'
|
|
30
30
|
expect(url).to eq("#{base_url}/authorize?redirect_to=#{CGI.escape(callback_with_return)}")
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
it 'appends state to the callback URI' do
|
|
34
|
+
url = Machina.authorize_url(state: 'abc123')
|
|
35
|
+
|
|
36
|
+
callback_with_state = 'http://localhost:3000/auth/machina/callback?state=abc123'
|
|
37
|
+
expect(url).to eq("#{base_url}/authorize?redirect_to=#{CGI.escape(callback_with_state)}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'appends state after return_to with an ampersand' do
|
|
41
|
+
url = Machina.authorize_url(return_to: '/dashboard', state: 'abc123')
|
|
42
|
+
|
|
43
|
+
callback = 'http://localhost:3000/auth/machina/callback?return_to=%2Fdashboard&state=abc123'
|
|
44
|
+
expect(url).to eq("#{base_url}/authorize?redirect_to=#{CGI.escape(callback)}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'escapes the state value' do
|
|
48
|
+
url = Machina.authorize_url(state: 'a+b/c=')
|
|
49
|
+
|
|
50
|
+
expect(CGI.unescape(url)).to include("state=#{CGI.escape('a+b/c=')}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'omits state when not given' do
|
|
54
|
+
expect(Machina.authorize_url).not_to include('state')
|
|
55
|
+
end
|
|
32
56
|
end
|
|
33
57
|
|
|
34
58
|
context 'when identity_callback_uri is not configured' do
|
|
@@ -40,6 +40,26 @@ RSpec.describe Machina::ControllerHelpers, type: :controller do
|
|
|
40
40
|
expect(CGI.unescape(response.location)).to include('http://localhost:3000/auth/machina/callback?return_to=')
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
it 'mints a state cookie and threads the same value through the redirect' do
|
|
44
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
|
|
45
|
+
|
|
46
|
+
get :index
|
|
47
|
+
|
|
48
|
+
state = controller.send(:cookies).signed[:machina_state]
|
|
49
|
+
expect(state).to be_present
|
|
50
|
+
expect(CGI.unescape(response.location)).to include("state=#{CGI.escape(state)}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'passes return_to as a relative path so the callback accepts it' do
|
|
54
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
|
|
55
|
+
|
|
56
|
+
get :index
|
|
57
|
+
|
|
58
|
+
# request.fullpath, not the absolute original_url — safe_return_to in the
|
|
59
|
+
# callback rejects absolute URLs and would otherwise drop the destination.
|
|
60
|
+
expect(CGI.unescape(response.location)).to include("return_to=#{CGI.escape('/index')}")
|
|
61
|
+
end
|
|
62
|
+
|
|
43
63
|
it 'returns json unauthorized for api-style requests' do
|
|
44
64
|
request.accept = 'application/json'
|
|
45
65
|
get :index, format: :json
|
|
@@ -154,7 +154,7 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
154
154
|
expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
it '
|
|
157
|
+
it 'negative-caches the rejection and passes through when Console returns non-200 on stale re-fetch' do
|
|
158
158
|
token = 'ps_stale_token'
|
|
159
159
|
cache_key = "machina:session:#{token}"
|
|
160
160
|
stale_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
|
|
@@ -169,10 +169,11 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
169
169
|
status, = middleware.call(env)
|
|
170
170
|
|
|
171
171
|
expect(status).to eq(200)
|
|
172
|
-
|
|
172
|
+
# The stale session is replaced by the short-lived negative sentinel, not deleted.
|
|
173
|
+
expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
|
|
173
174
|
end
|
|
174
175
|
|
|
175
|
-
it '
|
|
176
|
+
it 'negative-caches the rejection when Console returns non-200 after cache expires' do
|
|
176
177
|
token = 'ps_expired_cache'
|
|
177
178
|
cache_key = "machina:session:#{token}"
|
|
178
179
|
|
|
@@ -197,35 +198,70 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
197
198
|
status, = middleware.call(env)
|
|
198
199
|
|
|
199
200
|
expect(status).to eq(200)
|
|
200
|
-
expect(Machina.cache.read(cache_key)).to
|
|
201
|
+
expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
|
|
201
202
|
end
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let(:fresh_token) { 'ps_fresh_callback' }
|
|
204
|
+
it 'does not re-hit Console for a rejected token while the negative cache is warm' do
|
|
205
|
+
token = 'ps_dead_token'
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
207
|
+
allow(identity_client).to receive(:resolve_session).with(token).and_return(
|
|
208
|
+
Machina::IdentityClient::Response.new(status: 404, body: '{}'),
|
|
209
|
+
)
|
|
211
210
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
211
|
+
3.times do
|
|
212
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
213
|
+
status, = middleware.call(env)
|
|
214
|
+
expect(status).to eq(200)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Without negative caching this would be 3 calls; the sentinel absorbs the retries.
|
|
218
|
+
expect(identity_client).to have_received(:resolve_session).with(token).once
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
describe 'token query param' do
|
|
222
|
+
it 'never authenticates a request' do
|
|
223
|
+
allow(identity_client).to receive(:resolve_session)
|
|
224
|
+
|
|
225
|
+
env = Rack::MockRequest.env_for('/resource?token=ps_leaked_in_url')
|
|
226
|
+
status, _headers, body = middleware.call(env)
|
|
227
|
+
|
|
228
|
+
expect(status).to eq(200)
|
|
229
|
+
expect(JSON.parse(body.first)['user_id']).to be_nil
|
|
230
|
+
expect(identity_client).not_to have_received(:resolve_session)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
describe 'callback path' do
|
|
235
|
+
before do
|
|
236
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/machina/callback'
|
|
215
237
|
end
|
|
216
238
|
|
|
217
|
-
it '
|
|
239
|
+
it 'is skipped entirely, even with a stale session cookie' do
|
|
240
|
+
allow(identity_client).to receive(:resolve_session)
|
|
241
|
+
|
|
218
242
|
env = Rack::MockRequest.env_for(
|
|
219
|
-
'/
|
|
243
|
+
'/machina/callback?token=ps_fresh&state=abc',
|
|
220
244
|
'HTTP_COOKIE' => 'machina_session=ps_expired_cookie',
|
|
221
245
|
)
|
|
222
246
|
|
|
247
|
+
_status, headers, = middleware.call(env)
|
|
248
|
+
|
|
249
|
+
# The stale cookie must not be resolved or deleted — the callback
|
|
250
|
+
# controller overwrites it with the fresh token.
|
|
251
|
+
expect(identity_client).not_to have_received(:resolve_session)
|
|
252
|
+
expect(headers['set-cookie']).to be_nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'does not skip other paths' do
|
|
256
|
+
allow(identity_client).to receive(:resolve_session).with('ps_123').and_return(
|
|
257
|
+
Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_COOKIE' => 'machina_session=ps_123')
|
|
223
261
|
status, _headers, body = middleware.call(env)
|
|
224
262
|
|
|
225
263
|
expect(status).to eq(200)
|
|
226
|
-
|
|
227
|
-
expect(parsed['user_id']).to eq(MockResponses.session_resolution['data']['user']['id'])
|
|
228
|
-
expect(identity_client).not_to have_received(:resolve_session).with(stale_token)
|
|
264
|
+
expect(JSON.parse(body.first)['user_id']).to be_present
|
|
229
265
|
end
|
|
230
266
|
end
|
|
231
267
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../rails_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Machina::CallbacksController, type: :controller do
|
|
6
|
+
routes { Machina::Engine.routes }
|
|
7
|
+
|
|
8
|
+
let(:state) { 'a-random-state-value' }
|
|
9
|
+
let(:token) { 'ps_fresh_token' }
|
|
10
|
+
|
|
11
|
+
def sign_state_cookie(value)
|
|
12
|
+
cookies.signed[:machina_state] = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe 'with a valid state' do
|
|
16
|
+
before { sign_state_cookie(state) }
|
|
17
|
+
|
|
18
|
+
it 'stores the session cookie and redirects to return_to' do
|
|
19
|
+
get :show, params: { token:, state:, return_to: '/dashboard' }
|
|
20
|
+
|
|
21
|
+
expect(response).to redirect_to('/dashboard')
|
|
22
|
+
expect(response.cookies['machina_session']).to eq(token)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'sets the session cookie httponly' do
|
|
26
|
+
get :show, params: { token:, state: }
|
|
27
|
+
|
|
28
|
+
set_cookie = response.headers['Set-Cookie'].to_s
|
|
29
|
+
expect(set_cookie).to match(/machina_session=.*httponly/i)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'clears the state cookie' do
|
|
33
|
+
get :show, params: { token:, state: }
|
|
34
|
+
|
|
35
|
+
expect(response.cookies).to include('machina_state' => nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'resets the session on credential swap' do
|
|
39
|
+
session[:stale] = 'pre-login'
|
|
40
|
+
|
|
41
|
+
get :show, params: { token:, state: }
|
|
42
|
+
|
|
43
|
+
expect(session[:stale]).to be_nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'overwrites a stale session cookie' do
|
|
47
|
+
request.cookies['machina_session'] = 'ps_stale'
|
|
48
|
+
|
|
49
|
+
get :show, params: { token:, state: }
|
|
50
|
+
|
|
51
|
+
expect(response.cookies['machina_session']).to eq(token)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'defaults to root when return_to is blank' do
|
|
55
|
+
get :show, params: { token:, state: }
|
|
56
|
+
|
|
57
|
+
expect(response).to redirect_to('/')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'rejects an absolute return_to' do
|
|
61
|
+
get :show, params: { token:, state:, return_to: 'https://evil.example.com/' }
|
|
62
|
+
|
|
63
|
+
expect(response).to redirect_to('/')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'rejects a protocol-relative return_to' do
|
|
67
|
+
get :show, params: { token:, state:, return_to: '//evil.example.com/' }
|
|
68
|
+
|
|
69
|
+
expect(response).to redirect_to('/')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe 'with an invalid state' do
|
|
74
|
+
it 'rejects a mismatched state' do
|
|
75
|
+
sign_state_cookie(state)
|
|
76
|
+
|
|
77
|
+
get :show, params: { token:, state: 'forged' }
|
|
78
|
+
|
|
79
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
80
|
+
expect(response.cookies['machina_session']).to be_nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'rejects a missing state param' do
|
|
84
|
+
sign_state_cookie(state)
|
|
85
|
+
|
|
86
|
+
get :show, params: { token: }
|
|
87
|
+
|
|
88
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
89
|
+
expect(response.cookies['machina_session']).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'rejects when no state cookie was issued' do
|
|
93
|
+
get :show, params: { token:, state: }
|
|
94
|
+
|
|
95
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
96
|
+
expect(response.cookies['machina_session']).to be_nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'rejects a tampered (unsigned) state cookie' do
|
|
100
|
+
request.cookies['machina_state'] = state
|
|
101
|
+
|
|
102
|
+
get :show, params: { token:, state: }
|
|
103
|
+
|
|
104
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
105
|
+
expect(response.cookies['machina_session']).to be_nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'rejects a blank token' do
|
|
110
|
+
sign_state_cookie(state)
|
|
111
|
+
|
|
112
|
+
get :show, params: { state: }
|
|
113
|
+
|
|
114
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
115
|
+
expect(response.cookies['machina_session']).to be_nil
|
|
116
|
+
end
|
|
117
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: machina-auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZAR
|
|
@@ -137,6 +137,7 @@ extra_rdoc_files: []
|
|
|
137
137
|
files:
|
|
138
138
|
- Gemfile
|
|
139
139
|
- README.md
|
|
140
|
+
- app/controllers/machina/callbacks_controller.rb
|
|
140
141
|
- app/controllers/machina/webhooks_controller.rb
|
|
141
142
|
- config/routes.rb
|
|
142
143
|
- lib/generators/machina/install_generator.rb
|
|
@@ -188,6 +189,7 @@ files:
|
|
|
188
189
|
- spec/machina/webhook_receiver_spec.rb
|
|
189
190
|
- spec/machina/workspace_scoped_spec.rb
|
|
190
191
|
- spec/rails_helper.rb
|
|
192
|
+
- spec/requests/machina/callbacks_spec.rb
|
|
191
193
|
- spec/requests/machina/webhooks_spec.rb
|
|
192
194
|
- spec/spec_helper.rb
|
|
193
195
|
- spec/support/console_schema.rb
|