machina-auth 0.1.4 → 0.1.5

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: c8b85487ba807943451cf345d17e7aec7ee18a9b61a233adcdb213f86134cde0
4
- data.tar.gz: 49908d36c4fb879354aa6c3b9ae96e33eaed76d98df2cd3dbfb8a51ce72b03a4
3
+ metadata.gz: ca27cf7dd475d64e5bbf876084d6e7445a56ac65dbf9967d5b9b50a91f04f155
4
+ data.tar.gz: ad165e21c3628da603b016a7e40911f7efc3e60131cbad506ee1c5cb2dde48c8
5
5
  SHA512:
6
- metadata.gz: 97716d4c519933b65a8410615655adcf7e70a4013d9595e7ef5ca4a96ae2a57c44574640b33de5dc1adcd675b626c2ae39d09ffd0c5cf4fbcbb2932924988be9
7
- data.tar.gz: 868b463264e64e8551c938704bcfad481714e0d6a9f4aa8dcc6152eb4d8df1bda0c6a01ff2ab55f89e534ceeb03b1d53abed3792dc2afaa1de660c6726dd2e2c
6
+ metadata.gz: 3830bfe8f9b10c30bfedf347d3efa00ecc36feb09a028ed9372512c7c0a3f6817bee27a321c008394c6361b68c85e2aeb5ad0d71b41a72fd779ea8340d7e7fe6
7
+ data.tar.gz: f6c04f4aea2b6ad6f01c5632c3e49e92291a8341bb01c751f11eb22d6612aaad8e7f9b4c1124d77d9138c34094de00f41195cbabd60d6957293613111fa60bef
@@ -75,8 +75,8 @@ module Machina
75
75
  end
76
76
 
77
77
  def assign_session_attrs(data)
78
- @session_id = data.dig('session', 'id') || data['session_id']
79
78
  @type = data.dig('session', 'type')&.to_sym
79
+ @session_id = data.dig('session', 'id') || data['session_id']
80
80
  raw_expires = data.dig('session', 'expires_at') || data['expires_at']
81
81
  @expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
82
82
  end
@@ -8,20 +8,11 @@ module Machina
8
8
 
9
9
  included do
10
10
  helper_method :authorized, :logged_in? if respond_to?(:helper_method)
11
-
12
- rescue_from Machina::Unauthorized do |error|
13
- if request.format.json?
14
- render json: { error: 'forbidden', permission: error.message }, status: :forbidden
15
- else
16
- redirect_to main_app.respond_to?(:root_path) ? main_app.root_path : '/',
17
- alert: "You don't have permission to do that."
18
- end
19
- end
11
+ rescue_from Machina::Unauthorized, with: :respond_unauthorized
20
12
  end
21
13
 
22
14
  def authorized
23
- current = defined?(::Current) ? ::Current : Machina::Current
24
- current.authorized || Machina::Authorized::EMPTY
15
+ Machina::Current.authorized || Machina::Authorized::EMPTY
25
16
  end
26
17
 
27
18
  def logged_in?
@@ -58,6 +49,15 @@ module Machina
58
49
 
59
50
  private
60
51
 
52
+ def respond_unauthorized(error)
53
+ if request.format.json?
54
+ render json: { error: 'forbidden', permission: error.message }, status: :forbidden
55
+ else
56
+ path = main_app.respond_to?(:root_path) ? main_app.root_path : '/'
57
+ redirect_to path, alert: "You don't have permission to do that."
58
+ end
59
+ end
60
+
61
61
  def extract_bearer_token
62
62
  header = request.headers['Authorization'].to_s
63
63
  match = header.match(/\ABearer\s+(.+)\z/)
@@ -1,7 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- # Thread-safe per-request store for the currently authenticated Authorized object.
4
+ # Thread-safe per-request store for the currently authenticated {Authorized} object.
5
+ #
6
+ # The middleware writes exclusively to +Machina::Current.authorized+. Host
7
+ # apps that want +::Current.authorized+ should inherit from this class:
8
+ #
9
+ # class Current < Machina::Current
10
+ # # add app-specific attributes here
11
+ # end
12
+ #
13
+ # Note: each +CurrentAttributes+ subclass has independent thread-local
14
+ # storage, so +::Current.authorized+ and +Machina::Current.authorized+ hold
15
+ # separate values even when +::Current < Machina::Current+. The gem always
16
+ # reads and writes through +Machina::Current+.
5
17
  class Current < ActiveSupport::CurrentAttributes
6
18
  attribute :authorized
