Ctrl K
ComunidadGuías…

controller/passkey_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package controller

import (
	"bytes"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"maps"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/middleware"
	"github.com/QuantumNous/new-api/model"
	"github.com/QuantumNous/new-api/service"
	passkeysvc "github.com/QuantumNous/new-api/service/passkey"
	"github.com/QuantumNous/new-api/setting/system_setting"
	"github.com/gin-gonic/gin"
	"github.com/go-webauthn/webauthn/protocol"
	"github.com/go-webauthn/webauthn/webauthn"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"
)

type passkeyTestBody struct {
	*strings.Reader
}

type passkeyDomainBegin struct {
	FlowToken string   `json:"flow_token"`
	RPIDs     []string `json:"rp_ids"`
	Options   struct {
		PublicKey struct {
			Challenge string `json:"challenge"`
			RPID      string `json:"rpId"`
			RP        struct {
				ID string `json:"id"`
			} `json:"rp"`
		} `json:"publicKey"`
	} `json:"options"`
}

func TestGetStatusDoesNotExposePasskeyOrigins(t *testing.T) {
	settings := system_setting.GetPasskeySettings()
	originalSettings := *settings
	originalOptionMap := common.OptionMap
	originalServerAddress := system_setting.ServerAddress
	t.Cleanup(func() {
		*settings = originalSettings
		common.OptionMap = originalOptionMap
		system_setting.ServerAddress = originalServerAddress
	})
	common.OptionMap = map[string]string{}
	system_setting.ServerAddress = "https://www.example.com"
	*settings = system_setting.PasskeySettings{Enabled: true, RPID: "example.com"}

	for _, origins := range []string{"https://www.example.com,https://private.example.com", "", "[]"} {
		t.Run("origins="+origins, func(t *testing.T) {
			settings.Origins = origins
			response := httptest.NewRecorder()
			context, _ := gin.CreateTestContext(response)
			context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)

			GetStatus(context)

			require.Equal(t, http.StatusOK, response.Code)
			var payload struct {
				Success bool           `json:"success"`
				Data    map[string]any `json:"data"`
			}
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
			require.True(t, payload.Success)
			assert.NotContains(t, payload.Data, "passkey_origins")
			assert.NotContains(t, response.Body.String(), "private.example.com")
			assert.Equal(t, true, payload.Data["passkey_login"])
			assert.Equal(t, origins, settings.Origins)
		})
	}
}

func passkeyDomainRequest(t *testing.T, path string, payload any, identity service.AuthIdentity, origin string, handler gin.HandlerFunc) *httptest.ResponseRecorder {
	t.Helper()
	body, err := common.Marshal(payload)
	require.NoError(t, err)
	return securityEnrollmentRequest(http.MethodPost, path, string(body), "", identity, func(c *gin.Context) {
		c.Request.Header.Set("Origin", origin)
		handler(c)
	})
}

func decodePasskeyDomainBegin(t *testing.T, response *httptest.ResponseRecorder) passkeyDomainBegin {
	t.Helper()
	var result securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	require.True(t, result.Success, response.Body.String())
	var begin passkeyDomainBegin
	require.NoError(t, common.Unmarshal(result.Data, &begin))
	return begin
}

// Reuse the existing software authenticator, replacing the domain-bound bytes
// and recomputing the signature exactly as a browser/authenticator would.
func passkeyDomainAssertion(t *testing.T, key *ecdsa.PrivateKey, challenge, rpID, origin string, userID int, verified bool) map[string]any {
	t.Helper()
	var assertion map[string]any
	require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 1, verified), &assertion))
	response := assertion["response"].(map[string]any)
	clientData, err := common.Marshal(map[string]string{"type": "webauthn.get", "challenge": challenge, "origin": origin})
	require.NoError(t, err)
	authData, err := base64.RawURLEncoding.DecodeString(response["authenticatorData"].(string))
	require.NoError(t, err)
	rpHash := sha256.Sum256([]byte(rpID))
	copy(authData, rpHash[:])
	clientHash := sha256.Sum256(clientData)
	signedHash := sha256.Sum256(append(append([]byte{}, authData...), clientHash[:]...))
	signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:])
	require.NoError(t, err)
	response["clientDataJSON"] = base64.RawURLEncoding.EncodeToString(clientData)
	response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData)
	response["signature"] = base64.RawURLEncoding.EncodeToString(signature)
	response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(userID)))
	return assertion
}

