machina-auth 0.1.8 → 0.4.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: a6d8d740d4e6998400a56db8fe506ce1155221bb7ff216fedda0c48ced30aa06
4
- data.tar.gz: bbaf611e06b06f3f8fdf767203ec2106504ba97d8cc4e548881ca5f6909df611
3
+ metadata.gz: 60692b69e1db477292fba2a947650c2ac15099a616a05318071860a02a550694
4
+ data.tar.gz: 3625e766f4645037333365f8771e6363be979de0c484ff086b61ced3b335b84b
5
5
  SHA512:
6
- metadata.gz: 3ca99f12ea236b7a17a84cadc439aa713344c37b2ba2d9a4f59c9d6938b3a7a92f9994f6e79ace9a52203ef64d8dbf3733978f53d70502e14b164efb2bd013f3
7
- data.tar.gz: 0c01bf88a2ae86779fcbb2e73ddf94e3269169cade8ced2c50a308e10bd2f54d099c00d1c07fadcabaa2fe4a2ec258683e1b4285e227ad7b7d869f7ba9b80b31
6
+ metadata.gz: 10cb1b5e52daf7e76f344bfb8445e2ecca186d5cda9b1693dd376cd7ad4e503306229eabe510879ab72cd10c0a275b5c51ba2d84275758539b5fe1d8ca12b482
7
+ data.tar.gz: a168a7dd638f5663394dda922ba91582bfef041922325c635deff9d2ce79b7828916f21553ab5357d12a8b88276e6f8c560584898c212a9daaea0a4b560a7b98
@@ -5,9 +5,9 @@ module Machina
5
5
  # their organization/workspace context, and granted permissions.
6
6
  class Authorized
7
7
  attr_reader :user_id, :user_email, :user_name, :avatar_url,
8
- :organization_id, :org_name, :org_personal, :org_role,
9
- :workspace_id, :workspace_name,
10
- :session_id, :expires_at, :type
8
+ :organization_id, :org_name, :org_slug, :org_personal, :org_role,
9
+ :workspace_id, :workspace_name, :workspace_slug,
10
+ :session_id, :expires_at, :type, :integration_name
11
11
 
12
12
  def initialize(data = {})
13
13
  assign_user_attrs(data)
@@ -65,6 +65,7 @@ module Machina
65
65
  def assign_org_attrs(data)
66
66
  @organization_id = data.dig('organization', 'id')
67
67
  @org_name = data.dig('organization', 'name')
68
+ @org_slug = data.dig('organization', 'slug')
68
69
  @org_personal = data.dig('organization', 'personal')
69
70
  @org_role = data.dig('membership', 'policy_name') || data.dig('organization', 'role')
70
71
  end
@@ -72,13 +73,15 @@ module Machina
72
73
  def assign_workspace_attrs(data)
73
74
  @workspace_id = data.dig('workspace', 'id')
74
75
  @workspace_name = data.dig('workspace', 'name')
76
+ @workspace_slug = data.dig('workspace', 'slug')
75
77
  end
76
78
 
77
79
  def assign_session_attrs(data)
78
- @type = data.dig('session', 'type')&.to_sym
79
- @session_id = data.dig('session', 'id') || data['session_id']
80
- raw_expires = data.dig('session', 'expires_at') || data['expires_at']
81
- @expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
80
+ @type = data.dig('session', 'type')&.to_sym
81
+ @session_id = data.dig('session', 'id') || data['session_id']
82
+ @integration_name = data.dig('session', 'integration_name')
83
+ raw_expires = data.dig('session', 'expires_at') || data['expires_at']
84
+ @expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
82
85
  end
83
86
 
84
87
  EMPTY = new.freeze # rubocop:disable Lint/UselessConstantScoping
@@ -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
@@ -19,8 +19,17 @@ module Machina
19
19
  @connection = connection
20
20
  end
21
21
 