7
19
  end
@@ -42,9 +42,9 @@ module Machina
42
42
  ensure_configured!
43
43
 
44
44
  response = connection.post(path) do |request|
45
- request.headers['Authorization'] = "Bearer #{config.service_token}"
46
- request.headers['Content-Type'] = 'application/json'
47
45
  request.headers['Accept'] = 'application/json'
46
+ request.headers['Content-Type'] = 'application/json'
47
+ request.headers['Authorization'] = "Bearer #{config.service_token}"
48
48
  request.body = JSON.generate(payload)
49
49
  end
50
50
 
@@ -19,16 +19,20 @@ module Machina
19
19
  return @app.call(env) if token.blank?
20
20
 
21
21
  session_data = resolve_session(token)
22
- return unauthorized_response unless session_data
23
22
 
24
- authorized = Machina::Authorized.new(session_data)
25
- Machina::Current.authorized = authorized
26
- ::Current.authorized = authorized if defined?(::Current) && ::Current != Machina::Current
23
+ # Token present but invalid/expired: clear the stale cache entry and
24
+ # fall through with nil authorized. API callers get 401 from the
25
+ # controller; browser users get redirected to login by authenticate!.
26
+ unless session_data
27
+ Machina.cache.delete(cache_key(token))
28
+ return @app.call(env)
29
+ end
30
+
31
+ Machina::Current.authorized = Machina::Authorized.new(session_data)
27
32
 
28
33
  @app.call(env)
29
34
  ensure
30
35
  Machina::Current.reset
31
- ::Current.reset if defined?(::Current) && ::Current != Machina::Current
32
36
  end
33
37
 
34
38
  private
@@ -53,7 +57,10 @@ module Machina
53
57
  end
54
58
 
55
59
  def extract_token(request)
56
- request.cookies['machina_session'] || extract_bearer(request) || request.headers['X-Api-Key'] || request.params['token']
60
+ request.cookies['machina_session']
61
+ || extract_bearer(request)
62
+ || request.headers['X-Api-Key']
63
+ || request.params['token']
57
64
  end
58
65
 
59
66
  def extract_bearer(request)
@@ -90,16 +97,18 @@ module Machina
90
97
  end
91
98
 
92
99
  def cache_workspace_ref(data)
93
- workspace = data['workspace']
100
+ workspace = data['workspace']
94
101
  organization = data['organization']
95
102
 
96
103
  return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
97
104
  return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
98
105
 
99
106
  ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
100
- ref.organization_id = organization['id']
107
+
101
108
  ref.name = workspace['name']
102
109
  ref.cached_at = Time.current
110
+ ref.organization_id = organization['id']
111
+
103
112
  ref.save!
104
113
  rescue StandardError
105
114
  nil
@@ -109,10 +118,6 @@ module Machina
109
118
  "machina:session:#{token}"
110
119
  end
111
120
 