func TestPasskeyDomainsPreserveCredentialsAcrossVerificationFlows(t *testing.T) {
	for _, kind := range []string{"direct", "login factor", "sensitive action", "historical mixed case"} {
		t.Run(kind, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			key := newSecurityLoginPasskey(t, user.Id)
			settings := system_setting.GetPasskeySettings()
			legacyRPID := "www.example.com"
			if kind == "historical mixed case" {
				legacyRPID = "WWW.example.com"
			}
			settings.LegacyRPIDs = legacyRPID
			settings.Origins = "https://example.com,https://www.example.com"
			beginPath, finishPath := "/api/user/passkey/login/begin", "/api/user/passkey/login/finish"
			beginHandler, finishHandler := PasskeyLoginBegin, PasskeyLoginFinish
			request := map[string]any{"rp_id": legacyRPID}
			if kind == "login factor" {
				pending, err := service.StartLoginVerification(user, "password", nil)
				require.NoError(t, err)
				request["flow_token"] = pending.FlowToken
				beginPath, finishPath = "/api/user/login/passkey/begin", "/api/user/login/passkey/finish"
				beginHandler, finishHandler = LoginPasskeyBegin, LoginPasskeyFinish
			} else if kind == "sensitive action" {
				request["scope"] = service.VerificationScopeAccessTokenGenerate
				request["context"] = map[string]any{"scopes": []string{"profile:read"}, "expires_at": 0}
				beginPath, finishPath = "/api/user/passkey/verify/begin", "/api/user/passkey/verify/finish"
				beginHandler, finishHandler = PasskeyVerifyBegin, PasskeyVerifyFinish
			}
			begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, beginPath, request, identity, "https://www.example.com", beginHandler))
			assert.Equal(t, legacyRPID, begin.Options.PublicKey.RPID)
			assert.Equal(t, []string{"example.com", legacyRPID}, begin.RPIDs)
			before, err := model.GetPasskeyByUserID(user.Id)
			require.NoError(t, err)
			require.Nil(t, before.RPID)
			finish := map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, legacyRPID, "https://www.example.com", user.Id, true)}
			if kind == "login factor" {
				finish["flow_token"], finish["passkey_flow_token"] = request["flow_token"], begin.FlowToken
			}
			response := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
			var result securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
			require.True(t, result.Success, response.Body.String())
			after, err := model.GetPasskeyByUserID(user.Id)
			require.NoError(t, err)
			require.NotNil(t, after.RPID)
			assert.Equal(t, legacyRPID, *after.RPID)
			assert.Equal(t, before.CredentialID, after.CredentialID)
			assert.Equal(t, before.PublicKey, after.PublicKey)
			if kind == "sensitive action" {
				var proof service.SecurityProof
				require.NoError(t, common.Unmarshal(result.Data, &proof))
				assert.Equal(t, service.VerificationScopeAccessTokenGenerate, proof.Scope)
			}
			count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
			require.NoError(t, err)
			replay := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
			require.NoError(t, common.Unmarshal(replay.Body.Bytes(), &result))
			assert.False(t, result.Success)
			afterCount, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
			require.NoError(t, err)
			assert.Equal(t, count, afterCount)
			// Once identified, a browser hint cannot override the credential's binding.
			wa, ids, err := passkeysvc.BuildLoginWebAuthn(httptest.NewRequest(http.MethodPost, beginPath, nil), "example.com", *after.RPID)
			require.NoError(t, err)
			assert.Equal(t, legacyRPID, wa.Config.RPID)
			assert.Equal(t, []string{legacyRPID}, ids)
		})
	}
}

