fix(auth): log why a passkey ceremony was refused
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 3m3s

Every WebAuthn refusal answered "that passkey could not be verified" and
discarded the library's error, leaving a misconfigured relying party
undiagnosable. Log the stage, the derived RP ID and expected origin, the
request's Origin and X-Forwarded-Proto, and the library error with its
DevInfo. None of it is secret.
This commit is contained in:
2026-09-16 14:51:55 +00:00
parent 069e7e7c61
commit b1193c59e3
3 changed files with 23 additions and 0 deletions
+1
View File
@@ -212,6 +212,7 @@ func HandleEnrolPasskeyFinish(c *gin.Context) {
}
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
if err != nil {
logWebAuthnFailure(c, "enrolment registration", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
+1
View File
@@ -85,6 +85,7 @@ func HandlePasskeyLoginFinish(c *gin.Context) {
// that owner's.
cred, err := finishAssertion(c, instanceID, "", "")
if err != nil {
logWebAuthnFailure(c, "passwordless assertion", err)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion",
})
+21
View File
@@ -6,6 +6,7 @@ import (
"crypto/subtle"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"time"
@@ -42,6 +43,23 @@ func rpConfig(c *gin.Context) (string, string) {
return rpID, scheme + "://" + host
}
// logWebAuthnFailure records why a ceremony was refused. The response stays
// deliberately vague, so without this the only evidence of a misconfigured
// relying party - most often a proxy that terminates TLS without passing
// X-Forwarded-Proto: https - is a user reporting that their passkey "could not
// be verified". Nothing logged here is secret: RP ID, origins, and the
// library's error, whose DevInfo names expected and received values.
func logWebAuthnFailure(c *gin.Context, stage string, err error) {
rpID, origin := rpConfig(c)
detail := err.Error()
var perr *protocol.Error
if errors.As(err, &perr) && perr.DevInfo != "" {
detail += " (" + perr.DevInfo + ")"
}
log.Printf("webauthn: %s refused: rp_id=%q expected_origin=%q request_origin=%q x_forwarded_proto=%q: %s",
stage, rpID, origin, c.GetHeader("Origin"), c.GetHeader("X-Forwarded-Proto"), detail)
}
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
rpID, origin := rpConfig(c)
return webauthn.New(&webauthn.Config{
@@ -176,6 +194,7 @@ func HandleMFAWebAuthnFinish(c *gin.Context) {
}
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
if err != nil {
logWebAuthnFailure(c, "second-factor assertion", err)
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
if ferr != nil || left == 0 {
@@ -287,6 +306,7 @@ func HandleRegisterPasskeyFinish(c *gin.Context) {
}
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
if err != nil {
logWebAuthnFailure(c, "registration", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
@@ -382,6 +402,7 @@ func HandleStepUpWebAuthnFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
if err != nil {
logWebAuthnFailure(c, "step-up assertion", err)
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
return