machina-auth 0.1.3 → 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: 3aea4a4e50f2d2700c3e84c0a5c66715d7fad74f6da3f32438da1e372dee3531
4
- data.tar.gz: d6f1a257b9fe0dcf868fce3cffeac7c625cba2774d86402e6bf666567c466cc5
3
+ metadata.gz: ca27cf7dd475d64e5bbf876084d6e7445a56ac65dbf9967d5b9b50a91f04f155
4
+ data.tar.gz: ad165e21c3628da603b016a7e40911f7efc3e60131cbad506ee1c5cb2dde48c8
5
5
  SHA512:
6
- metadata.gz: 9d0137c9be1a30603f8cc165d234bf34e99f3325439ddf3aad96ea46ee96a5bedeb67388ede7edf54399391213bb904569f9186c454f5375cf392a3b8f0425f8
7
- data.tar.gz: b67d7e820671b4ed7e495853924ad1e8f134030960be3a4c8cfe5bc17a594a662294807b5e058ee3ec45d291b91afc58dec65ca918b29b36251cfff5c0f0e340
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
 
@@ -11,24 +11,28 @@ module Machina
11
11
 
12
12
  def call(env)
13
13
  request = ActionDispatch::Request.new(env)
14
-
14
+ # Skip paths allow the developer to disable the middleware from validating
15
+ # any token or headers on any matched URL or Path.
15
16
  return @app.call(env) if skip_path?(request)
16
17
 
17
18
  token = extract_token(request)
18
-
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,10 +57,10 @@ module Machina
53
57
  end
54
58
 
55
59
  def extract_token(request)
56
- request.cookies['machina_session'] ||
57
- extract_bearer(request) ||
58
- request.headers['X-Api-Key'] ||
59
- request.params['token']
60
+ request.cookies['machina_session']
61
+ || extract_bearer(request)
62
+ || request.headers['X-Api-Key']
63
+ || request.params['token']
60
64
  end
61
65
 
62
66
  def extract_bearer(request)
@@ -76,7 +80,11 @@ module Machina
76
80
 
77
81
  def fetch_from_identity_service(token)
78
82
  response = Machina.identity_client.resolve_session(token)
79
- return nil unless response.success?
83
+
84
+ unless response.success?
85
+ Machina.cache.delete(cache_key(token))
86
+ return nil
87
+ end
80
88
 
81
89
  data = unwrap_payload(response.parsed)
82
90
  Machina.cache.write(cache_key(token), data, expires_in: Machina.config.cache_ttl)
@@ -89,15 +97,18 @@ module Machina
89
97
  end
90
98
 
91
99
  def cache_workspace_ref(data)
92
- workspace = data['workspace']
100
+ workspace = data['workspace']
93
101
  organization = data['organization']
102
+
94
103
  return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
95
104
  return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
96
105
 
97
106
  ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
98
- ref.organization_id = organization['id']
107
+
99
108
  ref.name = workspace['name']
100
109
  ref.cached_at = Time.current
110
+ ref.organization_id = organization['id']
111
+
101
112
  ref.save!
102
113
  rescue StandardError
103
114
  nil
@@ -107,10 +118,6 @@ module Machina
107
118
  "machina:session:#{token}"
108
119
  end
109
120
 
110
- def unauthorized_response
111
- body = JSON.generate(error: 'unauthorized')
112
- [401, { 'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s }, [body]]
113
- end
114
121
  end
115
122
  end
116
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.3'
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
@@ -32,6 +32,7 @@ module Machina
32
32
 
33
33
  # Test helpers are opt-in; load with `require "machina/test_helpers"` or autoload
34
34
  autoload :TestHelpers, 'machina/test_helpers'
35
+
35
36
  class << self
36
37
  def configure
37
38
  yield(config)
@@ -56,26 +57,28 @@ module Machina
56
57
 
57
58
  # Builds the Console authorize URL.
58
59
  #
59
- # @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
60
72
  # @param return_to [String, nil] user's intended destination, appended to
61
- # the configured +identity_callback_uri+ as a query param
62
- # @return [String] the full authorize URL
63
- # @raise [ConfigurationError] when neither +redirect_to+ nor
64
- # +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
65
77
  def authorize_url(redirect_to: nil, return_to: nil)
66
- base = config.identity_service_url.to_s.sub(%r{/\z}, '')
67
-
68
- return "#{base}/authorize?redirect_to=#{CGI.escape(redirect_to)}" if redirect_to.present?
69
-
70
- callback = config.identity_callback_uri
71
- if callback.blank?
72
- raise ConfigurationError,
73
- 'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
74
- end
75
-
76
- target = return_to.present? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
78
+ redirect_target = redirect_to.presence || callback_redirect_target(return_to)
77
79
 
78
- "#{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)}"
79
82
  end
80
83
 
81
84
  # Convenience wrapper that delegates to {authorize_url} with +return_to+.
@@ -85,5 +88,23 @@ module Machina
85
88
  def login_url(return_to:)
86
89
  authorize_url(return_to:)
87
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
88
109
  end
89
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,8 +50,54 @@ 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
+ end
56
+
57
+ it 'evicts cached session and passes through when Console returns non-200 on stale re-fetch' do
58
+ token = 'ps_stale_token'
59
+ cache_key = "machina:session:#{token}"
60
+ stale_data = MockResponses.session_resolution_minimal['data'].merge(stale: true)
61
+
62
+ Machina.cache.write(cache_key, stale_data, expires_in: 5.minutes)
63
+
64
+ allow(identity_client).to receive(:resolve_session).with(token).and_return(
65
+ Machina::IdentityClient::Response.new(status: 404, body: '{}'),
66
+ )
67
+
68
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
69
+ status, = middleware.call(env)
70
+
71
+ expect(status).to eq(200)
72
+ expect(Machina.cache.read(cache_key)).to be_nil
73
+ end
74
+
75
+ it 'evicts cached session when Console returns non-200 after cache expires' do
76
+ token = 'ps_expired_cache'
77
+ cache_key = "machina:session:#{token}"
78
+
79
+ # First request: populate cache via successful resolution
80
+ allow(identity_client).to receive(:resolve_session).with(token).and_return(
81
+ Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution_minimal),
82
+ )
83
+
84
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
85
+ status, = middleware.call(env)
86
+ expect(status).to eq(200)
87
+
88
+ # Simulate cache expiry
89
+ Machina.cache.delete(cache_key)
90
+
91
+ # Console now rejects the token
92
+ allow(identity_client).to receive(:resolve_session).with(token).and_return(
93
+ Machina::IdentityClient::Response.new(status: 404, body: '{}'),
94
+ )
95
+
96
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
97
+ status, = middleware.call(env)
98
+
99
+ expect(status).to eq(200)
100
+ expect(Machina.cache.read(cache_key)).to be_nil
55
101
  end
56
102
 
57
103
  it 'uses the cache on subsequent requests' do
@@ -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.3
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