func TestPasskeyDomainsRejectInvalidAssertions(t *testing.T) {
	for _, failure := range []string{"wrong RP ID", "unlisted origin", "origin outside RP scope", "wrong user", "bad signature", "missing user verification", "expired challenge", "removed domain", "known different domain"} {
		t.Run(failure, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			key := newSecurityLoginPasskey(t, user.Id)
			settings := system_setting.GetPasskeySettings()
			settings.LegacyRPIDs = "www.example.com"
			settings.Origins = "https://example.com,https://www.example.com"
			begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
			rpID, origin, handle := "www.example.com", "https://www.example.com", user.Id
			switch failure {
			case "wrong RP ID":
				rpID = "example.com"
			case "unlisted origin":
				origin = "https://evil.example.com"
			case "origin outside RP scope":
				origin = "https://example.com"
			case "wrong user":
				handle++
			case "expired challenge":
				flow, err := model.GetAuthFlow(begin.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyLogin})
				require.NoError(t, err)
				var payload struct {
					SessionData webauthn.SessionData    `json:"session_data"`
					Security    passkeysvc.FlowSecurity `json:"security"`
				}
				require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
				payload.SessionData.Expires = time.Now().Add(-time.Second)
				encoded, err := common.Marshal(payload)
				require.NoError(t, err)
				require.NoError(t, model.DB.Model(flow).Update("payload", string(encoded)).Error)
			case "removed domain":
				settings.LegacyRPIDs = ""
			case "known different domain":
				require.NoError(t, model.DB.Model(&model.PasskeyCredential{}).Where("user_id = ?", user.Id).Update("rp_id", "example.com").Error)
			}
			assertion := passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, rpID, origin, handle, failure != "missing user verification")
			if failure == "bad signature" {
				assertion["response"].(map[string]any)["signature"] = "AA"
			}
			response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": assertion}, identity, "https://www.example.com", PasskeyLoginFinish)
			var result securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
			assert.False(t, result.Success, response.Body.String())
			assert.Empty(t, response.Header().Values("Set-Cookie"))
			count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
			require.NoError(t, err)
			assert.EqualValues(t, 1, count)
			stored, err := model.GetPasskeyByUserID(user.Id)
			require.NoError(t, err)
			if failure == "known different domain" {
				assert.Equal(t, "example.com", *stored.RPID)
			} else {
				assert.Nil(t, stored.RPID)
			}
		})
	}
}

func TestPasskeyDomainChoicesRespectOriginAndConfiguration(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	settings := system_setting.GetPasskeySettings()
	settings.LegacyRPIDs = "www.example.com"
	settings.Origins = "https://example.com,https://www.example.com"
	for _, test := range []struct {
		origin, hint string
		success      bool
		ids          []string
	}{
		{"https://www.example.com", "", true, []string{"example.com", "www.example.com"}},
		{"https://example.com", "", true, []string{"example.com"}},
		{"https://example.com", "www.example.com", false, nil},
		{"https://www.example.com", "unconfigured.example.com", false, nil},
		{"https://attacker.test", "", false, nil},
	} {
		response := passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": test.hint}, identity, test.origin, PasskeyLoginBegin)
		var result securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
		assert.Equal(t, test.success, result.Success, "%+v", test)
		if test.success {
			assert.Equal(t, test.ids, decodePasskeyDomainBegin(t, response).RPIDs)
		} else {
			assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
		}
	}
	settings.RPID, settings.LegacyRPIDs, settings.Origins, settings.AllowInsecureOrigin = "localhost", "", "http://localhost:3000,http://localhost:3001", true
	for _, origin := range []string{"http://localhost:3000", "http://localhost:3001"} {
		begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", nil, identity, origin, PasskeyLoginBegin))
		assert.Equal(t, "localhost", begin.Options.PublicKey.RPID)
	}
}

func setupPasskeyDomainOptions(t *testing.T) {
	t.Helper()
	require.NoError(t, model.DB.AutoMigrate(&model.Option{}))
	// These tests assert on the whole options table. The server-managed legacy
	// access token deadline written by the enrollment fixture is unrelated.
	require.NoError(t, model.DB.Delete(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Error)
	common.OptionMapRWMutex.RLock()
	previousOptions := maps.Clone(common.OptionMap)
	previousAddress := system_setting.ServerAddress
	common.OptionMapRWMutex.RUnlock()
	t.Cleanup(func() {
		common.OptionMapRWMutex.Lock()
		common.OptionMap = previousOptions
		system_setting.ServerAddress = previousAddress
		common.OptionMapRWMutex.Unlock()
	})
}

func TestPasskeyDomainRemovalProtectsExistingAndUnknownCredentials(t *testing.T) {
	for _, binding := range []string{"www.example.com", ""} {
		t.Run("binding="+binding, func(t *testing.T) {
			user, _ := setupSecurityEnrollmentTest(t)
			setupPasskeyDomainOptions(t)
			settings := system_setting.GetPasskeySettings()
			settings.LegacyRPIDs = "www.example.com"
			settings.Origins = "https://example.com,https://www.example.com"
			credential := &model.PasskeyCredential{UserID: user.Id, CredentialID: "existing", PublicKey: "key"}
			if binding != "" {
				credential.RPID = &binding
			}
			require.NoError(t, model.DB.Create(credential).Error)
			assert.Error(t, model.UpdateOption("passkey.legacy_rp_ids", ""))
			assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().LegacyRPIDs)
		})
	}
}