112
- def unauthorized_response
113
- body = JSON.generate(error: 'unauthorized')
114
- [401, { 'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s }, [body]]
115
- end
116
121
  end
117
122
  end
118
123
  end
@@ -16,14 +16,13 @@ module Machina
16
16
 
17
17
  product_id = manifest[:product_id] || Machina.config.product_id
18
18
  if product_id.blank?
19
- raise Machina::ConfigurationError,
20
- 'product_id is required for permission sync (set in machina.yml or Machina.config)'
19
+ raise Machina::ConfigurationError, 'product_id is required for permission sync (set in machina.yml or Machina.config)'
21
20
  end
22
21
 
23
22
  Machina.identity_client.sync_permissions(
24
23
  product_id:,
25
- permissions: manifest.fetch(:permissions),
26
24
  policies: manifest.fetch(:policies, []),
25
+ permissions: manifest.fetch(:permissions),
27
26
  )
28
27
  end
29
28
 
@@ -63,10 +63,10 @@ module Machina
63
63
  data = default_session_data
64
64
 
65
65
  data['user'].merge!(stringify(user))
66
- data['organization'].merge!(stringify(organization))
66
+ data['session'].merge!(stringify(session))
67
67
  data['workspace'].merge!(stringify(workspace))
68
+ data['organization'].merge!(stringify(organization))
68
69
  data['workspace']['id'] = tenant_ref.to_s if tenant_ref
69
- data['session'].merge!(stringify(session))
70
70
  data['permissions'] = permissions.map(&:to_s) if permissions
71
71
 
72
72
  authorized = Machina::Authorized.new(data)
@@ -87,9 +87,9 @@ module Machina
87
87
  def default_session_data
88
88
  {
89
89
  'user' => fake_user_data,
90
- 'organization' => fake_organization_data,
91
- 'workspace' => fake_workspace_data,
92
90
  'session' => fake_session_data,
91
+ 'workspace' => fake_workspace_data,
92
+ 'organization' => fake_organization_data,
93
93
  'permissions' => []
94
94
  }
95
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.5'
5
5
  end
@@ -5,13 +5,15 @@ module Machina
5
5
  # invalidating or marking cached sessions as stale when permissions change.
6
6
  class WebhookReceiver
7
7
  def initialize(request, cache: Machina.cache)
8
- @request = request
9
8
  @cache = cache
9
+ @request = request
10
10
  @raw_body = request.body.read
11
+
11
12
  request.body.rewind if request.body.respond_to?(:rewind)
12
- @payload = @raw_body.present? ? JSON.parse(@raw_body) : {}
13
+
13
14
  @event = request.headers['X-Machina-Event']
14
15
  @signature = request.headers['X-Machina-Signature'].to_s
16
+ @payload = @raw_body.present? ? JSON.parse(@raw_body) : {}
15
17
  end
16
18
 
17
19
  def valid?
@@ -12,8 +12,7 @@ module Machina
12
12
  validates :tenant_ref, presence: true
13
13
 
14
14
  scope :in_current_workspace, lambda {
15
- current = defined?(::Current) ? ::Current : Machina::Current
16
- where(tenant_ref: current.authorized&.workspace_id)
15
+ where(tenant_ref: Machina::Current.authorized&.workspace_id)
17
16
  }
18
17
  end
19
18
  end
data/lib/machina.rb CHANGED
@@ -57,26 +57,28 @@ module Machina
57
57
 
58
58
  # Builds the Console authorize URL.
59
59
  #
60
- # @param redirect_to [String, nil] explicit redirect URL (backwards compat)
60
+ # The Console's +/authorize+ endpoint requires a +redirect_to+ query param
61
+ # so it knows where to send the user after workspace selection. This method
62
+ # always produces that param — the two keyword arguments control how:
63
+ #
64
+ # 1. *Callback-based (preferred)* — uses the configured
65
+ # +identity_callback_uri+ as the redirect target. Pass +return_to+ to
66
+ # append the user's intended destination as a query param on the callback.
67
+ # 2. *Explicit* — pass +redirect_to+ directly to bypass callback resolution.
68
+ # Intended for backwards compatibility only.
69
+ #
70
+ # @param redirect_to [String, nil] explicit product redirect URL; when
71
+ # present the callback URI config is ignored
61
72
  # @param return_to [String, nil] user's intended destination, appended to
62
- # the configured +identity_callback_uri+ as a query param
63
- # @return [String] the full authorize URL
64
- # @raise [ConfigurationError] when neither +redirect_to+ nor
65
- # +identity_callback_uri+ is available
73
+ # +identity_callback_uri+ so the product app can restore it after auth
74
+ # @return [String] the full Console authorize URL
75
+ # @raise [ConfigurationError] when +redirect_to+ is omitted and
76
+ # +identity_callback_uri+ is not configured
66
77
  def authorize_url(redirect_to: nil, return_to: nil)
67
- base = config.identity_service_url.to_s.sub(%r{/\z}, '')
68
-
69
- return "#{base}/authorize?redirect_to=#{CGI.escape(redirect_to)}" if redirect_to.present?
70
-
71
- callback = config.identity_callback_uri
72
- if callback.blank?
73
- raise ConfigurationError,
74
- 'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
75
- end
76
-
77
- target = return_to.present? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
78
+ redirect_target = redirect_to.presence || callback_redirect_target(return_to)
78
79
 
79
- "#{base}/authorize?redirect_to=#{CGI.escape(target)}"
80
+ base = config.identity_service_url.to_s.sub(%r{/\z}, '')
81
+ "#{base}/authorize?redirect_to=#{CGI.escape(redirect_target)}"
80
82
  end
81
83
 
82
84
  # Convenience wrapper that delegates to {authorize_url} with +return_to+.
@@ -86,5 +88,23 @@ module Machina
86
88
  def login_url(return_to:)
87
89
  authorize_url(return_to:)
88
90
  end
91
+
92
+ private
93
+
94
+ # Resolves the redirect target from +identity_callback_uri+ config,
95
+ # optionally appending a +return_to+ query param.
96
+ #
97
+ # @param return_to [String, nil] path or URL the user wants after auth
98
+ # @return [String] the callback URI (with optional +return_to+)
99
+ # @raise [ConfigurationError] when +identity_callback_uri+ is blank
100
+ def callback_redirect_target(return_to)
101
+ callback = config.identity_callback_uri
102
+
103
+ if callback.blank?
104
+ raise ConfigurationError, 'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
105
+ end
106
+
107
+ return_to.present? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
108
+ end
89
109
  end
90
110
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rails_helper'
4
+ require_relative '../support/mock_responses'
5
+
6
+ RSpec.describe Machina::Current do
7
+ let(:data) { MockResponses.session_resolution['data'] }
8
+ let(:authorized) { Machina::Authorized.new(data) }
9
+
10
+ describe '.authorized' do
11
+ it 'returns the value set on Machina::Current' do
12
+ described_class.authorized = authorized
13
+
14
+ expect(described_class.authorized).to eq(authorized)
15
+ end
16
+
17
+ it 'is independent from host ::Current even when it inherits' do
18
+ # The dummy app's ::Current inherits from Machina::Current,
19
+ # but each CurrentAttributes subclass has its own thread-local storage.
20
+ described_class.authorized = authorized
21
+
22
+ expect(::Current.authorized).to be_nil
23
+ expect(described_class.authorized).to eq(authorized)
24
+ ensure
25
+ ::Current.reset
26
+ end
27
+
28
+ it 'returns nil when nothing is set' do
29
+ expect(described_class.authorized).to be_nil
30
+ end
31
+ end
32
+ end
@@ -42,7 +42,7 @@ RSpec.describe Machina::Middleware::Authentication do
42
42
  expect(workspace_ref).to have_attributes(name: 'Primary')
43
43
  end
44
44
 
45
- it 'returns unauthorized when resolution fails' do
45
+ it 'passes through with nil authorized when resolution fails' do
46
46
  allow(identity_client).to receive(:resolve_session).and_return(
47
47
  Machina::IdentityClient::Response.new(status: 404, body: '{}'),
48
48
  )
@@ -50,11 +50,11 @@ RSpec.describe Machina::Middleware::Authentication do
50
50
  env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => 'Bearer ps_123')
51
51
  status, _headers, body = middleware.call(env)
52
52
 
53
- expect(status).to eq(401)
54
- expect(JSON.parse(body.first)).to eq('error' => 'unauthorized')
53
+ expect(status).to eq(200)
54
+ expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
55
55
  end
56
56
 
57
- it 'evicts cached session when Console returns non-200 on stale re-fetch' do
57
+ it 'evicts cached session and passes through when Console returns non-200 on stale re-fetch' do
58
58
  token = 'ps_stale_token'
59
59
  cache_key = "machina:session:#{token}"
60
60
  stale_data = MockResponses.session_resolution_minimal['data'].merge(stale: true)
@@ -68,7 +68,7 @@ RSpec.describe Machina::Middleware::Authentication do
68
68
  env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
69
69
  status, = middleware.call(env)
70
70
 
71
- expect(status).to eq(401)
71
+ expect(status).to eq(200)
72
72
  expect(Machina.cache.read(cache_key)).to be_nil
73
73
  end
74
74
 
@@ -96,7 +96,7 @@ RSpec.describe Machina::Middleware::Authentication do
96
96
  env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
97
97
  status, = middleware.call(env)
98
98
 
99
- expect(status).to eq(401)
99
+ expect(status).to eq(200)
100
100
  expect(Machina.cache.read(cache_key)).to be_nil
101
101
  end
102
102
 
@@ -19,7 +19,7 @@ RSpec.describe Machina::WorkspaceScoped do
19
19
  end
20
20
 
21
21
  it 'filters records to the current workspace' do
22
- Current.authorized = Machina::Authorized.new(
22
+ Machina::Current.authorized = Machina::Authorized.new(
23
23
  'user' => { 'id' => 'u1' },
24
24
  'organization' => { 'id' => 'o1' },
25
25
  'workspace' => { 'id' => 'ws-1', 'name' => 'Test' },
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.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZAR
@@ -178,6 +178,7 @@ files:
178
178
  - spec/machina/authorized_spec.rb
179
179
  - spec/machina/configuration_spec.rb
180
180
  - spec/machina/controller_helpers_spec.rb
181
+ - spec/machina/current_spec.rb
181
182
  - spec/machina/identity_client_spec.rb
182
183
  - spec/machina/middleware/authentication_spec.rb
183
184
  - spec/machina/middleware/skip_paths_spec.rb