machina-auth 1.0.2 → 1.2.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: dc18de6ee1048be86f05320f0a2864092dce1b886689a98e042bb4b68385649f
4
+ data.tar.gz: 0657bf99e18fe6418ee481e3234ffe3a0dc5ae27fe9aab6dfff6363b7e682c56
5
5
  SHA512:
6
- metadata.gz: b58c5c91d1ca06f6d5d050f79e200c3c012473d262a2a5231c8cc3b3a2dc0c799b1f163dffb4963e243bfe2c8d5d915e87ac74db516a4f1f493cdb34c55510a4
7
- data.tar.gz: 2e8113a5ccfef4f7a6c07bba04127e746256da82ba8f0d79d7529625e5cb92acf2ac621beffacf80f552923e1b7858aae65f4de243d67f1d0775dbc01e43710a
6
+ metadata.gz: 6fb9094979c7931d23102d76f4a4da200943ac503c36ba165ee9bd20fa605a737a16cbada861890e1529434972fbf8c64f3f222cf85389bae462276f2387e734
7
+ data.tar.gz: 061dc831c51829d43b2e9a7f1f91a22b8ff41eade182ffb25ac2f53c0b8958c4a1189003e15403130d27b1808f63305835da2ac2330623b3c38527397617c070
data/README.md CHANGED
@@ -11,7 +11,7 @@ Rails engine that integrates product apps with the Machina Console identity serv
11
11
  - **`Machina::ControllerHelpers`** — `require_authorized!` and `authorize!` for controllers
12
12
  - **`Machina::WorkspaceScoped`** — concern that filters ActiveRecord queries to the current workspace
13
13
  - **Webhook receiver** — verifies HMAC signatures and invalidates cached sessions on permission/membership changes
14
- - **Permission sync** — pushes a YAML manifest of permissions and policies to the Console on boot
14
+ - **Permission sync** — pushes a YAML manifest of permissions and policies to the Console on boot (production servers/workers only by default — never under rake or `rails console`; see `sync_on_boot`) or via `rake machina:sync_permissions`
15
15
 
16
16
  ## Installation
17
17
 
@@ -35,6 +35,7 @@ Machina.configure do |config|
35
35
  config.service_token = ENV["MACHINA_SERVICE_TOKEN"]
36
36
  config.product_slug = "my-app"
37
37
  config.manifest = Rails.root.join("config/machina.yml")
38
+ config.sync_on_boot = Rails.env.production? # the default; sync the manifest in a background thread on boot
38
39
  end