func TestPasskeyDomainRemovalWithoutAffectedCredentialsNeedsNoOverride(t *testing.T) {
	user, _ := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.LegacyRPIDs = "www.example.com,WWW.example.com"
	upper := "WWW.example.com"
	require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, RPID: &upper, CredentialID: "uppercase", PublicKey: "key"}).Error)
	change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.legacy_rp_ids": upper}, false, "")
	require.NoError(t, err)
	assert.Equal(t, []string{"www.example.com"}, change.RemovedRPIDs)
	assert.Zero(t, change.AffectedCredentials)
	assert.Zero(t, change.UnknownCredentials)
	assert.False(t, change.ConfirmationRequired)
	assert.Equal(t, upper, settings.LegacyRPIDs)
}

func TestPasskeySnapshotDoesNotMaterializeDefaults(t *testing.T) {
	setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.RPID, settings.Origins = "", ""
	system_setting.ServerAddress = "https://www.example.com"
	assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().EffectiveRPID())
	assert.Empty(t, settings.RPID)
	assert.Empty(t, settings.Origins)
}

func TestPasskeyDomainPreviewConfirmationAndAudit(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.LegacyRPIDs = "www.example.com,WWW.example.com"
	settings.Origins = "https://example.com,https://www.example.com"
	lower, upper, empty := "www.example.com", "WWW.example.com", ""
	for i, binding := range []*string{&lower, &upper, nil, &empty, &lower} {
		credential := &model.PasskeyCredential{UserID: user.Id + i, RPID: binding, CredentialID: fmt.Sprint(i), PublicKey: "key"}
		require.NoError(t, model.DB.Create(credential).Error)
		if i == 4 {
			require.NoError(t, model.DB.Delete(credential).Error)
		}
	}
	request := map[string]any{"rp_id": "example.com", "legacy_rp_ids": upper, "origins": settings.Origins, "preview": true}
	response := passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
	var result securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	require.True(t, result.Success, response.Body.String())
	var preview model.PasskeyDomainChange
	require.NoError(t, common.Unmarshal(result.Data, &preview))
	assert.Equal(t, []string{lower}, preview.RemovedRPIDs)
	assert.EqualValues(t, 1, preview.AffectedCredentials)
	assert.EqualValues(t, 2, preview.UnknownCredentials)
	assert.True(t, preview.ConfirmationRequired)
	require.NotEmpty(t, preview.RemovalConfirmation)
	options, err := model.AllOption()
	require.NoError(t, err)
	assert.Empty(t, options, "preview must not materialize default rows")
	assert.Equal(t, "www.example.com,WWW.example.com", settings.LegacyRPIDs)
	assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.legacy_rp_ids": upper, "Notice": "must roll back"}), model.ErrPasskeyDomainRemovalConfirmation)
	blocked := passkeyDomainRequest(t, "/api/option/", map[string]string{"key": "passkey.legacy_rp_ids", "value": upper}, identity, "https://example.com", UpdateOption)
	assert.Equal(t, http.StatusConflict, blocked.Code)
	assert.Contains(t, blocked.Body.String(), "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED")
	request["preview"] = false
	request["removal_confirmation"] = preview.RemovalConfirmation
	// A new unknown credential makes the reviewed impact stale.
	require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 10, CredentialID: "new", PublicKey: "key"}).Error)
	response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
	assert.Equal(t, http.StatusConflict, response.Code)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	require.NoError(t, common.Unmarshal(result.Data, &preview))
	assert.EqualValues(t, 3, preview.UnknownCredentials)
	options, err = model.AllOption()
	require.NoError(t, err)
	assert.Empty(t, options)
	request["removal_confirmation"] = preview.RemovalConfirmation
	response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	require.True(t, result.Success, response.Body.String())
	assert.Equal(t, upper, settings.LegacyRPIDs)
	var audits []model.AuditLog
	require.NoError(t, model.LOG_DB.Find(&audits).Error)
	for _, audit := range audits {
		if audit.Action == "option.passkey_domains_blocked" {
			assert.False(t, audit.Success)
			assert.Equal(t, http.StatusConflict, audit.Status)
		}
		if audit.Action == "option.passkey_domains_confirmed" {
			assert.True(t, audit.Success)
			require.NotNil(t, audit.Other.Op)
			params, err := common.Marshal(audit.Other.Op.Params)
			require.NoError(t, err)
			var impact struct {
				Known, Unknown int64
				Confirmed      bool
				RemovedRPIDs   []string `json:"removed_rp_ids"`
			}
			require.NoError(t, common.Unmarshal(params, &impact))
			assert.EqualValues(t, 1, impact.Known)
			assert.EqualValues(t, 3, impact.Unknown)
			assert.True(t, impact.Confirmed)
			assert.Equal(t, []string{lower}, impact.RemovedRPIDs)
		}
	}
	encoded, err := common.Marshal(audits)
	require.NoError(t, err)
	assert.Contains(t, string(encoded), "option.passkey_domains_confirmed")
	assert.Contains(t, string(encoded), "option.passkey_domains_blocked")
	assert.NotContains(t, string(encoded), preview.RemovalConfirmation)
	// Simulate an old node's cache after removal: persistence must use DB trust.
	settings.LegacyRPIDs = lower + "," + upper
	assert.ErrorIs(t, model.UpdatePasskeyAssertionState(user.Id, &webauthn.Credential{ID: []byte("0")}, time.Now(), lower), system_setting.ErrPasskeyRPIDUnavailable)
	assert.ErrorIs(t, model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &lower, CredentialID: "replacement", PublicKey: "key"}), system_setting.ErrPasskeyRPIDUnavailable)
}

