diff --git a/backend/internal/middleware/error_handler.go b/backend/internal/middleware/error_handler.go index fd1949d2..e5336fcf 100644 --- a/backend/internal/middleware/error_handler.go +++ b/backend/internal/middleware/error_handler.go @@ -194,6 +194,22 @@ func writeErrorResponse(c *gin.Context, classified classifiedError, requestID st func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) { if classified.status < http.StatusInternalServerError { + cause := errors.Unwrap(err) + if cause == nil { + return + } + + slog.DebugContext(c.Request.Context(), "Request rejected", + slog.String("error_code", string(classified.code)), + slog.String("error_type", errorTypeName(err)), + slog.String("cause_type", errorTypeName(cause)), + slog.Int("http_status", classified.status), + slog.String("request_id", requestID), + slog.String("http_method", c.Request.Method), + slog.String("http_path", c.Request.URL.Path), + slog.Any("error", err), + slog.Any("cause", cause), + ) return } diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index d05d70db..cdeedd05 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -281,6 +281,7 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig Extensions: storedSession.Extensions, CredParams: storedSession.CredentialParams, } + discardUnrequestedFalseAppIDOutput(session.Extensions, credentialAssertionData) var user *model.User _, err := s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (gowebauthn.User, error) { @@ -535,6 +536,7 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s Extensions: storedSession.Extensions, CredParams: storedSession.CredentialParams, } + discardUnrequestedFalseAppIDOutput(session.Extensions, credentialAssertionData) // Validate the credential assertion var user *model.User @@ -589,6 +591,21 @@ func classifyPasskeyError(err error, fallback func(error) *apperror.Error) *appe return fallback(err) } +func discardUnrequestedFalseAppIDOutput(session protocol.SessionExtensions, credential *protocol.ParsedCredentialAssertionData) { + if credential == nil || credential.ClientExtensionResults.AppID == nil || *credential.ClientExtensionResults.AppID { + return + } + + for _, requested := range session.Requested { + if requested == protocol.ExtensionAppID { + return + } + } + + // Safari reports appid=false for security keys even when the relying party did not request the legacy extension + credential.ClientExtensionResults.AppID = nil +} + func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { hashedToken := utils.CreateSha256Hash(token) var reauthToken ReauthenticationToken diff --git a/backend/internal/webauthn/service_test.go b/backend/internal/webauthn/service_test.go index c270d1e3..283c210f 100644 --- a/backend/internal/webauthn/service_test.go +++ b/backend/internal/webauthn/service_test.go @@ -263,6 +263,66 @@ func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) { require.ErrorIs(t, err, cause) } +func TestDiscardUnrequestedFalseAppIDOutput(t *testing.T) { + tests := []struct { + name string + requested []string + appID bool + extra map[string]any + wantAppID *bool + wantError string + }{ + { + name: "unrequested false appid is discarded", + appID: false, + wantAppID: nil, + }, + { + name: "requested false appid is preserved", + requested: []string{protocol.ExtensionAppID}, + appID: false, + wantAppID: new(false), + }, + { + name: "unrequested true appid is rejected", + appID: true, + wantAppID: new(true), + wantError: "appid", + }, + { + name: "other unsolicited output is rejected", + appID: false, + extra: map[string]any{"example": true}, + wantAppID: nil, + wantError: "example", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + credential := &protocol.ParsedCredentialAssertionData{ + ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ + ClientExtensionResults: protocol.AuthenticationExtensionsClientOutputs{ + AppID: new(tc.appID), + Extra: tc.extra, + }, + }, + } + session := protocol.SessionExtensions{Requested: tc.requested} + + discardUnrequestedFalseAppIDOutput(session, credential) + + assert.Equal(t, tc.wantAppID, credential.ClientExtensionResults.AppID) + err := credential.ClientExtensionResults.Verify(session, protocol.AssertCeremony, protocol.UnsolicitedOutputPolicyReject) + if tc.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantError) + } + }) + } +} + func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) { service, err := newService(Dependencies{ DB: testutils.NewDatabaseForTest(t),