@@ -73,6 +73,7 @@ type Config struct {
7373 Ops OpsConfig `mapstructure:"ops"`
7474 JWT JWTConfig `mapstructure:"jwt"`
7575 Totp TotpConfig `mapstructure:"totp"`
76+ WebAuthn WebAuthnConfig `mapstructure:"webauthn"`
7677 LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
7778 WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
7879 OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
@@ -686,6 +687,16 @@ type CORSConfig struct {
686687 AllowCredentials bool `mapstructure:"allow_credentials"`
687688}
688689
690+ // WebAuthnConfig configures this deployment as a WebAuthn relying party.
691+ // RPID and RPOrigins are security boundaries and must never be inferred from
692+ // untrusted request Host or Origin headers.
693+ type WebAuthnConfig struct {
694+ Enabled bool `mapstructure:"enabled"`
695+ RPDisplayName string `mapstructure:"rp_display_name"`
696+ RPID string `mapstructure:"rp_id"`
697+ RPOrigins []string `mapstructure:"rp_origins"`
698+ }
699+
689700const MaxForwardedClientIPHeaders = 16
690701
691702type ForwardedClientIPSettings struct {
@@ -1875,12 +1886,21 @@ func setDefaults() {
18751886 viper .SetDefault ("cors.allowed_origins" , []string {})
18761887 viper .SetDefault ("cors.allow_credentials" , true )
18771888
1889+ // WebAuthn / Passkeys are opt-in because every deployment must explicitly
1890+ // declare its relying-party domain and trusted browser origins.
1891+ viper .SetDefault ("webauthn.enabled" , false )
1892+ viper .SetDefault ("webauthn.rp_display_name" , "Sub2API" )
1893+ viper .SetDefault ("webauthn.rp_id" , "" )
1894+ viper .SetDefault ("webauthn.rp_origins" , []string {})
1895+
18781896 // Security
18791897 viper .SetDefault ("security.url_allowlist.enabled" , false )
18801898 viper .SetDefault ("security.url_allowlist.upstream_hosts" , []string {
18811899 "api.openai.com" ,
18821900 "api.anthropic.com" ,
18831901 "api.kimi.com" ,
1902+ "api.moonshot.ai" ,
1903+ "api.moonshot.cn" ,
18841904 "open.bigmodel.cn" ,
18851905 "api.minimaxi.com" ,
18861906 "generativelanguage.googleapis.com" ,
@@ -2591,6 +2611,43 @@ func (c *Config) Validate() error {
25912611 }
25922612 warnIfInsecureURL ("server.frontend_url" , c .Server .FrontendURL )
25932613 }
2614+ if c .WebAuthn .Enabled {
2615+ c .WebAuthn .RPDisplayName = strings .TrimSpace (c .WebAuthn .RPDisplayName )
2616+ c .WebAuthn .RPID = strings .ToLower (strings .TrimSpace (c .WebAuthn .RPID ))
2617+ c .WebAuthn .RPOrigins = normalizeStringSlice (c .WebAuthn .RPOrigins )
2618+ if c .WebAuthn .RPDisplayName == "" {
2619+ return fmt .Errorf ("webauthn.rp_display_name is required when passkeys are enabled" )
2620+ }
2621+ if c .WebAuthn .RPID == "" {
2622+ return fmt .Errorf ("webauthn.rp_id is required when passkeys are enabled" )
2623+ }
2624+ if strings .Contains (c .WebAuthn .RPID , "://" ) || strings .ContainsAny (c .WebAuthn .RPID , "/:" ) {
2625+ return fmt .Errorf ("webauthn.rp_id must be a domain without scheme, port, or path" )
2626+ }
2627+ if len (c .WebAuthn .RPOrigins ) == 0 {
2628+ return fmt .Errorf ("webauthn.rp_origins must contain at least one origin when passkeys are enabled" )
2629+ }
2630+ for i , origin := range c .WebAuthn .RPOrigins {
2631+ u , err := url .Parse (origin )
2632+ if err != nil || u .Scheme == "" || u .Host == "" {
2633+ return fmt .Errorf ("webauthn.rp_origins contains invalid origin %q" , origin )
2634+ }
2635+ if u .User != nil || u .RawQuery != "" || u .Fragment != "" || u .Path != "" {
2636+ return fmt .Errorf ("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment" , origin )
2637+ }
2638+ u .Scheme = strings .ToLower (u .Scheme )
2639+ u .Host = strings .ToLower (u .Host )
2640+ host := strings .ToLower (u .Hostname ())
2641+ localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1"
2642+ if u .Scheme != "https" && (u .Scheme != "http" || ! localDevelopment ) {
2643+ return fmt .Errorf ("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)" , origin )
2644+ }
2645+ if host != c .WebAuthn .RPID && ! strings .HasSuffix (host , "." + c .WebAuthn .RPID ) {
2646+ return fmt .Errorf ("webauthn.rp_origins entry %q is not within relying party ID %q" , origin , c .WebAuthn .RPID )
2647+ }
2648+ c .WebAuthn .RPOrigins [i ] = u .Scheme + "://" + u .Host
2649+ }
2650+ }
25942651 if c .JWT .ExpireHour <= 0 {
25952652 return fmt .Errorf ("jwt.expire_hour must be positive" )
25962653 }
0 commit comments