machina-auth 1.2.0 → 1.2.2
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/app/controllers/machina/callbacks_controller.rb +19 -5
- data/lib/machina/controller_helpers.rb +18 -4
- data/lib/machina/middleware/authentication.rb +10 -3
- data/lib/machina/version.rb +1 -1
- data/spec/machina/controller_helpers_spec.rb +32 -0
- data/spec/machina/middleware/authentication_spec.rb +41 -0
- data/spec/requests/machina/callbacks_spec.rb +29 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66b857d79a8bb10d1bf4923827dc40e5ba31497b72034d299d0a6789d915cd8a
|
|
4
|
+
data.tar.gz: 614e23b95bfd42a36fff9d0884c67bec96dd3cbb795892ccb17c0637ed5c2f72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae953fa84f664a1e2bddfdafc01bd4ac076d6790ca78b89cf1e40b8cb95c8d3b804b110c8177c17175702a360f3ec53d376729d3f5ed5e4ec477aeb17bbe3cd0
|
|
7
|
+
data.tar.gz: 4cc174ca36e1bf7e928a737b00d1fa5eb3ab2676328e6526f7dded1263948f7fd66d3b603227bf1d45562552905c3dcc87c4593818ef02d4be0f94d951b16b99
|
|
@@ -25,9 +25,13 @@ module Machina
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
27
|
def valid_state?
|
|
28
|
-
|
|
28
|
+
# A duplicate callback arrives while the cookie is already marked
|
|
29
|
+
# consumed; the marker is not part of the nonce the URL carries.
|
|
30
|
+
expected = cookies.signed[:machina_state].to_s.delete_suffix(Machina::ControllerHelpers::CONSUMED_STATE_MARKER)
|
|
29
31
|
given = params[:state].to_s
|
|
30
|
-
|
|
32
|
+
# secure_compare raises on unequal lengths; a length mismatch is a mismatch.
|
|
33
|
+
expected.present? && given.bytesize == expected.bytesize &&
|
|
34
|
+
ActiveSupport::SecurityUtils.secure_compare(given, expected)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def reject(reason)
|
|
@@ -36,16 +40,26 @@ module Machina
|
|
|
36
40
|
render plain: 'Invalid authentication callback', status: :unprocessable_content
|
|
37
41
|
end
|
|
38
42
|
|
|
39
|
-
# A stale,
|
|
40
|
-
#
|
|
43
|
+
# A stale, forged or missing state proves nothing either way, so re-run the
|
|
44
|
+
# handshake instead of dead-ending. The nonce must be brand new: the cookie
|
|
45
|
+
# still holds the unmarked in-flight one, and issue_state would reuse it and
|
|
46
|
+
# stretch its expiry to 10 minutes. A mismatched callback has to burn that
|
|
47
|
+
# nonce, not refresh it — otherwise anything that can hit this endpoint can
|
|
48
|
+
# keep a victim's handshake alive indefinitely.
|
|
41
49
|
def restart_handshake(reason)
|
|
42
50
|
Rails.logger.warn("[machina] Auth callback rejected: #{reason}; restarting handshake")
|
|
51
|
+
cookies.delete(:machina_state)
|
|
43
52
|
redirect_to Machina.authorize_url(return_to: safe_return_to, state: issue_state), allow_other_host: true
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
# Marks the nonce consumed inside the signed value and keeps it only for
|
|
56
|
+
# the grace window. issue_state treats a marked value as spent, so a later
|
|
57
|
+
# handshake mints a fresh nonce instead of stretching this one to 10 minutes.
|
|
46
58
|
def extend_state_cookie
|
|
59
|
+
nonce = cookies.signed[:machina_state].to_s
|
|
60
|
+
.delete_suffix(Machina::ControllerHelpers::CONSUMED_STATE_MARKER)
|
|
47
61
|
cookies.signed[:machina_state] = {
|
|
48
|
-
value:
|
|
62
|
+
value: "#{nonce}#{Machina::ControllerHelpers::CONSUMED_STATE_MARKER}",
|
|
49
63
|
httponly: true,
|
|
50
64
|
same_site: :lax,
|
|
51
65
|
secure: Machina.secure_cookies?,
|
|
@@ -48,13 +48,27 @@ module Machina
|
|
|
48
48
|
cookies.delete(:machina_session)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Appended inside the signed cookie once the callback has consumed the
|
|
52
|
+
# nonce. It rides in the signed value so a client cannot forge or strip it,
|
|
53
|
+
# and it can never appear in a nonce: urlsafe_base64 emits only
|
|
54
|
+
# [A-Za-z0-9_-]. The callback strips it before comparing, so a duplicate
|
|
55
|
+
# callback inside the grace window still matches the URL it came back on.
|
|
56
|
+
CONSUMED_STATE_MARKER = ':consumed'
|
|
57
|
+
|
|
51
58
|
private
|
|
52
59
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
60
|
+
# CSRF state for the auth handshake. Reuses the nonce already in the
|
|
61
|
+
# signed cookie so a second tab, reload or prefetch does not overwrite a
|
|
62
|
+
# handshake still in flight, and re-issues the cookie with a fresh expiry
|
|
63
|
+
# so that handshake is not starved by the original one. Mints a new nonce
|
|
64
|
+
# when the cookie is absent, unreadable, or already consumed by a callback:
|
|
65
|
+
# a consumed nonce is single-use and must not be stretched past the grace
|
|
66
|
+
# window the callback set. The value rides on the callback URL through
|
|
67
|
+
# Console; the callback rejects a redirect whose state does not match.
|
|
56
68
|
def issue_state
|
|
57
|
-
|
|
69
|
+
held = cookies.signed[:machina_state].to_s
|
|
70
|
+
state = held.presence
|
|
71
|
+
state = SecureRandom.urlsafe_base64(32) if state.nil? || state.end_with?(CONSUMED_STATE_MARKER)
|
|
58
72
|
cookies.signed[:machina_state] = {
|
|
59
73
|
value: state,
|
|
60
74
|
httponly: true,
|
|
@@ -6,7 +6,7 @@ module Machina
|
|
|
6
6
|
module Middleware
|
|
7
7
|
# Rack middleware that extracts authentication tokens from incoming requests,
|
|
8
8
|
# resolves them against the identity service, and sets Current.authorized.
|
|
9
|
-
class Authentication
|
|
9
|
+
class Authentication # rubocop:disable Metrics/ClassLength
|
|
10
10
|
TRANSIENT_FAILURE = :transient_failure
|
|
11
11
|
|
|
12
12
|
# Negative-cache marker for a rejected token. A Hash, so it round-trips through any cache coder.
|
|
@@ -97,7 +97,7 @@ module Machina
|
|
|
97
97
|
match && match[1]
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
def resolve_session(token, hints)
|
|
100
|
+
def resolve_session(token, hints) # rubocop:disable Metrics/AbcSize
|
|
101
101
|
cached = Machina.cache.read(cache_key(token, hints))
|
|
102
102
|
return nil if cached == INVALID_SESSION
|
|
103
103
|
return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
|
|
@@ -109,7 +109,14 @@ module Machina
|
|
|
109
109
|
# signal transient failure so the caller preserves the cookie.
|
|
110
110
|
Rails.logger.warn("[machina] Identity service unreachable: #{e.class} — #{e.message}")
|
|
111
111
|
cached.presence || TRANSIENT_FAILURE
|
|
112
|
-
rescue
|
|
112
|
+
rescue Machina::ConfigurationError => e
|
|
113
|
+
# Misconfiguration is not a bad token: don't log the user out or negative-cache it.
|
|
114
|
+
Rails.logger.error("[machina] Cannot resolve sessions: #{e.message}")
|
|
115
|
+
raise if Rails.env.development?
|
|
116
|
+
|
|
117
|
+
TRANSIENT_FAILURE
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Rails.logger.error("[machina] Session resolve failed: #{e.class} — #{e.message}")
|
|
113
120
|
nil
|
|
114
121
|
end
|
|
115
122
|
|
data/lib/machina/version.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative '../rails_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Machina::ControllerHelpers, type: :controller do
|
|
6
|
+
include ActiveSupport::Testing::TimeHelpers
|
|
7
|
+
|
|
6
8
|
controller(ActionController::Base) do
|
|
7
9
|
include Machina::ControllerHelpers
|
|
8
10
|
|
|
@@ -50,6 +52,36 @@ RSpec.describe Machina::ControllerHelpers, type: :controller do
|
|
|
50
52
|
expect(CGI.unescape(response.location)).to include("state=#{CGI.escape(state)}")
|
|
51
53
|
end
|
|
52
54
|
|
|
55
|
+
it 'reuses the state already in the cookie on a second unauthenticated request' do
|
|
56
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
|
|
57
|
+
|
|
58
|
+
get :index
|
|
59
|
+
first = controller.send(:cookies).signed[:machina_state]
|
|
60
|
+
request.cookies[:machina_state] = response.cookies['machina_state']
|
|
61
|
+
|
|
62
|
+
get :index
|
|
63
|
+
|
|
64
|
+
second = controller.send(:cookies).signed[:machina_state]
|
|
65
|
+
expect(second).to eq(first)
|
|
66
|
+
expect(CGI.unescape(response.location)).to include("state=#{CGI.escape(first)}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 're-issues the reused state cookie httponly, same_site lax, with a fresh expiry' do
|
|
70
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
|
|
71
|
+
|
|
72
|
+
get :index
|
|
73
|
+
request.cookies[:machina_state] = response.cookies['machina_state']
|
|
74
|
+
travel 2.minutes
|
|
75
|
+
|
|
76
|
+
get :index
|
|
77
|
+
|
|
78
|
+
set_cookie = response.headers['Set-Cookie'].to_s
|
|
79
|
+
expect(set_cookie).to match(/machina_state=.*httponly/i)
|
|
80
|
+
expect(set_cookie).to match(/samesite=lax/i)
|
|
81
|
+
expires_at = Time.httpdate(set_cookie[/expires=([^;]+)/i, 1])
|
|
82
|
+
expect(expires_at).to be_within(5.seconds).of(10.minutes.from_now)
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
it 'passes return_to as a relative path so the callback accepts it' do
|
|
54
86
|
Machina.config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
|
|
55
87
|
|
|
@@ -401,6 +401,47 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
401
401
|
end
|
|
402
402
|
end
|
|
403
403
|
|
|
404
|
+
context 'when the service token is not configured' do
|
|
405
|
+
let(:config) do
|
|
406
|
+
Machina::Configuration.new.tap do |c|
|
|
407
|
+
c.identity_service_url = 'https://console.example.test'
|
|
408
|
+
c.service_token = ''
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
let(:env) { Rack::MockRequest.env_for('/resource', 'HTTP_COOKIE' => 'machina_session=ps_unconfigured') }
|
|
412
|
+
|
|
413
|
+
before do
|
|
414
|
+
allow(Machina).to receive(:identity_client).and_return(Machina::IdentityClient.new(config:))
|
|
415
|
+
allow(Rails.logger).to receive(:error)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it 'logs the misconfiguration and keeps the cookie and cache untouched' do
|
|
419
|
+
status, headers, = middleware.call(env)
|
|
420
|
+
|
|
421
|
+
expect(status).to eq(200)
|
|
422
|
+
expect(headers['set-cookie']).to be_nil
|
|
423
|
+
expect(Machina.cache.read('machina:session:ps_unconfigured')).to be_nil
|
|
424
|
+
expect(Rails.logger).to have_received(:error).with(/service_token is required/)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it 'raises in development so the cause is not hidden behind a redirect loop' do
|
|
428
|
+
allow(Rails.env).to receive(:development?).and_return(true)
|
|
429
|
+
|
|
430
|
+
expect { middleware.call(env) }.to raise_error(Machina::ConfigurationError, /service_token is required/)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
it 'logs an unexpected resolve error before treating the token as invalid' do
|
|
435
|
+
allow(identity_client).to receive(:resolve_session).with('ps_boom').and_raise(JSON::ParserError, 'bad body')
|
|
436
|
+
allow(Rails.logger).to receive(:error)
|
|
437
|
+
|
|
438
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => 'Bearer ps_boom')
|
|
439
|
+
status, = middleware.call(env)
|
|
440
|
+
|
|
441
|
+
expect(status).to eq(200)
|
|
442
|
+
expect(Rails.logger).to have_received(:error).with(/JSON::ParserError — bad body/)
|
|
443
|
+
end
|
|
444
|
+
|
|
404
445
|
it 'uses the cache on subsequent requests' do
|
|
405
446
|
response = Machina::IdentityClient::Response.new(
|
|
406
447
|
status: 200,
|
|
@@ -35,6 +35,28 @@ RSpec.describe Machina::CallbacksController, type: :controller do
|
|
|
35
35
|
expect(response.cookies['machina_state']).to be_present
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
it 'marks the nonce consumed so a later handshake mints a new one' do
|
|
39
|
+
Machina.config.identity_callback_uri = 'http://localhost:3000/machina/callback'
|
|
40
|
+
grace_expiry = Machina::CallbacksController::STATE_GRACE_PERIOD.from_now
|
|
41
|
+
|
|
42
|
+
get :show, params: { token:, state: }
|
|
43
|
+
|
|
44
|
+
consumed = response.cookies['machina_state']
|
|
45
|
+
expect(controller.send(:cookies).signed[:machina_state]).to end_with(
|
|
46
|
+
Machina::ControllerHelpers::CONSUMED_STATE_MARKER,
|
|
47
|
+
)
|
|
48
|
+
state_cookie = Array(response.headers['Set-Cookie']).find { |c| c.start_with?('machina_state=') }
|
|
49
|
+
expect(Time.httpdate(state_cookie[/expires=([^;]+)/i, 1])).to be_within(5.seconds).of(grace_expiry)
|
|
50
|
+
|
|
51
|
+
request.cookies['machina_state'] = consumed
|
|
52
|
+
|
|
53
|
+
follow_up = controller.send(:issue_state)
|
|
54
|
+
|
|
55
|
+
expect(follow_up).not_to eq(state)
|
|
56
|
+
expect(follow_up).not_to include(Machina::ControllerHelpers::CONSUMED_STATE_MARKER)
|
|
57
|
+
expect(controller.send(:cookies).signed[:machina_state]).to eq(follow_up)
|
|
58
|
+
end
|
|
59
|
+
|
|
38
60
|
it 'accepts a repeated callback and stores the newest token' do
|
|
39
61
|
get :show, params: { token: 'ps_first', state: }
|
|
40
62
|
request.cookies['machina_state'] = response.cookies['machina_state']
|
|
@@ -106,6 +128,13 @@ RSpec.describe Machina::CallbacksController, type: :controller do
|
|
|
106
128
|
end
|
|
107
129
|
|
|
108
130
|
include_examples 'restarts the handshake'
|
|
131
|
+
|
|
132
|
+
it 'burns the in-flight nonce instead of re-issuing it' do
|
|
133
|
+
minted = controller.send(:cookies).signed[:machina_state]
|
|
134
|
+
expect(minted).to be_present
|
|
135
|
+
expect(minted).not_to eq(state)
|
|
136
|
+
expect(CGI.unescape(response.location)).to include("state=#{CGI.escape(minted)}")
|
|
137
|
+
end
|
|
109
138
|
end
|
|
110
139
|
|
|
111
140
|
context 'with a missing state param' do
|
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: 1.2.
|
|
4
|
+
version: 1.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZAR
|
|
@@ -216,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
216
216
|
- !ruby/object:Gem::Version
|
|
217
217
|
version: '0'
|
|
218
218
|
requirements: []
|
|
219
|
-
rubygems_version: 4.0.
|
|
219
|
+
rubygems_version: 4.0.20
|
|
220
220
|
specification_version: 4
|
|
221
221
|
summary: Shared authentication and workspace scoping for Machina product apps
|
|
222
222
|
test_files: []
|