22
- def resolve_session(token)
23
- post('/internal/v1/sessions/resolve', { token: })
22
+ # @param token [String] raw session token (ps_) or API key (mk_)
23
+ # @param workspace_id [String, nil] optional workspace UUID hint for org-scoped API keys
24
+ # @param workspace_slug [String, nil] optional workspace slug hint (alternative to id)
25
+ # @param organization_slug [String, nil] optional organization slug; identity service
26
+ # rejects with 403 if the value does not match the token's bound organization
27
+ def resolve_session(token, workspace_id: nil, workspace_slug: nil, organization_slug: nil)
28
+ payload = { token: }
29
+ payload[:workspace_id] = workspace_id if workspace_id
30
+ payload[:workspace_slug] = workspace_slug if workspace_slug
31
+ payload[:organization_slug] = organization_slug if organization_slug
32
+ post('/internal/v1/sessions/resolve', payload)
24
33
  end
25
34
 
26
35
  def revoke_session(token)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Machina
4
+ module Middleware
5
+ class Authentication
6
+ # Value object representing the workspace + organization hints supplied
7
+ # with a resolve request. Read from Rack env or request headers, then
8
+ # forwarded to the identity service as kwargs.
9
+ #
10
+ # Sources, in order:
11
+ #
12
+ # 1. `env['machina.workspace_hint']` / `env['machina.organization_hint']`
13
+ # — set by the product's routes or a Rack middleware when the URL
14
+ # itself scopes to an org/workspace (e.g. `/mcp/:org/:workspace`).
15
+ # 2. `X-Machina-Workspace-Hint` / `X-Machina-Organization-Hint` request
16
+ # headers — fallback for clients that can't influence the URL.
17
+ #
18
+ # The values are passed through verbatim to the identity service. The
19
+ # organization hint is enforced as a match against the token's bound
20
+ # organization (403 on mismatch).
21
+ # Slug/UUID disambiguation is cheap: UUIDs match a narrow pattern.
22
+ # Anything else is treated as a slug.
23
+ HINT_UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
24
+ private_constant :HINT_UUID_RE
25
+
26
+ Hints = Data.define(:workspace, :organization) do
27
+ def self.from(env, request)
28
+ new(
29
+ workspace: env['machina.workspace_hint'].presence ||
30
+ request.headers['X-Machina-Workspace-Hint'].presence,
31
+ organization: env['machina.organization_hint'].presence ||
32
+ request.headers['X-Machina-Organization-Hint'].presence,
33
+ )
34
+ end
35
+
36
+ def empty?
37
+ workspace.nil? && organization.nil?
38
+ end
39
+
40
+ def to_resolve_kwargs
41
+ kwargs = {}
42
+ kwargs[HINT_UUID_RE.match?(workspace) ? :workspace_id : :workspace_slug] = workspace if workspace
43
+ kwargs[:organization_slug] = organization if organization
44
+ kwargs
45
+ end
46
+
47
+ def cache_suffix
48
+ empty? ? '' : ":#{organization}:#{workspace}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,44 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'authentication/hints'
4
+
3
5
  module Machina
4
6
  module Middleware
5
7
  # Rack middleware that extracts authentication tokens from incoming requests,
6
8
  # resolves them against the identity service, and sets Current.authorized.
7
9
  class Authentication
10
+ TRANSIENT_FAILURE = :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
16
+
8
17
  def initialize(app)
9
18
  @app = app
10
19
  end
11
20
 
12
21
  def call(env)
13
22
  request = ActionDispatch::Request.new(env)
14
- # Skip paths allow the developer to disable the middleware from validating
15
- # any token or headers on any matched URL or Path.
16
23
  return @app.call(env) if skip_path?(request)
17
24
 
18
25
  token = extract_token(request)
19
26
  return @app.call(env) if token.blank?
20
27
 
21
- session_data = resolve_session(token)
22
-
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
- return handle_expired_token(env, request, token) unless session_data
27
-
28
- Machina::Current.authorized = Machina::Authorized.new(session_data)
29
-
30
- @app.call(env)
28
+ dispatch_session(env, request, token, Hints.from(env, request))
31
29
  ensure
32
30
  Machina::Current.reset
33
31
  end
34
32
 
35
33
  private
36
34
 