39
40
  ```
40
41
 
@@ -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],
@@ -6,4 +6,6 @@ Machina.configure do |config|
6
6
  config.cache_store = Rails.cache
7
7
  config.cache_ttl = 5.minutes
8
8
  config.manifest = Rails.root.join("config/machina.yml")
9
+ # Sync the manifest to the Console on boot (default: production only).
10
+ # config.sync_on_boot = Rails.env.production?
9
11
  end
@@ -15,10 +15,17 @@ module Machina
15
15
  :skip_paths,
16
16
  :identity_callback_uri
17
17
 
18
+ attr_writer :sync_on_boot
19
+
18
20
  def initialize
19
21
  @cache_ttl = 5.minutes
20
22
  @negative_cache_ttl = 30.seconds
21
23
  @skip_paths = []
22
24
  end
25
+
26
+ # Resolved on read so Rails.env reflects the booted app, not gem load time.
27
+ def sync_on_boot
28
+ @sync_on_boot.nil? ? Rails.env.production? : @sync_on_boot
29
+ end
23
30
  end
24
31
  end
@@ -40,7 +40,7 @@ module Machina
40
40
  if token.present?
41
41
  Machina.cache.delete("machina:session:#{token}")
42
42
  begin
43
- Machina.identity_client.revoke_session(token)
43
+ Machina.identity_client.revoke_session(token) if Machina::IdentityClient.console_token?(token)
44
44
  rescue StandardError
45
45
  # Best-effort: local logout succeeds even if Console is unreachable
46
46
  end
@@ -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
@@ -16,18 +16,29 @@ module Machina
16
16
  end
17
17
  end
18
18
 
19
- # Sync the permission manifest to the Console on boot when configured.
19
+ # Sync the permission manifest to the Console on boot when enabled.
20
20
  # Runs in a background thread so it doesn't block server startup.
21
- # Skipped when manifest is not set.
22
- config.after_initialize do
23
- next if Machina.config.manifest.blank?
21
+ config.after_initialize { Machina::Engine.sync_permissions_on_boot }
22
+
23
+ def self.sync_permissions_on_boot
24
+ return unless Machina.config.sync_on_boot && Machina.config.manifest.present?
25
+ return if rake_or_console?
24
26
 
25
27
  Thread.new do
26
- Machina::PermissionSync.call!
27
- Rails.logger.info('[machina] Permission manifest synced to Console')
28
+ response = Machina::PermissionSync.call!
29
+ if response.success?
30
+ Rails.logger.info('[machina] Permission manifest synced to Console')
31
+ else
32
+ Rails.logger.warn("[machina] Permission sync failed on boot: #{Machina::PermissionSync.failure_summary(response)}")
33
+ end
28
34
  rescue StandardError => e
29
- Rails.logger.warn("[machina] Permission sync failed on boot: #{e.message}")
35
+ Rails.logger.warn("[machina] Permission sync failed on boot: #{e.class}: #{e.message}")
30
36
  end
31
37
  end
38
+
39
+ # Rake tasks (incl. machina:sync_permissions) and consoles boot the app too; only servers/workers sync.
40
+ def self.rake_or_console?
41
+ defined?(Rails::Console) || (defined?(Rake.application) && Rake.application.top_level_tasks.any?)
42
+ end
32
43
  end
33
44
  end
@@ -4,6 +4,9 @@ module Machina
4
4
  # HTTP client for communicating with the Machina Console identity service.
5
5
  # Handles session resolution, revocation, and permission syncing.
6
6
  class IdentityClient
7
+ # Session tokens (ps_) and API keys (mk_); the Console resolves nothing else.
8
+ TOKEN_PREFIXES = %w[ps_ mk_].freeze
9
+
7
10
  Response = Struct.new(:status, :body) do
8
11
  def success?
9
12
  status.between?(200, 299)
@@ -14,12 +17,16 @@ module Machina
14
17
  end
15
18
  end
16
19
 
20
+ def self.console_token?(token)
21
+ token.start_with?(*TOKEN_PREFIXES)
22
+ end
23
+
17
24
  def initialize(config: Machina.config, connection: nil)
18
25
  @config = config
19
26
  @connection = connection
20
27
  end
21
28
 
22
- # @param token [String] raw session token (ps_) or API key (mk_)
29
+ # @param token [String] raw session token (ps_) or API key (mk_), see TOKEN_PREFIXES
23
30
  # @param workspace_id [String, nil] optional workspace UUID hint for org-scoped API keys
24
31
  # @param workspace_slug [String, nil] optional workspace slug hint (alternative to id)
25
32
  # @param organization_slug [String, nil] optional organization slug; identity service
@@ -33,7 +33,8 @@ module Machina
33
33
  private
34
34
 
35
35
  def dispatch_session(env, request, token, hints)
36
- session_data = resolve_session(token, hints)
36
+ # A foreign token would only 404; leave it nil (rejected) without a Console call.
37
+ session_data = resolve_session(token, hints) if Machina::IdentityClient.console_token?(token)
37
38
 
38
39
  if session_data.is_a?(Hash)
39
40
  Machina::Current.authorized = Machina::Authorized.new(session_data)
@@ -11,6 +11,10 @@ module Machina
11
11
  new.call!
12
12
  end
13
13
 
14
+ def self.failure_summary(response)
15
+ "HTTP #{response.status}: #{response.body.to_s.truncate(200)}"
16
+ end
17
+
14
18
  def call!
15
19
  manifest = load_manifest
16
20
 
@@ -3,7 +3,9 @@
3
3
  namespace :machina do
4
4
  desc 'Sync permission manifest to Console'
5
5
  task sync_permissions: :environment do
6
- Machina::PermissionSync.call!
6
+ response = Machina::PermissionSync.call!
7
+ abort "Permission sync failed: #{Machina::PermissionSync.failure_summary(response)}" unless response.success?
8
+
7
9
  puts 'Permissions synced successfully.'
8
10
  end
9
11
  end
@@ -51,8 +51,7 @@ module Machina
51
51
  #
52
52
  # @return [Machina::Authorized]
53
53
  #
54
- # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
55
- def sign_in_as_machina(
54
+ def sign_in_as_machina( # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
56
55
  tenant_ref = nil,
57
56
  user: {},
58
57
  organization: {},
@@ -72,8 +71,7 @@ module Machina
72
71
  authorized = Machina::Authorized.new(data)
73
72
  Machina::Current.authorized = authorized
74
73
  authorized
75
- end
76
- # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
74
+ end # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
77
75
 
78
76
  # Clears the current authenticated context.
79
77
  def sign_out_machina
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '1.0.2'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -18,4 +18,22 @@ RSpec.describe Machina::Configuration do
18
18
  config.identity_callback_uri = 'http://localhost:3000/auth/machina/callback'
19
19
  expect(config.identity_callback_uri).to eq('http://localhost:3000/auth/machina/callback')
20
20
  end
21
+
22
+ it 'defaults sync_on_boot to false outside production' do
23
+ expect(described_class.new.sync_on_boot).to be(false)
24
+ end
25
+
26
+ it 'defaults sync_on_boot to true in production' do
27
+ allow(Rails).to receive(:env).and_return(ActiveSupport::EnvironmentInquirer.new('production'))
28
+
29
+ expect(described_class.new.sync_on_boot).to be(true)
30
+ end
31
+
32
+ it 'lets sync_on_boot be overridden' do
33
+ config = described_class.new
34
+ config.sync_on_boot = false
35
+ allow(Rails).to receive(:env).and_return(ActiveSupport::EnvironmentInquirer.new('production'))
36
+
37
+ expect(config.sync_on_boot).to be(false)
38
+ end
21
39
  end
@@ -111,6 +111,25 @@ RSpec.describe Machina::ControllerHelpers, type: :controller do
111
111
  expect(response.cookies['machina_session']).to be_nil
112
112
  end
113
113
 
114
+ it 'does not send a non-Console bearer token to the identity service' do
115
+ request.cookies.delete(:machina_session)
116
+ request.headers['Authorization'] = 'Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig'
117
+
118
+ delete :destroy
119
+
120
+ expect(response).to have_http_status(:ok)
121
+ expect(identity_client).not_to have_received(:revoke_session)
122
+ end
123
+
124
+ it 'revokes a Console bearer token' do
125
+ request.cookies.delete(:machina_session)
126
+ request.headers['Authorization'] = 'Bearer ps_bearer_token'
127
+
128
+ delete :destroy
129
+
130
+ expect(identity_client).to have_received(:revoke_session).with('ps_bearer_token')
131
+ end
132
+
114
133
  it 'handles missing token gracefully' do
115
134
  request.cookies.delete(:machina_session)
116
135
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rails_helper'
4
+ require 'rake'
5
+
6
+ RSpec.describe Machina::Engine do
7
+ describe '.sync_permissions_on_boot' do
8
+ let(:response) { Machina::IdentityClient::Response.new(status: 200, body: '{}') }
9
+
10
+ before do
11
+ allow(Machina::PermissionSync).to receive(:call!) { response }
12
+ allow(Rails.logger).to receive(:info)
13
+ allow(Rails.logger).to receive(:warn)
14
+ end
15
+
16
+ it 'syncs the manifest when sync_on_boot is true' do
17
+ Machina.config.sync_on_boot = true
18
+
19
+ described_class.sync_permissions_on_boot.join
20
+
21
+ expect(Machina::PermissionSync).to have_received(:call!)
22
+ expect(Rails.logger).to have_received(:info).with('[machina] Permission manifest synced to Console')
23
+ end
24
+
25
+ it 'skips the sync when sync_on_boot is false' do
26
+ Machina.config.sync_on_boot = false
27
+
28
+ expect(described_class.sync_permissions_on_boot).to be_nil
29
+ expect(Machina::PermissionSync).not_to have_received(:call!)
30
+ end
31
+
32
+ it 'skips the sync when no manifest is configured' do
33
+ Machina.config.sync_on_boot = true
34
+ Machina.config.manifest = nil
35
+
36
+ expect(described_class.sync_permissions_on_boot).to be_nil
37
+ expect(Machina::PermissionSync).not_to have_received(:call!)
38
+ end
39
+
40
+ it 'skips the sync when booted by a rake task' do
41
+ Machina.config.sync_on_boot = true
42
+ allow(Rake.application).to receive(:top_level_tasks).and_return(['machina:sync_permissions'])
43
+
44
+ expect(described_class.sync_permissions_on_boot).to be_nil
45
+ expect(Machina::PermissionSync).not_to have_received(:call!)
46
+ end
47
+
48
+ it 'skips the sync when booted by a Rails console' do
49
+ Machina.config.sync_on_boot = true
50
+ stub_const('Rails::Console', Class.new)
51
+
52
+ expect(described_class.sync_permissions_on_boot).to be_nil
53
+ expect(Machina::PermissionSync).not_to have_received(:call!)
54
+ end
55
+
56
+ context 'when the Console rejects the sync' do
57
+ let(:response) { Machina::IdentityClient::Response.new(status: 422, body: '{"error":"invalid manifest"}') }
58
+
59
+ it 'logs a failure with the status and body, not success' do
60
+ Machina.config.sync_on_boot = true
61
+
62
+ described_class.sync_permissions_on_boot.join
63
+
64
+ expect(Rails.logger).to have_received(:warn)
65
+ .with('[machina] Permission sync failed on boot: HTTP 422: {"error":"invalid manifest"}')
66
+ expect(Rails.logger).not_to have_received(:info)
67
+ end
68
+ end
69
+
70
+ it 'logs the exception class when the sync raises' do
71
+ Machina.config.sync_on_boot = true
72
+ allow(Machina::PermissionSync).to receive(:call!).and_raise(Faraday::ConnectionFailed, 'refused')
73
+
74
+ described_class.sync_permissions_on_boot.join
75
+
76
+ expect(Rails.logger).to have_received(:warn)
77
+ .with('[machina] Permission sync failed on boot: Faraday::ConnectionFailed: refused')
78
+ end
79
+ end
80
+ end
@@ -82,6 +82,18 @@ RSpec.describe Machina::IdentityClient do
82
82
  expect(response.parsed.dig('data', 'session_id')).to eq('d4e5f6a7-b8c9-0123-def0-1234567890ab')
83
83
  end
84
84
 
85
+ describe '.console_token?' do
86
+ it 'accepts session tokens and API keys' do
87
+ expect(described_class.console_token?('ps_123')).to be(true)
88
+ expect(described_class.console_token?('mk_123')).to be(true)
89
+ end
90
+
91
+ it 'rejects any other token' do
92
+ expect(described_class.console_token?('eyJhbGciOiJIUzI1NiJ9.e30.sig')).to be(false)
93
+ expect(described_class.console_token?('PS_123')).to be(false)
94
+ end
95
+ end
96
+
85
97
  it 'raises when required configuration is missing' do
86
98
  Machina.configure do |config|
87
99
  config.identity_service_url = nil
@@ -154,6 +154,56 @@ 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
+ describe 'tokens without a Console prefix' do
158
+ before { allow(identity_client).to receive(:resolve_session) }
159
+
160
+ it 'passes through unauthenticated without calling the identity service' do
161
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => 'Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig')
162
+ status, _headers, body = middleware.call(env)
163
+
164
+ expect(status).to eq(200)
165
+ expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
166
+ expect(identity_client).not_to have_received(:resolve_session)
167
+ expect(Machina.cache.read('machina:session:eyJhbGciOiJIUzI1NiJ9.e30.sig')).to be_nil
168
+ end
169
+
170
+ it 'deletes a cookie-sourced token like a Console rejection' do
171
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_COOKIE' => 'machina_session=legacy_token')
172
+ _status, headers, _body = middleware.call(env)
173
+
174
+ expect(identity_client).not_to have_received(:resolve_session)
175
+ expect(headers['set-cookie']).to include('max-age=0')
176
+ end
177
+
178
+ it 'skips a non-prefixed X-Api-Key without caching it' do
179
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_X_API_KEY' => 'sk_live_foreign')
180
+ _status, _headers, body = middleware.call(env)
181
+
182
+ expect(JSON.parse(body.first)['user_id']).to be_nil
183
+ expect(identity_client).not_to have_received(:resolve_session)
184
+ expect(Machina.cache.read('machina:session:sk_live_foreign')).to be_nil
185
+ end
186
+
187
+ it 'writes no negative cache entry for a non-prefixed cookie' do
188
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_COOKIE' => 'machina_session=legacy_token')
189
+ middleware.call(env)
190
+
191
+ expect(Machina.cache.exist?('machina:session:legacy_token')).to be(false)
192
+ end
193
+
194
+ it 'still resolves an mk_ API key sent via X-Api-Key' do
195
+ allow(identity_client).to receive(:resolve_session).with('mk_abc').and_return(
196
+ Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution_minimal),
197
+ )
198
+
199
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_X_API_KEY' => 'mk_abc')
200
+ _status, _headers, body = middleware.call(env)
201
+
202
+ expect(identity_client).to have_received(:resolve_session).with('mk_abc').once
203
+ expect(JSON.parse(body.first)['user_id']).to be_present
204
+ end
205
+ end
206
+
157
207
  it 'negative-caches the rejection and passes through when Console returns non-200 on stale re-fetch' do
158
208
  token = 'ps_stale_token'
159
209
  cache_key = "machina:session:#{token}"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rails_helper'
4
+ require 'rake'
5
+
6
+ RSpec.describe 'machina:sync_permissions' do
7
+ let(:task) do
8
+ Rake::Task.define_task(:environment)
9
+ unless Rake::Task.task_defined?('machina:sync_permissions')
10
+ load File.expand_path('../../lib/machina/tasks/sync.rake', __dir__)
11
+ end
12
+ Rake::Task['machina:sync_permissions']
13
+ end
14
+
15
+ after { task.reenable }
16
+
17
+ it 'reports success on a 2xx response' do
18
+ allow(Machina::PermissionSync).to receive(:call!)
19
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: '{}'))
20
+
21
+ expect { task.invoke }.to output("Permissions synced successfully.\n").to_stdout
22
+ end
23
+
24
+ it 'exits non-zero with the status on a non-2xx response' do
25
+ allow(Machina::PermissionSync).to receive(:call!)
26
+ .and_return(Machina::IdentityClient::Response.new(status: 500, body: 'boom'))
27
+
28
+ expect { task.invoke }
29
+ .to raise_error(SystemExit) { |e| expect(e.status).to eq(1) }
30
+ .and output("Permission sync failed: HTTP 500: boom\n").to_stderr
31
+ end
32
+ 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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZAR
@@ -181,10 +181,12 @@ files:
181
181
  - spec/machina/configuration_spec.rb
182
182
  - spec/machina/controller_helpers_spec.rb
183
183
  - spec/machina/current_spec.rb
184
+ - spec/machina/engine_spec.rb
184
185
  - spec/machina/identity_client_spec.rb
185
186
  - spec/machina/middleware/authentication_spec.rb
186
187
  - spec/machina/middleware/skip_paths_spec.rb
187
188
  - spec/machina/permission_sync_spec.rb
189
+ - spec/machina/sync_task_spec.rb
188
190
  - spec/machina/test_helpers_spec.rb
189
191
  - spec/machina/webhook_receiver_spec.rb
190
192
  - spec/machina/workspace_scoped_spec.rb