func TestPasskeyDomainSettingsAcceptExactHTTPSInternalHosts(t *testing.T) {
	for _, test := range []struct {
		rpID, origins string
		valid         bool
	}{
		{"intranet", "https://intranet:8443", true},
		{"intranet", "https://child.intranet", false},
		{"intranet", "http://intranet", false},
		{"com", "https://com", false},
		{"127.0.0.1", "https://127.0.0.1", false},
		{"intranet:8443", "https://intranet:8443", false},
		{"localhost", "http://localhost:3000", true},
	} {
		t.Run(test.rpID+"/"+test.origins, func(t *testing.T) {
			setupSecurityEnrollmentTest(t)
			setupPasskeyDomainOptions(t)
			change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": test.rpID, "passkey.legacy_rp_ids": "", "passkey.origins": test.origins}, false, "")
			if !test.valid {
				assert.ErrorIs(t, err, system_setting.ErrPasskeyRPIDInvalid)
				return
			}
			require.NoError(t, err)
			assert.Equal(t, test.rpID, change.RPID)
			assert.Equal(t, "example.com", change.LegacyRPIDs)
		})
	}
}

func TestPasskeyImplicitDomainChangesRetainPreviousTrust(t *testing.T) {
	setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.RPID, settings.Origins = "", ""
	system_setting.ServerAddress = "https://www.example.com"
	require.NoError(t, model.UpdateOption("ServerAddress", "https://example.com"))
	assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
	assert.Equal(t, "https://www.example.com,https://example.com", settings.Origins)
	// Moving the retained RP into the primary is not a removal.
	change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": "www.example.com", "passkey.legacy_rp_ids": ""}, false, "")
	require.NoError(t, err)
	assert.Empty(t, change.RemovedRPIDs)
	assert.Equal(t, "example.com", change.LegacyRPIDs)
}

func TestPasskeyRegistrationAndDomainRemovalSerialize(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
	require.NoError(t, model.UpdateOption("passkey.legacy_rp_ids", "www.example.com"))
	registering, release := make(chan struct{}), make(chan struct{})
	require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("passkey_registration_barrier", func(tx *gorm.DB) {
		if _, ok := tx.Statement.Dest.(*model.PasskeyCredential); ok {
			close(registering)
			<-release
		}
	}))
	t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("passkey_registration_barrier") })
	rpID := "www.example.com"
	registered, removed := make(chan error, 1), make(chan error, 1)
	go func() {
		registered <- model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &rpID, CredentialID: "concurrent", PublicKey: "key"})
	}()
	<-registering
	removing := make(chan struct{})
	go func() {
		close(removing)
		removed <- model.UpdateOption("passkey.legacy_rp_ids", "")
	}()
	<-removing
	// A concurrent snapshot must remain read-only while the write is pending.
	assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
	close(release)
	require.NoError(t, <-registered)
	assert.ErrorIs(t, <-removed, model.ErrPasskeyDomainRemovalConfirmation)
	stored, err := model.GetPasskeyByUserID(user.Id)
	require.NoError(t, err)
	require.NotNil(t, stored.RPID)
	assert.Equal(t, rpID, *stored.RPID)
}