37
- # Checks whether the request matches any configured skip path.
38
- #
39
- # Strings match as path prefixes. Regexes match against both the
40
- # request path and the full URL, allowing pattern-based exclusions
41
- # on host, path, or any combination.
35
+ def dispatch_session(env, request, token, hints)
36
+ session_data = resolve_session(token, hints)
37
+
38
+ if session_data.is_a?(Hash)
39
+ Machina::Current.authorized = Machina::Authorized.new(session_data)
40
+ return @app.call(env)
41
+ end
42
+
43
+ if transient_failure?(session_data)
44
+ @app.call(env)
45
+ else
46
+ handle_expired_token(env, request, token)
47
+ end
48
+ end
49
+
50
+ def transient_failure?(result)
51
+ result == TRANSIENT_FAILURE
52
+ end
53
+
54
+ # Strings match as path prefixes; regexes match path or full URL.
42
55
  def skip_path?(request)
43
56
  return false if Machina.config.skip_paths.blank?
44
57
 
@@ -53,13 +66,9 @@ module Machina
53
66
  end
54
67
  end
55
68
 
56
- # Clears the cache entry for the expired token and, when the token
57
- # originated from the session cookie, deletes that cookie so the
58
- # browser stops sending it on subsequent requests. This prevents a
59
- # redirect loop where an expired cookie shadows fresh callback tokens.
69
+ # Clears the cookie for a rejected token. The cache holds the INVALID_SESSION
70
+ # sentinel (written in fetch_from_identity_service) and must not be cleared here.
60
71
  def handle_expired_token(env, request, token)
61
- Machina.cache.delete(cache_key(token))
62
-
63
72
  status, headers, body = @app.call(env)
64
73
  Rack::Utils.delete_cookie_header!(headers, 'machina_session') if request.cookies['machina_session'] == token
65
74
  [status, headers, body]
@@ -84,25 +93,32 @@ module Machina
84
93
  match && match[1]
85
94
  end
86
95
 
87
- def resolve_session(token)
88
- cached = Machina.cache.read(cache_key(token))
89
- return cached if cached.present? && !(cached.is_a?(Hash) && cached[:stale])
90
-
91
- fetch_from_identity_service(token)
96
+ def resolve_session(token, hints)
97
+ cached = Machina.cache.read(cache_key(token, hints))
98
+ return nil if cached == INVALID_SESSION
99
+ return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
100
+
101
+ fetch_from_identity_service(token, hints)
102
+ rescue Faraday::Error => e
103
+ # Network-level failures (connection refused, timeout, DNS) are
104
+ # transient — return stale cached data when available, otherwise
105
+ # signal transient failure so the caller preserves the cookie.
106
+ Rails.logger.warn("[machina] Identity service unreachable: #{e.class} — #{e.message}")
107
+ cached.presence || TRANSIENT_FAILURE
92
108
  rescue StandardError
93
109
  nil
94
110
  end
95
111
 
96
- def fetch_from_identity_service(token)
97
- response = Machina.identity_client.resolve_session(token)
112
+ def fetch_from_identity_service(token, hints)
113
+ response = Machina.identity_client.resolve_session(token, **hints.to_resolve_kwargs)
98
114
 
99
115
  unless response.success?
100
- Machina.cache.delete(cache_key(token))
116
+ Machina.cache.write(cache_key(token, hints), INVALID_SESSION, expires_in: Machina.config.negative_cache_ttl)
101
117
  return nil
102
118
  end
103
119
 
104
120
  data = unwrap_payload(response.parsed)
105
- Machina.cache.write(cache_key(token), data, expires_in: Machina.config.cache_ttl)
121
+ Machina.cache.write(cache_key(token, hints), data, expires_in: Machina.config.cache_ttl)
106
122
  cache_workspace_ref(data)
107
123
  data
108
124
  end
@@ -112,25 +128,18 @@ module Machina
112
128
  end
113
129
 
114
130
  def cache_workspace_ref(data)
115
- workspace = data['workspace']
116
- organization = data['organization']
117
-
131
+ workspace, organization = data.values_at('workspace', 'organization')
118
132
  return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
119
133
  return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
120
134
 
121
135
  ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
122
-
123
- ref.name = workspace['name']
124
- ref.cached_at = Time.current
125
- ref.organization_id = organization['id']
126
-
127
- ref.save!
136
+ ref.update!(name: workspace['name'], cached_at: Time.current, organization_id: organization['id'])
128
137
  rescue StandardError
129
138
  nil
130
139
  end
131
140
 
132
- def cache_key(token)
133
- "machina:session:#{token}"
141
+ def cache_key(token, hints)
142
+ "machina:session:#{token}#{hints.cache_suffix}"
134
143
  end
