diff --git a/server/internal/auth/mfa_enrol.go b/server/internal/auth/mfa_enrol.go index d3c635b..566f9a5 100644 --- a/server/internal/auth/mfa_enrol.go +++ b/server/internal/auth/mfa_enrol.go @@ -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 } diff --git a/server/internal/auth/passkey_login.go b/server/internal/auth/passkey_login.go index 1ecb944..c945795 100644 --- a/server/internal/auth/passkey_login.go +++ b/server/internal/auth/passkey_login.go @@ -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", }) diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go index 7513773..219e778 100644 --- a/server/internal/auth/webauthn.go +++ b/server/internal/auth/webauthn.go @@ -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