func TestPasskeyDomainEndpointRequiresRoot(t *testing.T) {
	user, _ := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "domain-settings-test")
	require.NoError(t, err)
	router := gin.New()
	router.PUT("/api/option/passkey/domains", middleware.RootAuth(), UpdatePasskeyDomains)
	for _, bearer := range []string{"", bundle.AccessToken} {
		request := httptest.NewRequest(http.MethodPut, "/api/option/passkey/domains", strings.NewReader(`{"rp_id":"example.com","legacy_rp_ids":"","origins":"https://example.com","preview":true}`))
		if bearer != "" {
			request.Header.Set("Authorization", "Bearer "+bearer)
		}
		response := httptest.NewRecorder()
		router.ServeHTTP(response, request)
		assert.Contains(t, []int{http.StatusUnauthorized, http.StatusForbidden}, response.Code)
	}
	options, err := model.AllOption()
	require.NoError(t, err)
	assert.Empty(t, options)
}

func TestPasskeyDefaultsDoNotRestrictUnrelatedServerAddressSetup(t *testing.T) {
	setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.Enabled, settings.RPID, settings.LegacyRPIDs, settings.Origins = false, "", "", ""
	system_setting.ServerAddress = ""
	require.NoError(t, model.UpdateOption("ServerAddress", "http://127.0.0.1:3000"))
	assert.Equal(t, "http://127.0.0.1:3000", system_setting.ServerAddress)
	assert.Empty(t, settings.RPID)
	assert.Empty(t, settings.LegacyRPIDs)
}

func TestPasskeyRegistrationRejectsUnavailableRequestOrigin(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	settings := system_setting.GetPasskeySettings()
	settings.RPID = "www.example.com"
	settings.LegacyRPIDs = "example.com"
	settings.Origins = "https://example.com,https://www.example.com"
	proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
	response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, func(c *gin.Context) {
		c.Request.Header.Set("Origin", "https://example.com")
		PasskeyRegisterBegin(c)
	})
	var result securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	assert.False(t, result.Success)
	assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
	var count int64
	require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error)
	assert.Zero(t, count)
}

func TestPasskeyRPIDRotationRetainsDefaultsAndRollsBackOnFailure(t *testing.T) {
	setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	settings := system_setting.GetPasskeySettings()
	settings.RPID, settings.LegacyRPIDs = "", ""
	settings.Origins = "https://www.example.com,https://example.com,https://login.example.com"
	system_setting.ServerAddress = "https://www.example.com"
	require.NoError(t, model.DB.Create(&model.Option{Key: "passkey.rp_id", Value: ""}).Error)
	require.NoError(t, model.UpdateOption("passkey.rp_id", "example.com"))
	assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
	// Simulate a second node with an older local cache. Persisted domains win.
	system_setting.GetPasskeySettings().RPID = "www.example.com"
	require.NoError(t, model.UpdateOption("passkey.rp_id", "login.example.com"))
	assert.Equal(t, []string{"login.example.com", "www.example.com", "example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
	before, err := model.AllOption()
	require.NoError(t, err)
	snapshot := system_setting.PasskeySettingsSnapshot()
	failure := errors.New("synthetic option write failure")
	require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("passkey_option_failure", func(tx *gorm.DB) {
		if option, ok := tx.Statement.Dest.(*model.Option); ok && option.Key == "passkey.legacy_rp_ids" {
			tx.AddError(failure)
		}
	}))
	assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.rp_id": "next.example.com", "passkey.legacy_rp_ids": "www.example.com"}), failure)
	require.NoError(t, model.DB.Callback().Update().Remove("passkey_option_failure"))
	after, err := model.AllOption()
	require.NoError(t, err)
	assert.ElementsMatch(t, before, after)
	assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
	for _, invalid := range []string{"*.example.com", "https://example.com", "example.com:443", "example.com/path", "com"} {
		assert.ErrorIs(t, model.UpdateOption("passkey.rp_id", invalid), system_setting.ErrPasskeyRPIDInvalid)
		assert.ErrorIs(t, model.UpdateOption("passkey.legacy_rp_ids", invalid), system_setting.ErrPasskeyRPIDInvalid)
	}
	assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
	// Older settings accepted mixed case. Rotation must preserve its hash input.
	require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "passkey.rp_id"}).Update("value", "WWW.example.com").Error)
	require.NoError(t, model.UpdateOption("passkey.rp_id", "next.example.com"))
	assert.Contains(t, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs(), "WWW.example.com")
}