135
144
  end
136
145
  end
@@ -127,7 +127,8 @@ module Machina
127
127
  {
128
128
  'id' => '00000000-0000-4000-a000-000000001000',
129
129
  'type' => 'platform_session',
130
- 'expires_at' => (Time.now.utc + 86_400).iso8601
130
+ 'expires_at' => (Time.now.utc + 86_400).iso8601,
131
+ 'integration_name' => nil
131
132
  }
132
133
  end
133
134
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.1.8'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -63,7 +63,7 @@ module Machina
63
63
  entry = cache.read(key)
64
64
  next unless entry.is_a?(Hash)
65
65
 
66
- cache.write(key, entry.merge(stale: true))
66
+ cache.write(key, entry.merge('stale' => true))
67
67
  end
68
68
  end
69
69
 
@@ -68,6 +68,26 @@ RSpec.describe 'Session Resolution Contract' do
68
68
  end.not_to raise_error
69
69
  end
70
70
 
71
+ it 'accepts an optional workspace_id hint' do
72
+ expect do
73
+ ConsoleSchema.validate!(
74
+ { 'token' => 'mk_abc', 'workspace_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901' },
75
+ schema_key,
76
+ 'request.body',
77
+ )
78
+ end.not_to raise_error
79
+ end
80
+
81
+ it 'accepts an optional workspace_slug hint' do
82
+ expect do
83
+ ConsoleSchema.validate!(
84
+ { 'token' => 'mk_abc', 'workspace_slug' => 'zar' },
85
+ schema_key,
86
+ 'request.body',
87
+ )
88
+ end.not_to raise_error
89
+ end
90
+
71
91
  it 'fails when token is missing' do
72
92
  expect { ConsoleSchema.validate!({}, schema_key, 'request.body') }.to raise_error(/Schema validation failed/)
73
93
  end
@@ -18,6 +18,7 @@ RSpec.describe Machina::Authorized do
18
18
  it 'exposes organization attributes' do
