machina-auth 0.3.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: 95a0c331d428907d79a618631f8b39aea1aa20e297090e44229aed0bc93c0324
4
- data.tar.gz: 1489dd29fdf00291cb414b57f198d210bd9e1af818d373b4064b7cda6c0f1b66
3
+ metadata.gz: 64cd50975d04d89620d0ad73d6be045f93256efc4d34c57a563782167e659dc2
4
+ data.tar.gz: 3a962c88b3b58a015fb267de869acc17575cf7377df37b1bfe4439c0bbc66eba
5
5
  SHA512:
6
- metadata.gz: '08a671be6f57a8ef5098444165e335e33cfb85e371f7d34d7842699854bab66050f1de191dc203839dc7c4b94dc72a556803699bc08333e453a5552fd0b94007'
7
- data.tar.gz: 236485e85bfc0ef18afc0ddde3251b411f8579ff90b1fa870958b403e2175fbfbfce751d9c629d787c9848f709473e7f7f3779843ab6232b100aee27f1acfb83
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
@@ -10,12 +10,14 @@ module Machina
10
10
  :product_slug,
11
11
  :cache_store,
12
12
  :cache_ttl,
13
+ :negative_cache_ttl,
13
14
  :manifest,
14
15
  :skip_paths,
15
16
  :identity_callback_uri
16
17
 
17
18
  def initialize
18
19
  @cache_ttl = 5.minutes
20
+ @negative_cache_ttl = 30.seconds
19
21
  @skip_paths = []
20
22
  end
21
23
  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
@@ -8,7 +8,11 @@ module Machina
8
8
  # resolves them against the identity service, and sets Current.authorized.
9
9
  class Authentication
10
10
  TRANSIENT_FAILURE = :transient_failure
11
- private_constant :TRANSIENT_FAILURE
11
+
12
+ # Negative-cache marker for a rejected token. A Hash, so it round-trips through any cache coder.
13
+ INVALID_SESSION = { '__machina_invalid__' => true }.freeze
14
+
15
+ private_constant :TRANSIENT_FAILURE, :INVALID_SESSION
12
16
 
13
17
  def initialize(app)
14
18
  @app = app
@@ -16,7 +20,7 @@ module Machina
16
20
 
17
21
  def call(env)
18
22
  request = ActionDispatch::Request.new(env)
19
- return @app.call(env) if skip_path?(request)
23
+ return @app.call(env) if callback_path?(request) || skip_path?(request)
20
24
 
21
25
  token = extract_token(request)
22
26
  return @app.call(env) if token.blank?
@@ -39,7 +43,7 @@ module Machina
39
43
  if transient_failure?(session_data)
40
44
  @app.call(env)
41
45
  else
42
- handle_expired_token(env, request, token, hints)
46
+ handle_expired_token(env, request, token)
43
47
  end
44
48
  end
45
49
 
@@ -47,6 +51,16 @@ module Machina
47
51
  result == TRANSIENT_FAILURE
48
52
  end
49
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
+
50
64
  # Strings match as path prefixes; regexes match path or full URL.
51
65
  def skip_path?(request)
52
66
  return false if Machina.config.skip_paths.blank?
@@ -62,28 +76,20 @@ module Machina
62
76
  end
63
77
  end
64
78
 
65
- # Clears the cache and cookie for a token that Console explicitly rejected.
66
- def handle_expired_token(env, request, token, hints)
67
- Machina.cache.delete(cache_key(token, hints))
68
-
79
+ # Clears the cookie for a rejected token. The cache holds the INVALID_SESSION
80
+ # sentinel (written in fetch_from_identity_service) and must not be cleared here.
81
+ def handle_expired_token(env, request, token)
69
82
  status, headers, body = @app.call(env)
70
83
  Rack::Utils.delete_cookie_header!(headers, 'machina_session') if request.cookies['machina_session'] == token
71
84
  [status, headers, body]
72
85
  end
73
86
 
74
87
  def extract_token(request)
75
- extract_param_token(request)
76
- || request.cookies['machina_session']
88
+ request.cookies['machina_session']
77
89
  || extract_bearer(request)
78
90
  || request.headers['X-Api-Key']
79
91
  end
80
92
 
81
- def extract_param_token(request)
82
- request.params['token']
83
- rescue ActionDispatch::Http::Parameters::ParseError
84
- nil
85
- end
86
-
87
93
  def extract_bearer(request)
88
94
  auth_header = request.headers['Authorization'].to_s
89
95
  match = auth_header.match(/\ABearer\s+(.+)\z/)