func TestPasskeyRPIDRotationKeepsInFlightRegistration(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
	proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
	begin := decodePasskeyDomainBegin(t, securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin))
	assert.Equal(t, "example.com", begin.Options.PublicKey.RP.ID)
	require.NoError(t, model.UpdateOption("passkey.rp_id", "www.example.com"))
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	require.NoError(t, err)
	response := passkeyDomainRequest(t, "/api/user/passkey/register/finish", passkeyFinishRequest{FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0)}, identity, "https://example.com", PasskeyRegisterFinish)
	var result securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	require.True(t, result.Success, response.Body.String())
	credential, err := model.GetPasskeyByUserID(user.Id)
	require.NoError(t, err)
	require.NotNil(t, credential.RPID)
	assert.Equal(t, "example.com", *credential.RPID)
	// A later registration uses the current primary, not the retained domain.
	wa, err := passkeysvc.BuildWebAuthn(httptest.NewRequest(http.MethodPost, "https://www.example.com/api/user/passkey/register/begin", nil))
	require.NoError(t, err)
	options, _, err := wa.BeginRegistration(passkeysvc.NewWebAuthnUser(user, credential))
	require.NoError(t, err)
	assert.Equal(t, "www.example.com", options.Response.RelyingParty.ID)
}

func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) {
	gin.SetMode(gin.TestMode)
	var output bytes.Buffer
	common.LogWriterMu.Lock()
	previousWriter := gin.DefaultErrorWriter
	gin.DefaultErrorWriter = &output
	common.LogWriterMu.Unlock()
	t.Cleanup(func() {
		common.LogWriterMu.Lock()
		gin.DefaultErrorWriter = previousWriter
		common.LogWriterMu.Unlock()
	})
	response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/login/finish", "", "", service.AuthIdentity{}, func(c *gin.Context) {
		c.Set("passkey_rp_id", "www.example.com")
		writeSecurityOperationError(c, protocol.ErrVerification.WithDetails("private-flow-token").WithInfo("private-challenge"))
	})
	assert.Contains(t, response.Body.String(), "SECURITY_VERIFICATION_FAILED")
	assert.Contains(t, output.String(), "verification_error")
	assert.Contains(t, output.String(), "www.example.com")
	for _, secret := range []string{"private-flow-token", "private-challenge"} {
		assert.NotContains(t, response.Body.String(), secret)
		assert.NotContains(t, output.String(), secret)
	}
}

// The web console translates message_key, so every request language gets the
// same English source text.
func TestPasskeyDomainErrorsReturnMessageKeys(t *testing.T) {
	user, _ := setupSecurityEnrollmentTest(t)
	setupPasskeyDomainOptions(t)
	system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
	require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error)
	const (
		invalid     = "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
		unavailable = "This Passkey domain is not available on this website. Use its original website or another verification method."
		removal     = "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
	)
	for _, language := range []string{"zh-CN", "zh-TW", "en"} {
		t.Run(language, func(t *testing.T) {
			for _, request := range []struct {
				path, body, code, message string
				handler                   gin.HandlerFunc
			}{
				{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
				{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
				{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", removal, UpdateOption},
				{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", unavailable, PasskeyLoginBegin},
			} {
				response := httptest.NewRecorder()
				c, _ := gin.CreateTestContext(response)
				method := http.MethodPost
				if request.path == "/api/option/" {
					method = http.MethodPut
				}
				c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body))
				c.Request.Header.Set("Accept-Language", language)
				c.Request.Header.Set("Origin", "https://example.com")
				c.Set("id", user.Id)
				c.Set("role", common.RoleRootUser)
				request.handler(c)
				var result securityEnrollmentResponse
				require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
				expectedStatus := http.StatusOK
				if request.code == "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED" {
					expectedStatus = http.StatusConflict
				}
				assert.Equal(t, expectedStatus, response.Code)
				assert.False(t, result.Success)
				assert.Equal(t, request.code, result.Code)
				assert.Equal(t, request.message, result.Message)
				assert.Equal(t, request.message, result.MessageKey)
			}
		})
	}
}

func TestPasskeyRPIDMigrationPreservesExistingCredentials(t *testing.T) {
	for _, upgrade := range []bool{false, true} {
		t.Run(fmt.Sprintf("upgrade=%t", upgrade), func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			key := newSecurityLoginPasskey(t, user.Id)
			before, err := model.GetPasskeyByUserID(user.Id)
			require.NoError(t, err)
			if upgrade {
				// This schema is copied from the latest released model at
				// v1.0.0-rc.36, with the same GORM and database driver versions.
				require.NoError(t, model.DB.Migrator().DropTable(&model.PasskeyCredential{}))
				require.NoError(t, model.DB.AutoMigrate(&passkeyCredentialBeforeRPID{}))
				encoded, err := common.Marshal(before)
				require.NoError(t, err)
				var legacy passkeyCredentialBeforeRPID
				require.NoError(t, common.Unmarshal(encoded, &legacy))
				require.NoError(t, model.DB.Create(&legacy).Error)
			}
			pool, err := model.DB.DB()
			require.NoError(t, err)
			for range 2 {
				// Each migration represents a new application startup. Discard
				// PostgreSQL's old SELECT * prepared plans along with its old pool.
				pool.SetMaxIdleConns(0)
				pool.SetMaxIdleConns(2)
				require.NoError(t, model.DB.AutoMigrate(&model.PasskeyCredential{}))
			}
			after, err := model.GetPasskeyByUserID(user.Id)
			require.NoError(t, err)
			assert.Equal(t, before, after)
			assert.True(t, model.DB.Migrator().HasIndex(&model.PasskeyCredential{}, "idx_passkey_credentials_deleted_at"))
			assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "another-key", PublicKey: "key"}).Error)
			assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 1, CredentialID: before.CredentialID, PublicKey: "key"}).Error)
			system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
			system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
			begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
			response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, "www.example.com", "https://www.example.com", user.Id, true)}, identity, "https://www.example.com", PasskeyLoginFinish)
			var result securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
			assert.True(t, result.Success, response.Body.String())
		})
	}
}