19
19
  expect(authorized.organization_id).to eq('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
20
20
  expect(authorized.org_name).to eq('ZAR')
21
+ expect(authorized.org_slug).to eq('zar')
21
22
  expect(authorized.org_personal).to be(false)
22
23
  expect(authorized.org_role).to eq('admin')
23
24
  end
@@ -25,6 +26,15 @@ RSpec.describe Machina::Authorized do
25
26
  it 'exposes workspace attributes' do
26
27
  expect(authorized.workspace_id).to eq('b2c3d4e5-f6a7-8901-bcde-f12345678901')
27
28
  expect(authorized.workspace_name).to eq('Primary')
29
+ expect(authorized.workspace_slug).to eq('primary')
30
+ end
31
+
32
+ it 'returns nil for slugs when missing from payload (older Console)' do
33
+ data['organization'].delete('slug')
34
+ data['workspace'].delete('slug')
35
+ auth = described_class.new(data)
36
+ expect(auth.org_slug).to be_nil
37
+ expect(auth.workspace_slug).to be_nil
28
38
  end
29
39
 
30
40
  it 'aliases tenant_ref to workspace_id' do
@@ -36,6 +46,18 @@ RSpec.describe Machina::Authorized do
36
46
  end
37
47
  end
38
48
 
49
+ describe '#integration_name' do
50
+ it 'returns nil when not set' do
51
+ expect(authorized.integration_name).to be_nil
52
+ end
53
+
54
+ it 'returns the value from session data' do
55
+ data['session']['integration_name'] = 'GitHub Webhooks'
56
+ auth = described_class.new(data)
57
+ expect(auth.integration_name).to eq('GitHub Webhooks')
58
+ end
59
+ end
60
+
39
61
  describe '#type' do
40
62
  it 'returns a symbol' do
41
63
  expect(authorized.type).to eq(:platform_session)
@@ -25,6 +25,29 @@ RSpec.describe Machina::IdentityClient do
25
25
  expect(response.parsed).to eq('data' => { 'user' => { 'id' => 'd7e60485-114e-4142-8dc2-c3612f920cc9' } })
26
26
  end
27
27
 
28
+ it 'forwards a workspace_id hint when provided' do
29
+ stubs.post('/internal/v1/sessions/resolve') do |env|
30
+ expect(JSON.parse(env.body)).to eq(
31
+ 'token' => 'mk_123',
32
+ 'workspace_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
33
+ )
34
+ [200, { 'Content-Type' => 'application/json' }, JSON.generate(data: {})]
35
+ end
36
+
37
+ response = client.resolve_session('mk_123', workspace_id: 'b2c3d4e5-f6a7-8901-bcde-f12345678901')
38
+ expect(response).to be_success
39
+ end
40
+
41
+ it 'forwards a workspace_slug hint when provided' do
42
+ stubs.post('/internal/v1/sessions/resolve') do |env|
43
+ expect(JSON.parse(env.body)).to eq('token' => 'mk_123', 'workspace_slug' => 'zar')
44
+ [200, { 'Content-Type' => 'application/json' }, JSON.generate(data: {})]
45
+ end
46
+
47
+ response = client.resolve_session('mk_123', workspace_slug: 'zar')
48
+ expect(response).to be_success
49
+ end
50
+
28
51
  it 'syncs permissions for a product' do
29
52
  product_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
30
53
 
@@ -42,6 +42,106 @@ RSpec.describe Machina::Middleware::Authentication do
42
42
  expect(workspace_ref).to have_attributes(name: 'Primary')
43
43
  end
44
44
 
45
+ describe 'workspace hint forwarding' do
46
+ let(:mock) { MockResponses.session_resolution }
47
+
48
+ it 'forwards a slug hint from env to the identity client and keys the cache by it' do
49
+ expect(identity_client).to receive(:resolve_session)
50
+ .with('mk_abc', workspace_slug: 'my-org')
51
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
52
+
53
+ env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
54
+ env['machina.workspace_hint'] = 'my-org'
55
+
56
+ status, = middleware.call(env)
57
+ expect(status).to eq(200)
58
+ expect(Machina.cache.read('machina:session:mk_abc::my-org')).to be_a(Hash)
59
+ end
60
+
61
+ it 'forwards a uuid hint as workspace_id rather than slug' do
62
+ uuid = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'
63
+ expect(identity_client).to receive(:resolve_session)
64
+ .with('mk_abc', workspace_id: uuid)
65
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
66
+
67
+ env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
68
+ env['machina.workspace_hint'] = uuid
69
+
70
+ status, = middleware.call(env)
71
+ expect(status).to eq(200)
72
+ end
73
+
74
+ it 'reads the X-Machina-Workspace-Hint header when env is unset' do
75
+ expect(identity_client).to receive(:resolve_session)
76
+ .with('mk_abc', workspace_slug: 'zar')
77
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
78
+
79
+ env = Rack::MockRequest.env_for('/mcp',
80
+ 'HTTP_AUTHORIZATION' => 'Bearer mk_abc',
81
+ 'HTTP_X_MACHINA_WORKSPACE_HINT' => 'zar')
82
+
83
+ status, = middleware.call(env)
84
+ expect(status).to eq(200)
85
+ end
86
+
87
+ it 'does not forward a hint when none is set' do
88
+ expect(identity_client).to receive(:resolve_session)
89
+ .with('mk_abc')
90
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
91
+
92
+ env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
93
+
94
+ status, = middleware.call(env)
95
+ expect(status).to eq(200)
96
+ end
97
+
98
+ it 'forwards organization_slug from env to the identity client' do
99
+ expect(identity_client).to receive(:resolve_session)
100
+ .with('mk_abc', organization_slug: 'zar', workspace_slug: 'default')
101
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
102
+
103
+ env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
104
+ env['machina.organization_hint'] = 'zar'
105
+ env['machina.workspace_hint'] = 'default'
106
+
107
+ status, = middleware.call(env)
108
+ expect(status).to eq(200)
109
+ expect(Machina.cache.read('machina:session:mk_abc:zar:default')).to be_a(Hash)
110
+ end
111
+
112
+ it 'reads the X-Machina-Organization-Hint header when env is unset' do
113
+ expect(identity_client).to receive(:resolve_session)
114
+ .with('mk_abc', organization_slug: 'zar')
115
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
116
+
117
+ env = Rack::MockRequest.env_for('/mcp',
118
+ 'HTTP_AUTHORIZATION' => 'Bearer mk_abc',
119
+ 'HTTP_X_MACHINA_ORGANIZATION_HINT' => 'zar')
120
+
121
+ status, = middleware.call(env)
122
+ expect(status).to eq(200)
123
+ end
124
+
125
+ it 'caches hinted and unhinted responses under separate keys' do
126
+ allow(identity_client).to receive(:resolve_session)
127
+ .with('mk_abc')
128
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
129
+ allow(identity_client).to receive(:resolve_session)
130
+ .with('mk_abc', workspace_slug: 'zar')
131
+ .and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
132
+
133
+ plain_env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
134
+ middleware.call(plain_env)
135
+
136
+ hinted_env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
137
+ hinted_env['machina.workspace_hint'] = 'zar'
138
+ middleware.call(hinted_env)
139
+
140
+ expect(Machina.cache.read('machina:session:mk_abc')).to be_a(Hash)
141
+ expect(Machina.cache.read('machina:session:mk_abc::zar')).to be_a(Hash)
142
+ end
143
+ end
144
+
45
145
  it 'passes through with nil authorized when resolution fails' do
46
146
  allow(identity_client).to receive(:resolve_session).and_return(
47
147
  Machina::IdentityClient::Response.new(status: 404, body: '{}'),
@@ -54,10 +154,10 @@ RSpec.describe Machina::Middleware::Authentication do
54
154
  expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
55
155
  end
56
156
 
57
- 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
58
158
  token = 'ps_stale_token'
59
159
  cache_key = "machina:session:#{token}"
60
- stale_data = MockResponses.session_resolution_minimal['data'].merge(stale: true)
160
+ stale_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
61
161
 
62
162
  Machina.cache.write(cache_key, stale_data, expires_in: 5.minutes)
63
163
 
@@ -69,10 +169,11 @@ RSpec.describe Machina::Middleware::Authentication do
69
169
  status, = middleware.call(env)
70
170
 
71
171
  expect(status).to eq(200)
72
- 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)
73
174
  end
74
175
 
75
- 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
76
177
  token = 'ps_expired_cache'
77
178
  cache_key = "machina:session:#{token}"
78
179
 
@@ -97,7 +198,24 @@ RSpec.describe Machina::Middleware::Authentication do
97
198
  status, = middleware.call(env)
98
199
 
99
200
  expect(status).to eq(200)
100
- expect(Machina.cache.read(cache_key)).to be_nil
201
+ expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
202
+ end
203
+
204
+ it 'does not re-hit Console for a rejected token while the negative cache is warm' do
205
+ token = 'ps_dead_token'
206
+
207
+ allow(identity_client).to receive(:resolve_session).with(token).and_return(
208
+ Machina::IdentityClient::Response.new(status: 404, body: '{}'),
209
+ )
210
+
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
101
219
  end
102
220
 
103
221
  context 'when a stale cookie coexists with a fresh callback token' do
@@ -180,6 +298,41 @@ RSpec.describe Machina::Middleware::Authentication do
180
298
  expect(JSON.parse(body.first)['user_id']).to eq(mock['data']['user']['id'])
181
299
  end
182
300
 
301
+ context 'when the identity service is unreachable' do
302
+ it 'returns stale cached data instead of destroying the session' do
303
+ token = 'ps_network_blip'
304
+ cache_key = "machina:session:#{token}"
305
+ cached_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
306
+
307
+ Machina.cache.write(cache_key, cached_data, expires_in: 5.minutes)
308
+
309
+ allow(identity_client).to receive(:resolve_session).with(token).and_raise(
310
+ Faraday::ConnectionFailed.new('Connection refused'),
311
+ )
312
+
313
+ env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
314
+ status, _headers, body = middleware.call(env)
315
+
316
+ expect(status).to eq(200)
317
+ expect(JSON.parse(body.first)['user_id']).to eq(cached_data['user']['id'])
318
+ end
319
+
320
+ it 'does not delete the cookie on transient network failure' do
321
+ allow(identity_client).to receive(:resolve_session).with('ps_network_fail').and_raise(
322
+ Faraday::ConnectionFailed.new('Connection refused'),
323
+ )
324
+
325
+ env = Rack::MockRequest.env_for(
326
+ '/resource',
327
+ 'HTTP_COOKIE' => 'machina_session=ps_network_fail',
328
+ )
329
+
330
+ _status, headers, _body = middleware.call(env)
331
+
332
+ expect(headers['set-cookie']).to be_nil
333
+ end
334
+ end
335
+
183
336
  it 'uses the cache on subsequent requests' do
184
337
  response = Machina::IdentityClient::Response.new(
185
338
  status: 200,
@@ -21,6 +21,7 @@ RSpec.describe Machina::TestHelpers do
21
21
  expect(authorized.workspace_name).to eq('Test Workspace')
22
22
  expect(authorized.session_id).to eq('00000000-0000-4000-a000-000000001000')
23
23
  expect(authorized.type).to eq(:platform_session)
24
+ expect(authorized.integration_name).to be_nil
24
25
  expect(authorized.expires_at).to be_a(Time)
25
26
  expect(authorized.permissions).to eq([])
26
27
  end
@@ -60,6 +61,11 @@ RSpec.describe Machina::TestHelpers do
60
61
  expect(authorized.workspace_name).to eq('Staging')
61
62
  end
62
63
 
64
+ it 'overrides integration_name via session hash' do
65
+ authorized = sign_in_as_machina(session: { integration_name: 'Sentry Alerts' })
66
+ expect(authorized.integration_name).to eq('Sentry Alerts')
67
+ end
68
+
63
69
  it 'sets permissions' do
64
70
  authorized = sign_in_as_machina(permissions: ['reports.view', 'reports.create'])
65
71
  expect(authorized.can?('reports.view')).to be(true)
@@ -36,7 +36,7 @@ RSpec.describe Machina::WebhookReceiver do
36
36
  receiver = described_class.new(request, cache:)
37
37
 
38
38
  expect(receiver.process!).to be(true)
39
- expect(cache.read('machina:session:ps_123')).to include(stale: true)
39
+ expect(cache.read('machina:session:ps_123')).to include('stale' => true)
40
40
  end
41
41
  end
42
42
 
@@ -13,7 +13,11 @@ module MockResponses
13
13
  'data' => {
14
14
  'user' => session_user,
15
15
  'organization' => session_organization,
16
- 'workspace' => { 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'name' => 'Primary' },
16
+ 'workspace' => {
17
+ 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
18
+ 'name' => 'Primary',
19
+ 'slug' => 'primary'
20
+ },
17
21
  'membership' => { 'id' => 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'policy_name' => 'admin' },
18
22
  'permissions' => ['sessions.view'],
19
23
  'session' => session_meta
@@ -27,12 +31,12 @@ module MockResponses
27
31
  end
28
32
 
29
33
  def session_organization
30
- { 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'name' => 'ZAR', 'personal' => false }
34
+ { 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'name' => 'ZAR', 'slug' => 'zar', 'personal' => false }
31
35
  end
32
36
 
33
37
  def session_meta
34
38
  { 'id' => 'd4e5f6a7-b8c9-0123-def0-1234567890ab', 'type' => 'platform_session',
35
- 'expires_at' => '2026-03-20T12:00:00Z' }
39
+ 'expires_at' => '2026-03-20T12:00:00Z', 'integration_name' => nil }
36
40
  end
37
41
 
38
42
  # A minimal resolution response for cache tests (fewer fields populated).
@@ -42,7 +46,11 @@ module MockResponses
42
46
  'data' => {
43
47
  'user' => { 'id' => 'd7e60485-114e-4142-8dc2-c3612f920cc9' },
44
48
  'organization' => { 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' },
45
- 'workspace' => { 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'name' => 'Primary' },
49
+ 'workspace' => {
50
+ 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
51
+ 'name' => 'Primary',
52
+ 'slug' => 'primary'
53
+ },
46
54
  'permissions' => ['sessions.view'],
47
55
  'session' => { 'id' => 'd4e5f6a7-b8c9-0123-def0-1234567890ab', 'type' => 'platform_session',
48
56
  'expires_at' => nil }
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.8
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZAR
@@ -152,6 +152,7 @@ files:
152
152
  - lib/machina/errors.rb
153
153
  - lib/machina/identity_client.rb
154
154
  - lib/machina/middleware/authentication.rb
155
+ - lib/machina/middleware/authentication/hints.rb
155
156
  - lib/machina/permission_sync.rb
156
157
  - lib/machina/tasks/sync.rake
157
158
  - lib/machina/test_helpers.rb