Skip to content

Commit 2c3b821

Browse files
feat: validate userHandle in PasskeyAuthenticatable strategy
1 parent 99b3b3f commit 2c3b821

2 files changed

Lines changed: 52 additions & 9 deletions

File tree

lib/devise/strategies/passkey_authenticatable.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ def valid?
99

1010
def authenticate!
1111
passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
12-
stored_passkey = WebauthnCredential.passkey.find_by(external_id: passkey_from_params.id)
12+
resource = resource_class.find_by(webauthn_id: passkey_from_params.user_handle)
13+
stored_passkey = resource&.passkeys&.find_by(external_id: passkey_from_params.id)
1314

1415
return fail!(:passkey_not_found) if stored_passkey.blank?
1516

1617
verify_passkeys(passkey_from_params, stored_passkey)
1718

18-
resource = stored_passkey.public_send(resource_name)
1919
success!(resource)
2020
rescue WebAuthn::Error
2121
fail!(:passkey_verification_failed)
@@ -40,8 +40,8 @@ def verify_passkeys(passkey_from_params, stored_passkey)
4040
stored_passkey.update!(sign_count: passkey_from_params.sign_count)
4141
end
4242

43-
def resource_name
44-
mapping.to.name.underscore
43+
def resource_class
44+
mapping.to
4545
end
4646
end
4747
end

spec/requests/devise/passkey_authentication_spec.rb

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ def create_passkey_for(account, fake_client)
2222
)
2323
end
2424

25-
def generate_assertion(fake_client, challenge:, credential:)
25+
def generate_assertion(fake_client, challenge:, credential:, user_handle: nil)
2626
fake_client.get(
2727
challenge: challenge,
2828
allow_credentials: [credential.external_id],
29-
user_verified: true
29+
user_verified: true,
30+
user_handle: user_handle
3031
)
3132
end
3233

@@ -43,7 +44,8 @@ def generate_assertion(fake_client, challenge:, credential:)
4344
assertion = generate_assertion(
4445
client,
4546
challenge: session[:authentication_challenge],
46-
credential: passkey
47+
credential: passkey,
48+
user_handle: WebAuthn.configuration.encoder.decode(user.webauthn_id)
4749
)
4850

4951
expect do
@@ -64,7 +66,8 @@ def generate_assertion(fake_client, challenge:, credential:)
6466
assertion = generate_assertion(
6567
client,
6668
challenge: session[:authentication_challenge],
67-
credential: passkey
69+
credential: passkey,
70+
user_handle: WebAuthn.configuration.encoder.decode(user.webauthn_id)
6871
)
6972
passkey.destroy!
7073

@@ -83,7 +86,8 @@ def generate_assertion(fake_client, challenge:, credential:)
8386

8487
assertion = client.get(
8588
challenge: WebAuthn.configuration.encoder.encode("invalid_challenge"),
86-
allow_credentials: [passkey.external_id]
89+
allow_credentials: [passkey.external_id],
90+
user_handle: WebAuthn.configuration.encoder.decode(user.webauthn_id)
8791
)
8892

8993
post account_session_path, params: {
@@ -96,6 +100,45 @@ def generate_assertion(fake_client, challenge:, credential:)
96100
expect(controller.current_account).to be_nil
97101
end
98102

103+
it "rejects sign-in when userHandle is missing from the response" do
104+
get new_account_session_path
105+
106+
assertion = client.get(
107+
challenge: session[:authentication_challenge],
108+
allow_credentials: [passkey.external_id],
109+
user_verified: true
110+
)
111+
112+
post account_session_path, params: {
113+
public_key_credential: assertion.to_json
114+
}
115+
116+
expect(response).to have_http_status(:unprocessable_entity)
117+
expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_not_found"))
118+
expect(controller.current_account).to be_nil
119+
end
120+
121+
it "rejects sign-in when userHandle does not match the passkey owner" do
122+
other_user = Account.create!(email: "other@example.com", password: password)
123+
124+
get new_account_session_path
125+
126+
assertion = client.get(
127+
challenge: session[:authentication_challenge],
128+
allow_credentials: [passkey.external_id],
129+
user_verified: true,
130+
user_handle: WebAuthn.configuration.encoder.decode(other_user.webauthn_id)
131+
)
132+
133+
post account_session_path, params: {
134+
public_key_credential: assertion.to_json
135+
}
136+
137+
expect(response).to have_http_status(:unprocessable_entity)
138+
expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_not_found"))
139+
expect(controller.current_account).to be_nil
140+
end
141+
99142
it "fails when credential param is missing" do
100143
post account_session_path, params: {}
101144

0 commit comments

Comments
 (0)