setting_oauth.go 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/imroc/req/v3"
  13. )
  14. // CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本,
  15. // 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项,
  16. // 但 API 可被直接调用)。
  17. func CoerceDingTalkCorpPolicyForWrite(policy string) string {
  18. return coerceDeprecatedDingTalkCorpPolicy(policy)
  19. }
  20. // coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。
  21. // 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed
  22. // (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。
  23. func coerceDeprecatedDingTalkCorpPolicy(policy string) string {
  24. if policy == "whitelist" {
  25. slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none",
  26. "hint", "re-save DingTalk settings in admin UI to clear this warning")
  27. return "none"
  28. }
  29. return policy
  30. }
  31. func normalizeWeChatConnectModeSetting(raw string) string {
  32. switch strings.ToLower(strings.TrimSpace(raw)) {
  33. case "mp":
  34. return "mp"
  35. case "mobile":
  36. return "mobile"
  37. default:
  38. return "open"
  39. }
  40. }
  41. func defaultWeChatConnectScopeForMode(mode string) string {
  42. switch normalizeWeChatConnectModeSetting(mode) {
  43. case "mp":
  44. return "snsapi_userinfo"
  45. case "mobile":
  46. return ""
  47. }
  48. return defaultWeChatConnectScopes
  49. }
  50. func normalizeWeChatConnectScopeSetting(raw, mode string) string {
  51. switch normalizeWeChatConnectModeSetting(mode) {
  52. case "mp":
  53. switch strings.TrimSpace(raw) {
  54. case "snsapi_base":
  55. return "snsapi_base"
  56. case "snsapi_userinfo":
  57. return "snsapi_userinfo"
  58. default:
  59. return defaultWeChatConnectScopeForMode(mode)
  60. }
  61. case "mobile":
  62. return ""
  63. default:
  64. return defaultWeChatConnectScopes
  65. }
  66. }
  67. func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) {
  68. mode = normalizeWeChatConnectModeSetting(mode)
  69. rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
  70. rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
  71. rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
  72. openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
  73. mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
  74. mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
  75. if openConfigured || mpConfigured || mobileConfigured {
  76. openEnabled := strings.TrimSpace(rawOpen) == "true"
  77. mpEnabled := strings.TrimSpace(rawMP) == "true"
  78. mobileEnabled := strings.TrimSpace(rawMobile) == "true"
  79. return openEnabled, mpEnabled, mobileEnabled
  80. }
  81. if !enabled {
  82. return false, false, false
  83. }
  84. if mode == "mp" {
  85. return false, true, false
  86. }
  87. if mode == "mobile" {
  88. return false, false, true
  89. }
  90. return true, false, false
  91. }
  92. func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string {
  93. mode = normalizeWeChatConnectModeSetting(mode)
  94. switch mode {
  95. case "open":
  96. if openEnabled {
  97. return "open"
  98. }
  99. case "mp":
  100. if mpEnabled {
  101. return "mp"
  102. }
  103. case "mobile":
  104. if mobileEnabled {
  105. return "mobile"
  106. }
  107. }
  108. switch {
  109. case openEnabled:
  110. return "open"
  111. case mpEnabled:
  112. return "mp"
  113. case mobileEnabled:
  114. return "mobile"
  115. default:
  116. return mode
  117. }
  118. }
  119. func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) {
  120. mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode))
  121. rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled]
  122. rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled]
  123. rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled]
  124. openConfigured := hasOpen && strings.TrimSpace(rawOpen) != ""
  125. mpConfigured := hasMP && strings.TrimSpace(rawMP) != ""
  126. mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != ""
  127. if openConfigured || mpConfigured || mobileConfigured {
  128. openEnabled := strings.TrimSpace(rawOpen) == "true"
  129. mpEnabled := strings.TrimSpace(rawMP) == "true"
  130. mobileEnabled := strings.TrimSpace(rawMobile) == "true"
  131. _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled]
  132. if !enabledConfigured &&
  133. enabled &&
  134. !openEnabled &&
  135. !mpEnabled &&
  136. !mobileEnabled &&
  137. (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) {
  138. return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
  139. }
  140. return openEnabled, mpEnabled, mobileEnabled
  141. }
  142. if !enabled {
  143. return false, false, false
  144. }
  145. if base.OpenEnabled || base.MPEnabled || base.MobileEnabled {
  146. return base.OpenEnabled, base.MPEnabled, base.MobileEnabled
  147. }
  148. return parseWeChatConnectCapabilitySettings(settings, enabled, mode)
  149. }
  150. func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig {
  151. base := config.WeChatConnectConfig{}
  152. if s != nil && s.cfg != nil {
  153. base = s.cfg.WeChat
  154. }
  155. enabled := base.Enabled
  156. if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok {
  157. enabled = strings.TrimSpace(raw) == "true"
  158. }
  159. legacyAppID := strings.TrimSpace(firstNonEmpty(
  160. settings[SettingKeyWeChatConnectAppID],
  161. base.AppID,
  162. base.OpenAppID,
  163. base.MPAppID,
  164. base.MobileAppID,
  165. ))
  166. legacyAppSecret := strings.TrimSpace(firstNonEmpty(
  167. settings[SettingKeyWeChatConnectAppSecret],
  168. base.AppSecret,
  169. base.OpenAppSecret,
  170. base.MPAppSecret,
  171. base.MobileAppSecret,
  172. ))
  173. openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID))
  174. openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret))
  175. mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID))
  176. mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret))
  177. mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID))
  178. mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret))
  179. modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode)
  180. openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw)
  181. mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw)
  182. return WeChatConnectOAuthConfig{
  183. Enabled: enabled,
  184. LegacyAppID: legacyAppID,
  185. LegacyAppSecret: legacyAppSecret,
  186. OpenAppID: openAppID,
  187. OpenAppSecret: openAppSecret,
  188. MPAppID: mpAppID,
  189. MPAppSecret: mpAppSecret,
  190. MobileAppID: mobileAppID,
  191. MobileAppSecret: mobileAppSecret,
  192. OpenEnabled: openEnabled,
  193. MPEnabled: mpEnabled,
  194. MobileEnabled: mobileEnabled,
  195. Mode: mode,
  196. Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode),
  197. RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)),
  198. FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)),
  199. }
  200. }
  201. func DefaultWeChatConnectScopesForMode(mode string) string {
  202. return defaultWeChatConnectScopeForMode(mode)
  203. }
  204. func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) {
  205. cfg := s.effectiveWeChatConnectOAuthConfig(settings)
  206. if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) {
  207. return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
  208. }
  209. if cfg.OpenEnabled {
  210. if cfg.AppIDForMode("open") == "" {
  211. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured")
  212. }
  213. if cfg.AppSecretForMode("open") == "" {
  214. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured")
  215. }
  216. }
  217. if cfg.MPEnabled {
  218. if cfg.AppIDForMode("mp") == "" {
  219. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured")
  220. }
  221. if cfg.AppSecretForMode("mp") == "" {
  222. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured")
  223. }
  224. }
  225. if cfg.MobileEnabled {
  226. if cfg.AppIDForMode("mobile") == "" {
  227. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured")
  228. }
  229. if cfg.AppSecretForMode("mobile") == "" {
  230. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured")
  231. }
  232. }
  233. if v := strings.TrimSpace(cfg.RedirectURL); v != "" {
  234. if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
  235. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid")
  236. }
  237. }
  238. if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
  239. return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid")
  240. }
  241. return cfg, nil
  242. }
  243. func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) {
  244. cfg := s.effectiveWeChatConnectOAuthConfig(settings)
  245. if !cfg.Enabled {
  246. return false, false, false, false
  247. }
  248. openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != ""
  249. mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != ""
  250. mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != ""
  251. return openReady || mpReady, openReady, mpReady, mobileReady
  252. }
  253. func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
  254. switch strings.ToLower(strings.TrimSpace(provider)) {
  255. case "github":
  256. cfg := config.EmailOAuthProviderConfig{
  257. AuthorizeURL: defaultGitHubOAuthAuthorize,
  258. TokenURL: defaultGitHubOAuthToken,
  259. UserInfoURL: defaultGitHubOAuthUserInfo,
  260. EmailsURL: defaultGitHubOAuthEmails,
  261. Scopes: defaultGitHubOAuthScopes,
  262. FrontendRedirectURL: defaultGitHubOAuthFrontend,
  263. }
  264. if s != nil && s.cfg != nil {
  265. cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
  266. }
  267. return cfg
  268. case "google":
  269. cfg := config.EmailOAuthProviderConfig{
  270. AuthorizeURL: defaultGoogleOAuthAuthorize,
  271. TokenURL: defaultGoogleOAuthToken,
  272. UserInfoURL: defaultGoogleOAuthUserInfo,
  273. Scopes: defaultGoogleOAuthScopes,
  274. FrontendRedirectURL: defaultGoogleOAuthFrontend,
  275. }
  276. if s != nil && s.cfg != nil {
  277. cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
  278. }
  279. return cfg
  280. default:
  281. return config.EmailOAuthProviderConfig{}
  282. }
  283. }
  284. func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
  285. base.Enabled = override.Enabled
  286. if strings.TrimSpace(override.ClientID) != "" {
  287. base.ClientID = strings.TrimSpace(override.ClientID)
  288. }
  289. if strings.TrimSpace(override.ClientSecret) != "" {
  290. base.ClientSecret = strings.TrimSpace(override.ClientSecret)
  291. }
  292. if strings.TrimSpace(override.AuthorizeURL) != "" {
  293. base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
  294. }
  295. if strings.TrimSpace(override.TokenURL) != "" {
  296. base.TokenURL = strings.TrimSpace(override.TokenURL)
  297. }
  298. if strings.TrimSpace(override.UserInfoURL) != "" {
  299. base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
  300. }
  301. if strings.TrimSpace(override.EmailsURL) != "" {
  302. base.EmailsURL = strings.TrimSpace(override.EmailsURL)
  303. }
  304. if strings.TrimSpace(override.Scopes) != "" {
  305. base.Scopes = strings.TrimSpace(override.Scopes)
  306. }
  307. if strings.TrimSpace(override.RedirectURL) != "" {
  308. base.RedirectURL = strings.TrimSpace(override.RedirectURL)
  309. }
  310. if strings.TrimSpace(override.FrontendRedirectURL) != "" {
  311. base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
  312. }
  313. return base
  314. }
  315. func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
  316. cfg := s.effectiveEmailOAuthConfig(settings, provider)
  317. return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
  318. }
  319. func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
  320. cfg := s.emailOAuthBaseConfig(provider)
  321. switch strings.ToLower(strings.TrimSpace(provider)) {
  322. case "github":
  323. if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
  324. cfg.Enabled = raw == "true"
  325. }
  326. cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
  327. cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
  328. cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
  329. cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
  330. case "google":
  331. if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
  332. cfg.Enabled = raw == "true"
  333. }
  334. cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
  335. cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
  336. cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
  337. cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
  338. }
  339. return cfg
  340. }
  341. func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool {
  342. if base.UsePKCEExplicit {
  343. return base.UsePKCE
  344. }
  345. return true
  346. }
  347. func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool {
  348. if base.ValidateIDTokenExplicit {
  349. return base.ValidateIDToken
  350. }
  351. return true
  352. }
  353. func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool {
  354. if configured {
  355. return strings.TrimSpace(raw) == "true"
  356. }
  357. if explicit {
  358. return explicitValue
  359. }
  360. return false
  361. }
  362. func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) {
  363. rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{
  364. SettingKeyOIDCConnectUsePKCE,
  365. SettingKeyOIDCConnectValidateIDToken,
  366. })
  367. if err != nil {
  368. return false, false, fmt.Errorf("get oidc security write defaults: %w", err)
  369. }
  370. base := config.OIDCConnectConfig{}
  371. if s != nil && s.cfg != nil {
  372. base = s.cfg.OIDC
  373. }
  374. rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE]
  375. rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken]
  376. return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE),
  377. oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken),
  378. nil
  379. }
  380. func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
  381. provider = strings.ToLower(strings.TrimSpace(provider))
  382. if provider != "github" && provider != "google" {
  383. return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
  384. }
  385. keys := []string{
  386. SettingKeyGitHubOAuthEnabled,
  387. SettingKeyGitHubOAuthClientID,
  388. SettingKeyGitHubOAuthClientSecret,
  389. SettingKeyGitHubOAuthRedirectURL,
  390. SettingKeyGitHubOAuthFrontendRedirectURL,
  391. SettingKeyGoogleOAuthEnabled,
  392. SettingKeyGoogleOAuthClientID,
  393. SettingKeyGoogleOAuthClientSecret,
  394. SettingKeyGoogleOAuthRedirectURL,
  395. SettingKeyGoogleOAuthFrontendRedirectURL,
  396. }
  397. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  398. if err != nil {
  399. return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
  400. }
  401. cfg := s.effectiveEmailOAuthConfig(settings, provider)
  402. if !cfg.Enabled {
  403. return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
  404. }
  405. if strings.TrimSpace(cfg.ClientID) == "" {
  406. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
  407. }
  408. if strings.TrimSpace(cfg.ClientSecret) == "" {
  409. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
  410. }
  411. for label, rawURL := range map[string]string{
  412. "authorize": cfg.AuthorizeURL,
  413. "token": cfg.TokenURL,
  414. "userinfo": cfg.UserInfoURL,
  415. "redirect": cfg.RedirectURL,
  416. } {
  417. if strings.TrimSpace(rawURL) == "" {
  418. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
  419. }
  420. if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
  421. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
  422. }
  423. }
  424. if strings.TrimSpace(cfg.EmailsURL) != "" {
  425. if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
  426. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
  427. }
  428. }
  429. if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
  430. return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
  431. }
  432. return cfg, nil
  433. }
  434. // GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。
  435. //
  436. // 优先级:
  437. // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
  438. // - 否则回退到 config.yaml/env 的值
  439. func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
  440. if s == nil || s.cfg == nil {
  441. return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  442. }
  443. effective := s.cfg.LinuxDo
  444. keys := []string{
  445. SettingKeyLinuxDoConnectEnabled,
  446. SettingKeyLinuxDoConnectClientID,
  447. SettingKeyLinuxDoConnectClientSecret,
  448. SettingKeyLinuxDoConnectRedirectURL,
  449. }
  450. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  451. if err != nil {
  452. return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err)
  453. }
  454. if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
  455. effective.Enabled = raw == "true"
  456. }
  457. if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
  458. effective.ClientID = strings.TrimSpace(v)
  459. }
  460. if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
  461. effective.ClientSecret = strings.TrimSpace(v)
  462. }
  463. if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
  464. effective.RedirectURL = strings.TrimSpace(v)
  465. }
  466. if !effective.Enabled {
  467. return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
  468. }
  469. // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
  470. if strings.TrimSpace(effective.ClientID) == "" {
  471. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
  472. }
  473. if strings.TrimSpace(effective.AuthorizeURL) == "" {
  474. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
  475. }
  476. if strings.TrimSpace(effective.TokenURL) == "" {
  477. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
  478. }
  479. if strings.TrimSpace(effective.UserInfoURL) == "" {
  480. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured")
  481. }
  482. if strings.TrimSpace(effective.RedirectURL) == "" {
  483. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
  484. }
  485. if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
  486. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
  487. }
  488. if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
  489. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
  490. }
  491. if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
  492. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
  493. }
  494. if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
  495. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
  496. }
  497. if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
  498. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
  499. }
  500. if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
  501. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
  502. }
  503. method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
  504. switch method {
  505. case "", "client_secret_post", "client_secret_basic":
  506. if strings.TrimSpace(effective.ClientSecret) == "" {
  507. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
  508. }
  509. case "none":
  510. default:
  511. return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
  512. }
  513. return effective, nil
  514. }
  515. // GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。
  516. //
  517. // 优先级:
  518. // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
  519. // - 否则回退到 config.yaml/env 的值
  520. func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
  521. if s == nil || s.cfg == nil {
  522. return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  523. }
  524. effective := s.cfg.DingTalk
  525. keys := []string{
  526. SettingKeyDingTalkConnectEnabled,
  527. SettingKeyDingTalkConnectClientID,
  528. SettingKeyDingTalkConnectClientSecret,
  529. SettingKeyDingTalkConnectRedirectURL,
  530. SettingKeyDingTalkConnectCorpRestrictionPolicy,
  531. SettingKeyDingTalkConnectInternalCorpID,
  532. SettingKeyDingTalkConnectBypassRegistration,
  533. SettingKeyDingTalkConnectSyncCorpEmail,
  534. SettingKeyDingTalkConnectSyncDisplayName,
  535. SettingKeyDingTalkConnectSyncDept,
  536. SettingKeyDingTalkConnectSyncCorpEmailAttrKey,
  537. SettingKeyDingTalkConnectSyncDisplayNameAttrKey,
  538. SettingKeyDingTalkConnectSyncDeptAttrKey,
  539. }
  540. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  541. if err != nil {
  542. return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err)
  543. }
  544. if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
  545. effective.Enabled = raw == "true"
  546. }
  547. if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
  548. effective.ClientID = strings.TrimSpace(v)
  549. }
  550. if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
  551. effective.ClientSecret = strings.TrimSpace(v)
  552. }
  553. if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
  554. effective.RedirectURL = strings.TrimSpace(v)
  555. }
  556. if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
  557. effective.CorpRestrictionPolicy = strings.TrimSpace(v)
  558. }
  559. effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy)
  560. if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
  561. effective.InternalCorpID = strings.TrimSpace(v)
  562. }
  563. if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
  564. effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
  565. }
  566. // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false,
  567. // 以保证 OAuth callback 看到的 effective config 永远是一致状态。
  568. if effective.CorpRestrictionPolicy != "internal_only" {
  569. effective.BypassRegistration = false
  570. }
  571. if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
  572. effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
  573. }
  574. if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
  575. effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
  576. }
  577. if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
  578. effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
  579. }
  580. // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
  581. if effective.CorpRestrictionPolicy != "internal_only" {
  582. effective.SyncCorpEmail = false
  583. effective.SyncDisplayName = false
  584. effective.SyncDept = false
  585. }
  586. // 身份同步目标 attr key(DB 空 → fallback 默认值)
  587. if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" {
  588. effective.SyncCorpEmailAttrKey = v
  589. }
  590. if effective.SyncCorpEmailAttrKey == "" {
  591. effective.SyncCorpEmailAttrKey = "dingtalk_email"
  592. }
  593. if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" {
  594. effective.SyncDisplayNameAttrKey = v
  595. }
  596. if effective.SyncDisplayNameAttrKey == "" {
  597. effective.SyncDisplayNameAttrKey = "dingtalk_name"
  598. }
  599. if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" {
  600. effective.SyncDeptAttrKey = v
  601. }
  602. if effective.SyncDeptAttrKey == "" {
  603. effective.SyncDeptAttrKey = "dingtalk_department"
  604. }
  605. if !effective.Enabled {
  606. return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
  607. }
  608. // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
  609. if strings.TrimSpace(effective.ClientID) == "" {
  610. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured")
  611. }
  612. if strings.TrimSpace(effective.AuthorizeURL) == "" {
  613. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured")
  614. }
  615. if strings.TrimSpace(effective.TokenURL) == "" {
  616. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured")
  617. }
  618. if strings.TrimSpace(effective.UserInfoURL) == "" {
  619. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured")
  620. }
  621. if strings.TrimSpace(effective.RedirectURL) == "" {
  622. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured")
  623. }
  624. if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
  625. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured")
  626. }
  627. if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
  628. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid")
  629. }
  630. if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
  631. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid")
  632. }
  633. if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
  634. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid")
  635. }
  636. if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
  637. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid")
  638. }
  639. if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
  640. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid")
  641. }
  642. if strings.TrimSpace(effective.ClientSecret) == "" {
  643. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured")
  644. }
  645. // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal
  646. if effective.CorpRestrictionPolicy == "internal_only" {
  647. effective.AppType = "internal"
  648. }
  649. if err := config.ValidateDingTalkConfig(effective); err != nil {
  650. return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error())
  651. }
  652. return effective, nil
  653. }
  654. // GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
  655. //
  656. // WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。
  657. func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) {
  658. keys := []string{
  659. SettingKeyWeChatConnectEnabled,
  660. SettingKeyWeChatConnectAppID,
  661. SettingKeyWeChatConnectAppSecret,
  662. SettingKeyWeChatConnectOpenAppID,
  663. SettingKeyWeChatConnectOpenAppSecret,
  664. SettingKeyWeChatConnectMPAppID,
  665. SettingKeyWeChatConnectMPAppSecret,
  666. SettingKeyWeChatConnectMobileAppID,
  667. SettingKeyWeChatConnectMobileAppSecret,
  668. SettingKeyWeChatConnectOpenEnabled,
  669. SettingKeyWeChatConnectMPEnabled,
  670. SettingKeyWeChatConnectMobileEnabled,
  671. SettingKeyWeChatConnectMode,
  672. SettingKeyWeChatConnectScopes,
  673. SettingKeyWeChatConnectRedirectURL,
  674. SettingKeyWeChatConnectFrontendRedirectURL,
  675. }
  676. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  677. if err != nil {
  678. return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err)
  679. }
  680. return s.parseWeChatConnectOAuthConfig(settings)
  681. }
  682. // GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
  683. //
  684. // 优先级:
  685. // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
  686. // - 否则回退到 config.yaml/env 的值
  687. func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
  688. if s == nil || s.cfg == nil {
  689. return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  690. }
  691. effective := s.cfg.OIDC
  692. keys := []string{
  693. SettingKeyOIDCConnectEnabled,
  694. SettingKeyOIDCConnectProviderName,
  695. SettingKeyOIDCConnectClientID,
  696. SettingKeyOIDCConnectClientSecret,
  697. SettingKeyOIDCConnectIssuerURL,
  698. SettingKeyOIDCConnectDiscoveryURL,
  699. SettingKeyOIDCConnectAuthorizeURL,
  700. SettingKeyOIDCConnectTokenURL,
  701. SettingKeyOIDCConnectUserInfoURL,
  702. SettingKeyOIDCConnectJWKSURL,
  703. SettingKeyOIDCConnectScopes,
  704. SettingKeyOIDCConnectRedirectURL,
  705. SettingKeyOIDCConnectFrontendRedirectURL,
  706. SettingKeyOIDCConnectTokenAuthMethod,
  707. SettingKeyOIDCConnectUsePKCE,
  708. SettingKeyOIDCConnectValidateIDToken,
  709. SettingKeyOIDCConnectAllowedSigningAlgs,
  710. SettingKeyOIDCConnectClockSkewSeconds,
  711. SettingKeyOIDCConnectRequireEmailVerified,
  712. SettingKeyOIDCConnectUserInfoEmailPath,
  713. SettingKeyOIDCConnectUserInfoIDPath,
  714. SettingKeyOIDCConnectUserInfoUsernamePath,
  715. }
  716. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  717. if err != nil {
  718. return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err)
  719. }
  720. if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
  721. effective.Enabled = raw == "true"
  722. }
  723. if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
  724. effective.ProviderName = strings.TrimSpace(v)
  725. }
  726. if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
  727. effective.ClientID = strings.TrimSpace(v)
  728. }
  729. if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
  730. effective.ClientSecret = strings.TrimSpace(v)
  731. }
  732. if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
  733. effective.IssuerURL = strings.TrimSpace(v)
  734. }
  735. if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
  736. effective.DiscoveryURL = strings.TrimSpace(v)
  737. }
  738. if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
  739. effective.AuthorizeURL = strings.TrimSpace(v)
  740. }
  741. if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
  742. effective.TokenURL = strings.TrimSpace(v)
  743. }
  744. if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
  745. effective.UserInfoURL = strings.TrimSpace(v)
  746. }
  747. if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
  748. effective.JWKSURL = strings.TrimSpace(v)
  749. }
  750. if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
  751. effective.Scopes = strings.TrimSpace(v)
  752. }
  753. if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
  754. effective.RedirectURL = strings.TrimSpace(v)
  755. }
  756. if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
  757. effective.FrontendRedirectURL = strings.TrimSpace(v)
  758. }
  759. if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
  760. effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
  761. }
  762. if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
  763. effective.UsePKCE = raw == "true"
  764. } else {
  765. effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective)
  766. }
  767. if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
  768. effective.ValidateIDToken = raw == "true"
  769. } else {
  770. effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective)
  771. }
  772. if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
  773. effective.AllowedSigningAlgs = strings.TrimSpace(v)
  774. }
  775. if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
  776. if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
  777. effective.ClockSkewSeconds = parsed
  778. }
  779. }
  780. if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
  781. effective.RequireEmailVerified = raw == "true"
  782. }
  783. if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
  784. effective.UserInfoEmailPath = strings.TrimSpace(v)
  785. }
  786. if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
  787. effective.UserInfoIDPath = strings.TrimSpace(v)
  788. }
  789. if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
  790. effective.UserInfoUsernamePath = strings.TrimSpace(v)
  791. }
  792. if !effective.Enabled {
  793. return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
  794. }
  795. if strings.TrimSpace(effective.ProviderName) == "" {
  796. effective.ProviderName = "OIDC"
  797. }
  798. if strings.TrimSpace(effective.ClientID) == "" {
  799. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
  800. }
  801. if strings.TrimSpace(effective.IssuerURL) == "" {
  802. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured")
  803. }
  804. if strings.TrimSpace(effective.RedirectURL) == "" {
  805. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
  806. }
  807. if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
  808. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
  809. }
  810. if !scopesContainOpenID(effective.Scopes) {
  811. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid")
  812. }
  813. if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 {
  814. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600")
  815. }
  816. if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil {
  817. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid")
  818. }
  819. discoveryURL := strings.TrimSpace(effective.DiscoveryURL)
  820. if discoveryURL == "" {
  821. discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL)
  822. effective.DiscoveryURL = discoveryURL
  823. }
  824. if discoveryURL != "" {
  825. if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
  826. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
  827. }
  828. }
  829. needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
  830. strings.TrimSpace(effective.TokenURL) == "" ||
  831. (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
  832. if needsDiscovery && discoveryURL != "" {
  833. metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
  834. if resolveErr != nil {
  835. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
  836. }
  837. if strings.TrimSpace(effective.AuthorizeURL) == "" {
  838. effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint)
  839. }
  840. if strings.TrimSpace(effective.TokenURL) == "" {
  841. effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint)
  842. }
  843. if strings.TrimSpace(effective.UserInfoURL) == "" {
  844. effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint)
  845. }
  846. if strings.TrimSpace(effective.JWKSURL) == "" {
  847. effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI)
  848. }
  849. }
  850. if strings.TrimSpace(effective.AuthorizeURL) == "" {
  851. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
  852. }
  853. if strings.TrimSpace(effective.TokenURL) == "" {
  854. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
  855. }
  856. if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
  857. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
  858. }
  859. if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
  860. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
  861. }
  862. if v := strings.TrimSpace(effective.UserInfoURL); v != "" {
  863. if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
  864. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
  865. }
  866. }
  867. if effective.ValidateIDToken {
  868. if strings.TrimSpace(effective.JWKSURL) == "" {
  869. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured")
  870. }
  871. if strings.TrimSpace(effective.AllowedSigningAlgs) == "" {
  872. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured")
  873. }
  874. }
  875. if v := strings.TrimSpace(effective.JWKSURL); v != "" {
  876. if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
  877. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid")
  878. }
  879. }
  880. if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
  881. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
  882. }
  883. if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
  884. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
  885. }
  886. method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
  887. switch method {
  888. case "", "client_secret_post", "client_secret_basic":
  889. if strings.TrimSpace(effective.ClientSecret) == "" {
  890. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
  891. }
  892. case "none":
  893. default:
  894. return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
  895. }
  896. return effective, nil
  897. }
  898. func scopesContainOpenID(scopes string) bool {
  899. for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
  900. if scope == "openid" {
  901. return true
  902. }
  903. }
  904. return false
  905. }
  906. type oidcProviderMetadata struct {
  907. AuthorizationEndpoint string `json:"authorization_endpoint"`
  908. TokenEndpoint string `json:"token_endpoint"`
  909. UserInfoEndpoint string `json:"userinfo_endpoint"`
  910. JWKSURI string `json:"jwks_uri"`
  911. }
  912. func oidcDefaultDiscoveryURL(issuerURL string) string {
  913. issuerURL = strings.TrimSpace(issuerURL)
  914. if issuerURL == "" {
  915. return ""
  916. }
  917. return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
  918. }
  919. func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
  920. discoveryURL = strings.TrimSpace(discoveryURL)
  921. if discoveryURL == "" {
  922. return nil, fmt.Errorf("discovery url is empty")
  923. }
  924. resp, err := req.C().
  925. SetTimeout(15*time.Second).
  926. R().
  927. SetContext(ctx).
  928. SetHeader("Accept", "application/json").
  929. Get(discoveryURL)
  930. if err != nil {
  931. return nil, fmt.Errorf("request discovery document: %w", err)
  932. }
  933. if !resp.IsSuccessState() {
  934. return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
  935. }
  936. metadata := &oidcProviderMetadata{}
  937. if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
  938. return nil, fmt.Errorf("parse discovery document: %w", err)
  939. }
  940. return metadata, nil
  941. }