booth 0.0.3 → 0.0.4
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/CHANGELOG.md +3 -0
- data/app/assets/images/booth/fido/passkey_mark_b_reverse.svg +55 -0
- data/config/locales/de.yml +5 -5
- data/lib/booth/core/sessions/to_passport.rb +11 -1
- data/lib/booth/core/webauth/authentication_verification.rb +5 -5
- data/lib/booth/models/authenticator.rb +11 -0
- data/lib/booth/passport.rb +15 -0
- data/lib/booth/requests/authentication.rb +1 -1
- data/lib/booth/requests/storages/login.rb +4 -1
- data/lib/booth/requests/sudo.rb +12 -2
- data/lib/booth/test.rb +10 -17
- data/lib/booth/testing/incorporation_test_case.rb +3 -1
- data/lib/booth/testing/shortcuts.rb +8 -17
- data/lib/booth/testing/support/assert_logged_in.rb +26 -27
- data/lib/booth/testing/support/assert_logged_out.rb +15 -11
- data/lib/booth/testing/support/assert_partial.rb +20 -29
- data/lib/booth/testing/support/capybara_step_logger.rb +22 -0
- data/lib/booth/testing/support/{soft_reset_session.rb → clear_cookies.rb} +5 -6
- data/lib/booth/testing/support/cookie_data_from_browser.rb +38 -0
- data/lib/booth/testing/support/shortcuts/create_and_onboard.rb +4 -0
- data/lib/booth/testing/support/shortcuts/login_with_passkey.rb +6 -4
- data/lib/booth/testing/support/shortcuts/register_new_passkey.rb +6 -2
- data/lib/booth/testing/support/virtual_authenticator.rb +196 -0
- data/lib/booth/testing/support/virtual_authenticators/create.rb +22 -7
- data/lib/booth/testing/support/virtual_authenticators/destroy.rb +14 -9
- data/lib/booth/testing/support/virtual_authenticators/enable.rb +14 -2
- data/lib/booth/testing/support/virtual_authenticators/load.rb +7 -19
- data/lib/booth/testing/support/virtual_authenticators.rb +106 -0
- data/lib/booth/testing/support/visit.rb +1 -0
- data/lib/booth/testing/userland/login_remotely.rb +2 -2
- data/lib/booth/testing/userland/onboarding_first_time.rb +5 -4
- data/lib/booth/testing/userland/onboarding_to_reset_passkeys.rb +3 -3
- data/lib/booth/testing/userland/registration_with_passkey.rb +9 -6
- data/lib/booth/testing/userland/registration_without_passkey.rb +11 -7
- data/lib/booth/testing/userland/sessions_manage_behavior.rb +14 -3
- data/lib/booth/userland/webauths/index.rb +9 -3
- data/lib/booth/userland/webauths/new.rb +10 -2
- data/lib/booth/userland/webauths/transitions/create/choose_nickname.rb +1 -1
- data/lib/booth/userland/webauths/transitions/sudo/authentication_initiation.rb +5 -0
- data/lib/booth/userland/webauths/transitions/sudo/authentication_verification.rb +1 -1
- data/lib/booth/version.rb +1 -1
- metadata +8 -4
- data/lib/booth/testing/support/get_session_value.rb +0 -37
- data/lib/booth/testing/support/virtual_authenticators/manager.rb +0 -124
|
@@ -7,6 +7,8 @@ module Booth
|
|
|
7
7
|
class LoginWithPasskey
|
|
8
8
|
include ::Calls
|
|
9
9
|
include ::Capybara::DSL
|
|
10
|
+
include ::Booth::Testing::Support::CapybaraStepLogger
|
|
11
|
+
include ::Booth::Testing::Shortcuts
|
|
10
12
|
include ::Booth::Logging
|
|
11
13
|
|
|
12
14
|
option :routing_namespace
|
|
@@ -14,7 +16,8 @@ module Booth
|
|
|
14
16
|
option :username
|
|
15
17
|
|
|
16
18
|
def call
|
|
17
|
-
log { '
|
|
19
|
+
log { Paint['Starting Subroutine LoginWithPasskey', '#ff9900'] }
|
|
20
|
+
|
|
18
21
|
::Booth::Testing::Support::Visit.call(routing_namespace:,
|
|
19
22
|
controller: :logins,
|
|
20
23
|
action: :new)
|
|
@@ -39,12 +42,11 @@ module Booth
|
|
|
39
42
|
::Booth::Testing::Support::AssertPartial.call(namespace: :userland,
|
|
40
43
|
controller: :logins,
|
|
41
44
|
step: :enter_webauth)
|
|
42
|
-
|
|
43
45
|
click_on :authenticate
|
|
44
46
|
|
|
45
|
-
AssertLoggedIn.call(scope:, username:)
|
|
47
|
+
::Booth::Testing::Support::AssertLoggedIn.call(scope:, username:)
|
|
46
48
|
|
|
47
|
-
log { '
|
|
49
|
+
log { Paint['Finished Subroutine LoginWithPasskey', '#ff9900'] }
|
|
48
50
|
|
|
49
51
|
nil
|
|
50
52
|
end
|
|
@@ -7,12 +7,16 @@ module Booth
|
|
|
7
7
|
class RegisterNewPasskey
|
|
8
8
|
include ::Calls
|
|
9
9
|
include ::Capybara::DSL
|
|
10
|
+
include ::Booth::Logging
|
|
11
|
+
include ::Booth::Testing::Support::CapybaraStepLogger
|
|
10
12
|
|
|
11
13
|
option :routing_namespace
|
|
12
14
|
option :scope
|
|
13
15
|
option :username
|
|
14
16
|
|
|
15
17
|
def call
|
|
18
|
+
log { 'Starting Subroutine...' }
|
|
19
|
+
|
|
16
20
|
::Booth::Testing::Support::Visit.call(routing_namespace:,
|
|
17
21
|
controller: :webauths,
|
|
18
22
|
action: :new)
|
|
@@ -26,8 +30,8 @@ module Booth
|
|
|
26
30
|
::Booth::Testing::Support::AssertPartial.call(namespace: :userland,
|
|
27
31
|
controller: :webauths,
|
|
28
32
|
step: :choose_nickname)
|
|
29
|
-
|
|
30
|
-
fill_in :nickname, with: '
|
|
33
|
+
sleep 1
|
|
34
|
+
fill_in :nickname, with: 'Pristine Key'
|
|
31
35
|
click_on :submit
|
|
32
36
|
|
|
33
37
|
::Booth::Testing::Support::AssertPartial.call(namespace: :userland,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Booth
|
|
4
|
+
module Testing
|
|
5
|
+
module Support
|
|
6
|
+
# Represents a virtual WebAuthn credential that can be transferred between browser sessions.
|
|
7
|
+
# Stores the credential data (including private key) and tracks which authenticator ID
|
|
8
|
+
# exists in each browser session.
|
|
9
|
+
class VirtualAuthenticator
|
|
10
|
+
# Maps Capybara session names to their authenticator IDs
|
|
11
|
+
# Each browser session has its own isolated authenticator with the same credential
|
|
12
|
+
attr_accessor :authenticator_ids
|
|
13
|
+
attr_accessor :credential_data, :has_user_verification, :rp_id # Relying Party ID for this credential
|
|
14
|
+
|
|
15
|
+
def initialize(authenticator_id:, has_user_verification: true)
|
|
16
|
+
@authenticator_ids = {}
|
|
17
|
+
@has_user_verification = has_user_verification
|
|
18
|
+
@credential_data = nil
|
|
19
|
+
# Register the initial authenticator for the current session
|
|
20
|
+
register_authenticator_id(authenticator_id, Capybara.session_name.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Registers an authenticator ID for a specific browser session
|
|
24
|
+
def register_authenticator_id(authenticator_id, session_name = Capybara.session_name.to_s)
|
|
25
|
+
authenticator_ids[session_name] = authenticator_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Gets the authenticator ID for the current browser session
|
|
29
|
+
def current_authenticator_id
|
|
30
|
+
session_key = Capybara.session_name.to_s
|
|
31
|
+
authenticator_ids[session_key] || raise("No authenticator registered for session #{session_key}. Call import() first if this is a new browser session.")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Pulls credential data from the current browser session.
|
|
35
|
+
# On first pull (after WebAuthn registration), this captures the private key.
|
|
36
|
+
# On subsequent pulls, this updates the stored sign_count.
|
|
37
|
+
def pull
|
|
38
|
+
auth_id = current_authenticator_id
|
|
39
|
+
log do
|
|
40
|
+
"Pulling credentials from session #{Capybara.session_name} for authenticator #{auth_id}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
credentials = get_credentials_from_session(auth_id)
|
|
44
|
+
|
|
45
|
+
if credentials.empty?
|
|
46
|
+
raise "No credentials found in authenticator #{auth_id}. Ensure WebAuthn registration completed before pulling."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Store the first (and typically only) credential
|
|
50
|
+
@credential_data = credentials.first
|
|
51
|
+
|
|
52
|
+
# rpId might not be returned by getCredentials in imported credentials
|
|
53
|
+
# Use stored value if available, otherwise use what we got from browser
|
|
54
|
+
pulled_rp_id = @credential_data['rpId']
|
|
55
|
+
if pulled_rp_id
|
|
56
|
+
@rp_id = pulled_rp_id
|
|
57
|
+
log do
|
|
58
|
+
"Pulled credential #{credential_id} with sign_count #{sign_count} and rpId #{@rp_id}"
|
|
59
|
+
end
|
|
60
|
+
elsif @rp_id
|
|
61
|
+
log do
|
|
62
|
+
"Pulled credential #{credential_id} with sign_count #{sign_count} (rpId not returned, using stored: #{@rp_id})"
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
raise "PULL FAILED: No rpId found in credential data and none stored! Keys: #{@credential_data.keys.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
log { "PUSH: Credential data keys: #{@credential_data.keys.inspect}" }
|
|
69
|
+
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pushes the stored sign_count to the current browser session.
|
|
74
|
+
# Since WebAuthn.setCredentialProperties doesn't support signCount,
|
|
75
|
+
# we remove and re-add the credential with the correct sign_count.
|
|
76
|
+
def push
|
|
77
|
+
unless credential_data
|
|
78
|
+
raise 'No credential data to push. Call pull() first to capture credentials.'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
auth_id = current_authenticator_id
|
|
82
|
+
target_sign_count = sign_count
|
|
83
|
+
|
|
84
|
+
# First, read current state from browser
|
|
85
|
+
current_creds = get_credentials_from_session(auth_id)
|
|
86
|
+
target_cred = current_creds.find { |c| c['credentialId'] == credential_id }
|
|
87
|
+
|
|
88
|
+
if target_cred.nil?
|
|
89
|
+
raise "Credential #{credential_id} not found in browser session #{Capybara.session_name}. Import this authenticator first."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
current_browser_sign_count = target_cred['signCount']
|
|
93
|
+
|
|
94
|
+
log do
|
|
95
|
+
"PUSH: Session #{Capybara.session_name}, Auth #{auth_id}, Credential #{credential_id}, Target sign_count: #{target_sign_count}, Current browser sign_count: #{current_browser_sign_count}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if current_browser_sign_count == target_sign_count
|
|
99
|
+
log { "PUSH: Sign count already up to date (#{target_sign_count})" }
|
|
100
|
+
return self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Remove and re-add credential with updated sign_count
|
|
104
|
+
# (setCredentialProperties doesn't support signCount per CDP spec)
|
|
105
|
+
log { "PUSH: Removing credential #{credential_id} from authenticator #{auth_id}" }
|
|
106
|
+
remove_result = send_cdp_message(
|
|
107
|
+
'WebAuthn.removeCredential',
|
|
108
|
+
authenticatorId: auth_id,
|
|
109
|
+
credentialId: credential_id,
|
|
110
|
+
)
|
|
111
|
+
log { "PUSH: removeCredential result: #{remove_result.inspect}" }
|
|
112
|
+
|
|
113
|
+
log { "PUSH: Re-adding credential with sign_count #{target_sign_count}" }
|
|
114
|
+
# Create credential data with updated sign_count
|
|
115
|
+
updated_credential = credential_data.dup
|
|
116
|
+
updated_credential['signCount'] = target_sign_count
|
|
117
|
+
updated_credential['rpId'] = @rp_id
|
|
118
|
+
|
|
119
|
+
# Debug: show what fields are in the credential
|
|
120
|
+
log { "PUSH: Credential fields: #{updated_credential.keys.inspect}" }
|
|
121
|
+
log { "PUSH: rpId in credential: #{updated_credential['rpId'].inspect}" }
|
|
122
|
+
log { "PUSH: @rp_id instance variable: #{@rp_id.inspect}" }
|
|
123
|
+
|
|
124
|
+
raise 'PUSH FAILED: @rp_id is nil!' unless @rp_id
|
|
125
|
+
|
|
126
|
+
add_result = send_cdp_message(
|
|
127
|
+
'WebAuthn.addCredential',
|
|
128
|
+
authenticatorId: auth_id,
|
|
129
|
+
credential: updated_credential,
|
|
130
|
+
)
|
|
131
|
+
log { "PUSH: addCredential result: #{add_result.inspect}" }
|
|
132
|
+
|
|
133
|
+
# CRITICAL: Verify the update actually worked
|
|
134
|
+
sleep 0.5 # Give browser time to apply update
|
|
135
|
+
post_update_creds = get_credentials_from_session(auth_id)
|
|
136
|
+
post_update_cred = post_update_creds.find { |c| c['credentialId'] == credential_id }
|
|
137
|
+
|
|
138
|
+
if post_update_cred.nil?
|
|
139
|
+
raise "PUSH FAILED: Credential #{credential_id} not found after re-add!"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
actual_sign_count = post_update_cred['signCount']
|
|
143
|
+
log do
|
|
144
|
+
"PUSH: Post-update browser sign_count: #{actual_sign_count} (expected: #{target_sign_count})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
unless actual_sign_count == target_sign_count
|
|
148
|
+
raise "PUSH FAILED: Browser sign_count is #{actual_sign_count} but we expected #{target_sign_count}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
log { "PUSH: Successfully updated sign_count to #{target_sign_count} in browser" }
|
|
152
|
+
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns the credential ID
|
|
157
|
+
def credential_id
|
|
158
|
+
credential_data&.dig('credentialId')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns the current sign count
|
|
162
|
+
def sign_count
|
|
163
|
+
credential_data&.dig('signCount') || 0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Returns the private key (base64 encoded)
|
|
167
|
+
def private_key
|
|
168
|
+
credential_data&.dig('privateKey')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
include ::Booth::Logging
|
|
174
|
+
include ::Capybara::DSL
|
|
175
|
+
|
|
176
|
+
def get_credentials_from_session(authenticator_id)
|
|
177
|
+
result = send_cdp_message(
|
|
178
|
+
'WebAuthn.getCredentials',
|
|
179
|
+
authenticatorId: authenticator_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
result['credentials'] || []
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def send_cdp_message(method, params)
|
|
186
|
+
result = nil
|
|
187
|
+
Capybara.current_session.driver.with_playwright_page do |playwright_page|
|
|
188
|
+
cdp_session = playwright_page.context.new_cdp_session(playwright_page)
|
|
189
|
+
result = cdp_session.send_message(method, params:)
|
|
190
|
+
end
|
|
191
|
+
result
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -13,19 +13,34 @@ module Booth
|
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
15
|
log { "Adding Virtual Authenticator... #{options.as_json}" }
|
|
16
|
-
|
|
16
|
+
result = nil
|
|
17
|
+
page.driver.with_playwright_page do |playwright_page|
|
|
18
|
+
cdp_session = playwright_page.context.new_cdp_session(playwright_page)
|
|
19
|
+
log { '[WebAuthn] Sending addVirtualAuthenticator command...' }
|
|
20
|
+
result = cdp_session.send_message('WebAuthn.addVirtualAuthenticator', params: { options: options })
|
|
21
|
+
log { "[WebAuthn] addVirtualAuthenticator result: #{result.inspect}" }
|
|
22
|
+
end
|
|
23
|
+
authenticator_id = result['authenticatorId']
|
|
24
|
+
if authenticator_id
|
|
25
|
+
log { "[WebAuthn] ✓ Virtual Authenticator created with ID: #{authenticator_id}" }
|
|
26
|
+
else
|
|
27
|
+
log { "[WebAuthn] ✗ FAILED to create Virtual Authenticator - no authenticatorId in response" }
|
|
28
|
+
end
|
|
29
|
+
authenticator_id
|
|
17
30
|
end
|
|
18
31
|
|
|
19
32
|
private
|
|
20
33
|
|
|
21
|
-
# See `bundle open selenium-webdriver`
|
|
22
34
|
# See https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/#type-VirtualAuthenticatorOptions
|
|
23
35
|
def options
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
{
|
|
37
|
+
protocol: 'ctap2',
|
|
38
|
+
transport: 'internal',
|
|
39
|
+
hasResidentKey: true,
|
|
40
|
+
hasUserVerification: has_user_verification,
|
|
41
|
+
isUserVerified: true,
|
|
42
|
+
automaticPresenceSimulation: true
|
|
43
|
+
}
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Booth
|
|
4
4
|
module Testing
|
|
5
|
-
module
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
module Support
|
|
6
|
+
module VirtualAuthenticators
|
|
7
|
+
class Destroy
|
|
8
|
+
include ::Booth::Logging
|
|
9
|
+
include ::Calls
|
|
10
|
+
include ::Capybara::DSL
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
option :id
|
|
12
|
+
option :authenticator_id
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
def call
|
|
15
|
+
log { "Removing Virtual Authenticator with ID #{authenticator_id}" }
|
|
16
|
+
page.driver.with_playwright_page do |playwright_page|
|
|
17
|
+
cdp_session = playwright_page.context.new_cdp_session(playwright_page)
|
|
18
|
+
cdp_session.send_message('WebAuthn.removeVirtualAuthenticator', params: { authenticatorId: authenticator_id })
|
|
19
|
+
end
|
|
20
|
+
end
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
end
|
|
@@ -13,9 +13,21 @@ module Booth
|
|
|
13
13
|
def call
|
|
14
14
|
log { 'Ensuring enabled Chrome Virtual Authenticator Environment...' }
|
|
15
15
|
# The Environment *randomly* leaks from test to test, disabling it first works.
|
|
16
|
+
# This happens with both Selenium and Playwright.
|
|
16
17
|
# All you'll see is `NotAllowedError` in the JS console if you miss this.
|
|
17
|
-
page.driver.
|
|
18
|
-
|
|
18
|
+
page.driver.with_playwright_page do |playwright_page|
|
|
19
|
+
cdp_session = playwright_page.context.new_cdp_session(playwright_page)
|
|
20
|
+
|
|
21
|
+
# log { '[WebAuthn] Disabling any existing environment...' }
|
|
22
|
+
# disable_result = cdp_session.send_message('WebAuthn.disable')
|
|
23
|
+
# log { "[Disable result: #{disable_result.inspect}" }
|
|
24
|
+
|
|
25
|
+
# log { '[WebAuthn] Enabling virtual authenticator environment...' }
|
|
26
|
+
cdp_session.send_message('WebAuthn.enable')
|
|
27
|
+
# log { "[Enable result: #{enable_result.inspect}" }
|
|
28
|
+
|
|
29
|
+
# log { '[WebAuthn] Virtual Authenticator Environment is ready' }
|
|
30
|
+
end
|
|
19
31
|
end
|
|
20
32
|
end
|
|
21
33
|
end
|
|
@@ -9,27 +9,15 @@ module Booth
|
|
|
9
9
|
include ::Capybara::DSL
|
|
10
10
|
include ::Booth::Logging
|
|
11
11
|
|
|
12
|
-
option :
|
|
12
|
+
option :authenticator_id
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
instance = ::Booth::Testing::Support::VirtualAuthenticators::Create.call(
|
|
23
|
-
has_user_verification: true,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
log do
|
|
27
|
-
"Attaching Virtual Authenticator Secrets to #{virtual_authenticator.instance_variable_get(:@id)}"
|
|
28
|
-
end
|
|
29
|
-
instance.add_credential(credential)
|
|
30
|
-
# virtual_authenticator.instance_variable_set(:@id, instance.instance_variable_get(:@sign_count))
|
|
31
|
-
|
|
32
|
-
instance
|
|
15
|
+
raise NotImplementedError, 'Load needs to be reimplemented for Playwright using CDP'
|
|
16
|
+
# Previously this loaded credentials from a Selenium virtual authenticator object
|
|
17
|
+
# and added them to a new authenticator. With CDP, we'd need to:
|
|
18
|
+
# 1. Get credentials from the source authenticator via WebAuthn.getCredentials
|
|
19
|
+
# 2. Create a new authenticator
|
|
20
|
+
# 3. Add the credentials via WebAuthn.addCredential
|
|
33
21
|
end
|
|
34
22
|
end
|
|
35
23
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Booth
|
|
4
|
+
module Testing
|
|
5
|
+
module Support
|
|
6
|
+
# Factory and operations for VirtualAuthenticator objects.
|
|
7
|
+
# Provides methods to create and import authenticators across browser sessions.
|
|
8
|
+
module VirtualAuthenticators
|
|
9
|
+
include ::Booth::Logging
|
|
10
|
+
include ::Capybara::DSL
|
|
11
|
+
|
|
12
|
+
# Creates a new virtual authenticator in the current browser session.
|
|
13
|
+
# Returns a VirtualAuthenticator object that can be used to pull credentials
|
|
14
|
+
# after WebAuthn registration.
|
|
15
|
+
def self.create(has_user_verification: true)
|
|
16
|
+
log { "Creating virtual authenticator in session #{Capybara.session_name}" }
|
|
17
|
+
|
|
18
|
+
::Booth::Testing::Support::VirtualAuthenticators::Enable.call
|
|
19
|
+
|
|
20
|
+
result = send_cdp_message(
|
|
21
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
22
|
+
options: authenticator_options(has_user_verification:),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
authenticator_id = result['authenticatorId']
|
|
26
|
+
|
|
27
|
+
unless authenticator_id
|
|
28
|
+
raise 'Failed to create virtual authenticator - no authenticatorId in response'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
log { "Created virtual authenticator #{authenticator_id}" }
|
|
32
|
+
|
|
33
|
+
VirtualAuthenticator.new(
|
|
34
|
+
authenticator_id:,
|
|
35
|
+
has_user_verification:,
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Imports a VirtualAuthenticator into the current browser session.
|
|
40
|
+
# This creates a new authenticator and adds the credential with the stored private key.
|
|
41
|
+
def self.import(virtual_authenticator)
|
|
42
|
+
unless virtual_authenticator.credential_data
|
|
43
|
+
raise 'VirtualAuthenticator has no credential data to import. Call pull() on the source authenticator first.'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
log { "Importing authenticator into session #{Capybara.session_name}" }
|
|
47
|
+
|
|
48
|
+
::Booth::Testing::Support::VirtualAuthenticators::Enable.call
|
|
49
|
+
|
|
50
|
+
result = send_cdp_message(
|
|
51
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
52
|
+
options: authenticator_options(has_user_verification: virtual_authenticator.has_user_verification),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
new_authenticator_id = result['authenticatorId']
|
|
56
|
+
|
|
57
|
+
raise 'Failed to create authenticator for import' unless new_authenticator_id
|
|
58
|
+
|
|
59
|
+
# Add the credential with stored data
|
|
60
|
+
unless virtual_authenticator.rp_id
|
|
61
|
+
raise 'IMPORT FAILED: No rp_id set on VirtualAuthenticator'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
send_cdp_message(
|
|
65
|
+
'WebAuthn.addCredential',
|
|
66
|
+
authenticatorId: new_authenticator_id,
|
|
67
|
+
credential: virtual_authenticator.credential_data,
|
|
68
|
+
rpId: virtual_authenticator.rp_id,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
log do
|
|
72
|
+
"Imported credential #{virtual_authenticator.credential_id} into new authenticator #{new_authenticator_id}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Register the new authenticator ID for the current browser session
|
|
76
|
+
virtual_authenticator.register_authenticator_id(new_authenticator_id,
|
|
77
|
+
Capybara.session_name.to_s)
|
|
78
|
+
|
|
79
|
+
virtual_authenticator
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def self.authenticator_options(has_user_verification:)
|
|
85
|
+
{
|
|
86
|
+
protocol: 'ctap2',
|
|
87
|
+
transport: 'internal',
|
|
88
|
+
hasResidentKey: true,
|
|
89
|
+
hasUserVerification: has_user_verification,
|
|
90
|
+
isUserVerified: true,
|
|
91
|
+
automaticPresenceSimulation: true
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.send_cdp_message(method, params)
|
|
96
|
+
result = nil
|
|
97
|
+
Capybara.current_session.driver.with_playwright_page do |playwright_page|
|
|
98
|
+
cdp_session = playwright_page.context.new_cdp_session(playwright_page)
|
|
99
|
+
result = cdp_session.send_message(method, params:)
|
|
100
|
+
end
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -49,12 +49,12 @@ module Booth
|
|
|
49
49
|
assert_userland_view controller: :remote_logins, step: :remote_solved
|
|
50
50
|
|
|
51
51
|
using_session(:other_device) do
|
|
52
|
-
|
|
52
|
+
visit current_path
|
|
53
53
|
|
|
54
54
|
# ------------ SIGNIFICANT TEST --------------
|
|
55
55
|
# Webauth sudo is required after remote login.
|
|
56
56
|
# --------------------------------------------
|
|
57
|
-
|
|
57
|
+
assert_logged_in username: 'alice'
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
using_session(:yet_another_device) do
|
|
@@ -45,13 +45,14 @@ module Booth
|
|
|
45
45
|
# --------------------------------------------------------------------------
|
|
46
46
|
assert_userland_view controller: :onboardings, step: :success
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# ::Booth::Testing::Support::CheckBrowserState.call(operation: 'after assert_userland_view :success')
|
|
49
|
+
assert_logged_in username: 'alice'
|
|
49
50
|
|
|
51
|
+
# ::Booth::Testing::Support::CheckBrowserState.call(operation: 'after assert_logged_in')
|
|
50
52
|
# ----- SIGNIFICANT TEST -----
|
|
51
53
|
# Onboarding logs the user in.
|
|
52
54
|
# ----------------------------
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
# ::Booth::Testing::Support::CheckBrowserState.call(operation: 'before second visit')
|
|
55
56
|
visit_namespaced controller: :onboardings, action: :show,
|
|
56
57
|
params: { id: alices_onboarding.secret_key }
|
|
57
58
|
|
|
@@ -65,7 +66,7 @@ module Booth
|
|
|
65
66
|
# ---------------------------------------------------------
|
|
66
67
|
assert_userland_view controller: :onboardings, step: :wrong_user
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
clear_cookies
|
|
69
70
|
|
|
70
71
|
visit_namespaced controller: :onboardings, action: :show,
|
|
71
72
|
params: { id: alices_onboarding.secret_key }
|
|
@@ -64,11 +64,11 @@ module Booth
|
|
|
64
64
|
|
|
65
65
|
assert_userland_view controller: :webauths, step: :completed
|
|
66
66
|
|
|
67
|
-
authenticator = ::Booth::Models::Authenticator.sole
|
|
67
|
+
authenticator = ActiveRecord::Base.uncached { ::Booth::Models::Authenticator.sole }
|
|
68
68
|
|
|
69
69
|
assert_equal 'Latchkey', authenticator.nickname
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
clear_cookies
|
|
72
72
|
|
|
73
73
|
# Onboard via URL
|
|
74
74
|
|
|
@@ -100,7 +100,7 @@ module Booth
|
|
|
100
100
|
|
|
101
101
|
assert_userland_view controller: :webauths, step: :completed
|
|
102
102
|
|
|
103
|
-
authenticator = ::Booth::Models::Authenticator.sole
|
|
103
|
+
authenticator = ActiveRecord::Base.uncached { ::Booth::Models::Authenticator.sole }
|
|
104
104
|
|
|
105
105
|
assert_equal 'Superkey', authenticator.nickname
|
|
106
106
|
|
|
@@ -9,9 +9,14 @@ module Booth
|
|
|
9
9
|
|
|
10
10
|
# Register
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
begin
|
|
13
|
+
# Non-headless Chrome may cause Exception if HTTP status is 4xx
|
|
14
|
+
visit_namespaced controller: :registrations, action: :new
|
|
15
|
+
rescue Playwright::Error => e
|
|
16
|
+
raise unless e.message.include?('ERR_HTTP_RESPONSE_CODE_FAILURE')
|
|
17
|
+
end
|
|
13
18
|
|
|
14
|
-
return 'Skipping self-registration tests' if page.
|
|
19
|
+
return log { 'Skipping self-registration tests' } if page.status_code == 410
|
|
15
20
|
|
|
16
21
|
virtual_authenticators.create
|
|
17
22
|
|
|
@@ -39,7 +44,7 @@ module Booth
|
|
|
39
44
|
# --------------------------------------------
|
|
40
45
|
assert_userland_view controller: :webauths, step: :completed
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
clear_cookies
|
|
43
48
|
|
|
44
49
|
# Login
|
|
45
50
|
|
|
@@ -64,9 +69,7 @@ module Booth
|
|
|
64
69
|
# -----------------------------------------------------------------
|
|
65
70
|
click_on :authenticate
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
assert_userland_view controller: :webauths, step: :index
|
|
72
|
+
assert_logged_in username: 'alice'
|
|
70
73
|
|
|
71
74
|
# Try to register when already logged in
|
|
72
75
|
|
|
@@ -9,10 +9,14 @@ module Booth
|
|
|
9
9
|
|
|
10
10
|
# Register normally
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
begin
|
|
13
|
+
# Non-headless Chrome may cause Exception if HTTP status is 4xx
|
|
14
|
+
visit_namespaced controller: :registrations, action: :new
|
|
15
|
+
rescue Playwright::Error => e
|
|
16
|
+
raise unless e.message.include?('ERR_HTTP_RESPONSE_CODE_FAILURE')
|
|
17
|
+
end
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
return 'Skipping self-registration tests' if page.has_text?('HTTP ERROR 410')
|
|
19
|
+
return log { 'Skipping self-registration tests' } if page.status_code == 410
|
|
16
20
|
|
|
17
21
|
assert_userland_view controller: :registrations, step: :choose_username
|
|
18
22
|
|
|
@@ -53,17 +57,17 @@ module Booth
|
|
|
53
57
|
assert_userland_view controller: :remote_logins, step: :remote_solved
|
|
54
58
|
|
|
55
59
|
using_session(:other_device) do
|
|
56
|
-
|
|
60
|
+
visit current_path
|
|
57
61
|
|
|
58
62
|
# -------------------------- SIGNIFICANT TEST ---------------------------
|
|
59
63
|
# You can remote-login even though you don't have any Authenticators yet.
|
|
60
64
|
# -----------------------------------------------------------------------
|
|
61
|
-
|
|
65
|
+
assert_logged_in username: 'alice'
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
# Try to Login without having any Authenticators
|
|
65
69
|
|
|
66
|
-
|
|
70
|
+
clear_cookies
|
|
67
71
|
|
|
68
72
|
visit_namespaced controller: :logins, action: :new
|
|
69
73
|
|
|
@@ -93,7 +97,7 @@ module Booth
|
|
|
93
97
|
# ---------------- SIGNIFICANT TEST -------------------
|
|
94
98
|
# You cannot register a username that is already taken.
|
|
95
99
|
# -----------------------------------------------------
|
|
96
|
-
assert_text '
|
|
100
|
+
assert_text I18n.t('booth.username_already_exists')
|
|
97
101
|
end
|
|
98
102
|
end
|
|
99
103
|
end
|