@@ -92,6 +98,7 @@ module Machina
92
98
 
93
99
  def resolve_session(token, hints)
94
100
  cached = Machina.cache.read(cache_key(token, hints))
101
+ return nil if cached == INVALID_SESSION
95
102
  return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
96
103
 
97
104
  fetch_from_identity_service(token, hints)
@@ -109,7 +116,7 @@ module Machina
109
116
  response = Machina.identity_client.resolve_session(token, **hints.to_resolve_kwargs)
110
117
 
111
118
  unless response.success?
112
- Machina.cache.delete(cache_key(token, hints))
119
+ Machina.cache.write(cache_key(token, hints), INVALID_SESSION, expires_in: Machina.config.negative_cache_ttl)
113
120
  return nil
114
121
  end
115
122
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.3.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
@@ -154,7 +154,7 @@ 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
- it 'evicts cached session and passes through when Console returns non-200 on stale re-fetch' do
157
+ it 'negative-caches the rejection and passes through when Console returns non-200 on stale re-fetch' do
158
158
  token = 'ps_stale_token'
159
159
  cache_key = "machina:session:#{token}"
160
160
  stale_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
@@ -169,10 +169,11 @@ RSpec.describe Machina::Middleware::Authentication do
169
169
  status, = middleware.call(env)
170
170
 
171
171
  expect(status).to eq(200)
172
- expect(Machina.cache.read(cache_key)).to be_nil
172
+ # The stale session is replaced by the short-lived negative sentinel, not deleted.
173
+ expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
173
174
  end
174
175
 
175
- it 'evicts cached session when Console returns non-200 after cache expires' do
176
+ it 'negative-caches the rejection when Console returns non-200 after cache expires' do
176
177
  token = 'ps_expired_cache'
177
178
  cache_key = "machina:session:#{token}"
178
179
 
@@ -197,35 +198,70 @@ RSpec.describe Machina::Middleware::Authentication do
197
198
  status, = middleware.call(env)
198
199
 
199
200
  expect(status).to eq(200)
200
- expect(Machina.cache.read(cache_key)).to be_nil
201
+ expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
201
202
  end
202
203
 
203
- context 'when a stale cookie coexists with a fresh callback token' do
204
- let(:stale_token) { 'ps_expired_cookie' }
205
- let(:fresh_token) { 'ps_fresh_callback' }
204
+ it 'does not re-hit Console for a rejected token while the negative cache is warm' do
205
+ token = 'ps_dead_token'
206
206
 
207
- before do
208
- allow(identity_client).to receive(:resolve_session).with(stale_token).and_return(
209
- Machina::IdentityClient::Response.new(status: 404, body: '{}'),
210
- )
207
+ allow(identity_client).to receive(:resolve_session).with(token).and_return(
208
+ Machina::IdentityClient::Response.new(status: 404, body: '{}'),
209
+ )
211
210
 
212
- allow(identity_client).to receive(:resolve_session).with(fresh_token).and_return(
213
- Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution),
214
- )
211
+ 3.times do
212
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
213
+ status, = middleware.call(env)
214
+ expect(status).to eq(200)
215
+ end
216
+
217
+ # Without negative caching this would be 3 calls; the sentinel absorbs the retries.
218
+ expect(identity_client).to have_received(:resolve_session).with(token).once
219
+ end
220
+
221
+ describe 'token query param' do
222
+ it 'never authenticates a request' do
223
+ allow(identity_client).to receive(:resolve_session)
224
+
225
+ env = Rack::MockRequest.env_for('/resource?token=ps_leaked_in_url')
226
+ status, _headers, body = middleware.call(env)
227
+
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'
215
237
  end
216
238
 
217
- 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
+
218
242
  env = Rack::MockRequest.env_for(
219
- '/auth/machina/callback?token=ps_fresh_callback',
243
+ '/machina/callback?token=ps_fresh&state=abc',
220
244
  'HTTP_COOKIE' => 'machina_session=ps_expired_cookie',
221
245
  )
222
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')
223
261
  status, _headers, body = middleware.call(env)
224
262
 
225
263
  expect(status).to eq(200)
226
- parsed = JSON.parse(body.first)
227
- expect(parsed['user_id']).to eq(MockResponses.session_resolution['data']['user']['id'])
228
- expect(identity_client).not_to have_received(:resolve_session).with(stale_token)
264
+ expect(JSON.parse(body.first)['user_id']).to be_present
229
265
  end
230
266
  end
231
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.3.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