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 +4 -4
- data/README.md +2 -1
- data/app/controllers/machina/callbacks_controller.rb +25 -2
- data/lib/generators/machina/templates/machina.rb.tt +2 -0
- data/lib/machina/configuration.rb +7 -0
- data/lib/machina/controller_helpers.rb +2 -2
- data/lib/machina/engine.rb +18 -7
- data/lib/machina/identity_client.rb +8 -1
- data/lib/machina/middleware/authentication.rb +2 -1
- data/lib/machina/permission_sync.rb +4 -0
- data/lib/machina/tasks/sync.rake +3 -1
- data/lib/machina/test_helpers.rb +2 -4
- data/lib/machina/version.rb +1 -1
- data/spec/machina/configuration_spec.rb +18 -0
- data/spec/machina/controller_helpers_spec.rb +19 -0
- data/spec/machina/engine_spec.rb +80 -0
- data/spec/machina/identity_client_spec.rb +12 -0
- data/spec/machina/middleware/authentication_spec.rb +50 -0
- data/spec/machina/sync_task_spec.rb +32 -0
- data/spec/requests/machina/callbacks_spec.rb +49 -22
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc18de6ee1048be86f05320f0a2864092dce1b886689a98e042bb4b68385649f
|
|
4
|
+
data.tar.gz: 0657bf99e18fe6418ee481e3234ffe3a0dc5ae27fe9aab6dfff6363b7e682c56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
17
|
+
return restart_handshake('state mismatch') unless valid_state?
|
|
12
18
|
|
|
13
|
-
|
|
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
|
|
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
|
data/lib/machina/engine.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/machina/tasks/sync.rake
CHANGED
|
@@ -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
|
data/lib/machina/test_helpers.rb
CHANGED
|
@@ -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
|
data/lib/machina/version.rb
CHANGED
|
@@ -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 '
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
expect(response.cookies['machina_session']).to be_nil
|
|
108
|
+
include_examples 'restarts the handshake'
|
|
87
109
|
end
|
|
88
110
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
expect(response.cookies['machina_session']).to be_nil
|
|
123
|
+
include_examples 'restarts the handshake'
|
|
103
124
|
end
|
|
104
125
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|