feat(auth): add safe multi-RP ID passkey support

Preserve legacy credentials across primary RP ID changes and bind successful
assertions to their verified RP ID. Share domain selection across sign-in,
login verification and sensitive operations without replaying ceremonies.

Save domain settings atomically, preview known and unknown credential impact,
and require fresh confirmation before removing domains still in use. Recheck
trusted RP IDs when persisting credentials and record safe operation audits.

Use read-only configuration snapshots, accept trusted HTTPS intranet hosts,
check registration origins early, and preserve historical capitalization.
Update settings guidance and all supported translations.

Validation: SQLite 3.50.4, MySQL 8.0.46 and PostgreSQL 16.15, including fresh
and released-schema upgrade checks; Go regression and race checks; 79 frontend
tests; TypeScript, lint, frontend build and root Go build.

Security references: OWASP ASVS 5.0.0 V6.3.4 and V6.3.8, Authentication and
Session Management Cheat Sheets, and the WebAuthn RP ID/origin rules.
This commit is contained in:
CaIon
2026-09-11 23:15:29 +08:00
parent 74629e29f8
commit 385d2dfd10
48 changed files with 3280 additions and 441 deletions
+34
View File
@@ -1,8 +1,10 @@
package controller
import (
"errors"
"fmt"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -43,6 +45,11 @@ var auditContentTemplates = map[string]string{
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}",
"option.passkey_domains_failed": "Passkey domain update failed",
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
"channel.delete": "Deleted channel ${name} (ID: ${id})",
@@ -65,6 +72,33 @@ var auditContentTemplates = map[string]string{
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
}
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0
params := map[string]any{"success": err == nil, "confirmed": confirmed}
if change != nil {
params["domains"] = strings.Join(change.RemovedRPIDs, ", ")
params["removed_rp_ids"] = change.RemovedRPIDs
params["known"] = change.AffectedCredentials
params["unknown"] = change.UnknownCredentials
params["previous_rp_id"] = change.PreviousRPID
params["effective_rp_id"] = change.EffectiveRPID
}
action := "option.passkey_domains"
if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) {
action = "option.passkey_domains_blocked"
} else if err != nil {
action = "option.passkey_domains_failed"
} else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 {
action = "option.passkey_domains_confirmed"
}
auditInfo := &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
Status: c.Writer.Status(), Success: err == nil,
}
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c)
markAuditLogged(c)
}
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]any) string {
tmpl, ok := auditContentTemplates[action]
+14 -4
View File
@@ -41,6 +41,7 @@ func VerifyLogin(c *gin.Context) {
func LoginPasskeyBegin(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
RPID string `json:"rp_id"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
common.ApiErrorMsg(c, "参数错误")
@@ -56,7 +57,11 @@ func LoginPasskeyBegin(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
credentialRPID := ""
if credential.RPID != nil {
credentialRPID = *credential.RPID
}
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID)
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -75,7 +80,7 @@ func LoginPasskeyBegin(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options})
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options, "rp_ids": rpIDs})
}
func LoginPasskeyFinish(c *gin.Context) {
@@ -104,6 +109,7 @@ func LoginPasskeyFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
@@ -113,17 +119,21 @@ func LoginPasskeyFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
writeSecurityOperationError(c, service.ErrVerificationFailed)
return
}
validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil {
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now(), sessionData.RelyingPartyID); err != nil {
writeSecurityOperationError(c, err)
return
}
+3 -2
View File
@@ -44,10 +44,10 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
passkeySetting := system_setting.PasskeySettingsSnapshot()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
legalSetting := system_setting.GetLegalSettings()
data := gin.H{
@@ -117,7 +117,8 @@ func GetStatus(c *gin.Context) {
"oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(),
"passkey_login": passkeySetting.Enabled,
"passkey_display_name": passkeySetting.RPDisplayName,
"passkey_rp_id": passkeySetting.RPID,
"passkey_rp_id": passkeySetting.EffectiveRPID(),
"passkey_rp_ids": passkeySetting.RelyingPartyIDs(),
"passkey_origins": passkeySetting.Origins,
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
"passkey_user_verification": passkeySetting.UserVerification,
+61 -1
View File
@@ -1,6 +1,7 @@
package controller
import (
"errors"
"fmt"
"net/http"
"slices"
@@ -135,6 +136,50 @@ type OptionUpdateRequest struct {
Value any `json:"value"`
}
func UpdatePasskeyDomains(c *gin.Context) {
var request struct {
RPID *string `json:"rp_id"`
LegacyRPIDs *string `json:"legacy_rp_ids"`
Origins *string `json:"origins"`
Preview bool `json:"preview"`
RemovalConfirmation string `json:"removal_confirmation"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
"passkey.rp_id": *request.RPID, "passkey.legacy_rp_ids": *request.LegacyRPIDs, "passkey.origins": *request.Origins,
}, request.Preview, request.RemovalConfirmation)
if err != nil {
writePasskeyDomainSettingsError(c, err)
if !request.Preview {
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", err)
}
return
}
if !request.Preview {
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", nil)
}
common.ApiSuccess(c, change)
}
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
var removal *model.PasskeyDomainRemovalError
if errors.As(err, &removal) {
c.JSON(http.StatusConflict, gin.H{
"success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
"message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change,
})
return
}
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
writeSecurityOperationError(c, err)
return
}
common.ApiError(c, err)
}
func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := common.DecodeJson(c.Request.Body, &option)
@@ -431,9 +476,24 @@ func UpdateOption(c *gin.Context) {
return
}
}
if model.IsPasskeyDomainOption(option.Key) {
change, updateErr := model.UpdatePasskeyDomainOptions(map[string]string{option.Key: option.Value.(string)}, false, "")
if updateErr != nil {
writePasskeyDomainSettingsError(c, updateErr)
recordPasskeyDomainAudit(c, change, false, updateErr)
return
}
recordPasskeyDomainAudit(c, change, false, nil)
common.ApiSuccess(c, change)
return
}
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
writeSecurityOperationError(c, err)
} else {
common.ApiError(c, err)
}
return
}
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
+53 -28
View File
@@ -26,6 +26,7 @@ type passkeyFinishRequest struct {
}
type passkeyVerifyBeginRequest struct {
RPID string `json:"rp_id"`
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
}
@@ -42,7 +43,7 @@ func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
}
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -118,7 +119,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
}
func PasskeyRegisterFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -142,12 +143,6 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
writeSecurityOperationError(c, err)
@@ -171,6 +166,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
@@ -193,6 +194,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
passkeyCredential.RPID = &sessionData.RelyingPartyID
if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil {
writeSecurityOperationError(c, err)
return
@@ -281,7 +283,7 @@ func PasskeyStatus(c *gin.Context) {
}
func PasskeyLoginBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -289,7 +291,16 @@ func PasskeyLoginBegin(c *gin.Context) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
var request struct {
RPID string `json:"rp_id"`
}
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if common.DecodeJson(c.Request.Body, &request) != nil {
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
return
}
}
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, "")
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -316,6 +327,7 @@ func PasskeyLoginBegin(c *gin.Context) {
"message": "",
"data": gin.H{
"options": assertion,
"rp_ids": rpIDs,
"flow_token": flowToken,
"expires_at": expiresAt,
},
@@ -323,7 +335,7 @@ func PasskeyLoginBegin(c *gin.Context) {
}
func PasskeyLoginFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -342,12 +354,6 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyLogin,
@@ -357,6 +363,12 @@ func PasskeyLoginFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
@@ -369,6 +381,10 @@ func PasskeyLoginFinish(c *gin.Context) {
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
}
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
return nil, service.ErrVerificationFailed
}
// 通过凭证获取用户
user := &model.User{Id: credential.UserID}
if err := user.FillUserById(); err != nil {
@@ -415,7 +431,7 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil {
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now(), sessionData.RelyingPartyID); err != nil {
writeSecurityOperationError(c, err)
return
}
@@ -474,7 +490,7 @@ func AdminResetPasskey(c *gin.Context) {
}
func PasskeyVerifyBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -516,7 +532,11 @@ func PasskeyVerifyBegin(c *gin.Context) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
credentialRPID := ""
if credential.RPID != nil {
credentialRPID = *credential.RPID
}
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID)
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -544,6 +564,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
"message": "",
"data": gin.H{
"options": assertion,
"rp_ids": rpIDs,
"flow_token": flowToken,
"expires_at": expiresAt,
},
@@ -551,7 +572,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
}
func PasskeyVerifyFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
if !system_setting.PasskeySettingsSnapshot().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
@@ -576,12 +597,6 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -605,11 +620,21 @@ func PasskeyVerifyFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
writeSecurityOperationError(c, service.ErrVerificationFailed)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
if err != nil {
@@ -617,7 +642,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil {
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now(), sessionData.RelyingPartyID); err != nil {
writeSecurityOperationError(c, err)
return
}
+716
View File
@@ -1,9 +1,15 @@
package controller
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"strings"
@@ -11,18 +17,705 @@ import (
"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 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")
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
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{}))
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)
}
}
func TestPasskeyDomainErrorsRespectRequestLanguage(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)
for _, locale := range []struct {
language, invalid, unavailable, removal string
}{
{"zh-CN", "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。", "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。", "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"},
{"zh-TW", "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。", "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。", "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"},
{"en", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.", "This Passkey domain is not available on this website. Use its original website or another verification method.", "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."},
} {
t.Run(locale.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", locale.invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", locale.removal, UpdateOption},
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", locale.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", locale.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)
}
})
}
}
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) {
@@ -70,3 +763,26 @@ func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing
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" }
+19
View File
@@ -3,12 +3,18 @@ package controller
import (
"errors"
"net/http"
"slices"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"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"
)
@@ -34,6 +40,10 @@ func writeSecurityOperationError(c *gin.Context, err error) {
var code, message string
var protocolError *protocol.Error
switch {
case errors.Is(err, passkeysvc.ErrRPIDUnavailable):
code, message = "PASSKEY_RP_ID_UNAVAILABLE", i18n.T(c, i18n.MsgPasskeyRPIDUnavailable)
case errors.Is(err, system_setting.ErrPasskeyRPIDInvalid):
code, message = "PASSKEY_RP_ID_INVALID", i18n.T(c, i18n.MsgPasskeyRPIDInvalid)
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
case errors.Is(err, model.ErrEmailAlreadyTaken):
@@ -112,6 +122,15 @@ func writeSecurityOperationError(c *gin.Context, err error) {
return
}
c.Set("security_error_code", code)
if strings.Contains(c.Request.URL.Path, "/passkey/") {
reason := "verification_failed"
if errors.As(err, &protocolError) && slices.Contains([]string{"invalid_request", "challenge_mismatch", "parse_error", "auth_data", "verification_error", "invalid_signature", "invalid_key_type", "unsupported_key_algorithm"}, protocolError.Type) {
reason = protocolError.Type
}
// Protocol details can contain challenges and client-controlled data.
// Only fixed categories and the server-selected public RP ID are logged.
logger.LogWarn(c.Request.Context(), "passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id"))
}
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
}
+1 -1
View File
@@ -64,7 +64,7 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database: %s %s", dialect, version)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}))
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
model.DB, model.LOG_DB = db, logDB
dbType := common.DatabaseTypeSQLite
+8 -5
View File
@@ -221,11 +221,14 @@ const (
// Passkey related messages
const (
MsgPasskeyCreateFailed = "passkey.create_failed"
MsgPasskeyLoginAbnormal = "passkey.login_abnormal"
MsgPasskeyUpdateFailed = "passkey.update_failed"
MsgPasskeyInvalidUserId = "passkey.invalid_user_id"
MsgPasskeyVerifyFailed = "passkey.verify_failed"
MsgPasskeyCreateFailed = "passkey.create_failed"
MsgPasskeyLoginAbnormal = "passkey.login_abnormal"
MsgPasskeyUpdateFailed = "passkey.update_failed"
MsgPasskeyInvalidUserId = "passkey.invalid_user_id"
MsgPasskeyVerifyFailed = "passkey.verify_failed"
MsgPasskeyRPIDInvalid = "passkey.rp_id_invalid"
MsgPasskeyRPIDUnavailable = "passkey.rp_id_unavailable"
MsgPasskeyRPIDRemovalConfirmation = "passkey.rp_id_removal_confirmation"
)
// 2FA related messages
+3
View File
@@ -195,6 +195,9 @@ passkey.login_abnormal: "Passkey login status is abnormal"
passkey.update_failed: "Passkey credential update failed"
passkey.invalid_user_id: "Invalid user ID"
passkey.verify_failed: "Passkey verification failed, please try again or contact administrator"
passkey.rp_id_invalid: "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
passkey.rp_id_unavailable: "This Passkey domain is not available on this website. Use its original website or another verification method."
passkey.rp_id_removal_confirmation: "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
# 2FA messages
twofa.not_enabled: "User has not enabled 2FA"
+3
View File
@@ -196,6 +196,9 @@ passkey.login_abnormal: "Passkey 登录状态异常"
passkey.update_failed: "Passkey 凭证更新失败"
passkey.invalid_user_id: "无效的用户 ID"
passkey.verify_failed: "Passkey 验证失败,请重试或联系管理员"
passkey.rp_id_invalid: "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。"
passkey.rp_id_unavailable: "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。"
passkey.rp_id_removal_confirmation: "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"
# 2FA messages
twofa.not_enabled: "用户未启用2FA"
+3
View File
@@ -196,6 +196,9 @@ passkey.login_abnormal: "Passkey 登錄狀態異常"
passkey.update_failed: "Passkey 憑證更新失敗"
passkey.invalid_user_id: "無效的使用者 ID"
passkey.verify_failed: "Passkey 驗證失敗,請重試或聯繫管理員"
passkey.rp_id_invalid: "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。"
passkey.rp_id_unavailable: "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。"
passkey.rp_id_removal_confirmation: "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"
# 2FA messages
twofa.not_enabled: "使用者未啟用2FA"
+18
View File
@@ -195,13 +195,21 @@ func InitOptionMap() {
}
func loadOptionsFromDatabase() {
passkeyOptionMutex.Lock()
defer passkeyOptionMutex.Unlock()
options, _ := AllOption()
passkeyOptions := make(map[string]string)
for _, option := range options {
if IsPasskeyDomainOption(option.Key) {
passkeyOptions[option.Key] = option.Value
continue
}
err := updateOptionMap(option.Key, option.Value)
if err != nil {
common.SysLog("failed to update option map: " + err.Error())
}
}
applyPasskeyDomainOptions(passkeyOptions)
}
func SyncOptions(frequency int) {
@@ -226,6 +234,10 @@ func validateOptionValue(key string, value string) error {
}
func UpdateOption(key string, value string) error {
if IsPasskeyDomainOption(key) {
_, err := UpdatePasskeyDomainOptions(map[string]string{key: value}, false, "")
return err
}
if IsModelPricingOption(key) {
return UpdateModelPricingOptions(map[string]string{key: value})
}
@@ -256,6 +268,12 @@ func UpdateOptionsBulk(values map[string]string) error {
if len(values) == 0 {
return nil
}
for key := range values {
if IsPasskeyDomainOption(key) {
_, err := UpdatePasskeyDomainOptions(values, false, "")
return err
}
}
for key, value := range values {
if err := validateOptionValue(key, value); err != nil {
return err
+52 -20
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@@ -22,6 +23,7 @@ var (
type PasskeyCredential struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
RPID *string `json:"rp_id,omitempty" gorm:"column:rp_id;type:varchar(253)"`
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)"`
@@ -161,29 +163,49 @@ func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
// UpdatePasskeyAssertionState persists only fields produced by a successful
// assertion. Registration identity (credential ID, public key, AAGUID,
// transports and attestation metadata) is immutable on this path.
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error {
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() {
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time, rpID string) error {
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() || rpID == "" {
return fmt.Errorf("Passkey 保存失败,请重试")
}
credentialID := base64.StdEncoding.EncodeToString(credential.ID)
result := DB.Model(&PasskeyCredential{}).
Where("user_id = ? AND credential_id = ?", userID, credentialID).
Updates(map[string]any{
"sign_count": credential.Authenticator.SignCount,
"clone_warning": credential.Authenticator.CloneWarning,
"user_present": credential.Flags.UserPresent,
"user_verified": credential.Flags.UserVerified,
"backup_eligible": credential.Flags.BackupEligible,
"backup_state": credential.Flags.BackupState,
"last_used_at": lastUsedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
passkeyOptionMutex.Lock()
defer passkeyOptionMutex.Unlock()
return DB.Transaction(func(tx *gorm.DB) error {
if err := validatePasskeyRPIDWithTx(tx, rpID); err != nil {
return err
}
var stored PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ? AND credential_id = ?", userID, credentialID).First(&stored).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPasskeyNotFound
}
return err
}
// Compare in Go: the database collation may fold historical case.
if stored.RPID != nil && *stored.RPID != "" && *stored.RPID != rpID {
return system_setting.ErrPasskeyRPIDUnavailable
}
result := tx.Model(&PasskeyCredential{}).
Where("user_id = ? AND credential_id = ?", userID, credentialID).
Where("rp_id IS NULL OR rp_id = ? OR rp_id = ?", "", rpID).
Updates(map[string]any{
"rp_id": rpID,
"sign_count": credential.Authenticator.SignCount,
"clone_warning": credential.Authenticator.CloneWarning,
"user_present": credential.Flags.UserPresent,
"user_verified": credential.Flags.UserVerified,
"backup_eligible": credential.Flags.BackupEligible,
"backup_state": credential.Flags.BackupState,
"last_used_at": lastUsedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
})
}
func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error {
@@ -212,7 +234,17 @@ func upsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential, ident
if credential == nil || credential.UserID <= 0 {
return fmt.Errorf("Passkey 保存失败,请重试")
}
passkeyOptionMutex.Lock()
defer passkeyOptionMutex.Unlock()
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil && (credential.RPID == nil || *credential.RPID == "") {
return system_setting.ErrPasskeyRPIDUnavailable
}
if credential.RPID != nil && *credential.RPID != "" {
if err := validatePasskeyRPIDWithTx(tx, *credential.RPID); err != nil {
return err
}
}
if identity != nil {
if identity.UserID != credential.UserID {
return ErrUserSessionInactive
+270
View File
@@ -0,0 +1,270 @@
package model
import (
"crypto/hmac"
"errors"
"maps"
"slices"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var passkeyOptionMutex sync.Mutex
var errPasskeyDomainPreview = errors.New("passkey domain preview")
var ErrPasskeyDomainRemovalConfirmation = errors.New("Review the affected Passkeys and confirm the domain removal.")
// PasskeyDomainChange is both the preview and the authoritative saved result.
// Unknown credentials may belong to a removed domain; they are not a known loss.
type PasskeyDomainChange struct {
RPID string `json:"rp_id"`
LegacyRPIDs string `json:"legacy_rp_ids"`
Origins string `json:"origins"`
PreviousRPID string `json:"previous_rp_id"`
EffectiveRPID string `json:"effective_rp_id"`
RemovedRPIDs []string `json:"removed_rp_ids"`
AffectedCredentials int64 `json:"affected_credentials"`
UnknownCredentials int64 `json:"unknown_credentials"`
ConfirmationRequired bool `json:"confirmation_required"`
RemovalConfirmation string `json:"removal_confirmation"`
}
type PasskeyDomainRemovalError struct {
Change *PasskeyDomainChange
}
func (e *PasskeyDomainRemovalError) Error() string {
return ErrPasskeyDomainRemovalConfirmation.Error()
}
func (e *PasskeyDomainRemovalError) Unwrap() error { return ErrPasskeyDomainRemovalConfirmation }
// ServerAddress and origins participate because either can change an implicit RP ID.
func IsPasskeyDomainOption(key string) bool {
return key == "passkey.rp_id" || key == "passkey.legacy_rp_ids" || key == "passkey.origins" || key == "ServerAddress"
}
// lockPasskeyDomainSettings serializes trust changes and credential writes across
// nodes. Callers also hold passkeyOptionMutex for SQLite and local cache ordering.
// Always acquire these option rows before any user/session/credential row locks.
func lockPasskeyDomainSettings(tx *gorm.DB) (system_setting.PasskeySettings, string, error) {
common.OptionMapRWMutex.RLock()
settings, serverAddress := *system_setting.GetPasskeySettings(), system_setting.ServerAddress
common.OptionMapRWMutex.RUnlock()
rows := []Option{
{Key: "ServerAddress", Value: serverAddress},
{Key: "passkey.legacy_rp_ids", Value: settings.LegacyRPIDs},
{Key: "passkey.origins", Value: settings.Origins},
{Key: "passkey.rp_id", Value: settings.RPID},
}
// The first write also acquires SQLite's writer lock before any reads.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil {
return settings, serverAddress, err
}
for i := range rows {
if err := lockForUpdate(tx).Where(&Option{Key: rows[i].Key}).First(&rows[i]).Error; err != nil {
return settings, serverAddress, err
}
}
serverAddress = rows[0].Value
settings.LegacyRPIDs, settings.Origins, settings.RPID = rows[1].Value, rows[2].Value, rows[3].Value
return settings, serverAddress, nil
}
func validatePasskeyRPIDWithTx(tx *gorm.DB, rpID string) error {
settings, serverAddress, err := lockPasskeyDomainSettings(tx)
if err != nil {
return err
}
// Preserve the existing request-derived default only when no domain or
// origin has ever been configured. Finish still checks the signed origin.
if settings.RPID == "" && settings.LegacyRPIDs == "" && settings.Origins == "" && serverAddress == "" {
return nil
}
if !slices.Contains(settings.WithDefaults(serverAddress).RelyingPartyIDs(), rpID) {
return system_setting.ErrPasskeyRPIDUnavailable
}
return nil
}
// UpdatePasskeyDomainOptions keeps every supplied option in one transaction,
// including unrelated keys from UpdateOptionsBulk. Preview rolls back even the
// initial default rows and never publishes a local configuration change.
func UpdatePasskeyDomainOptions(values map[string]string, preview bool, confirmation string) (*PasskeyDomainChange, error) {
passkeyOptionMutex.Lock()
defer passkeyOptionMutex.Unlock()
values = maps.Clone(values)
for key, value := range values {
if err := validateOptionValue(key, value); err != nil {
return nil, err
}
}
var change *PasskeyDomainChange
err := DB.Transaction(func(tx *gorm.DB) error {
settings, serverAddress, err := lockPasskeyDomainSettings(tx)
if err != nil {
return err
}
previous := settings.WithDefaults(serverAddress)
before := [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}
if value, changed := values["ServerAddress"]; changed {
serverAddress = value
}
if value, changed := values["passkey.origins"]; changed {
origins := []string{}
for _, origin := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) {
origin = strings.TrimSpace(origin)
if origin != "" && !slices.Contains(origins, origin) {
origins = append(origins, origin)
}
}
settings.Origins = strings.Join(origins, ",")
} else if before[0] != serverAddress && (settings.Origins == "" || settings.Origins == "[]") {
// Retain the previously trusted implicit website as well as the new
// default, so retaining its RP ID does not leave its keys unusable.
origins := []string{}
for _, origin := range []string{previous.Origins, settings.WithDefaults(serverAddress).Origins} {
if origin != "" && !slices.Contains(origins, origin) {
origins = append(origins, origin)
}
}
settings.Origins = strings.Join(origins, ",")
}
if value, changed := values["passkey.rp_id"]; changed {
settings.RPID = strings.TrimSpace(value)
if settings.RPID != "" {
settings.RPID, err = system_setting.NormalizePasskeyRPID(settings.RPID, settings.WithDefaults(serverAddress).Origins)
if err != nil {
return err
}
}
}
if value, changed := values["passkey.legacy_rp_ids"]; changed {
settings.LegacyRPIDs = value
}
effective := settings.WithDefaults(serverAddress)
legacy, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins)
if err != nil {
return err
}
oldRPID, nextRPID := previous.EffectiveRPID(), effective.EffectiveRPID()
_, previousDomainError := system_setting.NormalizePasskeyRPID(oldRPID, previous.Origins)
if previousDomainError == nil && oldRPID != nextRPID && !slices.Contains(legacy, oldRPID) {
legacy = append(legacy, oldRPID)
}
settings.LegacyRPIDs = strings.Join(legacy, ",")
// Validate retained single-label domains against the complete proposed
// origin set too. Do not normalize their historical hash inputs.
if _, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins); err != nil {
return err
}
// ServerAddress also supports non-WebAuthn deployments (for example an
// IP during initial setup). Restrict explicit RP IDs, not that URL.
if settings.RPID != "" {
if _, err := system_setting.NormalizePasskeyRPID(nextRPID, effective.Origins); err != nil {
return err
}
}
change = &PasskeyDomainChange{
RPID: settings.RPID, LegacyRPIDs: settings.LegacyRPIDs, Origins: settings.Origins,
PreviousRPID: oldRPID, EffectiveRPID: nextRPID, RemovedRPIDs: []string{},
}
nextIDs := settings.WithDefaults(serverAddress).RelyingPartyIDs()
for _, id := range previous.RelyingPartyIDs() {
if !slices.Contains(nextIDs, id) {
change.RemovedRPIDs = append(change.RemovedRPIDs, id)
}
}
if len(change.RemovedRPIDs) > 0 {
// MySQL may compare/group text case-insensitively. Select only
// candidate RP values, then count exact bytes without credential IDs.
rows, err := tx.Model(&PasskeyCredential{}).Select("rp_id").
Where("rp_id IS NULL OR rp_id = ? OR rp_id IN ?", "", change.RemovedRPIDs).Rows()
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var rpID *string
if err := rows.Scan(&rpID); err != nil {
return err
}
if rpID == nil || *rpID == "" {
change.UnknownCredentials++
} else if slices.Contains(change.RemovedRPIDs, *rpID) {
change.AffectedCredentials++
}
}
if err := rows.Err(); err != nil {
return err
}
if err := rows.Close(); err != nil {
return err
}
}
change.ConfirmationRequired = change.AffectedCredentials > 0 || change.UnknownCredentials > 0
// This is an intent/freshness binding, not an authentication credential.
// Root authorization remains mandatory. No map iteration affects its hash.
payload, err := common.Marshal(struct {
Before [4]string
After [4]string
Removed []string
Known, Unknown int64
}{before, [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}, change.RemovedRPIDs, change.AffectedCredentials, change.UnknownCredentials})
if err != nil {
return err
}
change.RemovalConfirmation = common.GenerateHMACWithKey([]byte("passkey-domains-v1:"+common.SessionSecret), string(payload))
if preview {
return errPasskeyDomainPreview
}
if (change.ConfirmationRequired || confirmation != "") && !hmac.Equal([]byte(confirmation), []byte(change.RemovalConfirmation)) {
return &PasskeyDomainRemovalError{Change: change}
}
values["passkey.rp_id"], values["passkey.legacy_rp_ids"], values["passkey.origins"] = settings.RPID, settings.LegacyRPIDs, settings.Origins
values["ServerAddress"] = serverAddress
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if err := tx.Save(&Option{Key: key, Value: values[key]}).Error; err != nil {
return err
}
}
return nil
})
if errors.Is(err, errPasskeyDomainPreview) {
return change, nil
}
if err != nil {
return change, err
}
applyPasskeyDomainOptions(values)
for key, value := range values {
if !IsPasskeyDomainOption(key) {
if err := updateOptionMap(key, value); err != nil {
return change, err
}
}
}
return change, nil
}
func applyPasskeyDomainOptions(values map[string]string) {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
if common.OptionMap == nil {
common.OptionMap = make(map[string]string)
}
for _, key := range []string{"ServerAddress", "passkey.legacy_rp_ids", "passkey.origins", "passkey.rp_id"} {
if value, ok := values[key]; ok {
common.OptionMap[key] = value
if key == "ServerAddress" {
system_setting.ServerAddress = value
} else {
handleConfigUpdate(key, value)
}
}
}
}
+19 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
@@ -260,6 +261,22 @@ func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) {
truncateTables(t)
require.NoError(t, DB.AutoMigrate(&Option{}))
previousSettings := *system_setting.GetPasskeySettings()
domainKeys := map[string]any{"key": []string{"ServerAddress", "passkey.rp_id", "passkey.legacy_rp_ids", "passkey.origins"}}
var previousOptions []Option
require.NoError(t, DB.Where(domainKeys).Find(&previousOptions).Error)
t.Cleanup(func() {
*system_setting.GetPasskeySettings() = previousSettings
require.NoError(t, DB.Where(domainKeys).Delete(&Option{}).Error)
if len(previousOptions) > 0 {
require.NoError(t, DB.Create(&previousOptions).Error)
}
})
*system_setting.GetPasskeySettings() = system_setting.PasskeySettings{RPID: "example.com", Origins: "https://example.com"}
for _, option := range []Option{{Key: "passkey.rp_id", Value: "example.com"}, {Key: "passkey.legacy_rp_ids", Value: ""}, {Key: "passkey.origins", Value: "https://example.com"}} {
require.NoError(t, DB.Save(&option).Error)
}
user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
@@ -292,7 +309,7 @@ func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing
CloneWarning: true,
},
}
require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt))
require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt, "example.com"))
var updated PasskeyCredential
require.NoError(t, DB.First(&updated, stored.ID).Error)
@@ -312,7 +329,7 @@ func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing
assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix())
validated.ID = []byte("another-credential")
assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound)
assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt, "example.com"), ErrPasskeyNotFound)
}
func assertUserAuthVersion(t *testing.T, userID int, expected int64) {
+1
View File
@@ -205,6 +205,7 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.PUT("/passkey/domains", controller.UpdatePasskeyDomains)
optionRoute.GET("/model_pricing", controller.GetModelPricingConfig)
optionRoute.PATCH("/model_pricing", controller.UpdateModelPricingConfig)
optionRoute.POST("/model_pricing/convert", controller.PreviewModelPricingConversion)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
enabled := model.AccountLoginMethods{
Password: common.PasswordLoginEnabled,
Passkey: system_setting.GetPasskeySettings().Enabled,
Passkey: system_setting.PasskeySettingsSnapshot().Enabled,
WeChat: common.WeChatAuthEnabled,
}
for _, provider := range oauth.GetAllProviders() {
+90 -6
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -16,27 +17,45 @@ import (
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
var ErrRPIDUnavailable = system_setting.ErrPasskeyRPIDUnavailable
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
settings := system_setting.GetPasskeySettings()
if settings == nil {
return nil, errors.New("未找到 Passkey 设置")
}
return BuildWebAuthnForRPID(r, "")
}
// BuildWebAuthnForRPID uses a single configured RP ID, never a list of IDs to
// try against the same signed response. An empty ID is for new registrations.
func BuildWebAuthnForRPID(r *http.Request, selectedRPID string) (*webauthn.WebAuthn, error) {
settings := system_setting.PasskeySettingsSnapshot()
displayName := strings.TrimSpace(settings.RPDisplayName)
if displayName == "" {
displayName = common.SystemName
}
origins, err := resolveOrigins(r, settings)
origins, err := resolveOrigins(r, &settings)
if err != nil {
return nil, err
}
rpID, err := resolveRPID(r, settings, origins)
rpID, err := resolveRPID(r, &settings, origins)
if err != nil {
return nil, err
}
if selectedRPID != "" && selectedRPID != rpID {
if !slices.Contains(settings.RelyingPartyIDs(), selectedRPID) {
return nil, ErrRPIDUnavailable
}
rpID = selectedRPID
}
origins = originsForRPID(origins, rpID)
if len(origins) == 0 {
return nil, ErrRPIDUnavailable
}
if origin := r.Header.Get("Origin"); origin != "" && !protocol.IsOriginInHaystack(origin, origins) {
return nil, ErrRPIDUnavailable
}
selection := protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
@@ -73,6 +92,71 @@ func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
return webauthn.New(config)
}
// BuildLoginWebAuthn gives a known credential's binding precedence over a
// browser's last-successful-domain hint. All choices remain server controlled.
func BuildLoginWebAuthn(r *http.Request, hint, credentialRPID string) (*webauthn.WebAuthn, []string, error) {
settings := system_setting.PasskeySettingsSnapshot()
origins, err := resolveOrigins(r, &settings)
if err != nil {
return nil, nil, err
}
primary, err := resolveRPID(r, &settings, origins)
if err != nil {
return nil, nil, err
}
configured := append([]string{primary}, settings.RelyingPartyIDs()...)
available := []string{}
for _, id := range configured {
if credentialRPID != "" && id != credentialRPID {
continue
}
allowedOrigins := originsForRPID(origins, id)
if len(allowedOrigins) == 0 || (r.Header.Get("Origin") != "" && !protocol.IsOriginInHaystack(r.Header.Get("Origin"), allowedOrigins)) {
continue
}
if !slices.Contains(available, id) {
available = append(available, id)
}
}
if len(available) == 0 {
return nil, nil, ErrRPIDUnavailable
}
selected := credentialRPID
if selected == "" {
selected = hint
}
if selected == "" {
selected = available[0]
}
if !slices.Contains(available, selected) {
return nil, nil, ErrRPIDUnavailable
}
wa, err := BuildWebAuthnForRPID(r, selected)
return wa, available, err
}
func originsForRPID(origins []string, rpID string) []string {
allowed := []string{}
for _, origin := range origins {
parsed, err := url.Parse(origin)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.User != nil {
continue
}
host := strings.ToLower(parsed.Hostname())
domain := strings.ToLower(rpID)
if !strings.Contains(domain, ".") && domain != "localhost" {
if host == domain && parsed.Scheme == "https" {
allowed = append(allowed, origin)
}
continue
}
if host == domain || strings.HasSuffix(host, "."+domain) {
allowed = append(allowed, origin)
}
}
return allowed
}
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
originsStr := strings.TrimSpace(settings.Origins)
if originsStr != "" {
+6
View File
@@ -72,6 +72,12 @@ func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentit
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if payload.SessionData.RelyingPartyID == "" || payload.SessionData.Expires.IsZero() {
return model.ErrAuthFlowInvalid
}
if !time.Now().Before(payload.SessionData.Expires) {
return model.ErrAuthFlowExpired
}
if purpose == model.AuthFlowPurposePasskeyLogin {
return nil
}
+1 -1
View File
@@ -212,7 +212,7 @@ func securityVerificationPolicy(scope string, state model.UserVerificationState)
if method == VerificationMethodTwoFA && state.TwoFALocked {
option.Available, option.Reason = false, ErrVerificationLocked.Error()
}
if !system_setting.GetPasskeySettings().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
if !system_setting.PasskeySettingsSnapshot().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
option.Available, option.Reason = false, "Passkey authentication is disabled."
}
options = append(options, option)
+132 -13
View File
@@ -1,23 +1,138 @@
package system_setting
import (
"errors"
"net"
"net/url"
"slices"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
type PasskeySettings struct {
Enabled bool `json:"enabled"`
RPDisplayName string `json:"rp_display_name"`
RPID string `json:"rp_id"`
LegacyRPIDs string `json:"legacy_rp_ids"`
Origins string `json:"origins"`
AllowInsecureOrigin bool `json:"allow_insecure_origin"`
UserVerification string `json:"user_verification"`
AttachmentPreference string `json:"attachment_preference"`
}
var ErrPasskeyRPIDInvalid = errors.New("Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.")
var ErrPasskeyRPIDUnavailable = errors.New("This Passkey domain is not available on this website. Use its original website or another verification method.")
// PasskeySettingsSnapshot includes the effective defaults without sharing a
// mutable settings pointer with a WebAuthn ceremony.
func PasskeySettingsSnapshot() PasskeySettings {
common.OptionMapRWMutex.RLock()
settings, serverAddress := defaultPasskeySettings, ServerAddress
common.OptionMapRWMutex.RUnlock()
return settings.WithDefaults(serverAddress)
}
// EffectiveRPID preserves the existing default and port handling for settings
// created before explicit Passkey domains were introduced.
func (s PasskeySettings) EffectiveRPID() string {
rpID := strings.TrimSpace(s.RPID)
if rpID == "" {
for origin := range strings.SplitSeq(s.Origins, ",") {
if parsed, err := url.Parse(strings.TrimSpace(origin)); err == nil && parsed.Host != "" {
rpID = parsed.Host
break
}
}
}
if host, _, err := net.SplitHostPort(rpID); err == nil {
return host
}
return rpID
}
func NormalizePasskeyRPID(value string, configuredOrigins ...string) (string, error) {
rpID, err := idna.Lookup.ToASCII(strings.TrimSpace(value))
rpID = strings.ToLower(rpID)
if err != nil || rpID == "" || len(rpID) > 253 || strings.ContainsAny(rpID, ":/*@?#\\") || net.ParseIP(rpID) != nil {
return "", ErrPasskeyRPIDInvalid
}
for label := range strings.SplitSeq(rpID, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", ErrPasskeyRPIDInvalid
}
for _, c := range label {
if c != '-' && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
return "", ErrPasskeyRPIDInvalid
}
}
}
if rpID == "localhost" {
return rpID, nil
}
if _, err := publicsuffix.EffectiveTLDPlusOne(rpID); err == nil {
return rpID, nil
}
// An unknown single-label hostname is usable only at its exact, explicitly
// trusted HTTPS origin, never as a suffix of other hosts.
_, icann := publicsuffix.PublicSuffix(rpID)
if icann || strings.Contains(rpID, ".") {
return "", ErrPasskeyRPIDInvalid
}
for _, origins := range configuredOrigins {
for origin := range strings.SplitSeq(origins, ",") {
parsed, err := url.Parse(strings.TrimSpace(origin))
if err != nil || parsed.Scheme != "https" || parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
continue
}
if strings.EqualFold(parsed.Hostname(), rpID) {
return rpID, nil
}
}
}
return "", ErrPasskeyRPIDInvalid
}
func ParsePasskeyRPIDs(value string, configuredOrigins ...string) ([]string, error) {
ids := []string{}
for _, value := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) {
if strings.TrimSpace(value) == "" {
continue
}
_, err := NormalizePasskeyRPID(value, configuredOrigins...)
if err != nil {
return nil, err
}
// Legacy credentials hash the exact RP ID supplied at registration.
// Validate the domain without changing that historical value.
id := strings.TrimSpace(value)
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
return ids, nil
}
func (s PasskeySettings) RelyingPartyIDs() []string {
ids := []string{}
if primary := s.EffectiveRPID(); primary != "" {
ids = append(ids, primary)
}
legacy, err := ParsePasskeyRPIDs(s.LegacyRPIDs, s.Origins)
if err != nil {
return ids
}
for _, id := range legacy {
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
return ids
}
var defaultPasskeySettings = PasskeySettings{
Enabled: false,
RPDisplayName: common.SystemName,
@@ -33,18 +148,22 @@ func init() {
}
func GetPasskeySettings() *PasskeySettings {
if defaultPasskeySettings.RPID == "" && ServerAddress != "" {
// 从ServerAddress提取域名作为RPID
// ServerAddress可能是 "https://newapi.pro" 这种格式
serverAddr := strings.TrimSpace(ServerAddress)
if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
defaultPasskeySettings.RPID = parsed.Host
} else {
defaultPasskeySettings.RPID = serverAddr
}
}
if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" {
defaultPasskeySettings.Origins = ServerAddress
}
return &defaultPasskeySettings
}
func (s PasskeySettings) WithDefaults(serverAddress string) PasskeySettings {
if s.RPID == "" && serverAddress != "" {
// 从ServerAddress提取域名作为RPID
// ServerAddress可能是 "https://newapi.pro" 这种格式
serverAddr := strings.TrimSpace(serverAddress)
if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
s.RPID = parsed.Host
} else {
s.RPID = serverAddr
}
}
if s.Origins == "" || s.Origins == "[]" {
s.Origins = serverAddress
}
return s
}
@@ -0,0 +1,419 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// @vitest-environment-options {"url":"https://www.example.com"}
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { verify, verifyLogin } from '../../secure-verification/api'
import { SecureVerificationDialog } from '../../secure-verification/components/secure-verification-dialog'
import { useSecureVerification } from '../../secure-verification/hooks/use-secure-verification'
import type { SecurityProof } from '../../secure-verification/types'
import { PasskeyDomainSelector } from '../components/passkey-domain-selector'
const storageKey = 'passkey:last-successful-rp-id'
const operation = {
scope: 'channel.key.read',
context: { channel_id: 7 },
} as const
const proof: SecurityProof = {
proof_token: 'verified-proof',
scope: operation.scope,
method: 'passkey',
expires_at: 4102444800,
}
const credential = {
id: 'credential',
rawId: new Uint8Array([1, 2, 3]).buffer,
type: 'public-key',
response: {
clientDataJSON: new Uint8Array([1]).buffer,
authenticatorData: new Uint8Array([2]).buffer,
signature: new Uint8Array([3]).buffer,
userHandle: new Uint8Array([4]).buffer,
},
getClientExtensionResults: () => ({}),
}
function setupPasskeyNetwork() {
const get = vi.fn().mockResolvedValue(credential)
vi.stubGlobal('PublicKeyCredential', class {})
vi.stubGlobal('navigator', { credentials: { get } })
vi.spyOn(api, 'get').mockImplementation(async (url) => ({
data: {
success: true,
data:
url === '/api/status'
? {
passkey_rp_ids: ['example.com', 'www.example.com'],
passkey_origins: 'https://example.com,https://www.example.com',
}
: {
scope: operation.scope,
methods: [{ method: 'passkey', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
}))
let flow = 0
const post = vi
.spyOn(api, 'post')
.mockImplementation(async (url, data?: unknown) => {
const request = data as { rp_id?: string } | undefined
if (url.endsWith('/begin')) {
return {
data: {
success: true,
data: {
flow_token: `flow-${++flow}`,
rp_ids: ['example.com', 'www.example.com'],
options: {
publicKey: {
challenge: 'AQID',
rpId: request?.rp_id ?? 'example.com',
},
},
},
},
}
}
return { data: { success: true, data: proof } }
})
return { get, post }
}
function DomainVerificationHarness(props: {
onResult: (proof: SecurityProof | null) => void
}) {
const verification = useSecureVerification()
return (
<>
<button
type='button'
onClick={async () =>
props.onResult(await verification.requestVerification(operation))
}
>
Protected action
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
)
}
beforeEach(() => localStorage.clear())
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
localStorage.clear()
})
describe('Passkey domain compatibility', () => {
it('binds a login factor to its pending login and the selected domain challenge', async () => {
const { get, post } = setupPasskeyNetwork()
const bundle: AuthBundle = {
access_token: 'login-access',
token_type: 'Bearer',
access_expires_at: 4102444800,
user: { id: 7, username: 'user', role: 1 },
session: {
sid: 'login-session',
current: true,
login_method: 'passkey',
ip: '',
user_agent: '',
created_at: 1,
last_active_at: 1,
expires_at: 4102444800,
},
}
post
.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'domain-flow',
rp_ids: ['www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
.mockResolvedValueOnce({ data: { success: true, data: bundle } })
await expect(
verifyLogin(
{ method: 'passkey', rpID: 'www.example.com' },
{
require_verification: true,
flow_token: 'pending-login',
expires_at: 4102444800,
methods: [{ method: 'passkey', available: true }],
},
new AbortController().signal
)
).resolves.toEqual(bundle)
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(post).toHaveBeenNthCalledWith(
1,
'/api/user/login/passkey/begin',
{
flow_token: 'pending-login',
rp_id: 'www.example.com',
},
expect.objectContaining({ skipAuthRefresh: true })
)
expect(post).toHaveBeenLastCalledWith(
'/api/user/login/passkey/finish',
expect.objectContaining({
flow_token: 'pending-login',
passkey_flow_token: 'domain-flow',
}),
expect.anything()
)
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
})
it('uses the last successful domain without storing flows or credentials', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'www.example.com')
await expect(
verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(post).toHaveBeenNthCalledWith(
1,
'/api/user/passkey/verify/begin',
{ ...operation, rp_id: 'www.example.com' },
expect.anything()
)
expect(get).toHaveBeenCalledOnce()
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
expect(localStorage.length).toBe(1)
})
it('accepts the credential domain chosen by the server over a remembered hint', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'example.com')
post.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'known-flow',
rp_ids: ['www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
await verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
expect(get.mock.calls[0][0].publicKey.rpId).toBe('www.example.com')
expect(localStorage.getItem(storageKey)).toBe('www.example.com')
})
it('discards a removed remembered domain before opening a single browser prompt', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'removed.example.com')
post.mockResolvedValueOnce({
data: { success: false, code: 'PASSKEY_RP_ID_UNAVAILABLE' },
})
await verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
expect(post).toHaveBeenNthCalledWith(
2,
'/api/user/passkey/verify/begin',
operation,
expect.anything()
)
expect(get).toHaveBeenCalledOnce()
expect(localStorage.getItem(storageKey)).toBe('example.com')
})
it('does not remember or replay a failed finish request', async () => {
const { get, post } = setupPasskeyNetwork()
localStorage.setItem(storageKey, 'example.com')
post
.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'flow',
rp_ids: ['example.com', 'www.example.com'],
options: {
publicKey: { challenge: 'AQID', rpId: 'www.example.com' },
},
},
},
})
.mockResolvedValueOnce({
data: { success: false, code: 'SECURITY_VERIFICATION_FAILED' },
})
await expect(
verify(
{ method: 'passkey', rpID: 'www.example.com' },
operation,
false,
new AbortController().signal
)
).rejects.toThrow()
expect(get).toHaveBeenCalledOnce()
expect(post).toHaveBeenCalledTimes(2)
expect(localStorage.getItem(storageKey)).toBe('example.com')
})
it('allows a manual domain switch after cancellation with a fresh challenge', async () => {
const { get, post } = setupPasskeyNetwork()
get.mockRejectedValueOnce(
new DOMException('No matching credential', 'NotAllowedError')
)
const onResult = vi.fn()
const user = userEvent.setup()
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<DomainVerificationHarness onResult={onResult} />
</QueryClientProvider>
)
await user.click(screen.getByRole('button', { name: 'Protected action' }))
await user.click(await screen.findByRole('button', { name: 'Verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Passkey verification was cancelled or timed out'
)
expect(get).toHaveBeenCalledOnce()
expect(post).toHaveBeenCalledTimes(1)
expect(onResult).not.toHaveBeenCalled()
await user.click(
screen.getByRole('button', { name: 'Use a Passkey from another website' })
)
await user.click(
screen.getByRole('combobox', { name: 'Passkey website domain' })
)
await user.click(
await screen.findByRole('option', { name: 'www.example.com' })
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
await waitFor(() => expect(onResult).toHaveBeenCalledWith(proof))
expect(get).toHaveBeenCalledTimes(2)
expect(post).toHaveBeenNthCalledWith(
2,
'/api/user/passkey/verify/begin',
{ ...operation, rp_id: 'www.example.com' },
expect.anything()
)
expect(post).toHaveBeenLastCalledWith(
'/api/user/passkey/verify/finish',
expect.objectContaining({ flow_token: 'flow-2' }),
expect.anything()
)
})
it('supports verification when browser storage is blocked', async () => {
const { get } = setupPasskeyNetwork()
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Blocked')
})
await expect(
verify(
{ method: 'passkey' },
operation,
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(get).toHaveBeenCalledOnce()
})
it('does not submit an assertion after its operation is aborted', async () => {
const { get, post } = setupPasskeyNetwork()
const controller = new AbortController()
get.mockImplementation(async () => {
controller.abort()
return credential
})
await expect(
verify({ method: 'passkey' }, operation, false, controller.signal)
).rejects.toThrow()
expect(post).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(storageKey)).toBeNull()
})
})
it('keeps case-sensitive historical domains selectable and labels the distinction', async () => {
setupPasskeyNetwork()
vi.mocked(api.get).mockResolvedValue({
data: {
success: true,
data: {
passkey_rp_ids: ['www.example.com', 'WWW.example.com'],
passkey_origins: 'https://www.example.com',
},
},
})
const changed = vi.fn()
const user = userEvent.setup()
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<PasskeyDomainSelector value='www.example.com' onChange={changed} />
</QueryClientProvider>
)
await user.click(
await screen.findByRole('button', {
name: 'Use a Passkey from another website',
})
)
await user.click(screen.getByRole('combobox'))
expect(
await screen.findByRole('option', { name: 'www.example.com' })
).toBeVisible()
await user.click(
screen.getByRole('option', {
name: /WWW.example.com.*Historical capitalization/,
})
)
expect(changed).toHaveBeenCalledExactlyOnceWith('WWW.example.com')
})
+16 -9
View File
@@ -80,36 +80,43 @@ export function deletePasskey(
)
}
export async function beginPasskeyLogin(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin'
export function beginPasskeyLogin(
rpID?: string,
signal?: AbortSignal
): Promise<PasskeyOptionsPayload> {
return authResult(
api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin',
rpID ? { rp_id: rpID } : undefined,
{ ...authRequestOptions, signal, skipAuthRefresh: true }
)
)
return res.data
}
export async function finishPasskeyLogin(
flowToken: string,
payload: Record<string, unknown>
payload: Record<string, unknown>,
signal?: AbortSignal
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/login/finish',
{ flow_token: flowToken, credential: payload },
{ skipAuthRefresh: true }
{ skipAuthRefresh: true, signal }
)
return res.data
}
export function beginPasskeyVerification(
operation: VerificationOperation,
signal?: AbortSignal
signal?: AbortSignal,
rpID?: string
): Promise<PasskeyOptionsPayload> {
return authResult(
api.post(
'/api/user/passkey/verify/begin',
{
scope: operation.scope,
...(rpID ? { rp_id: rpID } : {}),
...(operation.context ? { context: operation.context } : {}),
},
{ ...authRequestOptions, signal }
+120
View File
@@ -0,0 +1,120 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
buildAssertionResult,
prepareCredentialRequestOptions,
} from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import type { PasskeyOptionsPayload } from './types'
const rememberedRPIDKey = 'passkey:last-successful-rp-id'
export interface PasskeyDomains {
rpID?: string
rpIDs: string[]
}
export interface PasskeySelection {
rpID?: string
onDomains?: (domains: PasskeyDomains) => void
}
export function rememberPasskeyRPID(rpID?: string) {
if (!rpID) return
try {
localStorage.setItem(rememberedRPIDKey, rpID)
} catch {
// Storage is optional; it never authorizes a credential or a domain.
}
}
export async function requestPasskeyAssertion(
begin: (rpID?: string) => Promise<PasskeyOptionsPayload>,
signal?: AbortSignal,
selection: PasskeySelection = {}
): Promise<{
flowToken: string
assertion: Record<string, unknown>
rpID?: string
}> {
signal?.throwIfAborted()
let remembered: string | undefined
try {
const value = localStorage.getItem(rememberedRPIDKey)
if (value && value.length <= 253) remembered = value
} catch {
// Private browsing and blocked storage still support Passkey verification.
}
let payload: PasskeyOptionsPayload
try {
payload = await begin(selection.rpID ?? remembered)
} catch (error) {
const failure = AuthOperationError.from(error)
if (
selection.rpID ||
!remembered ||
failure.code !== 'PASSKEY_RP_ID_UNAVAILABLE'
) {
throw failure
}
signal?.throwIfAborted()
try {
localStorage.removeItem(rememberedRPIDKey)
} catch {
/* optional storage */
}
// Retry a stale hint before opening a browser prompt. A dismissed prompt
// or a failed finish request is never retried automatically.
payload = await begin()
}
signal?.throwIfAborted()
if (!payload.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const publicKey = prepareCredentialRequestOptions(payload.options ?? payload)
let rpIDs = publicKey.rpId ? [publicKey.rpId] : []
if (Array.isArray(payload.rp_ids)) {
rpIDs = payload.rp_ids.filter((id): id is string => typeof id === 'string')
}
selection.onDomains?.({ rpID: publicKey.rpId, rpIDs })
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey,
signal,
})) as PublicKeyCredential | null
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new AuthOperationError(
'Passkey verification was cancelled or timed out'
)
}
throw error
}
signal?.throwIfAborted()
if (!credential) {
throw new AuthOperationError('Passkey verification was cancelled')
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new AuthOperationError('Unable to build Passkey assertion')
}
return { flowToken: payload.flow_token, assertion, rpID: publicKey.rpId }
}
@@ -0,0 +1,172 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { statusQueryOptions } from '@/lib/status-query'
import type { PasskeyDomains } from '../assertion'
interface PasskeyDomainSelectorProps {
domains?: PasskeyDomains | null
value?: string
onChange: (rpID: string) => void
disabled?: boolean
}
export function PasskeyDomainSelector(props: PasskeyDomainSelectorProps) {
const { t } = useTranslation()
const id = useId()
const [expanded, setExpanded] = useState(false)
const { data: status } = useQuery(statusQueryOptions)
const configured = Array.isArray(status?.passkey_rp_ids)
? status.passkey_rp_ids.filter(
(value): value is string => typeof value === 'string'
)
: []
const candidates = props.domains?.rpIDs ?? configured
const available = candidates.filter((rpID) => {
const domain = rpID.toLowerCase()
return (
window.location.hostname === domain ||
window.location.hostname.endsWith(`.${domain}`)
)
})
const otherWebsites: string[] = []
if (typeof status?.passkey_origins === 'string') {
for (const origin of status.passkey_origins.split(',')) {
try {
const url = new URL(origin.trim())
if (
url.protocol !== 'https:' &&
!(url.protocol === 'http:' && url.hostname === 'localhost')
) {
continue
}
if (
url.username ||
url.password ||
url.origin === window.location.origin
) {
continue
}
if (
!configured.some(
(rpID) =>
url.hostname === rpID.toLowerCase() ||
url.hostname.endsWith(`.${rpID.toLowerCase()}`)
)
) {
continue
}
if (!otherWebsites.includes(url.origin)) otherWebsites.push(url.origin)
} catch {
/* Ignore malformed status entries. */
}
}
}
if (available.length < 2 && otherWebsites.length === 0) return null
const value = props.value ?? props.domains?.rpID ?? available[0]
return (
<div className='space-y-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
disabled={props.disabled}
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={id}
>
{t('Use a Passkey from another website')}
</Button>
{expanded && (
<div id={id} className='space-y-2'>
{available.length > 1 && (
<>
<Label htmlFor={`${id}-domain`}>
{t('Passkey website domain')}
</Label>
<Select
value={available.includes(value ?? '') ? value : available[0]}
onValueChange={(value) => {
if (value) props.onChange(value)
}}
disabled={props.disabled}
>
<SelectTrigger id={`${id}-domain`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{available.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
{domain !== domain.toLowerCase() &&
available.some(
(other) =>
other !== domain &&
other.toLowerCase() === domain.toLowerCase()
) && <> · {t('Historical capitalization')}</>}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<p className='text-muted-foreground'>
{t(
'Choose the domain used when this Passkey was created, then try again.'
)}
</p>
{otherWebsites.length > 0 && (
<div className='space-y-1'>
<p>
{t(
'If your Passkey belongs to another website, start sign-in there:'
)}
</p>
{otherWebsites.map((origin) => (
<Button
key={origin}
variant='link'
size='sm'
className='h-auto p-0 break-all'
render={<a href={`${origin}/sign-in`} />}
>
{origin}
</Button>
))}
</div>
)}
</div>
)}
</div>
)
}
+1
View File
@@ -31,6 +31,7 @@ export interface PasskeyStatus {
}
export interface PasskeyOptionsPayload {
rp_ids?: string[]
options?: unknown
flow_token?: string
expires_at?: number
@@ -16,8 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
@@ -43,8 +45,11 @@ function Harness(props: {
operation?: RequestVerificationOptions
}) {
const verification = useSecureVerification()
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } })
)
return (
<>
<QueryClientProvider client={client}>
<button
type='button'
onClick={async () =>
@@ -60,7 +65,7 @@ function Harness(props: {
Protected action
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
</QueryClientProvider>
)
}
@@ -81,8 +86,11 @@ function LoginHarness(props: {
onResult: (bundle: AuthBundle | null) => void
}) {
const verification = useSecureVerification()
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } })
)
return (
<>
<QueryClientProvider client={client}>
<button
type='button'
onClick={async () =>
@@ -102,13 +110,15 @@ function LoginHarness(props: {
Continue sign-in
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
</QueryClientProvider>
)
}
it('lets a pending login switch from Passkey to 2FA without using authenticated verification endpoints', async () => {
const get = vi
.spyOn(api, 'get')
.mockResolvedValue({ data: { success: true, data: {} } })
vi.stubGlobal('PublicKeyCredential', class {})
const get = vi.spyOn(api, 'get')
const bundle: AuthBundle = {
access_token: 'verified-login',
token_type: 'Bearer',
@@ -152,7 +162,7 @@ it('lets a pending login switch from Passkey to 2FA without using authenticated
signal: expect.any(AbortSignal),
})
)
expect(get).not.toHaveBeenCalled()
expect(get.mock.calls.every(([url]) => url === '/api/status')).toBe(true)
})
it.each(['success', 'cancel', 'retry'] as const)(
@@ -18,11 +18,7 @@ For commercial licensing, please contact [email protected]
*/
import { api, isAuthBundle } from '@/lib/api'
import { buildOAuthAuthorizationUrl } from '@/lib/oauth'
import {
buildAssertionResult,
isPasskeySupported,
prepareCredentialRequestOptions,
} from '@/lib/passkey'
import { isPasskeySupported } from '@/lib/passkey'
import {
AuthOperationError,
authRequestOptions,
@@ -37,6 +33,12 @@ import {
beginPasskeyVerification,
finishPasskeyVerification,
} from '../passkey/api'
import {
requestPasskeyAssertion,
rememberPasskeyRPID,
type PasskeyDomains,
} from '../passkey/assertion'
import type { PasskeyOptionsPayload } from '../passkey/types'
import type { SystemStatus } from '../types'
import type {
SecurityProof,
@@ -134,7 +136,8 @@ export async function getLoginVerificationRequirements(
export async function verifyLogin(
input: VerificationInput,
challenge: LoginChallenge,
signal: AbortSignal
signal: AbortSignal,
onDomains?: (domains: PasskeyDomains) => void
): Promise<AuthBundle> {
if (challenge.expires_at * 1000 <= Date.now()) {
throw new AuthOperationError(
@@ -157,28 +160,35 @@ export async function verifyLogin(
)
)
} else if (input.method === 'passkey') {
const begin = await authResult<{ flow_token: string; options: unknown }>(
api.post(
'/api/user/login/passkey/begin',
{ flow_token: challenge.flow_token },
options
)
const passkey = await requestPasskeyAssertion(
(rpID) =>
authResult<PasskeyOptionsPayload>(
api.post(
'/api/user/login/passkey/begin',
{
flow_token: challenge.flow_token,
...(rpID ? { rp_id: rpID } : {}),
},
options
)
),
signal,
{ rpID: input.rpID, onDomains }
)
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const assertion = await requestPasskeyAssertion(begin.options, signal)
result = await authResult(
api.post(
'/api/user/login/passkey/finish',
{
flow_token: challenge.flow_token,
passkey_flow_token: begin.flow_token,
credential: assertion,
passkey_flow_token: passkey.flowToken,
credential: passkey.assertion,
},
options
)
)
signal.throwIfAborted()
if (!isAuthBundle(result)) throw new AuthOperationError('Login failed')
rememberPasskeyRPID(passkey.rpID)
} else {
throw new AuthOperationError(
'This verification method is not allowed for this action.'
@@ -193,7 +203,8 @@ export async function verify(
input: VerificationInput,
operation: VerificationOperation,
passwordEncryptionEnabled: boolean,
signal: AbortSignal
signal: AbortSignal,
onDomains?: (domains: PasskeyDomains) => void
): Promise<SecurityProof> {
try {
const operationFields = {
@@ -242,7 +253,7 @@ export async function verify(
break
}
case 'passkey':
proof = await verifyPasskey(operation, signal)
proof = await verifyPasskey(operation, signal, input.rpID, onDomains)
break
case 'oauth':
proof = await verifyOAuth(input.provider, operation, signal)
@@ -265,50 +276,30 @@ export async function verify(
async function verifyPasskey(
operation: VerificationOperation,
signal: AbortSignal
signal: AbortSignal,
rpID?: string,
onDomains?: (domains: PasskeyDomains) => void
): Promise<SecurityProof> {
const begin = await beginPasskeyVerification(operation, signal)
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const assertion = await requestPasskeyAssertion(
begin.options ?? begin,
const passkey = await requestPasskeyAssertion(
(selected) => beginPasskeyVerification(operation, signal, selected),
signal,
{ rpID, onDomains }
)
const proof = await finishPasskeyVerification(
passkey.flowToken,
passkey.assertion,
signal
)
return finishPasskeyVerification(begin.flow_token, assertion, signal)
}
async function requestPasskeyAssertion(
options: unknown,
signal: AbortSignal
): Promise<Record<string, unknown>> {
const publicKey = prepareCredentialRequestOptions(options)
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey,
signal,
})) as PublicKeyCredential | null
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new AuthOperationError(
'Passkey verification was cancelled or timed out'
)
}
throw error
}
signal.throwIfAborted()
if (!credential) {
throw new AuthOperationError(
'Passkey verification was cancelled',
'AUTH_CANCELLED'
)
if (
proof.proof_token &&
proof.method === 'passkey' &&
proof.scope === operation.scope &&
proof.expires_at * 1000 > Date.now()
) {
rememberPasskeyRPID(passkey.rpID)
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new AuthOperationError('Unable to build Passkey assertion')
}
return assertion
return proof
}
async function verifyOAuth(
@@ -26,6 +26,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { PasskeyDomains } from '../../passkey/assertion'
import { PasskeyDomainSelector } from '../../passkey/components/passkey-domain-selector'
import type {
SecureVerificationState,
VerificationInput,
@@ -34,6 +36,7 @@ import type {
interface SecureVerificationDialogProps {
state: SecureVerificationState
passkeyDomains?: PasskeyDomains | null
onVerify: () => void | Promise<void>
onCancel: () => void
onRetry: () => void
@@ -223,7 +226,15 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
: t('Enter the 6-digit authenticator code.')}
</p>
</TabsContent>
<TabsContent value='passkey'>
<TabsContent value='passkey' className='space-y-3'>
<PasskeyDomainSelector
domains={props.passkeyDomains}
value={input.method === 'passkey' ? input.rpID : undefined}
onChange={(rpID) =>
props.onInputChange({ method: 'passkey', rpID })
}
disabled={verifying}
/>
<p className='text-muted-foreground flex items-center gap-2 text-sm'>
<KeyRound className='size-5' />
{t(
@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useReducer, useRef } from 'react'
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { AuthOperationError } from '@/lib/secure-verification'
import type { AuthBundle } from '@/stores/auth-store'
import type { PasskeyDomains } from '../../passkey/assertion'
import {
checkVerificationMethods,
getLoginVerificationRequirements,
@@ -140,6 +141,9 @@ type PendingVerification = PendingVerificationBase &
export function useSecureVerification() {
const [state, dispatch] = useReducer(verificationReducer, { phase: 'idle' })
const pending = useRef<PendingVerification | null>(null)
const [passkeyDomains, setPasskeyDomains] = useState<PasskeyDomains | null>(
null
)
const cancel = useCallback(() => {
const current = pending.current
@@ -147,6 +151,7 @@ export function useSecureVerification() {
current?.controller.abort()
if (current) current.initialPassword = undefined
current?.resolve(null)
setPasskeyDomains(null)
dispatch({ type: 'reset' })
}, [])
@@ -242,6 +247,7 @@ export function useSecureVerification() {
submitting: false,
}
pending.current = current
setPasskeyDomains(null)
void loadRequirements(current)
})
},
@@ -264,6 +270,7 @@ export function useSecureVerification() {
submitting: false,
}
pending.current = current
setPasskeyDomains(null)
void loadRequirements(current)
})
},
@@ -288,7 +295,10 @@ export function useSecureVerification() {
const bundle = await verifyLogin(
input,
current.request.challenge,
current.controller.signal
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(bundle)
@@ -297,7 +307,10 @@ export function useSecureVerification() {
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal
current.controller.signal,
(domains) => {
if (pending.current === current) setPasskeyDomains(domains)
}
)
if (pending.current !== current) return
current.resolve(proof)
@@ -334,6 +347,7 @@ export function useSecureVerification() {
isActive: state.phase !== 'idle',
dialogProps: {
state,
passkeyDomains,
onCancel: cancel,
onRetry: retry,
onInputChange: setInput,
@@ -71,7 +71,7 @@ export interface VerificationRequirements {
export type VerificationInput =
| { method: '2fa'; code: string }
| { method: 'password'; password: string }
| { method: 'passkey' }
| { method: 'passkey'; rpID?: string }
| { method: 'oauth'; provider: string }
| { method: 'session' }
@@ -19,7 +19,7 @@ For commercial licensing, please contact [email protected]
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { Loader2, LogIn, KeyRound } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -46,14 +46,16 @@ import { loginFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import {
requestPasskeyAssertion,
rememberPasskeyRPID,
type PasskeyDomains,
} from '@/features/auth/passkey/assertion'
import { PasskeyDomainSelector } from '@/features/auth/passkey/components/passkey-domain-selector'
import type { AuthFormProps } from '@/features/auth/types'
import { useStatus } from '@/hooks/use-status'
import { handleServerError } from '@/lib/handle-server-error'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { isPasskeySupported as detectPasskeySupport } from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
@@ -69,6 +71,12 @@ export function UserAuthForm({
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [passkeyDomains, setPasskeyDomains] = useState<PasskeyDomains | null>(
null
)
const [passkeyRPID, setPasskeyRPID] = useState<string>()
const passkeyOperation = useRef<AbortController | null>(null)
useEffect(() => () => passkeyOperation.current?.abort(), [])
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0)
@@ -248,44 +256,32 @@ export function UserAuthForm({
return
}
if (passkeyOperation.current) return
const controller = new AbortController()
passkeyOperation.current = controller
setIsPasskeyLoading(true)
try {
const begin = await beginPasskeyLogin()
if (!begin.success) {
throw createServerError(begin, t('Failed to start Passkey login'))
}
const publicKey = prepareCredentialRequestOptions(
begin.data?.options ?? begin.data
const passkey = await requestPasskeyAssertion(
(rpID) => beginPasskeyLogin(rpID, controller.signal),
controller.signal,
{ rpID: passkeyRPID, onDomains: setPasskeyDomains }
)
const flowToken = begin.data?.flow_token
if (!flowToken) {
throw new Error(t('Login flow expired. Please sign in again.'))
}
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
toast.info(t('Passkey login was cancelled'))
return
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(t('Invalid Passkey response'))
}
const finish = await finishPasskeyLogin(flowToken, assertion)
const finish = await finishPasskeyLogin(
passkey.flowToken,
passkey.assertion,
controller.signal
)
controller.signal.throwIfAborted()
if (!finish.success) {
throw createServerError(finish, t('Failed to complete Passkey login'))
}
rememberPasskeyRPID(passkey.rpID)
if (await handleLoginResult(finish.data, redirectTo)) {
toast.success(t('Signed in with Passkey'))
}
} catch (error: unknown) {
if (controller.signal.aborted) return
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(t('Passkey login was cancelled or timed out'))
} else if (error instanceof Error) {
@@ -296,6 +292,9 @@ export function UserAuthForm({
)
}
} finally {
if (passkeyOperation.current === controller) {
passkeyOperation.current = null
}
setIsPasskeyLoading(false)
}
}
@@ -318,6 +317,12 @@ export function UserAuthForm({
)}
{t('Sign in with Passkey')}
</Button>
<PasskeyDomainSelector
domains={passkeyDomains}
value={passkeyRPID}
onChange={setPasskeyRPID}
disabled={passkeyButtonDisabled}
/>
{!passkeySupported && (
<p className='text-muted-foreground text-xs'>
{t('Passkey is not supported on this device.')}
+16
View File
@@ -27,6 +27,8 @@ import type {
SystemTaskResponse,
UpdateOptionRequest,
UpdateOptionResponse,
UpdatePasskeyDomainsRequest,
UpdatePasskeyDomainsResponse,
UpstreamChannelsResponse,
UpstreamRatiosResponse,
} from './types'
@@ -41,6 +43,20 @@ export async function updateSystemOption(request: UpdateOptionRequest) {
return res.data
}
export async function updatePasskeyDomains(
request: UpdatePasskeyDomainsRequest
) {
const res = await api.put<UpdatePasskeyDomainsResponse>(
'/api/option/passkey/domains',
request,
{
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 409,
}
)
return res.data
}
export async function confirmPaymentCompliance() {
const res = await api.post<ConfirmPaymentComplianceResponse>(
'/api/option/payment_compliance',
@@ -24,14 +24,19 @@ import userEvent from '@testing-library/user-event'
import { createInstance, type i18n } from 'i18next'
import { useState } from 'react'
import { I18nextProvider } from 'react-i18next'
import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
import { SettingsPageProvider } from '../../components/settings-page-context'
import type { PasskeyDomainChange } from '../../types'
import { PasskeySection } from '../passkey-section'
const domainHint =
'Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.'
const defaults = {
'passkey.enabled': true,
'passkey.rp_display_name': 'Example',
@@ -42,9 +47,30 @@ const defaults = {
'passkey.attachment_preference': '' as const,
}
function domainResponse(overrides: Partial<PasskeyDomainChange> = {}) {
return {
data: {
success: true,
data: {
rp_id: 'example.com',
legacy_rp_ids: window.location.hostname,
origins: defaults['passkey.origins'],
previous_rp_id: window.location.hostname,
effective_rp_id: 'example.com',
removed_rp_ids: [],
affected_credentials: 0,
unknown_credentials: 0,
confirmation_required: false,
removal_confirmation: 'reviewed-domains',
...overrides,
},
},
}
}
let testI18n: i18n
function Fixture(props: { rpId?: string; origins?: string }) {
function Fixture(props: { rpId?: string; origins?: string; legacy?: string }) {
const [container, setContainer] = useState<HTMLDivElement | null>(null)
const [client] = useState(
() =>
@@ -64,6 +90,7 @@ function Fixture(props: { rpId?: string; origins?: string }) {
defaultValues={{
...defaults,
'passkey.rp_id': props.rpId ?? '',
'passkey.legacy_rp_ids': props.legacy ?? '',
'passkey.origins': props.origins ?? defaults['passkey.origins'],
}}
/>
@@ -88,30 +115,77 @@ beforeEach(async () => {
})
describe('Passkey website guidance', () => {
it('previews server impact and cancels domain removal without saving other settings', async () => {
const put = vi.spyOn(api, 'put').mockResolvedValue({
data: {
success: true,
data: {
rp_id: 'example.com',
legacy_rp_ids: '',
origins: defaults['passkey.origins'],
previous_rp_id: 'example.com',
effective_rp_id: 'example.com',
removed_rp_ids: ['www.example.com'],
affected_credentials: 2,
unknown_credentials: 7,
confirmation_required: true,
removal_confirmation: 'reviewed-impact',
},
},
})
const user = userEvent.setup()
render(<Fixture rpId='example.com' legacy='www.example.com' />)
await user.clear(
screen.getByRole('textbox', { name: 'Compatible Passkey domains' })
)
await user.type(
screen.getByRole('textbox', { name: 'Passkey display name' }),
' changed'
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
const dialog = await screen.findByRole('alertdialog')
expect(
await within(dialog).findByText(
'Passkeys known to use removed domains: 2'
)
).toBeVisible()
expect(
within(dialog).getByText(
'Passkeys with an unknown domain that may be affected: 7'
)
).toBeVisible()
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(put).toHaveBeenCalledTimes(1)
expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true })
})
it('shows the effective domain for a blank field without suggesting a change and restores focus', async () => {
const put = vi.spyOn(api, 'put')
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('textbox', {
name: 'Passkey website domain',
name: 'Primary Passkey domain',
})
expect(input).toHaveValue('')
await waitFor(() => expect(input).not.toHaveClass('border-amber-500'))
expect(input).toHaveAccessibleDescription(
`The system currently uses: ${window.location.hostname}`
`${domainHint} The system currently uses: ${window.location.hostname}`
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(
screen.queryByText(/Passkeys only work on the website/)
).not.toBeInTheDocument()
const help = screen.getByRole('button', { name: 'Why set this?' })
const domains = screen.getByRole('group', { name: 'Passkey domains' })
const help = within(within(domains).getByRole('status')).getByRole(
'button',
{ name: 'Why set this?' }
)
help.focus()
await user.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', {
name: 'Why set a website domain?',
name: 'How to set up Passkey domains',
})
expect(dialog).toHaveAccessibleDescription(
'Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.'
'New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.'
)
expect(
await within(dialog).findByText(
@@ -135,7 +209,9 @@ describe('Passkey website guidance', () => {
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
.mockResolvedValue(
domainResponse({ previous_rp_id: 'example.com', legacy_rp_ids: '' })
)
const user = userEvent.setup()
render(<Fixture />)
await user.click(screen.getByRole('button', { name: 'Why set this?' }))
@@ -146,7 +222,7 @@ describe('Passkey website guidance', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
const input = screen.getByRole('textbox', {
name: 'Passkey website domain',
name: 'Primary Passkey domain',
})
expect(input).toHaveValue('example.com')
expect(input).not.toHaveClass('border-amber-500')
@@ -156,12 +232,19 @@ describe('Passkey website guidance', () => {
expect(put).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(put).toHaveBeenCalledWith('/api/option/', {
key: 'passkey.rp_id',
value: 'example.com',
})
expect(put).toHaveBeenCalledWith(
'/api/option/passkey/domains',
{
rp_id: 'example.com',
legacy_rp_ids: '',
origins: defaults['passkey.origins'],
preview: false,
removal_confirmation: 'reviewed-domains',
},
expect.any(Object)
)
)
expect(put).toHaveBeenCalledTimes(1)
expect(put).toHaveBeenCalledTimes(2)
await user.click(screen.getByRole('button', { name: 'Why set this?' }))
expect(
await screen.findByText('The system currently uses: example.com')
@@ -174,7 +257,7 @@ describe('Passkey website guidance', () => {
await user.click(screen.getByRole('button', { name: 'Why set this?' }))
expect(
screen.getByText(
'If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.'
'When the primary domain changes, the previous domain is automatically kept in the compatible domains list.'
)
).toBeVisible()
await user.click(
@@ -198,7 +281,7 @@ describe('Passkey website guidance', () => {
const user = userEvent.setup()
render(<Fixture rpId={window.location.hostname} />)
const input = screen.getByRole('textbox', {
name: 'Passkey website domain',
name: 'Primary Passkey domain',
})
expect(input).not.toHaveClass('border-amber-500')
await user.clear(input)
@@ -208,7 +291,7 @@ describe('Passkey website guidance', () => {
'focus-visible:border-amber-500'
)
expect(input).toHaveAccessibleDescription(
'This domain does not match the current website. Passkeys may not work here.'
`${domainHint} This domain does not match the current website. Passkeys may not work here.`
)
await user.clear(input)
await user.type(input, window.location.hostname)
@@ -224,7 +307,7 @@ describe('Passkey website guidance', () => {
it('keeps a matching parent domain unmarked and explains a missing website beside the website list', async () => {
render(<Fixture rpId='example.com' />)
expect(
screen.getByRole('textbox', { name: 'Passkey website domain' })
screen.getByRole('textbox', { name: 'Primary Passkey domain' })
).not.toHaveClass('border-amber-500')
const websites = screen.getByRole('textbox', {
name: 'Allowed Passkey websites',
@@ -240,24 +323,31 @@ describe('Passkey website guidance', () => {
})
it('disables filling while saving and enables it after the save finishes', async () => {
let finishSave!: (value: { data: { success: boolean } }) => void
vi.spyOn(api, 'put').mockImplementation(
() =>
new Promise((resolve) => {
finishSave = resolve
})
)
const reply = domainResponse({
rp_id: window.location.hostname,
effective_rp_id: window.location.hostname,
legacy_rp_ids: '',
})
let finishSave!: (value: ReturnType<typeof domainResponse>) => void
vi.spyOn(api, 'put')
.mockResolvedValueOnce(reply)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishSave = resolve
})
)
const user = userEvent.setup()
render(<Fixture />)
await user.type(
screen.getByRole('textbox', { name: 'Passkey website domain' }),
screen.getByRole('textbox', { name: 'Primary Passkey domain' }),
window.location.hostname
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await user.click(screen.getByRole('button', { name: 'Why set this?' }))
const fill = screen.getByRole('button', { name: 'Keep existing domain' })
expect(fill).toBeDisabled()
finishSave({ data: { success: true } })
finishSave(reply)
await waitFor(() => expect(fill).toBeEnabled())
})
@@ -266,15 +356,19 @@ describe('Passkey website guidance', () => {
const user = userEvent.setup()
render(<Fixture />)
await waitFor(() =>
expect(
screen.getByRole('textbox', { name: '通行密钥使用的网站域名' })
).not.toHaveClass('border-amber-500')
expect(screen.getByRole('textbox', { name: '主域名' })).not.toHaveClass(
'border-amber-500'
)
)
await user.click(screen.getByRole('button', { name: '为什么需要设置?' }))
const dialog = screen.getByRole('dialog', {
name: '为什么要填写网站域名?',
name: '如何设置通行密钥域名',
})
expect(dialog).not.toHaveTextContent(/RP ID|Origins?|依赖方/)
expect(within(dialog).getByText('主域名')).toBeVisible()
expect(within(dialog).getByText('兼容的通行密钥域名')).toBeVisible()
expect(dialog).toHaveTextContent('每行填写一个已有通行密钥使用的域名')
expect(dialog).toHaveTextContent('无法在 example.com 使用')
expect(
within(dialog).getByRole('button', { name: '保留现有域名' })
).toBeVisible()
@@ -296,101 +390,186 @@ describe('Passkey website guidance', () => {
})
it('requires confirmation before replacing an automatically resolved domain and lets users cancel', async () => {
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const put = vi.spyOn(api, 'put').mockResolvedValue(domainResponse())
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('textbox', {
name: 'Passkey website domain',
})
await waitFor(() =>
expect(input).toHaveAccessibleDescription(
`The system currently uses: ${window.location.hostname}`
)
await user.type(
screen.getByRole('textbox', { name: 'Primary Passkey domain' }),
'example.com'
)
await user.type(input, 'example.com')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
const dialog = screen.getByRole('alertdialog', {
const dialog = await screen.findByRole('alertdialog', {
name: 'Change the Passkey domain?',
})
expect(dialog).toHaveTextContent(
`Current domain: ${window.location.hostname}`
)
expect(dialog).toHaveTextContent('New domain: example.com')
expect(put).not.toHaveBeenCalled()
expect(put).toHaveBeenCalledTimes(1)
expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true })
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(put).not.toHaveBeenCalled()
expect(put).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await user.click(screen.getByRole('button', { name: 'Change domain' }))
await waitFor(() =>
expect(put).toHaveBeenCalledExactlyOnceWith('/api/option/', {
key: 'passkey.rp_id',
value: 'example.com',
})
await user.click(
await screen.findByRole('button', { name: 'Change domain' })
)
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(put).toHaveBeenCalledTimes(3)
expect(put).toHaveBeenLastCalledWith(
'/api/option/passkey/domains',
{
rp_id: 'example.com',
legacy_rp_ids: '',
origins: defaults['passkey.origins'],
preview: false,
removal_confirmation: 'reviewed-domains',
},
expect.any(Object)
)
expect(
screen.getByRole('textbox', { name: 'Compatible Passkey domains' })
).toHaveValue(window.location.hostname)
})
it('warns before clearing an explicit domain back to automatic configuration', async () => {
const put = vi.spyOn(api, 'put')
it('shows the server resolved default before clearing an explicit domain', async () => {
const put = vi.spyOn(api, 'put').mockResolvedValue(
domainResponse({
rp_id: '',
previous_rp_id: 'example.com',
effective_rp_id: window.location.hostname,
legacy_rp_ids: 'example.com',
})
)
const user = userEvent.setup()
render(<Fixture rpId='example.com' />)
await user.clear(
screen.getByRole('textbox', { name: 'Passkey website domain' })
screen.getByRole('textbox', { name: 'Primary Passkey domain' })
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
const dialog = screen.getByRole('alertdialog', {
const dialog = await screen.findByRole('alertdialog', {
name: 'Change the Passkey domain?',
})
expect(dialog).toHaveTextContent('Current domain: example.com')
expect(dialog).toHaveTextContent(
'New domain: Automatic from system website address'
)
expect(put).not.toHaveBeenCalled()
expect(dialog).toHaveTextContent(`New domain: ${window.location.hostname}`)
expect(put).toHaveBeenCalledTimes(1)
expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true })
})
it('still requires confirmation if the effective domain cannot be loaded', async () => {
it('uses the server preview when the public effective domain could not be loaded', async () => {
vi.mocked(api.get).mockRejectedValue(new Error('Offline'))
const put = vi.spyOn(api, 'put')
const put = vi.spyOn(api, 'put').mockResolvedValue(domainResponse())
const user = userEvent.setup()
render(<Fixture />)
await user.type(
screen.getByRole('textbox', { name: 'Passkey website domain' }),
screen.getByRole('textbox', { name: 'Primary Passkey domain' }),
'example.com'
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
expect(
screen.getByRole('alertdialog', { name: 'Change the Passkey domain?' })
).toHaveTextContent('Current domain: Unknown')
expect(put).not.toHaveBeenCalled()
expect(await screen.findByRole('alertdialog')).toHaveTextContent(
`Current domain: ${window.location.hostname}`
)
expect(put).toHaveBeenCalledTimes(1)
expect(put.mock.calls[0]?.[1]).toMatchObject({ preview: true })
})
it('keeps a failed domain change available for retry without an unhandled rejection', async () => {
const put = vi.spyOn(api, 'put').mockRejectedValueOnce(new Error('Offline'))
const errorToast = vi.spyOn(toast, 'error')
const put = vi
.spyOn(api, 'put')
.mockResolvedValueOnce(domainResponse())
.mockRejectedValueOnce(new Error('Offline'))
.mockResolvedValue(domainResponse())
const user = userEvent.setup()
render(<Fixture rpId={window.location.hostname} />)
const input = screen.getByRole('textbox', {
name: 'Passkey website domain',
name: 'Primary Passkey domain',
})
await user.clear(input)
await user.type(input, 'example.com')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await user.click(screen.getByRole('button', { name: 'Change domain' }))
await waitFor(() => expect(put).toHaveBeenCalledTimes(1))
await user.click(
await screen.findByRole('button', { name: 'Change domain' })
)
await waitFor(() => expect(put).toHaveBeenCalledTimes(2))
await waitFor(() => expect(errorToast).toHaveBeenCalledTimes(1))
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Change domain' })
).toBeEnabled()
)
put.mockResolvedValue({ data: { success: true } })
await user.click(screen.getByRole('button', { name: 'Change domain' }))
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(put).toHaveBeenCalledTimes(2)
expect(put).toHaveBeenCalledTimes(3)
})
it('requires a new confirmation when the affected credential count changes', async () => {
const errorToast = vi.spyOn(toast, 'error')
const first = domainResponse({
previous_rp_id: 'example.com',
legacy_rp_ids: '',
removed_rp_ids: ['www.example.com'],
affected_credentials: 1,
confirmation_required: true,
})
const updated = domainResponse({
...first.data.data,
affected_credentials: 2,
removal_confirmation: 'updated-impact',
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValueOnce(first)
.mockResolvedValueOnce({
data: {
...updated.data,
success: false,
code: 'PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED',
message: 'Review the updated impact before confirming again.',
},
})
.mockResolvedValueOnce(updated)
const user = userEvent.setup()
render(<Fixture rpId='example.com' legacy='www.example.com' />)
await user.clear(
screen.getByRole('textbox', { name: 'Compatible Passkey domains' })
)
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
const dialog = await screen.findByRole('alertdialog', {
name: 'Remove compatible Passkey domains?',
})
expect(dialog).toHaveTextContent('Passkeys known to use removed domains: 1')
await user.click(
within(dialog).getByRole('button', { name: 'Remove domains' })
)
expect(
await within(dialog).findByText(
'Passkeys known to use removed domains: 2'
)
).toBeVisible()
expect(within(dialog).getByRole('alert')).toHaveTextContent(
'Review the updated impact before confirming again.'
)
await waitFor(() =>
expect(
within(dialog).getByRole('button', { name: 'Remove domains' })
).toBeEnabled()
)
await user.click(
within(dialog).getByRole('button', { name: 'Remove domains' })
)
await waitFor(() =>
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
)
expect(put).toHaveBeenCalledTimes(3)
expect(put.mock.calls[2]?.[1]).toMatchObject({
preview: false,
removal_confirmation: 'updated-impact',
})
expect(errorToast).not.toHaveBeenCalled()
})
})
@@ -66,6 +66,7 @@ const defaultAuthSettings: AuthSettings = {
'passkey.enabled': false,
'passkey.rp_display_name': '',
'passkey.rp_id': '',
'passkey.legacy_rp_ids': '',
'passkey.origins': '',
'passkey.allow_insecure_origin': false,
'passkey.user_verification': 'preferred',
@@ -18,7 +18,7 @@ For commercial licensing, please contact [email protected]
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -26,6 +26,7 @@ import * as z from 'zod'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
@@ -51,13 +52,24 @@ import { statusQueryOptions } from '@/lib/status-query'
import { cn } from '@/lib/utils'
import {
SettingsControlGroup,
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
useUpdateOption,
useUpdatePasskeyDomains,
} from '../hooks/use-update-option'
import type { PasskeyDomainChange } from '../types'
const passkeyDomainKeys = [
'passkey.rp_id',
'passkey.legacy_rp_ids',
'passkey.origins',
] as const
type AttachmentPreference = '' | 'platform' | 'cross-platform'
type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform'
@@ -72,6 +84,7 @@ const passkeySchema = z.object({
enabled: z.boolean(),
rp_display_name: z.string(),
rp_id: z.string(),
legacy_rp_ids: z.string(),
origins: z.string(),
allow_insecure_origin: z.boolean(),
user_verification: z.enum(['required', 'preferred', 'discouraged']),
@@ -86,6 +99,7 @@ type FlatPasskeyDefaults = {
'passkey.enabled': boolean
'passkey.rp_display_name': string
'passkey.rp_id': string
'passkey.legacy_rp_ids'?: string
'passkey.origins': string
'passkey.allow_insecure_origin': boolean
'passkey.user_verification': 'required' | 'preferred' | 'discouraged'
@@ -107,6 +121,11 @@ const buildFormDefaults = (
enabled: defaults['passkey.enabled'],
rp_display_name: defaults['passkey.rp_display_name'] ?? '',
rp_id: defaults['passkey.rp_id'] ?? '',
legacy_rp_ids: (defaults['passkey.legacy_rp_ids'] ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
.join('\n'),
origins: (defaults['passkey.origins'] ?? '')
.split(',')
.map((origin) => origin.trim())
@@ -126,6 +145,14 @@ const normalizeFormValues = (
'passkey.enabled': values.passkey.enabled,
'passkey.rp_display_name': values.passkey.rp_display_name,
'passkey.rp_id': values.passkey.rp_id,
'passkey.legacy_rp_ids': [
...new Set(
values.passkey.legacy_rp_ids
.split(/[,\n]/)
.map((id) => id.trim())
.filter(Boolean)
),
].join(','),
'passkey.origins': values.passkey.origins
.split('\n')
.map((origin) => origin.trim())
@@ -144,10 +171,16 @@ interface PasskeySectionProps {
export function PasskeySection(props: PasskeySectionProps) {
const { t } = useTranslation()
const domainGroupTitleId = useId()
const updateOption = useUpdateOption()
const updateDomains = useUpdatePasskeyDomains()
const isSaving = updateOption.isPending || updateDomains.isPending
const [domainHelpOpen, setDomainHelpOpen] = useState(false)
const [pendingDomainChange, setPendingDomainChange] =
useState<FlatPasskeyDefaults | null>(null)
const [pendingDomainChange, setPendingDomainChange] = useState<{
values: FlatPasskeyDefaults
preview: PasskeyDomainChange
notice?: string
} | null>(null)
const formDefaults = useMemo(
() => buildFormDefaults(props.defaultValues),
@@ -205,54 +238,105 @@ export function PasskeySection(props: PasskeySectionProps) {
form.reset(buildFormDefaults(props.defaultValues))
}, [props.defaultValues, form])
const saveSettings = async (normalized: FlatPasskeyDefaults) => {
const saveSettings = async (
normalized: FlatPasskeyDefaults,
preview?: PasskeyDomainChange
) => {
const changedKeys = (
Object.keys(normalized) as Array<keyof FlatPasskeyDefaults>
).filter((key) => normalized[key] !== baselineRef.current[key])
).filter(
(key) => (normalized[key] ?? '') !== (baselineRef.current[key] ?? '')
)
if (changedKeys.length === 0) {
toast.info(t('No changes to save'))
return
}
const saved = { ...normalized }
try {
for (const key of changedKeys) {
await updateOption.mutateAsync({
key,
value: normalized[key],
if (passkeyDomainKeys.some((key) => changedKeys.includes(key))) {
const result = await updateDomains.mutateAsync({
rp_id: normalized['passkey.rp_id'],
legacy_rp_ids: normalized['passkey.legacy_rp_ids'] ?? '',
origins: normalized['passkey.origins'],
preview: false,
removal_confirmation: preview?.removal_confirmation,
})
if (!result.success) {
setPendingDomainChange({
values: normalized,
preview: result.data,
notice: result.message,
})
return
}
saved['passkey.rp_id'] = result.data.rp_id
saved['passkey.legacy_rp_ids'] = result.data.legacy_rp_ids
saved['passkey.origins'] = result.data.origins
baselineRef.current = {
...baselineRef.current,
'passkey.rp_id': saved['passkey.rp_id'],
'passkey.legacy_rp_ids': saved['passkey.legacy_rp_ids'],
'passkey.origins': saved['passkey.origins'],
}
}
for (const key of changedKeys) {
if (passkeyDomainKeys.some((domainKey) => domainKey === key)) continue
await updateOption.mutateAsync({ key, value: normalized[key] ?? '' })
}
} catch {
// useUpdateOption reports the error. Keep the form and confirmation for retry.
// The mutation reports the error once. Keep the draft available for retry.
return
}
baselineRef.current = normalized
baselineSerializedRef.current = JSON.stringify(normalized)
form.reset(buildFormDefaults(normalized))
baselineRef.current = saved
baselineSerializedRef.current = JSON.stringify(saved)
form.reset(buildFormDefaults(saved))
setPendingDomainChange(null)
}
const onSubmit = async (values: PasskeyFormValues) => {
const normalized = normalizeFormValues(values)
const nextRPID = normalized['passkey.rp_id'].trim()
const changesDomain =
nextRPID !== baselineRef.current['passkey.rp_id'].trim() &&
nextRPID !== existingRPID
if (changesDomain) {
setPendingDomainChange(normalized)
const changesDomains = passkeyDomainKeys.some(
(key) => (normalized[key] ?? '') !== (baselineRef.current[key] ?? '')
)
if (!changesDomains) {
await saveSettings(normalized)
return
}
await saveSettings(normalized)
try {
const result = await updateDomains.mutateAsync({
rp_id: normalized['passkey.rp_id'],
legacy_rp_ids: normalized['passkey.legacy_rp_ids'] ?? '',
origins: normalized['passkey.origins'],
preview: true,
})
const preview = result.data
const clearsPrimary =
baselineRef.current['passkey.rp_id'].trim() !== '' &&
normalized['passkey.rp_id'].trim() === ''
if (
preview.previous_rp_id !== preview.effective_rp_id ||
clearsPrimary ||
preview.removed_rp_ids.length > 0 ||
preview.confirmation_required
) {
setPendingDomainChange({ values: normalized, preview })
return
}
await saveSettings(normalized, preview)
} catch {
// Preview failures are reported by the mutation and never write settings.
}
}
const removedDomains = pendingDomainChange?.preview.removed_rp_ids ?? []
return (
<SettingsSection title={t('Passkey Authentication')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
isSaving={isSaving}
/>
<FormField
control={form.control}
@@ -281,7 +365,7 @@ export function PasskeySection(props: PasskeySectionProps) {
control={form.control}
name='passkey.rp_display_name'
render={({ field }) => (
<FormItem>
<FormItem data-settings-form-span='full' className='max-w-xl'>
<FormLabel>{t('Passkey display name')}</FormLabel>
<FormControl>
<Input
@@ -303,163 +387,238 @@ export function PasskeySection(props: PasskeySectionProps) {
)}
/>
<FormField
control={form.control}
name='passkey.rp_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Passkey website domain')}</FormLabel>
<FormControl>
<Input
className={cn(
hasDomainWarning &&
'border-amber-500 focus-visible:border-amber-500 focus-visible:ring-amber-500/20 dark:border-amber-400 dark:focus-visible:border-amber-400'
<SettingsControlGroup
role='group'
aria-labelledby={domainGroupTitleId}
className='grid items-start gap-4 space-y-0 p-4 lg:grid-cols-2'
>
<h4
id={domainGroupTitleId}
className='text-sm font-medium lg:col-span-2'
>
{t('Passkey domains')}
</h4>
<Alert
role='status'
className={cn(
'lg:col-span-2',
hasDomainWarning
? 'border-warning/40 bg-warning/10 text-amber-800 dark:text-amber-200'
: 'border-info/30 bg-info/10 text-info'
)}
>
<AlertDescription className='flex flex-wrap items-center gap-x-2 gap-y-1 text-inherit'>
<span>
{previewRPID === '' &&
t('Set the website where users will use their Passkeys.')}
{domainMismatch &&
t(
'This domain does not match the current website. Passkeys may not work here.'
)}
placeholder={t('e.g. example.com')}
value={field.value ?? ''}
onChange={(event) => field.onChange(event.target.value)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
<FormDescription
aria-live='polite'
className={cn(
hasDomainWarning && 'text-amber-700 dark:text-amber-400'
{!hasDomainWarning &&
t(
'New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.'
)}
>
{previewRPID === '' &&
t('Set the website where users will use their Passkeys.')}
{domainMismatch &&
t(
'This domain does not match the current website. Passkeys may not work here.'
)}
{!hasDomainWarning &&
rpId.trim() === '' &&
t('The system currently uses: {{domain}}', {
domain: serverRPID,
})}
{!hasDomainWarning &&
rpId.trim() !== '' &&
t('Enter the website domain, such as example.com.')}
</FormDescription>
<Dialog
open={domainHelpOpen}
onOpenChange={setDomainHelpOpen}
title={t('Why set a website domain?')}
description={t(
'Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.'
)}
contentClassName='sm:max-w-lg'
bodyClassName='space-y-3 text-sm break-words'
trigger={
</span>
<Dialog
open={domainHelpOpen}
onOpenChange={setDomainHelpOpen}
title={t('How to set up Passkey domains')}
description={t(
'New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.'
)}
contentClassName='sm:max-w-lg'
bodyClassName='space-y-3 text-sm break-words'
trigger={
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0 font-semibold text-inherit underline underline-offset-4'
>
{t('Why set this?')}
</Button>
}
footer={
<>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
variant='outline'
onClick={() => setDomainHelpOpen(false)}
>
{t('Why set this?')}
{t('Close')}
</Button>
}
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setDomainHelpOpen(false)}
>
{t('Close')}
</Button>
<Button
type='button'
disabled={
!canUseCurrentSite ||
statusLoading ||
updateOption.isPending
}
onClick={() => {
form.setValue('passkey.rp_id', suggestedRPID, {
<Button
type='button'
disabled={
!canUseCurrentSite || statusLoading || isSaving
}
onClick={() => {
form.setValue('passkey.rp_id', suggestedRPID, {
shouldDirty: true,
})
if (!form.getValues('passkey.origins').trim()) {
form.setValue('passkey.origins', currentOrigin, {
shouldDirty: true,
})
if (!form.getValues('passkey.origins').trim()) {
form.setValue('passkey.origins', currentOrigin, {
shouldDirty: true,
})
}
setDomainHelpOpen(false)
}}
>
{existingRPID
? t('Keep existing domain')
: t('Fill in this website')}
</Button>
</>
}
>
}
setDomainHelpOpen(false)
}}
>
{existingRPID
? t('Keep existing domain')
: t('Fill in this website')}
</Button>
</>
}
>
<dl className='grid gap-4'>
<div>
<dt className='font-medium'>
{t('Primary Passkey domain')}
</dt>
<dd className='text-muted-foreground mt-1'>
{t(
'New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.'
)}
</dd>
</div>
<div>
<dt className='font-medium'>
{t('Compatible Passkey domains')}
</dt>
<dd className='text-muted-foreground mt-1'>
{t(
'Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.'
)}
</dd>
</div>
</dl>
<div className='bg-muted space-y-2 rounded-lg p-3 break-all'>
<p>
{t(
'An empty field uses the system website address. If Passkeys already work, keep the existing domain.'
)}
{t('Current site: {{origin}}', {
origin: currentOrigin,
})}
</p>
<div className='bg-muted space-y-2 rounded-lg p-3 break-all'>
<p>
{t('Current site: {{origin}}', {
origin: currentOrigin,
{!existingRPID && (
<p className='font-medium'>
{t('For this website, you can enter: {{domain}}', {
domain: currentHostname,
})}
</p>
{!existingRPID && (
<p className='font-medium'>
{t('For this website, you can enter: {{domain}}', {
domain: currentHostname,
})}
</p>
)}
{serverRPID && !statusError && (
<p className='text-muted-foreground'>
{t('The system currently uses: {{domain}}', {
domain: serverRPID,
})}
</p>
)}
</div>
{statusError && (
<p>
{t(
'The current setting could not be loaded. You can still enter the website domain yourself.'
)}
)}
{serverRPID && !statusError && (
<p className='text-muted-foreground'>
{t('The system currently uses: {{domain}}', {
domain: serverRPID,
})}
</p>
)}
</div>
{statusError && (
<p>
{t(
'Enter only the domain, without https://, a port or a page path. example.com and www.example.com are different Passkey domains; changing between them does not migrate existing Passkeys.'
'The current setting could not be loaded. You can still enter the website domain yourself.'
)}
</p>
<p>
{t(
'If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.'
)}
</p>
{!canUseCurrentSite && (
<p>
{t(
'Use an HTTPS domain (or localhost for development) to configure Passkey.'
)}
</p>
)}
<p>
{t(
'A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.'
)}
<p className='text-muted-foreground'>
</p>
<p>
{t(
'When the primary domain changes, the previous domain is automatically kept in the compatible domains list.'
)}
</p>
{!canUseCurrentSite && (
<p>
{t(
'The button fills in this website and keeps any existing website addresses. Save changes on the settings page to apply.'
'Use an HTTPS domain (or localhost for development) to configure Passkey.'
)}
</p>
</Dialog>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<p className='text-muted-foreground'>
{t(
'Review the compatible domains and allowed websites, then save changes on the settings page.'
)}
</p>
</Dialog>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name='passkey.rp_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Primary Passkey domain')}</FormLabel>
<FormControl>
<Input
className={cn(
hasDomainWarning &&
'border-amber-500 focus-visible:border-amber-500 focus-visible:ring-amber-500/20 dark:border-amber-400 dark:focus-visible:border-amber-400'
)}
placeholder={t('e.g. example.com')}
value={field.value ?? ''}
onChange={(event) => field.onChange(event.target.value)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription aria-live='polite'>
{t(
'Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.'
)}{' '}
{hasDomainWarning && (
<span className='sr-only'>
{previewRPID === '' &&
t(
'Set the website where users will use their Passkeys.'
)}
{domainMismatch &&
t(
'This domain does not match the current website. Passkeys may not work here.'
)}
</span>
)}
{!hasDomainWarning && rpId.trim() === '' && (
<span className='mt-1 block'>
{t('The system currently uses: {{domain}}', {
domain: serverRPID,
})}
</span>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='passkey.legacy_rp_ids'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Compatible Passkey domains')}</FormLabel>
<FormControl>
<Textarea
rows={3}
placeholder='www.example.com'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsControlGroup>
<FormField
control={form.control}
@@ -611,29 +770,68 @@ export function PasskeySection(props: PasskeySectionProps) {
<ConfirmDialog
open={pendingDomainChange !== null}
onOpenChange={(open) => {
if (!open && !updateOption.isPending) setPendingDomainChange(null)
if (!open && !isSaving) setPendingDomainChange(null)
}}
title={t('Change the Passkey domain?')}
desc={t(
'Existing Passkeys remain tied to the old domain and may stop working. Keep the old domain to preserve access, or confirm only if you have a recovery plan.'
)}
confirmText={t('Change domain')}
destructive
isLoading={updateOption.isPending}
title={
removedDomains.length
? t('Remove compatible Passkey domains?')
: t('Change the Passkey domain?')
}
desc={
removedDomains.length
? t(
'Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.'
)
: t(
'New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.'
)
}
confirmText={
removedDomains.length ? t('Remove domains') : t('Change domain')
}
destructive={removedDomains.length > 0}
isLoading={isSaving}
handleConfirm={() => {
if (pendingDomainChange) void saveSettings(pendingDomainChange)
if (pendingDomainChange) {
void saveSettings(
pendingDomainChange.values,
pendingDomainChange.preview
)
}
}}
>
<div className='space-y-2 text-sm break-all'>
{pendingDomainChange?.notice && (
<Alert role='alert'>
<AlertDescription>{pendingDomainChange.notice}</AlertDescription>
</Alert>
)}
{removedDomains.length > 0 && pendingDomainChange && (
<>
<p>{removedDomains.join(', ')}</p>
<p>
{t('Passkeys known to use removed domains: {{amount}}', {
amount: pendingDomainChange.preview.affected_credentials,
})}
</p>
<p>
{t(
'Passkeys with an unknown domain that may be affected: {{amount}}',
{ amount: pendingDomainChange.preview.unknown_credentials }
)}
</p>
</>
)}
<p>
{t('Current domain: {{domain}}', {
domain: existingRPID || t('Unknown'),
domain:
pendingDomainChange?.preview.previous_rp_id || t('Unknown'),
})}
</p>
<p>
{t('New domain: {{domain}}', {
domain:
pendingDomainChange?.['passkey.rp_id'].trim() ||
pendingDomainChange?.preview.effective_rp_id ||
t('Automatic from system website address'),
})}
</p>
@@ -23,8 +23,8 @@ import { toast } from 'sonner'
import { handleServerError } from '@/lib/handle-server-error'
import { requireServerSuccess } from '@/lib/server-error-message'
import { updateSystemOption } from '../api'
import type { UpdateOptionRequest } from '../types'
import { updatePasskeyDomains, updateSystemOption } from '../api'
import type { UpdateOptionRequest, UpdatePasskeyDomainsRequest } from '../types'
// Configuration keys that require status refresh
const STATUS_RELATED_KEYS = new Set([
@@ -43,6 +43,7 @@ const STATUS_RELATED_KEYS = new Set([
'ServerAddress',
'passkey.enabled',
'passkey.rp_id',
'passkey.legacy_rp_ids',
'passkey.origins',
])
@@ -77,3 +78,32 @@ export function useUpdateOption() {
},
})
}
export function useUpdatePasskeyDomains() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (request: UpdatePasskeyDomainsRequest) => {
const result = await updatePasskeyDomains(request)
if (
result.code === 'PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED' &&
result.data
) {
return result
}
return requireServerSuccess(result)
},
onSuccess: (result, request) => {
if (request.preview || !result.success) return
queryClient.invalidateQueries({ queryKey: ['system-options'] })
queryClient.invalidateQueries({ queryKey: ['status'] })
try {
window.localStorage.removeItem('status')
} catch {
/* Storage may be disabled. */
}
toast.success(i18next.t('Setting updated successfully'))
},
onError: (error: Error) =>
handleServerError(error, i18next.t('Failed to update setting')),
})
}
+27
View File
@@ -39,6 +39,32 @@ export type UpdateOptionResponse = {
message: string
}
export interface PasskeyDomainChange {
rp_id: string
legacy_rp_ids: string
origins: string
previous_rp_id: string
effective_rp_id: string
removed_rp_ids: string[]
affected_credentials: number
unknown_credentials: number
confirmation_required: boolean
removal_confirmation: string
}
export interface UpdatePasskeyDomainsRequest {
rp_id: string
legacy_rp_ids: string
origins: string
preview: boolean
removal_confirmation?: string
}
export interface UpdatePasskeyDomainsResponse extends UpdateOptionResponse {
code?: string
data: PasskeyDomainChange
}
export type ConfirmPaymentComplianceResponse = {
success: boolean
message: string
@@ -163,6 +189,7 @@ export type AuthSettings = {
'passkey.enabled': boolean
'passkey.rp_display_name': string
'passkey.rp_id': string
'passkey.legacy_rp_ids': string
'passkey.origins': string
'passkey.allow_insecure_origin': boolean
'passkey.user_verification': 'required' | 'preferred' | 'discouraged'
@@ -485,6 +485,13 @@ const AUDIT_TEMPLATES: Record<string, string> = {
'user.oauth_unbind': 'Removed an OAuth binding for the user',
// System settings
'option.update': 'Updated system setting {{key}}',
'option.passkey_domains':
'Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}',
'option.passkey_domains_confirmed':
'Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}',
'option.passkey_domains_blocked':
'Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}',
'option.passkey_domains_failed': 'Passkey domain update failed',
'option.payment_compliance': 'Confirmed payment compliance',
'option.reset_ratio': 'Reset model ratios',
'option.clear_affinity_cache': 'Cleared channel affinity cache',
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "A billing multiplier. Lower ratios mean lower API call costs.",
"A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.",
"A matching hash confirms the published file, not the safety of its code.": "A matching hash confirms the published file, not the safety of its code.",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.",
"A security operation is already in progress.": "A security operation is already in progress.",
"A selected model no longer exists. Reload the model list.": "A selected model no longer exists. Reload the model list.",
"A vendor with this name already exists.": "A vendor with this name already exists.",
@@ -1036,6 +1037,7 @@
"Choose price sources": "Choose price sources",
"Choose the bundle type and define the items inside it.": "Choose the bundle type and define the items inside it.",
"Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.",
"Choose the domain used when this Passkey was created, then try again.": "Choose the domain used when this Passkey was created, then try again.",
"Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
"Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.",
"Clamped to": "Clamped to",
@@ -1152,6 +1154,7 @@
"Comparison operator": "Comparison operator",
"compatible API routes": "compatible API routes",
"Compatible API routes for common AI application workflows": "Compatible API routes for common AI application workflows",
"Compatible Passkey domains": "Compatible Passkey domains",
"Compilation failed": "Compilation failed",
"Complete API documentation with multi-language SDK support": "Complete API documentation with multi-language SDK support",
"Complete Order": "Complete Order",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "Confirm your identity with Two-factor Authentication before registering a Passkey.",
"Confirmed at {{time}} by user #{{userId}}": "Confirmed at {{time}} by user #{{userId}}",
"Confirmed payment compliance": "Confirmed payment compliance",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}",
"Confirming the account linked to your profile.": "Confirming the account linked to your profile.",
"Conflict": "Conflict",
"Connect": "Connect",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "Ensure the string has a specified prefix",
"Ensure the string has a specified suffix": "Ensure the string has a specified suffix",
"Enter 6-digit code": "Enter 6-digit code",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.",
"Enter a finite, non-negative price": "Enter a finite, non-negative price",
"Enter a finite, non-negative price.": "Enter a finite, non-negative price.",
"Enter a name": "Enter a name",
@@ -2073,6 +2078,8 @@
"Enter password": "Enter password",
"Enter password (8-20 characters)": "Enter password (8-20 characters)",
"Enter password (8–128 characters)": "Enter password (8–128 characters)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.",
"Enter quota in {{currency}}": "Enter quota in {{currency}}",
"Enter quota in tokens": "Enter quota in tokens",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "Enter sample usage to estimate the cost. Group and request multipliers are not included.",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "I voluntarily accept the system stability risks, including severe client timeouts and possible service crashes, and take responsibility for any resulting request backlog or service outage.",
"High-risk status code retry risk disclaimer": "### ⚠️ High-risk operation: 504/524 status code retry risk notice and disclaimer\n\nBy default, this project does not retry status codes `400` (bad request), `504` (gateway timeout), or `524` (a timeout occurred). Status codes 504 and 524 usually mean that **the request successfully reached the upstream AI service and upstream processing had begun, but the connection was closed because upstream processing took too long**. This usually points to an upstream service bottleneck.\n\nEnabling redirection/retry for these timeout status codes is an **extremely high-risk operation**. Before enabling it, you must carefully read and understand the following consequences:\n\n#### 1. Core risks (read carefully)\n\n1. 💸 Duplicate or multiple billing: Most upstream AI providers **still charge** for requests that started processing but were interrupted by a network timeout (504/524). A retry sends a brand-new upstream request and can result in **duplicate or multiple charges**.\n2. ⏳ Severe client timeout: Once a request has already timed out, adding retries can multiply total latency and cause severe or unacceptable timeouts for the final client or caller.\n3. 💥 Request backlog and service crash: Forced retries keep threads and connections occupied for longer. Under high concurrency, this can cause a serious **request backlog**, exhaust system resources, trigger cascading failures, and crash the proxy service.\n\n#### 2. Risk acknowledgement\n\nIf you still choose to enable this feature, you acknowledge all of the following:",
"Higher priority channels are selected first": "Higher priority channels are selected first",
"Historical capitalization": "Historical capitalization",
"Historical tokens": "Historical tokens",
"Historical Usage": "Historical Usage",
"History of MjProxy-style image tasks.": "History of MjProxy-style image tasks.",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "How to get an io.net API Key",
"How to reset my quota?": "How to reset my quota?",
"How to select keys: random or sequential polling": "How to select keys: random or sequential polling",
"How to set up Passkey domains": "How to set up Passkey domains",
"How will you use the platform?": "How will you use the platform?",
"HTTP Protocol": "HTTP Protocol",
"HTTP protocol must be Auto or HTTP/1.1": "HTTP protocol must be Auto or HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.",
"If your Passkey belongs to another website, start sign-in there:": "If your Passkey belongs to another website, start sign-in there:",
"Ignore": "Ignore",
"Ignore this version": "Ignore this version",
"Ignored upstream models": "Ignored upstream models",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "Invalid JSON in parameter override template",
"Invalid JSON string.": "Invalid JSON string.",
"Invalid model mapping format": "Invalid model mapping format",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.",
"Invalid Passkey registration response": "Invalid Passkey registration response",
"Invalid Passkey response": "Invalid Passkey response",
"Invalid payment redirect URL": "Invalid payment redirect URL",
@@ -2957,6 +2968,7 @@
"Keep local": "Keep local",
"Keep original value": "Keep original value",
"Keep original value (skip if target exists)": "Keep original value (skip if target exists)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.",
"Keep the platform ready": "Keep the platform ready",
"Keep this above 1 minute to avoid heavy database load": "Keep this above 1 minute to avoid heavy database load",
"Keep this vendor": "Keep this vendor",
@@ -3454,6 +3466,9 @@
"New model": "New model",
"New Models ({{count}})": "New Models ({{count}})",
"New name will be:": "New name will be:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.",
"New password": "New password",
"New Password": "New Password",
"New password must be different from current password": "New password must be different from current password",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "Passkey Authentication",
"Passkey authentication is disabled.": "Passkey authentication is disabled.",
"Passkey display name": "Passkey display name",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}",
"Passkey domain update failed": "Passkey domain update failed",
"Passkey domains": "Passkey domains",
"Passkey is not available in this browser": "Passkey is not available in this browser",
"Passkey is not supported in this environment": "Passkey is not supported in this environment",
"Passkey is not supported on this device": "Passkey is not supported on this device",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Passkey verification was cancelled",
"Passkey verification was cancelled or timed out": "Passkey verification was cancelled or timed out",
"Passkey website domain": "Passkey website domain",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.",
"Passkeys known to use removed domains: {{amount}}": "Passkeys known to use removed domains: {{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.",
"Passkeys with an unknown domain that may be affected: {{amount}}": "Passkeys with an unknown domain that may be affected: {{amount}}",
"Passthrough Template": "Passthrough Template",
"Password": "Password",
"Password / Access Token": "Password / Access Token",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "Pricing tier {{name}}",
"Pricing Type": "Pricing Type",
"Primary Model": "Primary Model",
"Primary Passkey domain": "Primary Passkey domain",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)",
"Priority": "Priority",
"Priority (Default)": "Priority (Default)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "Remove all log entries created before the selected timestamp.",
"Remove attachment": "Remove attachment",
"Remove branch": "Remove branch",
"Remove compatible Passkey domains?": "Remove compatible Passkey domains?",
"Remove condition": "Remove condition",
"Remove domains": "Remove domains",
"Remove Duplicates": "Remove Duplicates",
"Remove end condition": "Remove end condition",
"Remove filter": "Remove filter",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.",
"Review model rates before scaling traffic": "Review model rates before scaling traffic",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "Review the affected records before applying. Pricing, channels, and model names are preserved.",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "Review the compatible domains and allowed websites, then save changes on the settings page.",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.",
"Review your payment details": "Review your payment details",
"Review your purchase details before proceeding.": "Review your purchase details before proceeding.",
@@ -5616,6 +5641,7 @@
"This month": "This month",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.",
"This page has not been created yet.": "This page has not been created yet.",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "This Passkey domain is not available on this website. Use its original website or another verification method.",
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.",
"This plugin path does not resolve within the source repository.": "This plugin path does not resolve within the source repository.",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "Updated channel {{name}} (ID: {{id}})",
"Updated channel status (ID: {{id}})": "Updated channel status (ID: {{id}})",
"Updated daily": "Updated daily",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}",
"Updated successfully": "Updated successfully",
"Updated system setting {{key}}": "Updated system setting {{key}}",
"Updated user {{username}} (ID: {{id}})": "Updated user {{username}} (ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "Use 8–128 characters.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.",
"Use a different stable value for each instance, then restart the service.": "Use a different stable value for each instance, then restart the service.",
"Use a Passkey from another website": "Use a Passkey from another website",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "Use an HTTPS domain (or localhost for development) to configure Passkey.",
"Use authenticator code": "Use authenticator code",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "When no conditions are set, the operation always executes.",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "When the primary domain changes, the previous domain is automatically kept in the compatible domains list.",
"Whitelist": "Whitelist",
"Whitelist (Only allow listed domains)": "Whitelist (Only allow listed domains)",
"Whitelist (Only allow listed IPs)": "Whitelist (Only allow listed IPs)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "Un multiplicateur de facturation. Plus le ratio est faible, plus le coût des appels API est bas.",
"A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.",
"A matching hash confirms the published file, not the safety of its code.": "Une empreinte identique confirme le fichier publié, pas la sécurité de son code.",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "Une clé d’accès créée pour www.example.com ne fonctionne pas sur example.com. Gardez l’ancien site accessible pour permettre aux utilisateurs de s’y authentifier.",
"A security operation is already in progress.": "Une opération de sécurité est déjà en cours.",
"A selected model no longer exists. Reload the model list.": "Un modèle sélectionné n’existe plus. Rechargez la liste.",
"A vendor with this name already exists.": "Un fournisseur porte déjà ce nom.",
@@ -1036,6 +1037,7 @@
"Choose price sources": "Choisissez les sources de prix",
"Choose the bundle type and define the items inside it.": "Choisissez le type de bundle et définissez les éléments qu'il contient.",
"Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.",
"Choose the domain used when this Passkey was created, then try again.": "Choisissez le domaine utilisé lors de la création de cette clé d’accès, puis réessayez.",
"Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
"Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.",
"Clamped to": "Limité à",
@@ -1152,6 +1154,7 @@
"Comparison operator": "Opérateur de comparaison",
"compatible API routes": "routes API compatibles",
"Compatible API routes for common AI application workflows": "Routes API compatibles pour les workflows courants des applications d'IA",
"Compatible Passkey domains": "Domaines compatibles des clés d’accès",
"Compilation failed": "Échec de compilation",
"Complete API documentation with multi-language SDK support": "Documentation API complète avec support SDK multilingue",
"Complete Order": "Compléter la commande",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "Confirmez votre identité avec l’authentification à deux facteurs avant d’enregistrer une Passkey.",
"Confirmed at {{time}} by user #{{userId}}": "Confirmé le {{time}} par l’utilisateur #{{userId}}",
"Confirmed payment compliance": "Conformité de paiement confirmée",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "Suppression des domaines de clés d’accès confirmée : {{domains}} ; affectées {{known}} ; domaine inconnu {{unknown}}",
"Confirming the account linked to your profile.": "Vérification du compte associé à votre profil.",
"Conflict": "Conflit",
"Connect": "Connecter",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "S'assurer que la chaîne a un préfixe spécifié",
"Ensure the string has a specified suffix": "S'assurer que la chaîne a un suffixe spécifié",
"Enter 6-digit code": "Saisir le code à 6 chiffres",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "Saisissez un domaine comme example.com ou localhost, sans protocole, port ni chemin. Placez les adresses avec un port dans « Sites autorisés pour les Passkeys ».",
"Enter a finite, non-negative price": "Saisissez un prix fini et positif ou nul",
"Enter a finite, non-negative price.": "Saisissez un prix fini et non négatif.",
"Enter a name": "Saisir un nom",
@@ -2073,6 +2078,8 @@
"Enter password": "Saisir le mot de passe",
"Enter password (8-20 characters)": "Saisir le mot de passe (8-20 caractères)",
"Enter password (8–128 characters)": "Saisissez un mot de passe (8 à 128 caractères)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "Saisissez les anciens domaines, un par ligne, sans protocole, port ni chemin. Des ports différents sur un même domaine ne nécessitent pas de domaines compatibles.",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "Saisissez les anciens domaines des clés d’accès, un par ligne. Les clés existantes restent utilisables avec ces domaines ; les nouvelles utilisent le domaine principal.",
"Enter quota in {{currency}}": "Saisir le quota en {{currency}}",
"Enter quota in tokens": "Saisir le quota en tokens",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "Saisissez l’usage d’une requête pour estimer son coût, hors multiplicateurs de groupe et de requête.",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "J'accepte volontairement les risques pour la stabilité du système, notamment les délais d'attente sévères côté client et les pannes possibles du service, et j'assume toute accumulation de requêtes ou indisponibilité qui en résulterait.",
"High-risk status code retry risk disclaimer": "### ⚠️ Opération à haut risque : avertissement et clause de non-responsabilité pour la relance des codes 504/524\n\nPar défaut, ce projet ne relance pas les codes `400` (requête incorrecte), `504` (délai d'attente de la passerelle) et `524` (délai d'attente dépassé). Les codes 504 et 524 signifient généralement que **la requête est bien parvenue au service IA en amont et que le traitement avait commencé, mais que la connexion s'est fermée parce que le traitement en amont a pris trop de temps**. Cela indique généralement un goulot d'étranglement du service en amont.\n\nActiver la redirection ou la relance pour ces codes de délai d'attente est une **opération à risque extrêmement élevé**. Avant de l'activer, vous devez lire attentivement et comprendre les conséquences suivantes :\n\n#### 1. Risques principaux (à lire attentivement)\n\n1. 💸 Facturation double ou multiple : la plupart des fournisseurs d'IA en amont **facturent quand même** les requêtes dont le traitement a commencé mais qui ont été interrompues par un délai réseau (504/524). Une relance envoie une toute nouvelle requête en amont et peut entraîner une **facturation double ou multiple**.\n2. ⏳ Délai d'attente sévère côté client : lorsqu'une requête a déjà expiré, les relances peuvent multiplier la latence totale et provoquer des délais sévères ou inacceptables pour le client final.\n3. 💥 Accumulation de requêtes et panne du service : les relances forcées occupent plus longtemps les threads et les connexions. En cas de forte concurrence, cela peut provoquer une importante **accumulation de requêtes**, épuiser les ressources, déclencher des défaillances en cascade et faire tomber le proxy.\n\n#### 2. Acceptation des risques\n\nSi vous choisissez malgré tout d'activer cette fonction, vous reconnaissez les éléments suivants :",
"Higher priority channels are selected first": "Les canaux de priorité plus élevée sont sélectionnés en premier",
"Historical capitalization": "Ancienne casse",
"Historical tokens": "Anciens jetons",
"Historical Usage": "Utilisation historique",
"History of MjProxy-style image tasks.": "Historique des tâches d'images style MjProxy.",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "Comment obtenir une clé API io.net",
"How to reset my quota?": "Comment réinitialiser mon quota ?",
"How to select keys: random or sequential polling": "Comment sélectionner les clés : sondage aléatoire ou séquentiel",
"How to set up Passkey domains": "Configurer les domaines des clés d’accès",
"How will you use the platform?": "Comment allez-vous utiliser la plateforme ?",
"HTTP Protocol": "Protocole HTTP",
"HTTP protocol must be Auto or HTTP/1.1": "Le protocole HTTP doit être Auto ou HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "Si des utilisateurs ont déjà des Passkeys, modifier ce domaine peut les obliger à se connecter autrement et à les configurer de nouveau.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Si vous fournissez des services d’IA générative au public en Chine continentale, vous remplirez les obligations légales applicables, notamment le dépôt, l’évaluation de sécurité, la sécurité du contenu, le traitement des plaintes, l’étiquetage du contenu généré, la conservation des journaux et la protection des informations personnelles.",
"If your Passkey belongs to another website, start sign-in there:": "Si votre clé d’accès appartient à un autre site, connectez-vous sur ce site :",
"Ignore": "Ignorer",
"Ignore this version": "Ignorer cette version",
"Ignored upstream models": "Modèles amont ignorés",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "JSON invalide dans le modèle de remplacement de paramètres",
"Invalid JSON string.": "Chaîne JSON invalide.",
"Invalid model mapping format": "Format de mappage de modèle invalide",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Domaine de clé d’accès invalide. Saisissez un domaine sans protocole, port, chemin ni caractère générique.",
"Invalid Passkey registration response": "Réponse d'enregistrement de passe-clé invalide",
"Invalid Passkey response": "Réponse Passkey invalide",
"Invalid payment redirect URL": "URL de redirection de paiement non valide",
@@ -2957,6 +2968,7 @@
"Keep local": "Conserver les valeurs locales",
"Keep original value": "Conserver la valeur originale",
"Keep original value (skip if target exists)": "Conserver la valeur originale (ignorer si la cible existe)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Conservez les domaines des clés existantes, un par ligne. Si le domaine principal passe de www.example.com à example.com, gardez www.example.com ici.",
"Keep the platform ready": "Gardez la plateforme prête",
"Keep this above 1 minute to avoid heavy database load": "Gardez cette valeur au-dessus de 1 minute pour éviter une charge excessive de la base de données",
"Keep this vendor": "Fournisseur à conserver",
@@ -3454,6 +3466,9 @@
"New model": "Nouveau modèle",
"New Models ({{count}})": "Nouveaux modèles ({{count}})",
"New name will be:": "Le nouveau nom sera :",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "Les nouvelles clés d’accès utilisent le domaine principal. Les domaines compatibles permettent de continuer à utiliser les clés existantes.",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "Les nouvelles clés d’accès utilisent ce domaine. Saisissez uniquement le domaine, sans https://, port ni chemin. Un champ vide utilise l’adresse du site système.",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "Les nouvelles clés d’accès utiliseront le nouveau domaine principal. L’ancien domaine sera conservé pour que les clés existantes restent utilisables.",
"New password": "Nouveau mot de passe",
"New Password": "Nouveau mot de passe",
"New password must be different from current password": "Le nouveau mot de passe doit être différent de l'actuel",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "Authentification Passkey",
"Passkey authentication is disabled.": "L’authentification Passkey est désactivée.",
"Passkey display name": "Nom affiché pour les Passkeys",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "Modification des domaines de clés d’accès bloquée : {{domains}} ; affectées {{known}} ; domaine inconnu {{unknown}}",
"Passkey domain update failed": "Échec de la mise à jour des domaines de clés d’accès",
"Passkey domains": "Domaines des clés d’accès",
"Passkey is not available in this browser": "Passkey n'est pas disponible dans ce navigateur",
"Passkey is not supported in this environment": "Passkey n'est pas pris en charge dans cet environnement",
"Passkey is not supported on this device": "Passkey n'est pas pris en charge sur cet appareil",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "La vérification par Passkey a été annulée",
"Passkey verification was cancelled or timed out": "La vérification par Passkey a été annulée ou a expiré",
"Passkey website domain": "Domaine du site pour les Passkeys",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "Les clés d’accès des domaines supprimés cesseront de fonctionner. Confirmez uniquement si les utilisateurs concernés peuvent se connecter autrement.",
"Passkeys known to use removed domains: {{amount}}": "Clés d’accès utilisant les domaines supprimés : {{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "Les Passkeys fonctionnent uniquement sur le site pour lequel elles ont été créées. Cela empêche d’autres sites de les utiliser à mauvais escient.",
"Passkeys with an unknown domain that may be affected: {{amount}}": "Clés d’accès de domaine inconnu pouvant être affectées : {{amount}}",
"Passthrough Template": "Modèle de transmission",
"Password": "Mot de passe",
"Password / Access Token": "Mot de passe / Jeton d'accès",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "Palier tarifaire {{name}}",
"Pricing Type": "Type de tarification",
"Primary Model": "Modèle principal",
"Primary Passkey domain": "Domaine principal",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "Priorise la réutilisation du dernier canal ayant réussi, basé sur les clés extraites du contexte de la requête (routage persistant)",
"Priority": "Priorité",
"Priority (Default)": "Priorité (Par défaut)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "Supprimer toutes les entrées de journal créées avant l'horodatage sélectionné.",
"Remove attachment": "Supprimer la pièce jointe",
"Remove branch": "Supprimer la branche",
"Remove compatible Passkey domains?": "Supprimer des domaines compatibles ?",
"Remove condition": "Supprimer la condition",
"Remove domains": "Supprimer les domaines",
"Remove Duplicates": "Supprimer les doublons",
"Remove end condition": "Supprimer la borne de fin",
"Remove filter": "Supprimer le filtre",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "Vérifiez chaque ajout et écrasement avant l’application. Les prix, canaux et droits des groupes restent inchangés.",
"Review model rates before scaling traffic": "Consulter les tarifs des modèles avant d'augmenter le trafic",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "Vérifiez les fiches concernées avant d’appliquer. Les tarifs, canaux et noms de modèles sont conservés.",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "Vérifiez les domaines compatibles et les sites autorisés, puis enregistrez les modifications sur la page des paramètres.",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "Vérifiez les tarifs et l’expression de facturation. La confirmation met à jour le brouillon ; enregistrez les tarifs du modèle pour les appliquer.",
"Review your payment details": "Vérifier vos détails de paiement",
"Review your purchase details before proceeding.": "Vérifiez les détails de votre achat avant de continuer.",
@@ -5616,6 +5641,7 @@
"This month": "Ce mois-ci",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "Ce tarif OpenRouter Claude déduit les écritures en cache du coût amont et doit être converti manuellement.",
"This page has not been created yet.": "Cette page n'a pas encore été créée.",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "Ce domaine de clé d’accès n’est pas disponible sur ce site. Utilisez le site d’origine ou une autre méthode de vérification.",
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "Ce plugin ne dispose d’aucune version intégrée de secours. Sa suppression ou désactivation rend la plateforme indisponible.",
"This plugin path does not resolve within the source repository.": "Ce chemin de plugin ne pointe pas dans le dépôt de la source.",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "Canal {{name}} mis à jour (ID : {{id}})",
"Updated channel status (ID: {{id}})": "Statut du canal mis à jour (ID : {{id}})",
"Updated daily": "Mis à jour quotidiennement",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "Domaines de clés d’accès mis à jour : supprimés {{domains}} ; affectées {{known}} ; domaine inconnu {{unknown}}",
"Updated successfully": "Mise à jour réussie",
"Updated system setting {{key}}": "Paramètre système {{key}} mis à jour",
"Updated user {{username}} (ID: {{id}})": "Utilisateur {{username}} mis à jour (ID : {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "Utilisez 8 à 128 caractères.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Utilisez un navigateur ou un appareil compatible avec l'authentification biométrique ou une clé de sécurité pour enregistrer une clé d'accès (Passkey).",
"Use a different stable value for each instance, then restart the service.": "Utilisez une valeur stable différente pour chaque instance, puis redémarrez le service.",
"Use a Passkey from another website": "Utiliser une clé d’accès d’un autre site",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Utilisez un chemin pour l’ajouter à la Base URL du canal, ou saisissez une URL complète pour remplacer la Base URL pour cette route.",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "Configurez Passkey sur un domaine HTTPS, ou sur localhost en développement.",
"Use authenticator code": "Utiliser le code de l'authentificateur",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "Sans conditions, l'opération s'exécute toujours.",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "Lorsque la surveillance des performances est activée et que les ressources dépassent le seuil, les nouvelles requêtes Relay seront rejetées.",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "Lors de l'exécution dans des conteneurs ou des environnements éphémères, assurez-vous que le fichier SQLite est mappé à un stockage persistant pour éviter la perte de données au redémarrage.",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "Lorsque le domaine principal change, l’ancien domaine est automatiquement conservé dans la liste des domaines compatibles.",
"Whitelist": "Liste blanche",
"Whitelist (Only allow listed domains)": "Liste blanche (Autoriser uniquement les domaines listés)",
"Whitelist (Only allow listed IPs)": "Liste blanche (Autoriser uniquement les adresses IP listées)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "課金倍率です。倍率が低いほど API 呼び出しコストは低くなります。",
"A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。",
"A matching hash confirms the published file, not the safety of its code.": "ハッシュの一致は公開ファイルとの同一性を示すだけで、コードの安全性は保証しません。",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "www.example.com で作成したパスキーは example.com では使用できません。ユーザーが引き続き認証できるよう、以前のウェブサイトを維持してください。",
"A security operation is already in progress.": "セキュリティ操作を実行中です。",
"A selected model no longer exists. Reload the model list.": "選択したモデルは既に削除されています。一覧を再読み込みしてください。",
"A vendor with this name already exists.": "この名前のプロバイダーは既に存在します。",
@@ -1036,6 +1037,7 @@
"Choose price sources": "価格の取得元を選択",
"Choose the bundle type and define the items inside it.": "バンドルタイプを選択し、その中のアイテムを定義してください。",
"Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。",
"Choose the domain used when this Passkey was created, then try again.": "このパスキーの作成時に使用したドメインを選び、再試行してください。",
"Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
"Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。",
"Clamped to": "制限後の値",
@@ -1152,6 +1154,7 @@
"Comparison operator": "比較演算子",
"compatible API routes": "互換APIルート",
"Compatible API routes for common AI application workflows": "一般的なAIアプリケーションワークフロー向けの互換APIルート",
"Compatible Passkey domains": "互換用パスキードメイン",
"Compilation failed": "コンパイル失敗",
"Complete API documentation with multi-language SDK support": "多言語SDKをサポートする完全なAPIドキュメント",
"Complete Order": "手動チャージ",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "パスキーを登録する前に、二要素認証で本人確認を行ってください。",
"Confirmed at {{time}} by user #{{userId}}": "ユーザー #{{userId}} が {{time}} に確認しました",
"Confirmed payment compliance": "支払いコンプライアンスを確認しました",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "パスキードメインの削除を確認:{{domains}}、影響あり {{known}}、ドメイン不明 {{unknown}}",
"Confirming the account linked to your profile.": "連携済みのアカウントを確認しています。",
"Conflict": "競合",
"Connect": "接続",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "文字列に指定のプレフィックスがあることを確認",
"Ensure the string has a specified suffix": "文字列に指定のサフィックスがあることを確認",
"Enter 6-digit code": "6桁のコードを入力",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "example.com や localhost などのドメインのみを入力し、プロトコル、ポート、パスは含めないでください。ポート付きのアドレスは「Passkey の利用を許可するサイト」に入力します。",
"Enter a finite, non-negative price": "有限の非負の料金を入力してください",
"Enter a finite, non-negative price.": "有限の非負の料金を入力してください。",
"Enter a name": "名前を入力",
@@ -2073,6 +2078,8 @@
"Enter password": "パスワードを入力",
"Enter password (8-20 characters)": "パスワードを入力 (8~20文字)",
"Enter password (8–128 characters)": "パスワードを入力(8~128文字)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "以前のドメインを1行に1つ入力し、プロトコル、ポート、パスは含めないでください。同じドメインでポートのみが異なる場合、互換ドメインへの追加は不要です。",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "以前のパスキードメインを1行に1つ入力してください。既存のパスキーはこれらのドメインで引き続き認証でき、新しいパスキーはメインドメインを使用します。",
"Enter quota in {{currency}}": "{{currency}} でクォータを入力",
"Enter quota in tokens": "トークン単位でクォータを入力",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "1回のリクエストの使用量を入力して料金を確認できます。グループ倍率とリクエストルールの倍率は含みません。",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "深刻なクライアントタイムアウトやサービス停止を含むシステム安定性のリスクを自発的に受け入れ、その結果生じるリクエスト滞留やサービス停止の責任を負います。",
"High-risk status code retry risk disclaimer": "### ⚠️ 高リスク操作:504/524 ステータスコードのリトライに関するリスク通知と免責事項\n\n本プロジェクトは既定で、`400`(不正なリクエスト)、`504`(ゲートウェイタイムアウト)、`524`(タイムアウト発生)をリトライしません。504 と 524 は通常、**リクエストが上流 AI サービスに正常に到達して上流側で処理が始まっているものの、上流処理に時間がかかりすぎて接続が切断された**ことを意味します。多くの場合、これは上流サービス側のボトルネックです。\n\nこれらのタイムアウトコードでリダイレクトまたはリトライを有効にすることは、**極めて高リスクな操作**です。有効にする前に、次の重大な影響を必ず読み、理解してください。\n\n#### 1. 主なリスク(必ずお読みください)\n\n1. 💸 二重・多重課金:多くの上流 AI プロバイダーは、処理開始後にネットワークタイムアウト(504/524)で中断されたリクエストにも**通常どおり課金します**。リトライでは新しい上流リクエストが送信されるため、**二重または多重課金**になる可能性があります。\n2. ⏳ 深刻なクライアントタイムアウト:すでにタイムアウトしたリクエストにリトライを重ねると、総待ち時間が何倍にもなり、最終クライアントで深刻または許容できないタイムアウトが発生する可能性があります。\n3. 💥 リクエスト滞留とサービス停止:強制リトライはスレッドと接続を長時間占有します。高負荷時には深刻な**リクエスト滞留**、リソース枯渇、連鎖障害を招き、プロキシサービスが停止する可能性があります。\n\n#### 2. リスクの確認\n\nそれでもこの機能を有効にする場合は、以下のすべてを確認したものとみなされます。",
"Higher priority channels are selected first": "優先度の高いチャネルが先に選択されます",
"Historical capitalization": "以前の大文字・小文字",
"Historical tokens": "過去のトークン",
"Historical Usage": "履歴使用状況",
"History of MjProxy-style image tasks.": "MjProxyスタイルの画像タスクの履歴。",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "io.net API キーの取得方法",
"How to reset my quota?": "クォータをリセットするには?",
"How to select keys: random or sequential polling": "キーの選択方法: ランダムまたは順次ポーリング",
"How to set up Passkey domains": "パスキーのドメイン設定方法",
"How will you use the platform?": "プラットフォームをどのように使用しますか?",
"HTTP Protocol": "HTTP プロトコル",
"HTTP protocol must be Auto or HTTP/1.1": "HTTP プロトコルは自動または HTTP/1.1 である必要があります",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "すでに Passkey を登録しているユーザーは、ドメインの変更後、別の方法でログインして再登録が必要になる場合があります。",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "中国本土で一般向けに生成 AI サービスを提供する場合、届出、セキュリティ評価、コンテンツ安全、苦情対応、生成コンテンツのラベル表示、ログ保存、個人情報保護などの法的義務を履行します。",
"If your Passkey belongs to another website, start sign-in there:": "パスキーが別のサイトに属する場合は、そのサイトでログインしてください:",
"Ignore": "無視",
"Ignore this version": "このバージョンの通知を停止",
"Ignored upstream models": "無視する上流モデル",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "パラメータオーバーライドテンプレートのJSONが無効です",
"Invalid JSON string.": "無効な JSON 文字列です。",
"Invalid model mapping format": "無効なモデルマッピング形式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "パスキードメインが無効です。プロトコル、ポート、パス、ワイルドカードを含めず、ドメインを入力してください。",
"Invalid Passkey registration response": "無効なパスキー登録応答",
"Invalid Passkey response": "無効なパスキー応答",
"Invalid payment redirect URL": "無効な支払いリダイレクトURL",
@@ -2957,6 +2968,7 @@
"Keep local": "ローカルを保持",
"Keep original value": "元の値を保持",
"Keep original value (skip if target exists)": "元の値を保持(ターゲットが存在する場合はスキップ)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "既存のパスキーで使用しているドメインを1行に1つ入力します。メインドメインを www.example.com から example.com に変更した場合は、ここに www.example.com を残してください。",
"Keep the platform ready": "プラットフォームを準備状態に保つ",
"Keep this above 1 minute to avoid heavy database load": "データベースへの負荷を避けるため、これを1分以上に保ってください",
"Keep this vendor": "このプロバイダーを残す",
@@ -3454,6 +3466,9 @@
"New model": "新しいモデル",
"New Models ({{count}})": "新しいモデル ({{count}})",
"New name will be:": "新しい名前は次のようになります:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "新しいパスキーはメインドメインを使用します。既存のパスキーは互換ドメインで引き続き使用できます。",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "新しいパスキーはこのドメインを使用します。https://、ポート、パスを含めず、ドメインのみ入力してください。空欄の場合はシステムのウェブサイトアドレスを使用します。",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "新しいパスキーは新しいメインドメインを使用します。既存のパスキーで引き続き認証できるよう、以前のドメインは保持されます。",
"New password": "新しいパスワード",
"New Password": "新しいパスワード",
"New password must be different from current password": "新しいパスワードは現在のものと異なっていなければなりません",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "パスキー認証",
"Passkey authentication is disabled.": "Passkey認証は無効になっています。",
"Passkey display name": "Passkey の表示名",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "パスキードメインの変更を拒否:{{domains}}、影響あり {{known}}、ドメイン不明 {{unknown}}",
"Passkey domain update failed": "パスキードメインの更新に失敗しました",
"Passkey domains": "パスキーのドメイン",
"Passkey is not available in this browser": "このブラウザではパスキーが利用できません",
"Passkey is not supported in this environment": "この環境ではパスキーはサポートされていません",
"Passkey is not supported on this device": "このデバイスではパスキーがサポートされていません",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Passkey 認証がキャンセルされました",
"Passkey verification was cancelled or timed out": "Passkey 認証がキャンセルされたか、タイムアウトしました",
"Passkey website domain": "Passkey を使うサイトのドメイン",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "削除したドメインのパスキーは使えなくなります。対象ユーザーが別の方法でログインできる場合のみ確定してください。",
"Passkeys known to use removed domains: {{amount}}": "削除するドメインを使用しているパスキー:{{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "Passkey は作成したサイトでのみ使えます。他のサイトでの悪用を防ぐためです。",
"Passkeys with an unknown domain that may be affected: {{amount}}": "ドメインが不明で影響を受ける可能性があるパスキー:{{amount}}",
"Passthrough Template": "透過テンプレート",
"Password": "パスワード",
"Password / Access Token": "パスワード / アクセストークン",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "料金段階 {{name}}",
"Pricing Type": "価格タイプ",
"Primary Model": "プライマリモデル",
"Primary Passkey domain": "メインドメイン",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "リクエストコンテキストから抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します(スティッキールーティング)",
"Priority": "優先度",
"Priority (Default)": "優先度 (デフォルト)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "選択したタイムスタンプより前に作成されたすべてのログエントリを削除します。",
"Remove attachment": "添付ファイルを削除",
"Remove branch": "分岐を削除",
"Remove compatible Passkey domains?": "互換用パスキードメインを削除しますか?",
"Remove condition": "条件を削除",
"Remove domains": "ドメインを削除",
"Remove Duplicates": "重複を削除",
"Remove end condition": "終了条件を削除",
"Remove filter": "フィルターを削除",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "追加・上書きする内容を適用前に確認してください。料金、チャネル、グループ権限は変更されません。",
"Review model rates before scaling traffic": "トラフィック拡大前にモデル料金を確認",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "適用前に影響するレコードを確認してください。料金、チャネル、モデル名は維持されます。",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "互換ドメインと許可するウェブサイトを確認し、設定ページで変更を保存してください。",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "料金と課金式を確認してください。確定すると下書きが更新され、モデル料金の保存後に適用されます。",
"Review your payment details": "支払い詳細を確認",
"Review your purchase details before proceeding.": "続行前に購入詳細を確認してください。",
@@ -5616,6 +5641,7 @@
"This month": "今月",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "このOpenRouter Claude料金は上流の費用からキャッシュ書き込み使用量を逆算するため、手動で変換してください。",
"This page has not been created yet.": "このページはまだ作成されていません。",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "このパスキードメインは現在のサイトでは使えません。元のサイトか、別の認証方法を使用してください。",
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "このプラグインには組み込みのフォールバックがありません。削除または無効化するとプラットフォームが利用できなくなります。",
"This plugin path does not resolve within the source repository.": "このプラグインのパスはソースリポジトリ内を指していません。",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "チャネル {{name}} を更新しました(ID: {{id}})",
"Updated channel status (ID: {{id}})": "チャネルの状態を更新(ID: {{id}})",
"Updated daily": "毎日更新",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "パスキードメインを更新:削除 {{domains}}、影響あり {{known}}、ドメイン不明 {{unknown}}",
"Updated successfully": "正常に更新されました",
"Updated system setting {{key}}": "システム設定 {{key}} を更新しました",
"Updated user {{username}} (ID: {{id}})": "ユーザー {{username}} を更新しました(ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "8~128文字で設定してください。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "生体認証またはセキュリティキーを備えた互換性のあるブラウザまたはデバイスを使用して、パスキーを登録してください。",
"Use a different stable value for each instance, then restart the service.": "インスタンスごとに異なる安定した値を使用し、その後サービスを再起動してください。",
"Use a Passkey from another website": "別のサイトのパスキーを使用",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "パスを入力するとチャネルの Base URL に追加されます。完全な URL を入力すると、このルートでは Base URL を使わずその URL を使用します。",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "Passkey の設定には HTTPS のドメインを使用してください。開発時は localhost も使えます。",
"Use authenticator code": "認証コードを使用",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "条件が設定されていない場合、操作は常に実行されます。",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "パフォーマンス監視を有効にすると、システムリソース使用率が閾値を超えた場合、新しいRelayリクエストが拒否されます。",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "コンテナまたは一時的な環境で実行する場合、再起動時のデータ損失を防ぐために、SQLiteファイルが永続ストレージにマッピングされていることを確認してください。",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "メインドメインを変更すると、以前のドメインは互換用ドメイン一覧に自動的に保持されます。",
"Whitelist": "ホワイトリスト",
"Whitelist (Only allow listed domains)": "ホワイトリスト (リストされたドメインのみを許可)",
"Whitelist (Only allow listed IPs)": "ホワイトリスト (リストされたIPのみを許可)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "Множитель тарификации. Чем ниже коэффициент, тем ниже стоимость вызовов API.",
"A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.",
"A matching hash confirms the published file, not the safety of its code.": "Совпадение хеша подтверждает соответствие опубликованному файлу, но не безопасность кода.",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "Ключ доступа, созданный для www.example.com, не работает на example.com. Сохраните доступ к прежнему сайту, чтобы пользователи могли проходить проверку на нём.",
"A security operation is already in progress.": "Операция безопасности уже выполняется.",
"A selected model no longer exists. Reload the model list.": "Выбранная модель больше не существует. Обновите список моделей.",
"A vendor with this name already exists.": "Поставщик с таким именем уже существует.",
@@ -1036,6 +1037,7 @@
"Choose price sources": "Выберите источники цен",
"Choose the bundle type and define the items inside it.": "Выберите тип пакета и определите элементы внутри него.",
"Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.",
"Choose the domain used when this Passkey was created, then try again.": "Выберите домен, использованный при создании этого ключа доступа, и повторите попытку.",
"Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
"Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.",
"Clamped to": "Ограничено до",
@@ -1152,6 +1154,7 @@
"Comparison operator": "Оператор сравнения",
"compatible API routes": "совместимых API-маршрутов",
"Compatible API routes for common AI application workflows": "Совместимые API-маршруты для типовых сценариев ИИ-приложений",
"Compatible Passkey domains": "Совместимые домены ключей доступа",
"Compilation failed": "Ошибка компиляции",
"Complete API documentation with multi-language SDK support": "Полная документация API с поддержкой SDK на нескольких языках",
"Complete Order": "Вывод заказа",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "Подтвердите свою личность с помощью двухфакторной аутентификации перед регистрацией Passkey.",
"Confirmed at {{time}} by user #{{userId}}": "Подтверждено {{time}} пользователем #{{userId}}",
"Confirmed payment compliance": "Соответствие платежа подтверждено",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "Удаление доменов ключей доступа подтверждено: {{domains}}; затронуто {{known}}; домен неизвестен {{unknown}}",
"Confirming the account linked to your profile.": "Проверяем привязанный к профилю аккаунт.",
"Conflict": "Противоречие",
"Connect": "Подключение",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "Убедиться, что строка имеет указанный префикс",
"Ensure the string has a specified suffix": "Убедиться, что строка имеет указанный суффикс",
"Enter 6-digit code": "Введите 6-значный код",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "Укажите домен, например example.com или localhost, без протокола, порта и пути. Адреса с портами добавьте в «Сайты, разрешённые для Passkey».",
"Enter a finite, non-negative price": "Введите конечную неотрицательную цену",
"Enter a finite, non-negative price.": "Введите конечную неотрицательную цену.",
"Enter a name": "Введите имя",
@@ -2073,6 +2078,8 @@
"Enter password": "Введите пароль",
"Enter password (8-20 characters)": "Введите пароль (8-20 символов)",
"Enter password (8–128 characters)": "Введите пароль (8–128 символов)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "Укажите прежние домены, по одному на строку, без протокола, порта и пути. Разные порты одного домена не требуют добавления совместимых доменов.",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "Введите прежние домены ключей доступа, по одному в строке. Существующие ключи продолжат работать с этими доменами, а новые будут использовать основной домен.",
"Enter quota in {{currency}}": "Введите квоту в {{currency}}",
"Enter quota in tokens": "Введите квоту в токенах",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "Введите объём использования для одного запроса. Множители группы и правил запроса не учитываются.",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "Я добровольно принимаю риски для стабильности системы, включая серьёзные тайм-ауты клиента и возможный сбой сервиса, и несу ответственность за возникшую очередь запросов или недоступность сервиса.",
"High-risk status code retry risk disclaimer": "### ⚠️ Операция высокого риска: предупреждение и отказ от ответственности при повторах для кодов 504/524\n\nПо умолчанию проект не повторяет запросы при кодах `400` (неверный запрос), `504` (тайм-аут шлюза) и `524` (истекло время ожидания). Коды 504 и 524 обычно означают, что **запрос успешно дошёл до вышестоящего ИИ-сервиса и обработка на его стороне уже началась, но соединение закрылось из-за слишком долгой обработки вышестоящим сервисом**. Обычно это указывает на узкое место именно вышестоящего сервиса.\n\nВключение перенаправления или повторов для таких кодов тайм-аута — **операция чрезвычайно высокого риска**. Перед включением внимательно прочитайте и поймите следующие последствия:\n\n#### 1. Основные риски (прочитайте внимательно)\n\n1. 💸 Двойное или многократное списание: большинство вышестоящих ИИ-провайдеров **всё равно взимают плату** за запросы, обработка которых началась, но была прервана сетевым тайм-аутом (504/524). Повтор отправляет новый запрос вышестоящему сервису и может привести к **двойному или многократному списанию**.\n2. ⏳ Серьёзный тайм-аут клиента: если запрос уже завершился по тайм-ауту, повторы могут многократно увеличить общую задержку и вызвать неприемлемое ожидание у конечного клиента.\n3. 💥 Очередь запросов и сбой сервиса: принудительные повторы дольше занимают потоки и соединения. При высокой нагрузке это может создать серьёзную **очередь запросов**, исчерпать ресурсы, вызвать каскадный отказ и остановить прокси-сервис.\n\n#### 2. Принятие рисков\n\nЕсли вы всё же включаете эту функцию, вы подтверждаете следующее:",
"Higher priority channels are selected first": "Каналы с более высоким приоритетом выбираются первыми",
"Historical capitalization": "Исторический регистр",
"Historical tokens": "Предыдущие токены",
"Historical Usage": "История использования",
"History of MjProxy-style image tasks.": "История задач генерации изображений в стиле MjProxy.",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "Как получить ключ API io.net",
"How to reset my quota?": "Как сбросить мою квоту?",
"How to select keys: random or sequential polling": "Как выбирать ключи: случайно или последовательный опрос",
"How to set up Passkey domains": "Настройка доменов ключей доступа",
"How will you use the platform?": "Как вы будете использовать платформу?",
"HTTP Protocol": "HTTP-протокол",
"HTTP protocol must be Auto or HTTP/1.1": "HTTP-протокол должен быть Auto или HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "Если у пользователей уже есть Passkey, после смены домена им может потребоваться войти другим способом и настроить Passkey заново.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Если вы предоставляете услуги генеративного ИИ населению материкового Китая, вы будете выполнять юридические обязанности, включая регистрацию, оценку безопасности, безопасность контента, обработку жалоб, маркировку сгенерированного контента, хранение журналов и защиту персональных данных.",
"If your Passkey belongs to another website, start sign-in there:": "Если ключ доступа относится к другому сайту, войдите на том сайте:",
"Ignore": "Игнорировать",
"Ignore this version": "Игнорировать эту версию",
"Ignored upstream models": "Игнорируемые upstream-модели",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "Недопустимый JSON в шаблоне переопределения параметров",
"Invalid JSON string.": "Недопустимая строка JSON.",
"Invalid model mapping format": "Неверный формат сопоставления моделей",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Недопустимый домен ключа доступа. Введите домен без протокола, порта, пути и подстановочных символов.",
"Invalid Passkey registration response": "Неверный ответ на регистрацию Passkey",
"Invalid Passkey response": "Недопустимый ответ Passkey",
"Invalid payment redirect URL": "Недопустимый URL перенаправления для оплаты",
@@ -2957,6 +2968,7 @@
"Keep local": "Сохранять локальные данные",
"Keep original value": "Сохранить исходное значение",
"Keep original value (skip if target exists)": "Сохранить исходное значение (пропустить если цель существует)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Укажите домены существующих ключей доступа, по одному на строку. Если основной домен изменился с www.example.com на example.com, сохраните здесь www.example.com.",
"Keep the platform ready": "Поддерживайте платформу в готовности",
"Keep this above 1 minute to avoid heavy database load": "Держите это значение выше 1 минуты, чтобы избежать высокой нагрузки на базу данных",
"Keep this vendor": "Сохранить этого поставщика",
@@ -3454,6 +3466,9 @@
"New model": "Новая модель",
"New Models ({{count}})": "Новые модели ({{count}})",
"New name will be:": "Новое имя будет:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "Новые ключи доступа используют основной домен. Совместимые домены позволяют продолжать использовать существующие ключи.",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "Новые ключи доступа используют этот домен. Укажите только домен, без https://, порта или пути. Если поле пустое, используется адрес сайта системы.",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "Новые ключи доступа будут использовать новый основной домен. Прежний домен будет сохранён, чтобы существующие ключи продолжали работать.",
"New password": "Новый пароль",
"New Password": "Новый пароль",
"New password must be different from current password": "Новый пароль должен отличаться от текущего пароля",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "Аутентификация Passkey",
"Passkey authentication is disabled.": "Аутентификация Passkey отключена.",
"Passkey display name": "Отображаемое имя Passkey",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "Изменение доменов ключей доступа заблокировано: {{domains}}; затронуто {{known}}; домен неизвестен {{unknown}}",
"Passkey domain update failed": "Не удалось обновить домены ключей доступа",
"Passkey domains": "Домены ключей доступа",
"Passkey is not available in this browser": "Passkey недоступен в этом браузере",
"Passkey is not supported in this environment": "Passkey не поддерживается в этой среде",
"Passkey is not supported on this device": "Passkey не поддерживается на этом устройстве",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Проверка Passkey отменена",
"Passkey verification was cancelled or timed out": "Проверка Passkey отменена или истекло время ожидания",
"Passkey website domain": "Домен сайта для Passkey",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "Ключи доступа удалённых доменов перестанут работать. Подтверждайте только если затронутые пользователи могут войти другим способом.",
"Passkeys known to use removed domains: {{amount}}": "Ключи доступа, использующие удаляемые домены: {{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "Passkey работают только на сайте, для которого были созданы. Это защищает их от использования посторонними сайтами.",
"Passkeys with an unknown domain that may be affected: {{amount}}": "Ключи доступа с неизвестным доменом, которые могут быть затронуты: {{amount}}",
"Passthrough Template": "Шаблон сквозной передачи",
"Password": "Пароль",
"Password / Access Token": "Пароль / Токен доступа",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "Тарифный уровень {{name}}",
"Pricing Type": "Тип ценообразования",
"Primary Model": "Основная модель",
"Primary Passkey domain": "Основной домен",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "Приоритет повторного использования последнего успешного канала на основе ключей из контекста запроса (липкая маршрутизация)",
"Priority": "Приоритет",
"Priority (Default)": "Приоритет (По умолчанию)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "Удалить все записи журнала, созданные до выбранной отметки времени.",
"Remove attachment": "Удалить вложение",
"Remove branch": "Удалить ветвь",
"Remove compatible Passkey domains?": "Удалить совместимые домены ключей доступа?",
"Remove condition": "Удалить условие",
"Remove domains": "Удалить домены",
"Remove Duplicates": "Удалить дубликаты",
"Remove end condition": "Удалить конечное условие",
"Remove filter": "Удалить фильтр",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "Перед применением проверьте каждое добавление и перезапись. Цены, каналы и права групп не изменятся.",
"Review model rates before scaling traffic": "Проверьте тарифы моделей перед масштабированием трафика",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "Перед применением проверьте затронутые записи. Цены, каналы и имена моделей сохраняются.",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "Проверьте совместимые домены и разрешённые сайты, затем сохраните изменения на странице настроек.",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "Проверьте цены и выражение тарификации. Подтверждение обновит черновик; изменения вступят в силу после сохранения цен модели.",
"Review your payment details": "Проверьте свои платежные данные",
"Review your purchase details before proceeding.": "Просмотрите детали покупки перед продолжением.",
@@ -5616,6 +5641,7 @@
"This month": "В этом месяце",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "Этот тариф OpenRouter Claude определяет объём записи в кэш по стоимости провайдера и требует ручного преобразования.",
"This page has not been created yet.": "Эта страница еще не создана.",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "Этот домен ключа доступа недоступен на текущем сайте. Используйте исходный сайт или другой способ проверки.",
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "У этого плагина нет встроенной резервной версии. Его удаление или отключение сделает платформу недоступной.",
"This plugin path does not resolve within the source repository.": "Этот путь плагина не ведёт внутрь репозитория источника.",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "Обновлён канал {{name}} (ID: {{id}})",
"Updated channel status (ID: {{id}})": "Обновлён статус канала (ID: {{id}})",
"Updated daily": "Обновляется ежедневно",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "Домены ключей доступа обновлены: удалены {{domains}}; затронуто {{known}}; домен неизвестен {{unknown}}",
"Updated successfully": "Обновлено успешно",
"Updated system setting {{key}}": "Обновлён системный параметр {{key}}",
"Updated user {{username}} (ID: {{id}})": "Обновлён пользователь {{username}} (ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "Используйте от 8 до 128 символов.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Используйте совместимый браузер или устройство с биометрической аутентификацией или ключ безопасности для регистрации ключа доступа.",
"Use a different stable value for each instance, then restart the service.": "Используйте разные стабильные значения для каждого экземпляра, затем перезапустите сервис.",
"Use a Passkey from another website": "Использовать ключ доступа другого сайта",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Укажите путь, чтобы добавить его к Base URL канала, или введите полный URL, чтобы переопределить Base URL для этого маршрута.",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "Настраивайте Passkey на домене с HTTPS или на localhost для разработки.",
"Use authenticator code": "Использовать код аутентификатора",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "Без условий операция выполняется всегда.",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "Когда мониторинг включён и использование ресурсов превышает порог, новые Relay-запросы будут отклонены.",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "При работе в контейнерах или эфемерных средах убедитесь, что файл SQLite сопоставлен с постоянным хранилищем, чтобы избежать потери данных при перезапуске.",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "При изменении основного домена прежний домен автоматически сохраняется в списке совместимых доменов.",
"Whitelist": "Белый список",
"Whitelist (Only allow listed domains)": "Белый список (Разрешить только указанные домены)",
"Whitelist (Only allow listed IPs)": "Белый список (Разрешить только указанные IP-адреса)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "Hệ số tính phí. Tỷ lệ càng thấp thì chi phí gọi API càng thấp.",
"A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.",
"A matching hash confirms the published file, not the safety of its code.": "Mã băm khớp chỉ xác nhận tệp đúng với bản phát hành, không đảm bảo mã an toàn.",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "Khóa truy cập tạo cho www.example.com không thể dùng trên example.com. Hãy duy trì trang web cũ để người dùng vẫn có thể xác minh tại đó.",
"A security operation is already in progress.": "Một thao tác bảo mật đang được thực hiện.",
"A selected model no longer exists. Reload the model list.": "Một mô hình đã chọn không còn tồn tại. Hãy tải lại danh sách.",
"A vendor with this name already exists.": "Nhà cung cấp có tên này đã tồn tại.",
@@ -1036,6 +1037,7 @@
"Choose price sources": "Chọn nguồn giá",
"Choose the bundle type and define the items inside it.": "Chọn loại gói và định nghĩa các mục bên trong nó.",
"Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.",
"Choose the domain used when this Passkey was created, then try again.": "Chọn tên miền đã dùng khi tạo khóa truy cập này rồi thử lại.",
"Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
"Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.",
"Clamped to": "Giới hạn thành",
@@ -1152,6 +1154,7 @@
"Comparison operator": "Toán tử so sánh",
"compatible API routes": "tuyến API tương thích",
"Compatible API routes for common AI application workflows": "Các tuyến API tương thích cho quy trình ứng dụng AI phổ biến",
"Compatible Passkey domains": "Tên miền khóa truy cập tương thích",
"Compilation failed": "Biên dịch thất bại",
"Complete API documentation with multi-language SDK support": "Tài liệu API đầy đủ với hỗ trợ SDK đa ngôn ngữ",
"Complete Order": "Hoàn thành đơn hàng",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "Hãy xác minh danh tính bằng Xác thực hai yếu tố trước khi đăng ký Passkey.",
"Confirmed at {{time}} by user #{{userId}}": "Được xác nhận lúc {{time}} bởi người dùng #{{userId}}",
"Confirmed payment compliance": "Đã xác nhận tuân thủ thanh toán",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "Đã xác nhận xóa miền khóa truy cập: {{domains}}; bị ảnh hưởng {{known}}; chưa rõ miền {{unknown}}",
"Confirming the account linked to your profile.": "Đang xác minh tài khoản đã liên kết với hồ sơ của bạn.",
"Conflict": "Xung đột",
"Connect": "Kết nối",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "Đảm bảo chuỗi có tiền tố chỉ định",
"Ensure the string has a specified suffix": "Đảm bảo chuỗi có hậu tố chỉ định",
"Enter 6-digit code": "Nhập mã 6 chữ số",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "Chỉ nhập tên miền như example.com hoặc localhost, không kèm giao thức, cổng hay đường dẫn. Điền địa chỉ có cổng vào «Các trang được phép dùng Passkey».",
"Enter a finite, non-negative price": "Nhập giá hữu hạn, không âm",
"Enter a finite, non-negative price.": "Nhập mức giá hữu hạn, không âm.",
"Enter a name": "Nhập tên",
@@ -2073,6 +2078,8 @@
"Enter password": "Nhập mật khẩu",
"Enter password (8-20 characters)": "Nhập mật khẩu (8-20 ký tự)",
"Enter password (8–128 characters)": "Nhập mật khẩu (8–128 ký tự)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "Nhập các tên miền cũ, mỗi dòng một tên miền, không kèm giao thức, cổng hay đường dẫn. Các cổng khác nhau trên cùng tên miền không cần thêm vào danh sách tên miền tương thích.",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "Nhập các tên miền khóa truy cập cũ, mỗi dòng một tên miền. Các khóa hiện có vẫn xác minh được với những tên miền này; khóa mới dùng tên miền chính.",
"Enter quota in {{currency}}": "Nhập hạn mức bằng {{currency}}",
"Enter quota in tokens": "Nhập hạn mức bằng token",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "Nhập mức sử dụng của một yêu cầu để ước tính chi phí, chưa áp dụng hệ số nhóm và quy tắc yêu cầu.",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "Tôi tự nguyện chấp nhận rủi ro về độ ổn định hệ thống, gồm hết thời gian chờ nghiêm trọng ở phía máy khách và khả năng dịch vụ gặp sự cố, đồng thời chịu trách nhiệm về tình trạng tồn đọng yêu cầu hoặc gián đoạn dịch vụ phát sinh.",
"High-risk status code retry risk disclaimer": "### ⚠️ Thao tác rủi ro cao: cảnh báo và tuyên bố miễn trừ trách nhiệm khi thử lại mã 504/524\n\nTheo mặc định, dự án không thử lại với mã `400` (yêu cầu không hợp lệ), `504` (gateway hết thời gian chờ) hoặc `524` (đã hết thời gian chờ). Mã 504 và 524 thường có nghĩa là **yêu cầu đã đến dịch vụ AI thượng nguồn thành công và phía thượng nguồn đã bắt đầu xử lý, nhưng kết nối bị đóng vì quá trình xử lý ở thượng nguồn mất quá nhiều thời gian**. Điều này thường cho thấy nút thắt nằm ở dịch vụ thượng nguồn.\n\nBật chuyển hướng hoặc thử lại cho các mã hết thời gian chờ này là một **thao tác có rủi ro cực kỳ cao**. Trước khi bật, bạn phải đọc kỹ và hiểu các hậu quả sau:\n\n#### 1. Rủi ro chính (hãy đọc kỹ)\n\n1. 💸 Tính phí hai lần hoặc nhiều lần: phần lớn nhà cung cấp AI thượng nguồn **vẫn tính phí** cho yêu cầu đã bắt đầu xử lý nhưng bị ngắt do hết thời gian chờ mạng (504/524). Mỗi lần thử lại gửi một yêu cầu hoàn toàn mới đến thượng nguồn và có thể gây **tính phí hai lần hoặc nhiều lần**.\n2. ⏳ Hết thời gian chờ nghiêm trọng ở phía máy khách: khi một yêu cầu đã hết thời gian chờ, việc thử lại có thể làm tổng độ trễ tăng nhiều lần và gây thời gian chờ nghiêm trọng hoặc không thể chấp nhận cho máy khách cuối.\n3. 💥 Tồn đọng yêu cầu và sự cố dịch vụ: buộc thử lại giữ luồng và kết nối lâu hơn. Khi tải cao, điều này có thể gây **tồn đọng yêu cầu** nghiêm trọng, cạn kiệt tài nguyên, phát sinh lỗi dây chuyền và làm dịch vụ proxy ngừng hoạt động.\n\n#### 2. Xác nhận rủi ro\n\nNếu vẫn chọn bật tính năng này, bạn xác nhận tất cả nội dung sau:",
"Higher priority channels are selected first": "Các kênh ưu tiên cao hơn được chọn trước tiên",
"Historical capitalization": "Kiểu chữ hoa/thường cũ",
"Historical tokens": "Token trước đây",
"Historical Usage": "Lịch sử sử dụng",
"History of MjProxy-style image tasks.": "Lịch sử các tác vụ hình ảnh kiểu MjProxy.",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "Cách lấy Khóa API io.net",
"How to reset my quota?": "Cách đặt lại hạn mức của tôi?",
"How to select keys: random or sequential polling": "Cách chọn khóa: thăm dò ngẫu nhiên hay tuần tự",
"How to set up Passkey domains": "Cách thiết lập tên miền khóa truy cập",
"How will you use the platform?": "Bạn sẽ sử dụng nền tảng như thế nào?",
"HTTP Protocol": "Giao thức HTTP",
"HTTP protocol must be Auto or HTTP/1.1": "Giao thức HTTP phải là Tự động hoặc HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "Nếu người dùng đã có Passkey, đổi tên miền này có thể yêu cầu họ đăng nhập bằng cách khác và thiết lập lại Passkey.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Nếu bạn cung cấp dịch vụ AI tạo sinh cho công chúng tại Trung Quốc đại lục, bạn sẽ thực hiện các nghĩa vụ pháp lý bao gồm đăng ký, đánh giá an toàn, an toàn nội dung, xử lý khiếu nại, gắn nhãn nội dung được tạo, lưu giữ nhật ký và bảo vệ thông tin cá nhân.",
"If your Passkey belongs to another website, start sign-in there:": "Nếu khóa truy cập thuộc trang web khác, hãy đăng nhập tại trang đó:",
"Ignore": "Bỏ qua",
"Ignore this version": "Bỏ qua phiên bản này",
"Ignored upstream models": "Mô hình upstream bị bỏ qua",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "JSON không hợp lệ trong mẫu ghi đè tham số",
"Invalid JSON string.": "Chuỗi JSON không hợp lệ.",
"Invalid model mapping format": "Định dạng ánh xạ mô hình không hợp lệ",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "Tên miền khóa truy cập không hợp lệ. Chỉ nhập tên miền, không có giao thức, cổng, đường dẫn hoặc ký tự đại diện.",
"Invalid Passkey registration response": "Phản hồi đăng ký Passkey không hợp lệ",
"Invalid Passkey response": "Phản hồi Passkey không hợp lệ",
"Invalid payment redirect URL": "URL chuyển hướng thanh toán không hợp lệ",
@@ -2957,6 +2968,7 @@
"Keep local": "Giữ dữ liệu cục bộ",
"Keep original value": "Giữ giá trị gốc",
"Keep original value (skip if target exists)": "Giữ giá trị gốc (bỏ qua nếu đích đã tồn tại)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "Giữ các tên miền mà khóa truy cập hiện có sử dụng, mỗi dòng một tên miền. Nếu tên miền chính đổi từ www.example.com sang example.com, hãy giữ www.example.com tại đây.",
"Keep the platform ready": "Giữ nền tảng luôn sẵn sàng",
"Keep this above 1 minute to avoid heavy database load": "Giữ cái này trên 1 phút để tránh tải nặng cơ sở dữ liệu",
"Keep this vendor": "Giữ nhà cung cấp này",
@@ -3454,6 +3466,9 @@
"New model": "Mô hình mới",
"New Models ({{count}})": "Mô hình mới ({{count}})",
"New name will be:": "Tên mới sẽ là:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "Khóa truy cập mới sử dụng tên miền chính. Các tên miền tương thích giúp khóa truy cập hiện có tiếp tục hoạt động.",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "Khóa truy cập mới sử dụng tên miền này. Chỉ nhập tên miền, không kèm https://, cổng hoặc đường dẫn. Để trống sẽ sử dụng địa chỉ trang web của hệ thống.",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "Khóa truy cập mới sẽ dùng tên miền chính mới. Tên miền cũ được giữ lại để các khóa hiện có vẫn có thể xác minh.",
"New password": "Mật khẩu mới",
"New Password": "Mật khẩu mới",
"New password must be different from current password": "Mật khẩu mới phải khác với mật khẩu hiện tại",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "Xác thực khóa truy cập",
"Passkey authentication is disabled.": "Xác thực Passkey đã bị tắt.",
"Passkey display name": "Tên hiển thị Passkey",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "Đã chặn thay đổi miền khóa truy cập: {{domains}}; bị ảnh hưởng {{known}}; chưa rõ miền {{unknown}}",
"Passkey domain update failed": "Không thể cập nhật miền khóa truy cập",
"Passkey domains": "Tên miền khóa truy cập",
"Passkey is not available in this browser": "Passkey không khả dụng trong trình duyệt này",
"Passkey is not supported in this environment": "Passkey không được hỗ trợ trong môi trường này",
"Passkey is not supported on this device": "Passkey không được hỗ trợ trên thiết bị này",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Đã hủy xác minh Passkey",
"Passkey verification was cancelled or timed out": "Xác minh Passkey đã bị hủy hoặc hết thời gian chờ",
"Passkey website domain": "Tên miền trang dùng Passkey",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "Khóa truy cập của các tên miền bị xóa sẽ ngừng hoạt động. Chỉ xác nhận khi người dùng bị ảnh hưởng có thể đăng nhập bằng cách khác.",
"Passkeys known to use removed domains: {{amount}}": "Khóa truy cập đang dùng miền bị xóa: {{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "Passkey chỉ hoạt động trên trang đã tạo chúng. Điều này giúp ngăn trang khác sử dụng trái phép.",
"Passkeys with an unknown domain that may be affected: {{amount}}": "Khóa truy cập chưa rõ miền và có thể bị ảnh hưởng: {{amount}}",
"Passthrough Template": "Mẫu truyền qua",
"Password": "Mật khẩu",
"Password / Access Token": "Mật khẩu / Access Token",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "Bậc giá {{name}}",
"Pricing Type": "Price type",
"Primary Model": "Mô hình chính",
"Primary Passkey domain": "Tên miền chính",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "Ưu tiên sử dụng lại kênh thành công gần nhất dựa trên các khóa trích xuất từ ngữ cảnh yêu cầu (định tuyến dính)",
"Priority": "Ưu tiên",
"Priority (Default)": "Ưu tiên (Mặc định)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "Xóa tất cả các mục nhật ký được tạo trước mốc thời gian đã chọn.",
"Remove attachment": "Xóa tệp đính kèm",
"Remove branch": "Xóa nhánh",
"Remove compatible Passkey domains?": "Xóa tên miền khóa truy cập tương thích?",
"Remove condition": "Gỡ điều kiện",
"Remove domains": "Xóa tên miền",
"Remove Duplicates": "Xóa trùng lặp",
"Remove end condition": "Xóa điều kiện kết thúc",
"Remove filter": "Xóa bộ lọc",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "Kiểm tra từng mục thêm và ghi đè trước khi áp dụng. Giá, kênh và quyền nhóm không thay đổi.",
"Review model rates before scaling traffic": "Xem giá mô hình trước khi mở rộng lưu lượng",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "Kiểm tra các bản ghi bị ảnh hưởng trước khi áp dụng. Giá, kênh và tên mô hình được giữ nguyên.",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "Kiểm tra các tên miền tương thích và trang web được phép, rồi lưu thay đổi trên trang cài đặt.",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "Kiểm tra giá và biểu thức tính phí. Xác nhận chỉ cập nhật bản nháp; hãy lưu giá mô hình để áp dụng.",
"Review your payment details": "Xem lại chi tiết thanh toán của bạn",
"Review your purchase details before proceeding.": "Xem lại chi tiết mua hàng trước khi tiếp tục.",
@@ -5616,6 +5641,7 @@
"This month": "Tháng này",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "Giá OpenRouter Claude này suy ra lượng ghi bộ nhớ đệm từ chi phí nhà cung cấp và cần chuyển đổi thủ công.",
"This page has not been created yet.": "Trang này chưa được tạo.",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "Tên miền khóa truy cập này không dùng được trên trang web hiện tại. Hãy dùng trang web ban đầu hoặc phương thức xác minh khác.",
"This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "Plugin này không có bản tích hợp dự phòng. Xóa hoặc tắt plugin sẽ khiến nền tảng không khả dụng.",
"This plugin path does not resolve within the source repository.": "Đường dẫn plugin này không nằm trong kho của nguồn.",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "Đã cập nhật kênh {{name}} (ID: {{id}})",
"Updated channel status (ID: {{id}})": "Đã cập nhật trạng thái kênh (ID: {{id}})",
"Updated daily": "Cập nhật hàng ngày",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "Đã cập nhật miền khóa truy cập: đã xóa {{domains}}; bị ảnh hưởng {{known}}; chưa rõ miền {{unknown}}",
"Updated successfully": "Cập nhật thành công",
"Updated system setting {{key}}": "Đã cập nhật cài đặt hệ thống {{key}}",
"Updated user {{username}} (ID: {{id}})": "Đã cập nhật người dùng {{username}} (ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "Sử dụng 8–128 ký tự.",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Sử dụng trình duyệt hoặc thiết bị tương thích có xác thực sinh trắc học hoặc khóa bảo mật để đăng ký Khóa truy cập.",
"Use a different stable value for each instance, then restart the service.": "Dùng một giá trị ổn định khác nhau cho mỗi phiên bản, sau đó khởi động lại dịch vụ.",
"Use a Passkey from another website": "Dùng khóa truy cập của trang web khác",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "Dùng đường dẫn để nối vào Base URL của kênh, hoặc nhập URL đầy đủ để ghi đè Base URL cho tuyến này.",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "Dùng tên miền HTTPS để cấu hình Passkey, hoặc localhost khi phát triển.",
"Use authenticator code": "Sử dụng mã xác thực",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "Khi không có điều kiện, thao tác luôn được thực thi.",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "Khi giám sát hiệu suất được bật và mức sử dụng tài nguyên vượt quá ngưỡng, các yêu cầu Relay mới sẽ bị từ chối.",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "Khi chạy trong container hoặc môi trường tạm thời, hãy đảm bảo tệp SQLite được ánh xạ vào bộ nhớ lưu trữ bền vững để tránh mất dữ liệu khi khởi động lại.",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "Khi đổi tên miền chính, tên miền cũ sẽ tự động được giữ trong danh sách tên miền tương thích.",
"Whitelist": "Danh sách trắng",
"Whitelist (Only allow listed domains)": "Danh sách trắng (Chỉ cho phép các tên miền được liệt kê)",
"Whitelist (Only allow listed IPs)": "Danh sách trắng (Chỉ cho phép các IP đã liệt kê)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "收費乘數,倍率越低,API 呼叫費用越低。",
"A focused home for keys, balance, routing, and service health.": "集中展示金鑰、餘額、路由和服務健康狀態。",
"A matching hash confirms the published file, not the safety of its code.": "校驗一致僅表示檔案與發布版本相符,不代表程式碼安全。",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "為 www.example.com 建立的通行金鑰無法在 example.com 使用。請保留舊網站,讓使用者仍能在該網站驗證。",
"A security operation is already in progress.": "已有安全操作正在進行。",
"A selected model no longer exists. Reload the model list.": "所選模型已不存在,請重新載入模型清單。",
"A vendor with this name already exists.": "此供應商名稱已存在。",
@@ -1036,6 +1037,7 @@
"Choose price sources": "選擇價格來源",
"Choose the bundle type and define the items inside it.": "選擇捆綁包類型並定義其中的項目。",
"Choose the default charts, range, and time granularity for model analytics.": "選擇模型呼叫分析的預設圖表、範圍和時間粒度。",
"Choose the domain used when this Passkey was created, then try again.": "請選擇建立此通行金鑰時使用的網域,然後重試。",
"Choose where to fetch upstream metadata.": "選擇從何處獲取上游元數據。",
"Choose which charts are selected by default when opening model analytics.": "選擇打開模型呼叫分析時預設選中的圖表。",
"Clamped to": "限制為",
@@ -1152,6 +1154,7 @@
"Comparison operator": "比較運算子",
"compatible API routes": "兼容 API 路由",
"Compatible API routes for common AI application workflows": "兼容常見 AI 套用工作流的 API 路由",
"Compatible Passkey domains": "相容的通行金鑰網域",
"Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文件,支援多語言 SDK",
"Complete Order": "補單",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "在註冊 Passkey 前請使用兩步驗證確認你的身份。",
"Confirmed at {{time}} by user #{{userId}}": "用戶 #{{userId}} 於 {{time}} 確認",
"Confirmed payment compliance": "確認支付合規",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "已確認刪除通行金鑰網域:{{domains}};已知受影響 {{known}};歸屬未知 {{unknown}}",
"Confirming the account linked to your profile.": "正在確認已綁定的帳號。",
"Conflict": "矛盾",
"Connect": "連接",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "確保字串有指定前綴",
"Ensure the string has a specified suffix": "確保字串有指定後綴",
"Enter 6-digit code": "輸入 6 位數字代碼",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "只填寫網域,例如 example.com 或 localhost,不包含通訊協定、連接埠或路徑。含連接埠的完整位址請填入「允許使用通行密鑰的網站」。",
"Enter a finite, non-negative price": "請輸入有限的非負價格",
"Enter a finite, non-negative price.": "請輸入有限的非負價格。",
"Enter a name": "輸入名稱",
@@ -2073,6 +2078,8 @@
"Enter password": "輸入密碼",
"Enter password (8-20 characters)": "輸入密碼(8-20 個字元)",
"Enter password (8–128 characters)": "輸入密碼(8–128 個字元)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "每行填寫一個舊網域,不包含通訊協定、連接埠或路徑。同一網域的不同連接埠無須加入相容清單。",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "每行填寫一個舊通行金鑰網域。現有通行金鑰仍可透過這些網域驗證,新通行金鑰使用主要網域。",
"Enter quota in {{currency}}": "輸入 {{currency}} 額度",
"Enter quota in tokens": "輸入令牌配額",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "填寫一次請求的用量,查看預估費用。結果未乘以群組倍率和請求規則倍率。",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "我自願承擔系統穩定性風險:本人知悉該操作可能導致用戶端嚴重逾時及服務崩潰。若因本人開啟此功能導致請求積壓或服務不可用,後果由本人自行承擔。",
"High-risk status code retry risk disclaimer": "### ⚠️ 高風險操作:504/524 狀態碼重試風險告知與免責聲明\n\n本專案預設對 `400`(請求錯誤)、`504`(閘道逾時)與 `524`(CDN 逾時)狀態碼不進行重試。504 與 524 錯誤通常代表**請求已成功送達上游 AI 服務,且上游正在處理,但因上游處理時間過長導致連線中斷**。這通常表示逾時源於上游服務瓶頸。\n\n開啟此類逾時狀態碼的重新導向/重試屬於**極高風險操作**。在開啟該功能前,您必須仔細閱讀並知悉以下嚴重後果:\n\n#### 一、核心風險告知(請仔細閱讀)\n\n1. 💸 雙重/多重計費風險:多數 AI 上游廠商對於已開始處理但因網路原因中斷(504/524)的請求**仍然會扣費**。此時若觸發重試,將會向上游發起全新請求,導致您被**雙重甚至多重計費**。\n2. ⏳ 用戶端嚴重逾時:單次請求已觸發逾時,疊加重試機制會使總請求耗時成倍增加,導致最終用戶端(或呼叫方)出現嚴重甚至無法接受的逾時現象。\n3. 💥 請求積壓與系統崩潰風險:強制重試逾時請求會長時間占用系統執行緒與連線數。在高併發場景下,這將導致嚴重的**請求積壓**,進而耗盡系統資源,引發雪崩效應,造成整個代理服務崩潰。\n\n#### 二、風險確認聲明\n\n若您堅持開啟該功能,即代表您作出以下確認:",
"Higher priority channels are selected first": "優先級更高的渠道優先被選中",
"Historical capitalization": "歷史大小寫",
"Historical tokens": "歷史權杖",
"Historical Usage": "歷史使用情況",
"History of MjProxy-style image tasks.": "MjProxy 風格圖像任務歷史。",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "如何獲取 io.net API 金鑰",
"How to reset my quota?": "如何重置我的配額?",
"How to select keys: random or sequential polling": "金鑰選擇方式:隨機或順序輪詢",
"How to set up Passkey domains": "如何設定通行金鑰網域",
"How will you use the platform?": "您將如何使用本平台?",
"HTTP Protocol": "HTTP 協定",
"HTTP protocol must be Auto or HTTP/1.1": "HTTP 協定必須是自動或 HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "如果問題持續出現,請到 GitHub Issues 反饋。",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "如果使用者已經綁定過通行密鑰,變更這裡後,可能需要透過其他方式登入並重新綁定。",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中國大陸向公眾提供生成式人工智能服務,你將履行備案、安全評估、內容安全、投訴處理、生成內容標識、日誌留存和個人資訊保護等法律義務。",
"If your Passkey belongs to another website, start sign-in there:": "如果通行金鑰屬於其他網站,請前往該網站登入:",
"Ignore": "忽略",
"Ignore this version": "忽略此版本提醒",
"Ignored upstream models": "已忽略上游模型",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "參數覆蓋模板中的 JSON 格式無效",
"Invalid JSON string.": "無效的 JSON 字串。",
"Invalid model mapping format": "無效的模型映射格式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。",
"Invalid Passkey registration response": "無效的 Passkey 註冊回應",
"Invalid Passkey response": "無效的 Passkey 回應",
"Invalid payment redirect URL": "無效的支付跳轉連結",
@@ -2957,6 +2968,7 @@
"Keep local": "保留本機",
"Keep original value": "保留原值",
"Keep original value (skip if target exists)": "保留原值(目標已有值時不覆蓋)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "每行填寫一個既有通行金鑰使用的網域。例如,主要網域從 www.example.com 改為 example.com 後,應在此保留 www.example.com。",
"Keep the platform ready": "保持平台就緒",
"Keep this above 1 minute to avoid heavy database load": "保持此值大於 1 分鐘以避免資料庫負載過重",
"Keep this vendor": "保留此供應商",
@@ -3454,6 +3466,9 @@
"New model": "新模型",
"New Models ({{count}})": "新模型 ({{count}})",
"New name will be:": "新名稱將是:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "新通行金鑰使用主要網域,既有通行金鑰可透過相容網域繼續使用。",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "新通行金鑰使用此網域。只填寫網域,不包含 https://、連接埠或路徑;留空時使用系統網站位址。",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "新通行金鑰將使用新的主要網域。原網域會被保留,現有通行金鑰仍可驗證。",
"New password": "新密碼",
"New Password": "新密碼",
"New password must be different from current password": "新密碼必須與目前密碼不同",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "通行金鑰認證",
"Passkey authentication is disabled.": "Passkey 驗證已停用。",
"Passkey display name": "通行密鑰顯示名稱",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "已阻止通行金鑰網域變更:{{domains}};已知受影響 {{known}};歸屬未知 {{unknown}}",
"Passkey domain update failed": "通行金鑰網域更新失敗",
"Passkey domains": "通行金鑰網域",
"Passkey is not available in this browser": "此瀏覽器中不支援 Passkey",
"Passkey is not supported in this environment": "此環境中不支援 Passkey",
"Passkey is not supported on this device": "此設備不支援 Passkey",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Passkey 驗證已取消",
"Passkey verification was cancelled or timed out": "Passkey 驗證已取消或逾時",
"Passkey website domain": "通行密鑰使用的網站網域",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "被移除網域的通行金鑰將無法使用。請確認受影響使用者可透過其他方式登入後再繼續。",
"Passkeys known to use removed domains: {{amount}}": "使用被刪除網域的通行金鑰:{{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "通行密鑰只認建立它的網站,防止被其他網站冒用。",
"Passkeys with an unknown domain that may be affected: {{amount}}": "網域歸屬未知、可能受影響的通行金鑰:{{amount}}",
"Passthrough Template": "透傳模板",
"Password": "密碼",
"Password / Access Token": "密碼 / 存取令牌",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "計費級距 {{name}}",
"Pricing Type": "定價類型",
"Primary Model": "主模型",
"Primary Passkey domain": "主要網域",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "基於請求上下文提取的 Key,優先複用上一次成功的渠道(粘滯選路)",
"Priority": "優先級",
"Priority (Default)": "優先級(預設)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "移除所選時間戳之前建立的所有日誌條目。",
"Remove attachment": "移除附件",
"Remove branch": "刪除分支",
"Remove compatible Passkey domains?": "移除相容的通行金鑰網域?",
"Remove condition": "移除條件",
"Remove domains": "移除網域",
"Remove Duplicates": "移除重複項",
"Remove end condition": "刪除結束條件",
"Remove filter": "移除篩選",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "套用前逐項檢查新增及覆寫內容,定價、渠道及分組權限不會變更。",
"Review model rates before scaling traffic": "擴展流量前查看模型費率",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "套用前請檢查受影響的記錄。定價、管道與模型名稱維持原值。",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "檢查相容網域和允許使用的網站,然後在設定頁儲存變更。",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "請核對價格與計費運算式。確認後僅更新草稿,儲存模型定價後生效。",
"Review your payment details": "查看您的付款詳情",
"Review your purchase details before proceeding.": "在繼續之前,請審閱您的購買詳情。",
@@ -5616,6 +5641,7 @@
"This month": "本月獲得",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "此 OpenRouter Claude 定價根據上游費用反推快取寫入用量,需要手動轉換。",
"This page has not been created yet.": "此頁面尚未建立。",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。",
"This plan does not allow balance redemption": "該套餐不允許使用餘額兌換",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "此外掛没有內建回落版本,刪除或禁用后该平台将不可用。",
"This plugin path does not resolve within the source repository.": "該外掛路徑並未指向來源儲存庫內的位置。",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})",
"Updated channel status (ID: {{id}})": "更新渠道狀態(ID: {{id}})",
"Updated daily": "每日更新",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "已更新通行金鑰網域:刪除 {{domains}};已知受影響 {{known}};歸屬未知 {{unknown}}",
"Updated successfully": "更新成功",
"Updated system setting {{key}}": "修改系統設定 {{key}}",
"Updated user {{username}} (ID: {{id}})": "更新用戶 {{username}}(ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "使用 8–128 個字元。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "請使用支援生物識別認證或安全金鑰的兼容瀏覽器或設備來註冊通行金鑰。",
"Use a different stable value for each instance, then restart the service.": "每個實例使用不同且穩定的值,然後重啟服務。",
"Use a Passkey from another website": "使用其他網站的通行金鑰",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "填寫以 / 開頭的路徑時會自動拼接渠道 Base URL;填寫完整 URL 時,此路由會直接使用該 URL。",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "請透過 HTTPS 網域設定 Passkey;本機開發可使用 localhost。",
"Use authenticator code": "使用驗證器代碼",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "沒有條件時,預設總是執行該操作。",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "啟用效能監控後,當系統資源使用率超過設定閾值時,將拒絕新的 Relay 請求。",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "在容器或臨時環境中執行時,請確保 SQLite 檔案映射到持久儲存,以避免重新啟動時數據遺失。",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "變更主要網域時,原網域會自動保留在相容網域清單中。",
"Whitelist": "白名單",
"Whitelist (Only allow listed domains)": "白名單(僅允許列出的域)",
"Whitelist (Only allow listed IPs)": "白名單(僅允許列出的 IP)",
+29
View File
@@ -150,6 +150,7 @@
"A billing multiplier. Lower ratios mean lower API call costs.": "计费乘数,倍率越低,API 调用费用越低。",
"A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。",
"A matching hash confirms the published file, not the safety of its code.": "校验一致仅说明文件与发布版本相符,不代表代码安全。",
"A Passkey created for www.example.com cannot be used on example.com. Keep the old website available so users can still verify there.": "为 www.example.com 创建的通行密钥无法在 example.com 使用。请保留旧网站,让用户仍能在那里验证。",
"A security operation is already in progress.": "已有安全操作正在进行。",
"A selected model no longer exists. Reload the model list.": "所选模型已不存在,请重新加载模型列表。",
"A vendor with this name already exists.": "此供应商名称已存在。",
@@ -1036,6 +1037,7 @@
"Choose price sources": "选择价格来源",
"Choose the bundle type and define the items inside it.": "选择捆绑包类型并定义其中的项目。",
"Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。",
"Choose the domain used when this Passkey was created, then try again.": "请选择创建此通行密钥时使用的域名,然后重试。",
"Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
"Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。",
"Clamped to": "钳制为",
@@ -1152,6 +1154,7 @@
"Comparison operator": "比较运算符",
"compatible API routes": "兼容 API 路由",
"Compatible API routes for common AI application workflows": "兼容常见 AI 应用工作流的 API 路由",
"Compatible Passkey domains": "兼容的通行密钥域名",
"Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文档,支持多语言 SDK",
"Complete Order": "补单",
@@ -1263,6 +1266,7 @@
"Confirm your identity with Two-factor Authentication before registering a Passkey.": "在注册 Passkey 前请使用两步验证确认你的身份。",
"Confirmed at {{time}} by user #{{userId}}": "用户 #{{userId}} 于 {{time}} 确认",
"Confirmed payment compliance": "确认支付合规",
"Confirmed removal of Passkey domains: {{domains}}; affected {{known}}; unknown {{unknown}}": "已确认删除通行密钥域名:{{domains}};已知受影响 {{known}};归属未知 {{unknown}}",
"Confirming the account linked to your profile.": "正在确认已绑定的账号。",
"Conflict": "矛盾",
"Connect": "连接",
@@ -2021,6 +2025,7 @@
"Ensure the string has a specified prefix": "确保字符串有指定前缀",
"Ensure the string has a specified suffix": "确保字符串有指定后缀",
"Enter 6-digit code": "输入 6 位数字代码",
"Enter a domain such as example.com or localhost, without a protocol, port or path. Put addresses with ports in Allowed Passkey websites.": "只填域名,例如 example.com 或 localhost,不包含协议、端口或路径。带端口的完整地址请填到“允许使用通行密钥的网站”。",
"Enter a finite, non-negative price": "请输入有限的非负价格",
"Enter a finite, non-negative price.": "请输入有限的非负价格。",
"Enter a name": "输入名称",
@@ -2073,6 +2078,8 @@
"Enter password": "输入密码",
"Enter password (8-20 characters)": "输入密码(8-20 个字符)",
"Enter password (8–128 characters)": "输入密码(8–128 个字符)",
"Enter previous domains, one per line, without a protocol, port or path. Different ports on the same domain do not need compatible domains.": "每行填写一个旧域名,不包含协议、端口或路径。同一域名的不同端口无需加入兼容列表。",
"Enter previous Passkey domains, one per line. Existing Passkeys can still verify with these domains; new Passkeys use the primary domain.": "每行填写一个旧通行密钥域名。已有通行密钥仍可通过这些域名验证,新通行密钥使用主域名。",
"Enter quota in {{currency}}": "输入 {{currency}} 额度",
"Enter quota in tokens": "输入令牌配额",
"Enter sample usage to estimate the cost. Group and request multipliers are not included.": "填写一次请求的用量,查看预计费用。结果未乘以分组倍率和请求规则倍率。",
@@ -2697,6 +2704,7 @@
"High-risk status code retry risk check 4": "我自愿承担系统稳定性风险:本人知晓该操作可能导致客户端严重超时及服务崩溃。若因本人开启此功能导致请求积压或服务不可用,后果由本人自行承担。",
"High-risk status code retry risk disclaimer": "### ⚠️ 高危操作:504/524 状态码重试风险告知与免责声明\n\n本项目默认对 `400`(请求错误)、`504`(网关超时)和 `524`(CDN 超时)状态码不进行重试。504 和 524 错误通常意味着**请求已成功送达上游 AI 服务,且上游正在处理,但因上游处理时间过长导致连接断开**。这通常说明超时源于上游服务瓶颈。\n\n开启对此类超时状态码的重定向/重试属于**极高风险操作**。在开启该功能前,您必须仔细阅读并知悉以下严重后果:\n\n#### 一、核心风险告知(请仔细阅读)\n\n1. 💸 双重/多重计费风险:绝大多数 AI 上游厂商对于已经开始处理但因网络原因中断(504/524)的请求**依然会进行扣费**。此时若触发重试,将会向上游发起全新请求,导致您被**双重甚至多重计费**。\n2. ⏳ 客户端严重超时:单次请求已经触发超时,叠加重试机制将会使总请求耗时成倍增加,导致您的最终客户端(或调用方)出现严重甚至完全无法接受的超时现象。\n3. 💥 请求积压与系统崩溃风险:强制重试超时请求会长时间占用系统线程和连接数。在高并发场景下,这会导致严重的**请求积压**,进而耗尽系统资源,引发雪崩效应,导致您的整个代理服务崩溃。\n\n#### 二、风险确认声明\n\n如果您坚持开启该功能,即代表您作出以下确认:",
"Higher priority channels are selected first": "优先级更高的渠道优先被选中",
"Historical capitalization": "历史大小写",
"Historical tokens": "历史令牌",
"Historical Usage": "历史使用情况",
"History of MjProxy-style image tasks.": "MjProxy 风格图像任务历史。",
@@ -2725,6 +2733,7 @@
"How to get an io.net API Key": "如何获取 io.net API 密钥",
"How to reset my quota?": "如何重置我的配额?",
"How to select keys: random or sequential polling": "密钥选择方式:随机或顺序轮询",
"How to set up Passkey domains": "如何设置通行密钥域名",
"How will you use the platform?": "您将如何使用本平台?",
"HTTP Protocol": "HTTP 协议",
"HTTP protocol must be Auto or HTTP/1.1": "HTTP 协议必须是自动或 HTTP/1.1",
@@ -2774,6 +2783,7 @@
"If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。",
"If users already have Passkeys, changing this domain may require them to sign in another way and set up their Passkeys again.": "如果用户已经绑定过通行密钥,更改这里后,可能需要通过其他方式登录并重新绑定。",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中国大陆向公众提供生成式人工智能服务,你将履行备案、安全评估、内容安全、投诉处理、生成内容标识、日志留存和个人信息保护等法律义务。",
"If your Passkey belongs to another website, start sign-in there:": "如果通行密钥属于其他网站,请前往该网站登录:",
"Ignore": "忽略",
"Ignore this version": "忽略该版本提醒",
"Ignored upstream models": "已忽略上游模型",
@@ -2887,6 +2897,7 @@
"Invalid JSON in parameter override template": "参数覆盖模板中的 JSON 格式无效",
"Invalid JSON string.": "无效的 JSON 字符串。",
"Invalid model mapping format": "无效的模型映射格式",
"Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.": "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。",
"Invalid Passkey registration response": "无效的 Passkey 注册响应",
"Invalid Passkey response": "无效的 Passkey 响应",
"Invalid payment redirect URL": "无效的支付跳转链接",
@@ -2957,6 +2968,7 @@
"Keep local": "保留本地",
"Keep original value": "保留原值",
"Keep original value (skip if target exists)": "保留原值(目标已有值时不覆盖)",
"Keep the domains used by existing Passkeys, one per line. If the primary domain changed from www.example.com to example.com, enter www.example.com here.": "每行填写一个已有通行密钥使用的域名。例如,主域名从 www.example.com 改成 example.com 后,应在这里保留 www.example.com。",
"Keep the platform ready": "保持平台就绪",
"Keep this above 1 minute to avoid heavy database load": "保持此值大于 1 分钟以避免数据库负载过重",
"Keep this vendor": "保留此供应商",
@@ -3454,6 +3466,9 @@
"New model": "新模型",
"New Models ({{count}})": "新模型 ({{count}})",
"New name will be:": "新名称将是:",
"New Passkeys use the primary domain. Compatible domains keep existing Passkeys working.": "新通行密钥使用主域名,已有通行密钥可通过兼容域名继续使用。",
"New Passkeys use this domain. Enter a domain without https://, a port or a path. An empty field uses the system website address.": "新通行密钥使用此域名。只填写域名,不包含 https://、端口或路径;留空时使用系统网站地址。",
"New Passkeys will use the new primary domain. The previous domain will be kept so existing Passkeys can still verify.": "新通行密钥将使用新的主域名。原域名会被保留,已有通行密钥仍可验证。",
"New password": "新密码",
"New Password": "新密码",
"New password must be different from current password": "新密码必须与当前密码不同",
@@ -3969,6 +3984,9 @@
"Passkey Authentication": "通行密钥认证",
"Passkey authentication is disabled.": "Passkey 认证已关闭。",
"Passkey display name": "通行密钥显示名称",
"Passkey domain change blocked: {{domains}}; affected {{known}}; unknown {{unknown}}": "已阻止通行密钥域名变更:{{domains}};已知受影响 {{known}};归属未知 {{unknown}}",
"Passkey domain update failed": "通行密钥域名更新失败",
"Passkey domains": "通行密钥域名",
"Passkey is not available in this browser": "此浏览器中不支持 Passkey",
"Passkey is not supported in this environment": "此环境中不支持 Passkey",
"Passkey is not supported on this device": "此设备不支持 Passkey",
@@ -3988,7 +4006,10 @@
"Passkey verification was cancelled": "Passkey 验证已取消",
"Passkey verification was cancelled or timed out": "Passkey 验证已取消或超时",
"Passkey website domain": "通行密钥使用的网站域名",
"Passkeys for removed domains will stop working. Confirm only if affected users can sign in another way.": "被移除域名的通行密钥将无法使用。请确认受影响用户可以通过其他方式登录后再继续。",
"Passkeys known to use removed domains: {{amount}}": "使用被删除域名的通行密钥:{{amount}}",
"Passkeys only work on the website they were created for. This helps prevent other websites from misusing them.": "通行密钥只认创建它的网站,防止被其他网站冒用。",
"Passkeys with an unknown domain that may be affected: {{amount}}": "域名归属未知、可能受影响的通行密钥:{{amount}}",
"Passthrough Template": "透传模板",
"Password": "密码",
"Password / Access Token": "密码 / 访问令牌",
@@ -4300,6 +4321,7 @@
"Pricing tier {{name}}": "计费档位 {{name}}",
"Pricing Type": "定价类型",
"Primary Model": "主模型",
"Primary Passkey domain": "主域名",
"Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)": "基于请求上下文提取的 Key,优先复用上一次成功的渠道(粘滞选路)",
"Priority": "优先级",
"Priority (Default)": "优先级(默认)",
@@ -4546,7 +4568,9 @@
"Remove all log entries created before the selected timestamp.": "移除所选时间戳之前创建的所有日志条目。",
"Remove attachment": "移除附件",
"Remove branch": "删除分支",
"Remove compatible Passkey domains?": "移除兼容的通行密钥域名?",
"Remove condition": "移除条件",
"Remove domains": "移除域名",
"Remove Duplicates": "移除重复项",
"Remove end condition": "删除结束条件",
"Remove filter": "移除筛选",
@@ -4745,6 +4769,7 @@
"Review every addition and overwrite before applying. Pricing, channels, and group access are not changed.": "应用前逐项检查新增和覆盖内容,定价、渠道及分组权限不会改变。",
"Review model rates before scaling traffic": "扩展流量前查看模型费率",
"Review the affected records before applying. Pricing, channels, and model names are preserved.": "应用前请检查受影响的记录。定价、渠道和模型名称保持原值。",
"Review the compatible domains and allowed websites, then save changes on the settings page.": "检查兼容域名和允许使用的网站,然后在设置页保存更改。",
"Review the prices and billing expression. Confirming updates the draft; save model pricing to apply it.": "请核对价格和计费表达式。确认后仅更新草稿,保存模型定价后生效。",
"Review your payment details": "查看您的付款详情",
"Review your purchase details before proceeding.": "在继续之前,请审阅您的购买详情。",
@@ -5616,6 +5641,7 @@
"This month": "本月获得",
"This OpenRouter Claude price derives cache-write usage from upstream cost and must be converted manually.": "此 OpenRouter Claude 定价根据上游费用反推缓存写入用量,需要手动转换。",
"This page has not been created yet.": "此页面尚未创建。",
"This Passkey domain is not available on this website. Use its original website or another verification method.": "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。",
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
"This plugin has no factory fallback. Deleting or disabling it makes this platform unavailable.": "此插件没有出厂回落版本,删除或禁用后该平台将不可用。",
"This plugin path does not resolve within the source repository.": "该插件路径未指向源仓库内的位置。",
@@ -5964,6 +5990,7 @@
"Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})",
"Updated channel status (ID: {{id}})": "更新渠道状态(ID: {{id}})",
"Updated daily": "每日更新",
"Updated Passkey domains: removed {{domains}}; affected {{known}}; unknown {{unknown}}": "已更新通行密钥域名:删除 {{domains}};已知受影响 {{known}};归属未知 {{unknown}}",
"Updated successfully": "更新成功",
"Updated system setting {{key}}": "修改系统设置 {{key}}",
"Updated user {{username}} (ID: {{id}})": "更新用户 {{username}}(ID: {{id}})",
@@ -6057,6 +6084,7 @@
"Use 8–128 characters.": "使用 8–128 个字符。",
"Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。",
"Use a different stable value for each instance, then restart the service.": "每个实例使用不同且稳定的值,然后重启服务。",
"Use a Passkey from another website": "使用其他网站的通行密钥",
"Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.": "填写以 / 开头的路径时会自动拼接渠道 Base URL;填写完整 URL 时,此路由会直接使用该 URL。",
"Use an HTTPS domain (or localhost for development) to configure Passkey.": "请通过 HTTPS 域名配置 Passkey;本地开发可使用 localhost。",
"Use authenticator code": "使用验证器代码",
@@ -6362,6 +6390,7 @@
"When no conditions are set, the operation always executes.": "没有条件时,默认总是执行该操作。",
"When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.": "启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求。",
"When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.": "在容器或临时环境中运行时,请确保 SQLite 文件映射到持久存储,以避免重启时数据丢失。",
"When the primary domain changes, the previous domain is automatically kept in the compatible domains list.": "修改主域名时,原域名会自动保留在兼容域名列表中。",
"Whitelist": "白名单",
"Whitelist (Only allow listed domains)": "白名单(仅允许列出的域)",
"Whitelist (Only allow listed IPs)": "白名单(仅允许列出的 IP)",
+4
View File
@@ -34,6 +34,10 @@ const serverErrorMessageKeys = {
'Telegram login has changed. Reload the page and start Telegram OAuth again.',
AUTH_INTERNAL_ERROR: 'Please try again later.',
SECURITY_VERIFICATION_FAILED: 'Verification failed. Please try again.',
PASSKEY_RP_ID_UNAVAILABLE:
'This Passkey domain is not available on this website. Use its original website or another verification method.',
PASSKEY_RP_ID_INVALID:
'Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.',
SECURITY_VERIFICATION_FLOW_REQUIRED:
'This verification method requires its dedicated verification flow.',
SECURITY_VERIFICATION_LOCKED: