Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1016 lines
42 KiB
Go
1016 lines
42 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/imroc/req/v3"
|
||
)
|
||
|
||
// CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本,
|
||
// 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项,
|
||
// 但 API 可被直接调用)。
|
||
func CoerceDingTalkCorpPolicyForWrite(policy string) string {
|
||
return coerceDeprecatedDingTalkCorpPolicy(policy)
|
||
}
|
||
|
||
// coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。
|
||
// 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed
|
||
// (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。
|
||
func coerceDeprecatedDingTalkCorpPolicy(policy string) string {
|
||
if policy == "whitelist" {
|
||
slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none",
|
||
"hint", "re-save DingTalk settings in admin UI to clear this warning")
|
||
return "none"
|
||
}
|
||
return policy
|
||
}
|
||
|
||
func normalizeWeChatConnectModeSetting(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "mp":
|
||
return "mp"
|
||
case "mobile":
|
||
return "mobile"
|
||
default:
|
||
return "open"
|
||
}
|
||
}
|
||
|
||
func defaultWeChatConnectScopeForMode(mode string) string {
|
||
switch normalizeWeChatConnectModeSetting(mode) {
|
||
case "mp":
|
||
return "snsapi_userinfo"
|
||
case "mobile":
|
||
return ""
|
||
}
|
||
return defaultWeChatConnectScopes
|
||
}
|
||
|
||
func normalizeWeChatConnectScopeSetting(raw, mode string) string {
|
||
switch normalizeWeChatConnectModeSetting(mode) {
|
||
case "mp":
|
||
switch strings.TrimSpace(raw) {
|
||
case "snsapi_base":
|
||
return "snsapi_base"
|
||
case "snsapi_userinfo":
|
||
return "snsapi_userinfo"
|
||
default:
|
||
return defaultWeChatConnectScopeForMode(mode)
|
||
}
|
||
case "mobile":
|
||
return ""
|
||
default:
|
||
return defaultWeChatConnectScopes
|
||
}
|
||
}
|
||
|
||
func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) {
|
||
mode = normalizeWeChatConnectModeSetting(mode)
|
||
rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
|
||
rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
|
||
rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
|
||
openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
|
||
mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
|
||
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
||
|
||
if openConfigured || mpConfigured || mobileConfigured {
|
||
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
||
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
||
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
||
return openEnabled, mpEnabled, mobileEnabled
|
||
}
|
||
|
||
if !enabled {
|
||
return false, false, false
|
||
}
|
||
if mode == "mp" {
|
||
return false, true, false
|
||
}
|
||
if mode == "mobile" {
|
||
return false, false, true
|
||
}
|
||
return true, false, false
|
||
}
|
||
|
||
func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string {
|
||
mode = normalizeWeChatConnectModeSetting(mode)
|
||
switch mode {
|
||
case "open":
|
||
if openEnabled {
|
||
return "open"
|
||
}
|
||
case "mp":
|
||
if mpEnabled {
|
||
return "mp"
|
||
}
|
||
case "mobile":
|
||
if mobileEnabled {
|
||
return "mobile"
|
||
}
|
||
}
|
||
switch {
|
||
case openEnabled:
|
||
return "open"
|
||
case mpEnabled:
|
||
return "mp"
|
||
case mobileEnabled:
|
||
return "mobile"
|
||
default:
|
||
return mode
|
||
}
|
||
}
|
||
|
||
func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) {
|
||
mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode))
|
||
rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
|
||
rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
|
||
rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
|
||
openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
|
||
mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
|
||
mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
|
||
|
||
if openConfigured || mpConfigured || mobileConfigured {
|
||
openEnabled := strings.TrimSpace(rawOpen) == "true"
|
||
mpEnabled := strings.TrimSpace(rawMP) == "true"
|
||
mobileEnabled := strings.TrimSpace(rawMobile) == "true"
|
||
_, enabledConfigured := settings[SettingKeyWeChatConnectEnabled]
|
||
if !enabledConfigured &&
|
||
enabled &&
|
||
!openEnabled &&
|
||
!mpEnabled &&
|
||
!mobileEnabled &&
|
||
(base.OpenEnabled || base.MPEnabled || base.MobileEnabled) {
|
||
return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
|
||
}
|
||
return openEnabled, mpEnabled, mobileEnabled
|
||
}
|
||
if !enabled {
|
||
return false, false, false
|
||
}
|
||
if base.OpenEnabled || base.MPEnabled || base.MobileEnabled {
|
||
return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
|
||
}
|
||
return parseWeChatConnectCapabilitySettings(settings, enabled, mode)
|
||
}
|
||
|
||
func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig {
|
||
base := config.WeChatConnectConfig{}
|
||
if s != nil && s.cfg != nil {
|
||
base = s.cfg.WeChat
|
||
}
|
||
|
||
enabled := base.Enabled
|
||
if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok {
|
||
enabled = strings.TrimSpace(raw) == "true"
|
||
}
|
||
|
||
legacyAppID := strings.TrimSpace(firstNonEmpty(
|
||
settings[SettingKeyWeChatConnectAppID],
|
||
base.AppID,
|
||
base.OpenAppID,
|
||
base.MPAppID,
|
||
base.MobileAppID,
|
||
))
|
||
legacyAppSecret := strings.TrimSpace(firstNonEmpty(
|
||
settings[SettingKeyWeChatConnectAppSecret],
|
||
base.AppSecret,
|
||
base.OpenAppSecret,
|
||
base.MPAppSecret,
|
||
base.MobileAppSecret,
|
||
))
|
||
openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID))
|
||
openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret))
|
||
mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID))
|
||
mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret))
|
||
mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID))
|
||
mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret))
|
||
|
||
modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode)
|
||
openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw)
|
||
mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw)
|
||
|
||
return WeChatConnectOAuthConfig{
|
||
Enabled: enabled,
|
||
LegacyAppID: legacyAppID,
|
||
LegacyAppSecret: legacyAppSecret,
|
||
OpenAppID: openAppID,
|
||
OpenAppSecret: openAppSecret,
|
||
MPAppID: mpAppID,
|
||
MPAppSecret: mpAppSecret,
|
||
MobileAppID: mobileAppID,
|
||
MobileAppSecret: mobileAppSecret,
|
||
OpenEnabled: openEnabled,
|
||
MPEnabled: mpEnabled,
|
||
MobileEnabled: mobileEnabled,
|
||
Mode: mode,
|
||
Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode),
|
||
RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)),
|
||
FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)),
|
||
}
|
||
}
|
||
|
||
func DefaultWeChatConnectScopesForMode(mode string) string {
|
||
return defaultWeChatConnectScopeForMode(mode)
|
||
}
|
||
|
||
func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) {
|
||
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
||
|
||
if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
||
}
|
||
if cfg.OpenEnabled {
|
||
if cfg.AppIDForMode("open") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured")
|
||
}
|
||
if cfg.AppSecretForMode("open") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured")
|
||
}
|
||
}
|
||
if cfg.MPEnabled {
|
||
if cfg.AppIDForMode("mp") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured")
|
||
}
|
||
if cfg.AppSecretForMode("mp") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured")
|
||
}
|
||
}
|
||
if cfg.MobileEnabled {
|
||
if cfg.AppIDForMode("mobile") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured")
|
||
}
|
||
if cfg.AppSecretForMode("mobile") == "" {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured")
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(cfg.RedirectURL); v != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid")
|
||
}
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
||
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) {
|
||
cfg := s.effectiveWeChatConnectOAuthConfig(settings)
|
||
if !cfg.Enabled {
|
||
return false, false, false, false
|
||
}
|
||
|
||
openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != ""
|
||
mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != ""
|
||
mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != ""
|
||
|
||
return openReady || mpReady, openReady, mpReady, mobileReady
|
||
}
|
||
|
||
func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
|
||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||
case "github":
|
||
cfg := config.EmailOAuthProviderConfig{
|
||
AuthorizeURL: defaultGitHubOAuthAuthorize,
|
||
TokenURL: defaultGitHubOAuthToken,
|
||
UserInfoURL: defaultGitHubOAuthUserInfo,
|
||
EmailsURL: defaultGitHubOAuthEmails,
|
||
Scopes: defaultGitHubOAuthScopes,
|
||
FrontendRedirectURL: defaultGitHubOAuthFrontend,
|
||
}
|
||
if s != nil && s.cfg != nil {
|
||
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
|
||
}
|
||
return cfg
|
||
case "google":
|
||
cfg := config.EmailOAuthProviderConfig{
|
||
AuthorizeURL: defaultGoogleOAuthAuthorize,
|
||
TokenURL: defaultGoogleOAuthToken,
|
||
UserInfoURL: defaultGoogleOAuthUserInfo,
|
||
Scopes: defaultGoogleOAuthScopes,
|
||
FrontendRedirectURL: defaultGoogleOAuthFrontend,
|
||
}
|
||
if s != nil && s.cfg != nil {
|
||
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
|
||
}
|
||
return cfg
|
||
default:
|
||
return config.EmailOAuthProviderConfig{}
|
||
}
|
||
}
|
||
|
||
func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
|
||
base.Enabled = override.Enabled
|
||
if strings.TrimSpace(override.ClientID) != "" {
|
||
base.ClientID = strings.TrimSpace(override.ClientID)
|
||
}
|
||
if strings.TrimSpace(override.ClientSecret) != "" {
|
||
base.ClientSecret = strings.TrimSpace(override.ClientSecret)
|
||
}
|
||
if strings.TrimSpace(override.AuthorizeURL) != "" {
|
||
base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
|
||
}
|
||
if strings.TrimSpace(override.TokenURL) != "" {
|
||
base.TokenURL = strings.TrimSpace(override.TokenURL)
|
||
}
|
||
if strings.TrimSpace(override.UserInfoURL) != "" {
|
||
base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
|
||
}
|
||
if strings.TrimSpace(override.EmailsURL) != "" {
|
||
base.EmailsURL = strings.TrimSpace(override.EmailsURL)
|
||
}
|
||
if strings.TrimSpace(override.Scopes) != "" {
|
||
base.Scopes = strings.TrimSpace(override.Scopes)
|
||
}
|
||
if strings.TrimSpace(override.RedirectURL) != "" {
|
||
base.RedirectURL = strings.TrimSpace(override.RedirectURL)
|
||
}
|
||
if strings.TrimSpace(override.FrontendRedirectURL) != "" {
|
||
base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
|
||
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||
return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
|
||
}
|
||
|
||
func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
|
||
cfg := s.emailOAuthBaseConfig(provider)
|
||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||
case "github":
|
||
if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
|
||
cfg.Enabled = raw == "true"
|
||
}
|
||
cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
|
||
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
|
||
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
|
||
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
|
||
case "google":
|
||
if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
|
||
cfg.Enabled = raw == "true"
|
||
}
|
||
cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
|
||
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
|
||
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
|
||
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool {
|
||
if base.UsePKCEExplicit {
|
||
return base.UsePKCE
|
||
}
|
||
return true
|
||
}
|
||
|
||
func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool {
|
||
if base.ValidateIDTokenExplicit {
|
||
return base.ValidateIDToken
|
||
}
|
||
return true
|
||
}
|
||
|
||
func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool {
|
||
if configured {
|
||
return strings.TrimSpace(raw) == "true"
|
||
}
|
||
if explicit {
|
||
return explicitValue
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) {
|
||
rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{
|
||
SettingKeyOIDCConnectUsePKCE,
|
||
SettingKeyOIDCConnectValidateIDToken,
|
||
})
|
||
if err != nil {
|
||
return false, false, fmt.Errorf("get oidc security write defaults: %w", err)
|
||
}
|
||
|
||
base := config.OIDCConnectConfig{}
|
||
if s != nil && s.cfg != nil {
|
||
base = s.cfg.OIDC
|
||
}
|
||
|
||
rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE]
|
||
rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken]
|
||
|
||
return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE),
|
||
oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken),
|
||
nil
|
||
}
|
||
|
||
func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
if provider != "github" && provider != "google" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
|
||
}
|
||
keys := []string{
|
||
SettingKeyGitHubOAuthEnabled,
|
||
SettingKeyGitHubOAuthClientID,
|
||
SettingKeyGitHubOAuthClientSecret,
|
||
SettingKeyGitHubOAuthRedirectURL,
|
||
SettingKeyGitHubOAuthFrontendRedirectURL,
|
||
SettingKeyGoogleOAuthEnabled,
|
||
SettingKeyGoogleOAuthClientID,
|
||
SettingKeyGoogleOAuthClientSecret,
|
||
SettingKeyGoogleOAuthRedirectURL,
|
||
SettingKeyGoogleOAuthFrontendRedirectURL,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
|
||
}
|
||
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||
if !cfg.Enabled {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||
}
|
||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||
}
|
||
for label, rawURL := range map[string]string{
|
||
"authorize": cfg.AuthorizeURL,
|
||
"token": cfg.TokenURL,
|
||
"userinfo": cfg.UserInfoURL,
|
||
"redirect": cfg.RedirectURL,
|
||
} {
|
||
if strings.TrimSpace(rawURL) == "" {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
|
||
}
|
||
}
|
||
if strings.TrimSpace(cfg.EmailsURL) != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
|
||
}
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
||
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。
|
||
//
|
||
// 优先级:
|
||
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||
// - 否则回退到 config.yaml/env 的值
|
||
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
||
if s == nil || s.cfg == nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||
}
|
||
|
||
effective := s.cfg.LinuxDo
|
||
|
||
keys := []string{
|
||
SettingKeyLinuxDoConnectEnabled,
|
||
SettingKeyLinuxDoConnectClientID,
|
||
SettingKeyLinuxDoConnectClientSecret,
|
||
SettingKeyLinuxDoConnectRedirectURL,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err)
|
||
}
|
||
|
||
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
||
effective.Enabled = raw == "true"
|
||
}
|
||
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientSecret = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.RedirectURL = strings.TrimSpace(v)
|
||
}
|
||
if !effective.Enabled {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||
}
|
||
|
||
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
|
||
if strings.TrimSpace(effective.ClientID) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.RedirectURL) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
||
}
|
||
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||
}
|
||
|
||
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
||
switch method {
|
||
case "", "client_secret_post", "client_secret_basic":
|
||
if strings.TrimSpace(effective.ClientSecret) == "" {
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||
}
|
||
case "none":
|
||
default:
|
||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
||
}
|
||
|
||
return effective, nil
|
||
}
|
||
|
||
// GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。
|
||
//
|
||
// 优先级:
|
||
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||
// - 否则回退到 config.yaml/env 的值
|
||
func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
|
||
if s == nil || s.cfg == nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||
}
|
||
|
||
effective := s.cfg.DingTalk
|
||
|
||
keys := []string{
|
||
SettingKeyDingTalkConnectEnabled,
|
||
SettingKeyDingTalkConnectClientID,
|
||
SettingKeyDingTalkConnectClientSecret,
|
||
SettingKeyDingTalkConnectRedirectURL,
|
||
SettingKeyDingTalkConnectCorpRestrictionPolicy,
|
||
SettingKeyDingTalkConnectInternalCorpID,
|
||
SettingKeyDingTalkConnectBypassRegistration,
|
||
SettingKeyDingTalkConnectSyncCorpEmail,
|
||
SettingKeyDingTalkConnectSyncDisplayName,
|
||
SettingKeyDingTalkConnectSyncDept,
|
||
SettingKeyDingTalkConnectSyncCorpEmailAttrKey,
|
||
SettingKeyDingTalkConnectSyncDisplayNameAttrKey,
|
||
SettingKeyDingTalkConnectSyncDeptAttrKey,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err)
|
||
}
|
||
|
||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||
effective.Enabled = raw == "true"
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientSecret = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.RedirectURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
|
||
effective.CorpRestrictionPolicy = strings.TrimSpace(v)
|
||
}
|
||
effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy)
|
||
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.InternalCorpID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
|
||
effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false,
|
||
// 以保证 OAuth callback 看到的 effective config 永远是一致状态。
|
||
if effective.CorpRestrictionPolicy != "internal_only" {
|
||
effective.BypassRegistration = false
|
||
}
|
||
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
|
||
effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
|
||
if effective.CorpRestrictionPolicy != "internal_only" {
|
||
effective.SyncCorpEmail = false
|
||
effective.SyncDisplayName = false
|
||
effective.SyncDept = false
|
||
}
|
||
|
||
// 身份同步目标 attr key(DB 空 → fallback 默认值)
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" {
|
||
effective.SyncCorpEmailAttrKey = v
|
||
}
|
||
if effective.SyncCorpEmailAttrKey == "" {
|
||
effective.SyncCorpEmailAttrKey = "dingtalk_email"
|
||
}
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" {
|
||
effective.SyncDisplayNameAttrKey = v
|
||
}
|
||
if effective.SyncDisplayNameAttrKey == "" {
|
||
effective.SyncDisplayNameAttrKey = "dingtalk_name"
|
||
}
|
||
if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" {
|
||
effective.SyncDeptAttrKey = v
|
||
}
|
||
if effective.SyncDeptAttrKey == "" {
|
||
effective.SyncDeptAttrKey = "dingtalk_department"
|
||
}
|
||
|
||
if !effective.Enabled {
|
||
return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
|
||
}
|
||
|
||
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
|
||
if strings.TrimSpace(effective.ClientID) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.RedirectURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured")
|
||
}
|
||
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid")
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid")
|
||
}
|
||
if strings.TrimSpace(effective.ClientSecret) == "" {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured")
|
||
}
|
||
|
||
// 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal
|
||
if effective.CorpRestrictionPolicy == "internal_only" {
|
||
effective.AppType = "internal"
|
||
}
|
||
|
||
if err := config.ValidateDingTalkConfig(effective); err != nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error())
|
||
}
|
||
|
||
return effective, nil
|
||
}
|
||
|
||
// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
|
||
//
|
||
// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。
|
||
func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) {
|
||
keys := []string{
|
||
SettingKeyWeChatConnectEnabled,
|
||
SettingKeyWeChatConnectAppID,
|
||
SettingKeyWeChatConnectAppSecret,
|
||
SettingKeyWeChatConnectOpenAppID,
|
||
SettingKeyWeChatConnectOpenAppSecret,
|
||
SettingKeyWeChatConnectMPAppID,
|
||
SettingKeyWeChatConnectMPAppSecret,
|
||
SettingKeyWeChatConnectMobileAppID,
|
||
SettingKeyWeChatConnectMobileAppSecret,
|
||
SettingKeyWeChatConnectOpenEnabled,
|
||
SettingKeyWeChatConnectMPEnabled,
|
||
SettingKeyWeChatConnectMobileEnabled,
|
||
SettingKeyWeChatConnectMode,
|
||
SettingKeyWeChatConnectScopes,
|
||
SettingKeyWeChatConnectRedirectURL,
|
||
SettingKeyWeChatConnectFrontendRedirectURL,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err)
|
||
}
|
||
return s.parseWeChatConnectOAuthConfig(settings)
|
||
}
|
||
|
||
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
|
||
//
|
||
// 优先级:
|
||
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||
// - 否则回退到 config.yaml/env 的值
|
||
func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
|
||
if s == nil || s.cfg == nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||
}
|
||
|
||
effective := s.cfg.OIDC
|
||
|
||
keys := []string{
|
||
SettingKeyOIDCConnectEnabled,
|
||
SettingKeyOIDCConnectProviderName,
|
||
SettingKeyOIDCConnectClientID,
|
||
SettingKeyOIDCConnectClientSecret,
|
||
SettingKeyOIDCConnectIssuerURL,
|
||
SettingKeyOIDCConnectDiscoveryURL,
|
||
SettingKeyOIDCConnectAuthorizeURL,
|
||
SettingKeyOIDCConnectTokenURL,
|
||
SettingKeyOIDCConnectUserInfoURL,
|
||
SettingKeyOIDCConnectJWKSURL,
|
||
SettingKeyOIDCConnectScopes,
|
||
SettingKeyOIDCConnectRedirectURL,
|
||
SettingKeyOIDCConnectFrontendRedirectURL,
|
||
SettingKeyOIDCConnectTokenAuthMethod,
|
||
SettingKeyOIDCConnectUsePKCE,
|
||
SettingKeyOIDCConnectValidateIDToken,
|
||
SettingKeyOIDCConnectAllowedSigningAlgs,
|
||
SettingKeyOIDCConnectClockSkewSeconds,
|
||
SettingKeyOIDCConnectRequireEmailVerified,
|
||
SettingKeyOIDCConnectUserInfoEmailPath,
|
||
SettingKeyOIDCConnectUserInfoIDPath,
|
||
SettingKeyOIDCConnectUserInfoUsernamePath,
|
||
}
|
||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||
if err != nil {
|
||
return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err)
|
||
}
|
||
|
||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||
effective.Enabled = raw == "true"
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ProviderName = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientID = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
||
effective.ClientSecret = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.IssuerURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.DiscoveryURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.AuthorizeURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.TokenURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.UserInfoURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.JWKSURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
|
||
effective.Scopes = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.RedirectURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||
effective.FrontendRedirectURL = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
|
||
effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
|
||
effective.UsePKCE = raw == "true"
|
||
} else {
|
||
effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective)
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||
effective.ValidateIDToken = raw == "true"
|
||
} else {
|
||
effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
|
||
if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
|
||
effective.ClockSkewSeconds = parsed
|
||
}
|
||
}
|
||
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
|
||
effective.RequireEmailVerified = raw == "true"
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
|
||
effective.UserInfoEmailPath = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
|
||
effective.UserInfoIDPath = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
|
||
effective.UserInfoUsernamePath = strings.TrimSpace(v)
|
||
}
|
||
|
||
if !effective.Enabled {
|
||
return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||
}
|
||
if strings.TrimSpace(effective.ProviderName) == "" {
|
||
effective.ProviderName = "OIDC"
|
||
}
|
||
if strings.TrimSpace(effective.ClientID) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||
}
|
||
if strings.TrimSpace(effective.IssuerURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.RedirectURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
||
}
|
||
if !scopesContainOpenID(effective.Scopes) {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid")
|
||
}
|
||
if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600")
|
||
}
|
||
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid")
|
||
}
|
||
|
||
discoveryURL := strings.TrimSpace(effective.DiscoveryURL)
|
||
if discoveryURL == "" {
|
||
discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL)
|
||
effective.DiscoveryURL = discoveryURL
|
||
}
|
||
if discoveryURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
|
||
}
|
||
}
|
||
|
||
needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
|
||
strings.TrimSpace(effective.TokenURL) == "" ||
|
||
(effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
|
||
if needsDiscovery && discoveryURL != "" {
|
||
metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
|
||
if resolveErr != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
|
||
}
|
||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||
effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint)
|
||
}
|
||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||
effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint)
|
||
}
|
||
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
||
effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint)
|
||
}
|
||
if strings.TrimSpace(effective.JWKSURL) == "" {
|
||
effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI)
|
||
}
|
||
}
|
||
|
||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
||
}
|
||
if v := strings.TrimSpace(effective.UserInfoURL); v != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
||
}
|
||
}
|
||
if effective.ValidateIDToken {
|
||
if strings.TrimSpace(effective.JWKSURL) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured")
|
||
}
|
||
if strings.TrimSpace(effective.AllowedSigningAlgs) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured")
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(effective.JWKSURL); v != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid")
|
||
}
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||
}
|
||
|
||
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
||
switch method {
|
||
case "", "client_secret_post", "client_secret_basic":
|
||
if strings.TrimSpace(effective.ClientSecret) == "" {
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||
}
|
||
case "none":
|
||
default:
|
||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
||
}
|
||
|
||
return effective, nil
|
||
}
|
||
|
||
func scopesContainOpenID(scopes string) bool {
|
||
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
||
if scope == "openid" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
type oidcProviderMetadata struct {
|
||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||
TokenEndpoint string `json:"token_endpoint"`
|
||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||
JWKSURI string `json:"jwks_uri"`
|
||
}
|
||
|
||
func oidcDefaultDiscoveryURL(issuerURL string) string {
|
||
issuerURL = strings.TrimSpace(issuerURL)
|
||
if issuerURL == "" {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
||
}
|
||
|
||
func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
|
||
discoveryURL = strings.TrimSpace(discoveryURL)
|
||
if discoveryURL == "" {
|
||
return nil, fmt.Errorf("discovery url is empty")
|
||
}
|
||
|
||
resp, err := req.C().
|
||
SetTimeout(15*time.Second).
|
||
R().
|
||
SetContext(ctx).
|
||
SetHeader("Accept", "application/json").
|
||
Get(discoveryURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("request discovery document: %w", err)
|
||
}
|
||
if !resp.IsSuccessState() {
|
||
return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
|
||
}
|
||
|
||
metadata := &oidcProviderMetadata{}
|
||
if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
|
||
return nil, fmt.Errorf("parse discovery document: %w", err)
|
||
}
|
||
return metadata, nil
|
||
}
|