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" }