func (*passkeyTestBody) Close() error { return nil }

func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
	gin.SetMode(gin.TestMode)
	bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}`
	body := &passkeyTestBody{Reader: strings.NewReader(bodyText)}
	request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil)
	request.Body = body
	request.ContentLength = int64(len(bodyText))
	context, _ := gin.CreateTestContext(httptest.NewRecorder())
	context.Request = request

	parsed, err := parsePasskeyFinishRequest(context)
	require.NoError(t, err)
	assert.Equal(t, "flow-1", parsed.FlowToken)
	assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential))
	assert.Same(t, body, context.Request.Body)
	assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
}

func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	system_setting.GetPasskeySettings().UserVerification = "required"
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	require.NoError(t, err)
	payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister})
	require.NoError(t, err)
	token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
		Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID,
		Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute),
	})
	require.NoError(t, err)
	body, err := common.Marshal(passkeyFinishRequest{
		FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0),
	})
	require.NoError(t, err)
	response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
	var result securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
	assert.False(t, result.Success)
	assert.Equal(t, "AUTH_FLOW_INVALID", result.Code)
	_, err = model.GetPasskeyByUserID(identity.UserID)
	assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
	flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister})
	require.NoError(t, err)
	assert.Nil(t, flow.ConsumedAt)
}

type passkeyCredentialBeforeRPID struct {
	ID              int            `json:"id" gorm:"primaryKey"`
	UserID          int            `json:"user_id" gorm:"uniqueIndex;not null"`
	CredentialID    string         `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
	PublicKey       string         `json:"public_key" gorm:"type:text;not null"`                        // base64 encoded
	AttestationType string         `json:"attestation_type" gorm:"type:varchar(255)"`
	AAGUID          string         `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
	SignCount       uint32         `json:"sign_count" gorm:"default:0"`
	CloneWarning    bool           `json:"clone_warning"`
	UserPresent     bool           `json:"user_present"`
	UserVerified    bool           `json:"user_verified"`
	BackupEligible  bool           `json:"backup_eligible"`
	BackupState     bool           `json:"backup_state"`
	Transports      string         `json:"transports" gorm:"type:text"`
	Attachment      string         `json:"attachment" gorm:"type:varchar(32)"`
	LastUsedAt      *time.Time     `json:"last_used_at"`
	CreatedAt       time.Time      `json:"created_at"`
	UpdatedAt       time.Time      `json:"updated_at"`
	DeletedAt       gorm.DeletedAt `json:"-" gorm:"index"`
}

func (passkeyCredentialBeforeRPID) TableName() string { return "passkey_credentials" }