machina-auth 1.0.2 → 1.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: 0ef95dc9a17c0abc2f72fd81cc6bc78ca5baea5a058fb8800087c0ab1753fa8f
4
- data.tar.gz: 85e89ffab0def4950f3da9ad9513d0cca3475cd222af0cd7fa12f9c56a8e18f7
3
+ metadata.gz: 43343ff99400bd16b180b6df3091ec04b98808ee6f3109292fbdcc357289543c
4
+ data.tar.gz: bea7a1a87b91e44fbd9e2fafd4235e2083aa11160e93a5fbae79bb96edd0aa18
5
5
  SHA512:
6
- metadata.gz: b58c5c91d1ca06f6d5d050f79e200c3c012473d262a2a5231c8cc3b3a2dc0c799b1f163dffb4963e243bfe2c8d5d915e87ac74db516a4f1f493cdb34c55510a4
7
- data.tar.gz: 2e8113a5ccfef4f7a6c07bba04127e746256da82ba8f0d79d7529625e5cb92acf2ac621beffacf80f552923e1b7858aae65f4de243d67f1d0775dbc01e43710a
6
+ metadata.gz: 34ee949454a8421c955c5ba1c52f00ae768629747c305d989760bd40012c0e445bc6b3ce3181326284ad0b8fd942dea6197af2a95bc57e01a0ed845cfdcea351
7
+ data.tar.gz: b3d44a849ca3cd050ea7b85a393c922536d48065e9f0158d6a47f39cd44985b213d8d902f049df14274e2f8808ba184dd6ffc428606b971c5cf5c96a998cd085
@@ -6,11 +6,17 @@ module Machina
6
6
  # echoed back or the token is rejected (login-CSRF / session fixation).
7
7
  # The engine has no ApplicationController; Base is required for cookies.
8
8
  class CallbacksController < ActionController::Base # rubocop:disable Rails/ApplicationController
9
+ include Machina::ControllerHelpers
10
+
11
+ # Duplicate callbacks from the same browser (a double-submitted Console
12
+ # picker) arrive within seconds; the state stays valid this long after use.
13
+ STATE_GRACE_PERIOD = 1.minute
14
+
9
15
  def show
10
16
  return reject('missing token') if params[:token].blank?
11
- return reject('state mismatch') unless valid_state?
17
+ return restart_handshake('state mismatch') unless valid_state?
12
18
 
13
- cookies.delete(:machina_state)
19
+ extend_state_cookie
14
20
  reset_session
15
21
  store_session_cookie
16
22
  redirect_to safe_return_to
@@ -30,6 +36,23 @@ module Machina
30
36
  render plain: 'Invalid authentication callback', status: :unprocessable_content
31
37
  end
32
38
 
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.
41
+ def restart_handshake(reason)
42
+ Rails.logger.warn("[machina] Auth callback rejected: #{reason}; restarting handshake")
43
+ redirect_to Machina.authorize_url(return_to: safe_return_to, state: issue_state), allow_other_host: true
44
+ end
45
+
46
+ def extend_state_cookie
47
+ cookies.signed[:machina_state] = {
48
+ value: cookies.signed[:machina_state],
49
+ httponly: true,
50
+ same_site: :lax,
51
+ secure: Machina.secure_cookies?,
52
+ expires: STATE_GRACE_PERIOD
53
+ }
54
+ end
55
+
33
56
  def store_session_cookie
