machina-auth 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60692b69e1db477292fba2a947650c2ac15099a616a05318071860a02a550694
4
- data.tar.gz: 3625e766f4645037333365f8771e6363be979de0c484ff086b61ced3b335b84b
3
+ metadata.gz: 64cd50975d04d89620d0ad73d6be045f93256efc4d34c57a563782167e659dc2
4
+ data.tar.gz: 3a962c88b3b58a015fb267de869acc17575cf7377df37b1bfe4439c0bbc66eba
5
5
  SHA512:
6
- metadata.gz: 10cb1b5e52daf7e76f344bfb8445e2ecca186d5cda9b1693dd376cd7ad4e503306229eabe510879ab72cd10c0a275b5c51ba2d84275758539b5fe1d8ca12b482
7
- data.tar.gz: a168a7dd638f5663394dda922ba91582bfef041922325c635deff9d2ce79b7828916f21553ab5357d12a8b88276e6f8c560584898c212a9daaea0a4b560a7b98
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, headers, or params and resolves them against the Console
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
@@ -2,4 +2,5 @@
2
2
 
3
3
  Machina::Engine.routes.draw do
4
4
  post 'webhooks', to: 'webhooks#create'
5
+ get 'callback', to: 'callbacks#show'
5
6
  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.original_url), allow_other_host: true
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
@@ -20,7 +20,7 @@ module Machina
20
20
 
21
21
  def call(env)
22
22
  request = ActionDispatch::Request.new(env)
23
- return @app.call(env) if skip_path?(request)
23
+ return @app.call(env) if callback_path?(request) || skip_path?(request)
24
24
 
25
25
  token = extract_token(request)
26
26
  return @app.call(env) if token.blank?
@@ -51,6 +51,16 @@ module Machina
51
51
  result == TRANSIENT_FAILURE
52
52
  end
53
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
+
54
64
  # Strings match as path prefixes; regexes match path or full URL.
55
65
  def skip_path?(request)
56
66
  return false if Machina.config.skip_paths.blank?
@@ -75,18 +85,11 @@ module Machina
75
85
  end
76
86
 
77
87
  def extract_token(request)
78
- extract_param_token(request)
79
- || request.cookies['machina_session']
88
+ request.cookies['machina_session']
80
89
  || extract_bearer(request)
81
90
  || request.headers['X-Api-Key']
82
91
  end
83
92
 
84
- def extract_param_token(request)
85
- request.params['token']
86
- rescue ActionDispatch::Http::Parameters::ParseError
87
- nil
88
- end
89
-
90
93
  def extract_bearer(request)
91
94
  auth_header = request.headers['Authorization'].to_s
92
95
  match = auth_header.match(/\ABearer\s+(.+)\z/)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.4.0'
4
+ VERSION = '1.0.0'
5
5
  end
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? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
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
@@ -218,32 +218,50 @@ RSpec.describe Machina::Middleware::Authentication do
218
218
  expect(identity_client).to have_received(:resolve_session).with(token).once
219
219
  end
220
220
 
221
- context 'when a stale cookie coexists with a fresh callback token' do
222
- let(:stale_token) { 'ps_expired_cookie' }
223
- let(:fresh_token) { 'ps_fresh_callback' }
221
+ describe 'token query param' do
222
+ it 'never authenticates a request' do
223
+ allow(identity_client).to receive(:resolve_session)
224
224
 
225
- before do
226
- allow(identity_client).to receive(:resolve_session).with(stale_token).and_return(
227
- Machina::IdentityClient::Response.new(status: 404, body: '{}'),
228
- )
225
+ env = Rack::MockRequest.env_for('/resource?token=ps_leaked_in_url')
226
+ status, _headers, body = middleware.call(env)
229
227
 
230
- allow(identity_client).to receive(:resolve_session).with(fresh_token).and_return(
231
- Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution),
232
- )
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'
233
237
  end
234
238
 
235
- it 'prefers the query param token over the stale cookie' do
239
+ it 'is skipped entirely, even with a stale session cookie' do
240
+ allow(identity_client).to receive(:resolve_session)
241
+
236
242
  env = Rack::MockRequest.env_for(
237
- '/auth/machina/callback?token=ps_fresh_callback',
243
+ '/machina/callback?token=ps_fresh&state=abc',
238
244
  'HTTP_COOKIE' => 'machina_session=ps_expired_cookie',
239
245
  )
240
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')
241
261
  status, _headers, body = middleware.call(env)
242
262
 
243
263
  expect(status).to eq(200)
244
- parsed = JSON.parse(body.first)
245
- expect(parsed['user_id']).to eq(MockResponses.session_resolution['data']['user']['id'])
246
- expect(identity_client).not_to have_received(:resolve_session).with(stale_token)
264
+ expect(JSON.parse(body.first)['user_id']).to be_present
247
265
  end
248
266
  end
249
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.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