Class: Booth::Testing::Support::VirtualAuthenticator

Inherits:
Object
  • Object
show all
Includes:
Logging, Capybara::DSL
Defined in:
lib/booth/testing/support/virtual_authenticator.rb

Overview

Represents a virtual WebAuthn credential that can be transferred between browser sessions. Stores the credential data (including private key) and tracks which authenticator ID exists in each browser session.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(authenticator_id:, has_user_verification: true) ⇒ VirtualAuthenticator



15
16
17
18
19
20
21
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 15

def initialize(authenticator_id:, has_user_verification: true)
  @authenticator_ids = {}
  @has_user_verification = has_user_verification
  @credential_data = nil
  # Register the initial authenticator for the current session
  register_authenticator_id(authenticator_id, Capybara.session_name.to_s)
end

Instance Attribute Details

#authenticator_idsObject

Maps Capybara session names to their authenticator IDs Each browser session has its own isolated authenticator with the same credential



12
13
14
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 12

def authenticator_ids
  @authenticator_ids
end

#credential_dataObject

Relying Party ID for this credential



13
14
15
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 13

def credential_data
  @credential_data
end

#has_user_verificationObject

Relying Party ID for this credential



13
14
15
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 13

def has_user_verification
  @has_user_verification
end

#rp_idObject

Relying Party ID for this credential



13
14
15
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 13

def rp_id
  @rp_id
end

Instance Method Details

#credential_idObject

Returns the credential ID



157
158
159
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 157

def credential_id
  credential_data&.dig('credentialId')
end

#current_authenticator_idObject

Gets the authenticator ID for the current browser session



29
30
31
32
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 29

def current_authenticator_id
  session_key = Capybara.session_name.to_s
  authenticator_ids[session_key] || raise("No authenticator registered for session #{session_key}. Call import() first if this is a new browser session.")
end

#private_keyObject

Returns the private key (base64 encoded)



167
168
169
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 167

def private_key
  credential_data&.dig('privateKey')
end

#pullObject

Pulls credential data from the current browser session. On first pull (after WebAuthn registration), this captures the private key. On subsequent pulls, this updates the stored sign_count.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 37

def pull
  auth_id = current_authenticator_id
  log do
    "Pulling credentials from session #{Capybara.session_name} for authenticator #{auth_id}"
  end

  credentials = get_credentials_from_session(auth_id)

  if credentials.empty?
    raise "No credentials found in authenticator #{auth_id}. Ensure WebAuthn registration completed before pulling."
  end

  # Store the first (and typically only) credential
  @credential_data = credentials.first

  # rpId might not be returned by getCredentials in imported credentials
  # Use stored value if available, otherwise use what we got from browser
  pulled_rp_id = @credential_data['rpId']
  if pulled_rp_id
    @rp_id = pulled_rp_id
    log do
      "Pulled credential #{credential_id} with sign_count #{sign_count} and rpId #{@rp_id}"
    end
  elsif @rp_id
    log do
      "Pulled credential #{credential_id} with sign_count #{sign_count} (rpId not returned, using stored: #{@rp_id})"
    end
  else
    raise "PULL FAILED: No rpId found in credential data and none stored! Keys: #{@credential_data.keys.inspect}"
  end

  log { "PUSH: Credential data keys: #{@credential_data.keys.inspect}" }

  self
end

#pushObject

Pushes the stored sign_count to the current browser session. Since WebAuthn.setCredentialProperties doesn’t support signCount, we remove and re-add the credential with the correct sign_count.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 76

def push
  unless credential_data
    raise 'No credential data to push. Call pull() first to capture credentials.'
  end

  auth_id = current_authenticator_id
  target_sign_count = sign_count

  # First, read current state from browser
  current_creds = get_credentials_from_session(auth_id)
  target_cred = current_creds.find { |c| c['credentialId'] == credential_id }

  if target_cred.nil?
    raise "Credential #{credential_id} not found in browser session #{Capybara.session_name}. Import this authenticator first."
  end

  current_browser_sign_count = target_cred['signCount']

  log do
    "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}"
  end

  if current_browser_sign_count == target_sign_count
    log { "PUSH: Sign count already up to date (#{target_sign_count})" }
    return self
  end

  # Remove and re-add credential with updated sign_count
  # (setCredentialProperties doesn't support signCount per CDP spec)
  log { "PUSH: Removing credential #{credential_id} from authenticator #{auth_id}" }
  remove_result = send_cdp_message(
    'WebAuthn.removeCredential',
    authenticatorId: auth_id,
    credentialId: credential_id,
  )
  log { "PUSH: removeCredential result: #{remove_result.inspect}" }

  log { "PUSH: Re-adding credential with sign_count #{target_sign_count}" }
  # Create credential data with updated sign_count
  updated_credential = credential_data.dup
  updated_credential['signCount'] = target_sign_count
  updated_credential['rpId'] = @rp_id

  # Debug: show what fields are in the credential
  log { "PUSH: Credential fields: #{updated_credential.keys.inspect}" }
  log { "PUSH: rpId in credential: #{updated_credential['rpId'].inspect}" }
  log { "PUSH: @rp_id instance variable: #{@rp_id.inspect}" }

  raise 'PUSH FAILED: @rp_id is nil!' unless @rp_id

  add_result = send_cdp_message(
    'WebAuthn.addCredential',
    authenticatorId: auth_id,
    credential: updated_credential,
  )
  log { "PUSH: addCredential result: #{add_result.inspect}" }

  # CRITICAL: Verify the update actually worked
  sleep 0.5 # Give browser time to apply update
  post_update_creds = get_credentials_from_session(auth_id)
  post_update_cred = post_update_creds.find { |c| c['credentialId'] == credential_id }

  if post_update_cred.nil?
    raise "PUSH FAILED: Credential #{credential_id} not found after re-add!"
  end

  actual_sign_count = post_update_cred['signCount']
  log do
    "PUSH: Post-update browser sign_count: #{actual_sign_count} (expected: #{target_sign_count})"
  end

  unless actual_sign_count == target_sign_count
    raise "PUSH FAILED: Browser sign_count is #{actual_sign_count} but we expected #{target_sign_count}"
  end

  log { "PUSH: Successfully updated sign_count to #{target_sign_count} in browser" }

  self
end

#register_authenticator_id(authenticator_id, session_name = Capybara.session_name.to_s) ⇒ Object

Registers an authenticator ID for a specific browser session



24
25
26
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 24

def register_authenticator_id(authenticator_id, session_name = Capybara.session_name.to_s)
  authenticator_ids[session_name] = authenticator_id
end

#sign_countObject

Returns the current sign count



162
163
164
# File 'lib/booth/testing/support/virtual_authenticator.rb', line 162

def sign_count
  credential_data&.dig('signCount') || 0
end