34
57
  cookies[:machina_session] = {
35
58
  value: params[:token],
@@ -50,7 +50,7 @@ module Machina
50
50
 
51
51
  private
52
52
 
53
- # Mints the single-use CSRF state for the auth handshake. The value rides
53
+ # Mints the CSRF state for the auth handshake. The value rides
54
54
  # on the callback URL through Console; the callback rejects a redirect
55
55
  # whose state does not match this cookie.
56
56
  def issue_state
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '1.0.2'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -29,10 +29,21 @@ RSpec.describe Machina::CallbacksController, type: :controller do
29
29
  expect(set_cookie).to match(/machina_session=.*httponly/i)
30
30
  end
31
31
 
32
- it 'clears the state cookie' do
32
+ it 'keeps the state cookie so a duplicate callback still succeeds' do
33
33
  get :show, params: { token:, state: }
34
34
 
35
- expect(response.cookies).to include('machina_state' => nil)
35
+ expect(response.cookies['machina_state']).to be_present
36
+ end
37
+
38
+ it 'accepts a repeated callback and stores the newest token' do
39
+ get :show, params: { token: 'ps_first', state: }
40
+ request.cookies['machina_state'] = response.cookies['machina_state']
41
+ request.cookies['machina_session'] = 'ps_first'
42
+
43
+ get :show, params: { token: 'ps_second', state:, return_to: '/dashboard' }
44
+
45
+ expect(response).to redirect_to('/dashboard')
46
+ expect(response.cookies['machina_session']).to eq('ps_second')
36
47
  end
37
48
 
38
49
  it 'resets the session on credential swap' do
@@ -77,38 +88,54 @@ RSpec.describe Machina::CallbacksController, type: :controller do
77
88
  end
78
89
 
79
90
  describe 'with an invalid state' do
80
- it 'rejects a mismatched state' do
81
- sign_state_cookie(state)
91
+ before { Machina.config.identity_callback_uri = 'http://localhost:3000/machina/callback' }
92
+
93
+ shared_examples 'restarts the handshake' do
94
+ it 'redirects to Console with a fresh state and no session cookie' do
95
+ expect(response).to redirect_to(%r{\Ahttps://machina\.example\.test/authorize\?redirect_to=})
96
+ expect(CGI.unescape(response.location)).to include('return_to=%2Fdashboard', 'state=')
97
+ expect(response.cookies['machina_state']).to be_present
98
+ expect(response.cookies['machina_session']).to be_nil
99
+ end
100
+ end
82
101
 
83
- get :show, params: { token:, state: 'forged' }
102
+ context 'with a mismatched state' do
103
+ before do
104
+ sign_state_cookie(state)
105
+ get :show, params: { token:, state: 'forged', return_to: '/dashboard' }
106
+ end
84
107
 
85
- expect(response).to have_http_status(:unprocessable_content)
86
- expect(response.cookies['machina_session']).to be_nil
108
+ include_examples 'restarts the handshake'
87
109
  end
88
110
 
89
- it 'rejects a missing state param' do
90
- sign_state_cookie(state)
111
+ context 'with a missing state param' do
112
+ before do
113
+ sign_state_cookie(state)
114
+ get :show, params: { token:, return_to: '/dashboard' }
115
+ end
91
116
 
92
- get :show, params: { token: }
93
-
94
- expect(response).to have_http_status(:unprocessable_content)
95
- expect(response.cookies['machina_session']).to be_nil
117
+ include_examples 'restarts the handshake'
96
118
  end
97
119
 
98
- it 'rejects when no state cookie was issued' do
99
- get :show, params: { token:, state: }
120
+ context 'when no state cookie was issued' do
121
+ before { get :show, params: { token:, state:, return_to: '/dashboard' } }
100
122
 
101
- expect(response).to have_http_status(:unprocessable_content)
102
- expect(response.cookies['machina_session']).to be_nil
123
+ include_examples 'restarts the handshake'
103
124
  end
104
125
 
105
- it 'rejects a tampered (unsigned) state cookie' do
106
- request.cookies['machina_state'] = state
126
+ context 'with a tampered (unsigned) state cookie' do
127
+ before do
128
+ request.cookies['machina_state'] = state
129
+ get :show, params: { token:, state:, return_to: '/dashboard' }
130
+ end
107
131
 
108
- get :show, params: { token:, state: }
132
+ include_examples 'restarts the handshake'
133
+ end
134
+
135
+ it 'does not reflect an unsafe return_to into the restarted handshake' do
136
+ get :show, params: { token:, state:, return_to: '//evil.example.com/' }
109
137
 
110
- expect(response).to have_http_status(:unprocessable_content)
111
- expect(response.cookies['machina_session']).to be_nil
138
+ expect(CGI.unescape(response.location)).to include('return_to=%2F&').or include('return_to=%2F"')
112
139
  end
113
140
  end
114
141
 
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.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZAR