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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc18de6ee1048be86f05320f0a2864092dce1b886689a98e042bb4b68385649f
4
- data.tar.gz: 0657bf99e18fe6418ee481e3234ffe3a0dc5ae27fe9aab6dfff6363b7e682c56
3
+ metadata.gz: 66b857d79a8bb10d1bf4923827dc40e5ba31497b72034d299d0a6789d915cd8a
4
+ data.tar.gz: 614e23b95bfd42a36fff9d0884c67bec96dd3cbb795892ccb17c0637ed5c2f72
5
5
  SHA512:
6
- metadata.gz: 6fb9094979c7931d23102d76f4a4da200943ac503c36ba165ee9bd20fa605a737a16cbada861890e1529434972fbf8c64f3f222cf85389bae462276f2387e734
7
- data.tar.gz: 061dc831c51829d43b2e9a7f1f91a22b8ff41eade182ffb25ac2f53c0b8958c4a1189003e15403130d27b1808f63305835da2ac2330623b3c38527397617c070
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
- expected = cookies.signed[:machina_state]
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
- expected.present? && given.present? && ActiveSupport::SecurityUtils.secure_compare(given, expected)
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, overwritten or missing state proves nothing either way, so
40
- # re-run the handshake with a fresh nonce instead of dead-ending.
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: cookies.signed[:machina_state],
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
- # Mints the 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.
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
- state = SecureRandom.urlsafe_base64(32)
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 StandardError
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '1.2.0'
4
+ VERSION = '1.2.2'
5
5
  end
@@ -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.0
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.3
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: []