auth_oidc_oauth.go 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  1. package handler
  2. import (
  3. "context"
  4. "crypto/ecdsa"
  5. "crypto/elliptic"
  6. "crypto/rsa"
  7. "crypto/sha256"
  8. "encoding/base64"
  9. "encoding/hex"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "log"
  14. "math/big"
  15. "net/http"
  16. "net/url"
  17. "strconv"
  18. "strings"
  19. "time"
  20. dbent "github.com/Wei-Shaw/sub2api/ent"
  21. "github.com/Wei-Shaw/sub2api/internal/config"
  22. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  23. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  24. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  25. "github.com/Wei-Shaw/sub2api/internal/service"
  26. "github.com/gin-gonic/gin"
  27. "github.com/golang-jwt/jwt/v5"
  28. "github.com/imroc/req/v3"
  29. "github.com/tidwall/gjson"
  30. )
  31. const (
  32. oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc"
  33. oidcOAuthStateCookieName = "oidc_oauth_state"
  34. oidcOAuthVerifierCookie = "oidc_oauth_verifier"
  35. oidcOAuthRedirectCookie = "oidc_oauth_redirect"
  36. oidcOAuthNonceCookie = "oidc_oauth_nonce"
  37. oidcOAuthIntentCookieName = "oidc_oauth_intent"
  38. oidcOAuthBindUserCookieName = "oidc_oauth_bind_user"
  39. oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
  40. oidcOAuthDefaultRedirectTo = "/dashboard"
  41. oidcOAuthDefaultFrontendCB = "/auth/oidc/callback"
  42. )
  43. type oidcTokenResponse struct {
  44. AccessToken string `json:"access_token"`
  45. TokenType string `json:"token_type"`
  46. ExpiresIn int64 `json:"expires_in"`
  47. RefreshToken string `json:"refresh_token,omitempty"`
  48. Scope string `json:"scope,omitempty"`
  49. IDToken string `json:"id_token,omitempty"`
  50. }
  51. type oidcTokenExchangeError struct {
  52. StatusCode int
  53. ProviderError string
  54. ProviderDescription string
  55. Body string
  56. }
  57. func (e *oidcTokenExchangeError) Error() string {
  58. if e == nil {
  59. return ""
  60. }
  61. parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)}
  62. if strings.TrimSpace(e.ProviderError) != "" {
  63. parts = append(parts, "error="+strings.TrimSpace(e.ProviderError))
  64. }
  65. if strings.TrimSpace(e.ProviderDescription) != "" {
  66. parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription))
  67. }
  68. return strings.Join(parts, " ")
  69. }
  70. type oidcIDTokenClaims struct {
  71. Email string `json:"email,omitempty"`
  72. EmailVerified *bool `json:"email_verified,omitempty"`
  73. PreferredUsername string `json:"preferred_username,omitempty"`
  74. Name string `json:"name,omitempty"`
  75. Nonce string `json:"nonce,omitempty"`
  76. Azp string `json:"azp,omitempty"`
  77. jwt.RegisteredClaims
  78. }
  79. type oidcUserInfoClaims struct {
  80. Email string
  81. Username string
  82. Subject string
  83. EmailVerified *bool
  84. DisplayName string
  85. AvatarURL string
  86. }
  87. type oidcJWKSet struct {
  88. Keys []oidcJWK `json:"keys"`
  89. }
  90. type oidcJWK struct {
  91. Kty string `json:"kty"`
  92. Kid string `json:"kid"`
  93. Use string `json:"use"`
  94. Alg string `json:"alg"`
  95. N string `json:"n"`
  96. E string `json:"e"`
  97. Crv string `json:"crv"`
  98. X string `json:"x"`
  99. Y string `json:"y"`
  100. }
  101. // OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
  102. // GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
  103. func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
  104. if !h.requireActionCaptchaForOAuthLoginStart(c) {
  105. return
  106. }
  107. cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
  108. if err != nil {
  109. response.ErrorFrom(c, err)
  110. return
  111. }
  112. state, err := oauth.GenerateState()
  113. if err != nil {
  114. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  115. return
  116. }
  117. redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
  118. if redirectTo == "" {
  119. redirectTo = oidcOAuthDefaultRedirectTo
  120. }
  121. browserSessionKey, err := generateOAuthPendingBrowserSession()
  122. if err != nil {
  123. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
  124. return
  125. }
  126. secureCookie := isRequestHTTPS(c)
  127. oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie)
  128. oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
  129. intent := normalizeOAuthIntent(c.Query("intent"))
  130. oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie)
  131. captureOAuthPromoCode(c, secureCookie)
  132. setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
  133. clearOAuthPendingSessionCookie(c, secureCookie)
  134. if intent == oauthIntentBindCurrentUser {
  135. bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
  136. if err != nil {
  137. response.ErrorFrom(c, err)
  138. return
  139. }
  140. oidcSetCookie(c, oidcOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), oidcOAuthCookieMaxAgeSec, secureCookie)
  141. } else {
  142. oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
  143. }
  144. codeChallenge := ""
  145. if cfg.UsePKCE {
  146. verifier, genErr := oauth.GenerateCodeVerifier()
  147. if genErr != nil {
  148. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr))
  149. return
  150. }
  151. codeChallenge = oauth.GenerateCodeChallenge(verifier)
  152. oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie)
  153. }
  154. nonce := ""
  155. if cfg.ValidateIDToken {
  156. nonce, err = oauth.GenerateState()
  157. if err != nil {
  158. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err))
  159. return
  160. }
  161. oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie)
  162. }
  163. redirectURI := strings.TrimSpace(cfg.RedirectURL)
  164. if redirectURI == "" {
  165. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured"))
  166. return
  167. }
  168. authURL, err := buildOIDCAuthorizeURL(cfg, state, nonce, codeChallenge, redirectURI)
  169. if err != nil {
  170. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
  171. return
  172. }
  173. respondOAuthStart(c, authURL)
  174. }
  175. // OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
  176. // GET /api/v1/auth/oauth/oidc/callback?code=...&state=...
  177. func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
  178. cfg, cfgErr := h.getOIDCOAuthConfig(c.Request.Context())
  179. if cfgErr != nil {
  180. response.ErrorFrom(c, cfgErr)
  181. return
  182. }
  183. frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
  184. if frontendCallback == "" {
  185. frontendCallback = oidcOAuthDefaultFrontendCB
  186. }
  187. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  188. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  189. return
  190. }
  191. code := strings.TrimSpace(c.Query("code"))
  192. state := strings.TrimSpace(c.Query("state"))
  193. if code == "" || state == "" {
  194. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  195. return
  196. }
  197. secureCookie := isRequestHTTPS(c)
  198. defer func() {
  199. oidcClearCookie(c, oidcOAuthStateCookieName, secureCookie)
  200. oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie)
  201. oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie)
  202. oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
  203. oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
  204. oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
  205. clearOAuthPromoCodeCookie(c, secureCookie)
  206. }()
  207. expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
  208. if err != nil || expectedState == "" || state != expectedState {
  209. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
  210. return
  211. }
  212. redirectTo, _ := readCookieDecoded(c, oidcOAuthRedirectCookie)
  213. redirectTo = sanitizeFrontendRedirectPath(redirectTo)
  214. if redirectTo == "" {
  215. redirectTo = oidcOAuthDefaultRedirectTo
  216. }
  217. browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
  218. if strings.TrimSpace(browserSessionKey) == "" {
  219. redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
  220. return
  221. }
  222. intent, _ := readCookieDecoded(c, oidcOAuthIntentCookieName)
  223. intent = normalizeOAuthIntent(intent)
  224. codeVerifier := ""
  225. if cfg.UsePKCE {
  226. codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
  227. if codeVerifier == "" {
  228. redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
  229. return
  230. }
  231. }
  232. expectedNonce := ""
  233. if cfg.ValidateIDToken {
  234. expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie)
  235. if expectedNonce == "" {
  236. redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "")
  237. return
  238. }
  239. }
  240. redirectURI := strings.TrimSpace(cfg.RedirectURL)
  241. if redirectURI == "" {
  242. redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "")
  243. return
  244. }
  245. tokenResp, err := oidcExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
  246. if err != nil {
  247. description := ""
  248. var exchangeErr *oidcTokenExchangeError
  249. if errors.As(err, &exchangeErr) && exchangeErr != nil {
  250. log.Printf(
  251. "[OIDC OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s",
  252. exchangeErr.StatusCode,
  253. exchangeErr.ProviderError,
  254. exchangeErr.ProviderDescription,
  255. truncateLogValue(exchangeErr.Body, 2048),
  256. )
  257. description = exchangeErr.Error()
  258. } else {
  259. log.Printf("[OIDC OAuth] token exchange failed: %v", err)
  260. description = err.Error()
  261. }
  262. redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description))
  263. return
  264. }
  265. var idClaims *oidcIDTokenClaims
  266. if cfg.ValidateIDToken {
  267. if strings.TrimSpace(tokenResp.IDToken) == "" {
  268. redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "")
  269. return
  270. }
  271. idClaims, err = oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce)
  272. if err != nil {
  273. log.Printf("[OIDC OAuth] id_token validation failed: %v", err)
  274. redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "")
  275. return
  276. }
  277. }
  278. userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp)
  279. if err != nil {
  280. log.Printf("[OIDC OAuth] userinfo fetch failed: %v", err)
  281. redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
  282. return
  283. }
  284. subject := ""
  285. if idClaims != nil {
  286. subject = strings.TrimSpace(idClaims.Subject)
  287. }
  288. if subject == "" {
  289. subject = strings.TrimSpace(userInfoClaims.Subject)
  290. }
  291. if subject == "" {
  292. redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "")
  293. return
  294. }
  295. issuer := ""
  296. if idClaims != nil {
  297. issuer = strings.TrimSpace(idClaims.Issuer)
  298. }
  299. if issuer == "" {
  300. issuer = strings.TrimSpace(cfg.IssuerURL)
  301. }
  302. if issuer == "" {
  303. redirectOAuthError(c, frontendCallback, "missing_issuer", "missing issuer claim", "")
  304. return
  305. }
  306. emailVerified := userInfoClaims.EmailVerified
  307. if emailVerified == nil && idClaims != nil {
  308. emailVerified = idClaims.EmailVerified
  309. }
  310. if idClaims != nil && userInfoClaims.Subject != "" && idClaims.Subject != "" && strings.TrimSpace(userInfoClaims.Subject) != strings.TrimSpace(idClaims.Subject) {
  311. redirectOAuthError(c, frontendCallback, "subject_mismatch", "userinfo subject does not match id_token", "")
  312. return
  313. }
  314. identityKey := oidcIdentityKey(issuer, subject)
  315. compatEmail := strings.TrimSpace(userInfoClaims.Email)
  316. if compatEmail == "" && idClaims != nil {
  317. compatEmail = strings.TrimSpace(idClaims.Email)
  318. }
  319. email := oidcSyntheticEmailFromIdentityKey(identityKey)
  320. username := firstNonEmpty(
  321. userInfoClaims.Username,
  322. func() string {
  323. if idClaims != nil {
  324. return idClaims.PreferredUsername
  325. }
  326. return ""
  327. }(),
  328. func() string {
  329. if idClaims != nil {
  330. return idClaims.Name
  331. }
  332. return ""
  333. }(),
  334. oidcFallbackUsername(subject),
  335. )
  336. identityRef := service.PendingAuthIdentityKey{
  337. ProviderType: "oidc",
  338. ProviderKey: issuer,
  339. ProviderSubject: subject,
  340. }
  341. upstreamClaims := map[string]any{
  342. "email": email,
  343. "username": username,
  344. "subject": subject,
  345. "issuer": issuer,
  346. "email_verified": emailVerified != nil && *emailVerified,
  347. "provider_fallback": strings.TrimSpace(cfg.ProviderName),
  348. "suggested_display_name": firstNonEmpty(userInfoClaims.DisplayName, func() string {
  349. if idClaims != nil {
  350. return idClaims.Name
  351. }
  352. return ""
  353. }(), username),
  354. "suggested_avatar_url": userInfoClaims.AvatarURL,
  355. }
  356. if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
  357. upstreamClaims["compat_email"] = compatEmail
  358. }
  359. if intent == oauthIntentBindCurrentUser {
  360. targetUserID, err := h.readOAuthBindUserIDFromCookie(c, oidcOAuthBindUserCookieName)
  361. if err != nil {
  362. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "")
  363. return
  364. }
  365. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  366. Intent: oauthIntentBindCurrentUser,
  367. Identity: identityRef,
  368. TargetUserID: &targetUserID,
  369. ResolvedEmail: email,
  370. RedirectTo: redirectTo,
  371. BrowserSessionKey: browserSessionKey,
  372. UpstreamIdentityClaims: upstreamClaims,
  373. CompletionResponse: map[string]any{
  374. "redirect": redirectTo,
  375. },
  376. }); err != nil {
  377. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
  378. return
  379. }
  380. redirectToFrontendCallback(c, frontendCallback)
  381. return
  382. }
  383. existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityRef)
  384. if err != nil {
  385. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  386. return
  387. }
  388. if existingIdentityUser != nil {
  389. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  390. Intent: oauthIntentLogin,
  391. Identity: identityRef,
  392. TargetUserID: &existingIdentityUser.ID,
  393. ResolvedEmail: existingIdentityUser.Email,
  394. RedirectTo: redirectTo,
  395. BrowserSessionKey: browserSessionKey,
  396. UpstreamIdentityClaims: upstreamClaims,
  397. CompletionResponse: map[string]any{
  398. "redirect": redirectTo,
  399. },
  400. }); err != nil {
  401. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  402. return
  403. }
  404. redirectToFrontendCallback(c, frontendCallback)
  405. return
  406. }
  407. compatEmailUser, err := h.findOIDCCompatEmailUser(c.Request.Context(), compatEmail)
  408. if err != nil {
  409. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  410. return
  411. }
  412. if cfg.RequireEmailVerified {
  413. if emailVerified == nil || !*emailVerified {
  414. redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "")
  415. return
  416. }
  417. }
  418. // 快捷路径:当上游返回已验证邮箱、部署不要求额外确认且本地没有同邮箱账号时,
  419. // 直接信任上游身份完成注册/登录,避免展示 choice 页。
  420. if compatEmailUser == nil &&
  421. strings.TrimSpace(compatEmail) != "" &&
  422. emailVerified != nil && *emailVerified {
  423. if handled := h.tryOIDCVerifiedEmailFastPath(
  424. c,
  425. frontendCallback,
  426. redirectTo,
  427. identityRef,
  428. compatEmail,
  429. username,
  430. upstreamClaims,
  431. ); handled {
  432. return
  433. }
  434. }
  435. if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
  436. if err := h.createOIDCOAuthChoicePendingSession(
  437. c,
  438. identityRef,
  439. email,
  440. email,
  441. redirectTo,
  442. browserSessionKey,
  443. upstreamClaims,
  444. compatEmail,
  445. compatEmailUser,
  446. true,
  447. ); err != nil {
  448. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  449. return
  450. }
  451. redirectToFrontendCallback(c, frontendCallback)
  452. return
  453. }
  454. if err := h.createOIDCOAuthChoicePendingSession(
  455. c,
  456. identityRef,
  457. email,
  458. email,
  459. redirectTo,
  460. browserSessionKey,
  461. upstreamClaims,
  462. compatEmail,
  463. compatEmailUser,
  464. h.isForceEmailOnThirdPartySignup(c.Request.Context()),
  465. ); err != nil {
  466. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  467. return
  468. }
  469. redirectToFrontendCallback(c, frontendCallback)
  470. }
  471. func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
  472. client := h.entClient()
  473. if client == nil {
  474. return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  475. }
  476. email = strings.TrimSpace(strings.ToLower(email))
  477. if email == "" ||
  478. strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
  479. strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
  480. strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) ||
  481. strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) {
  482. return nil, nil
  483. }
  484. userEntity, err := findUserByNormalizedEmail(ctx, client, email)
  485. if err != nil {
  486. if errors.Is(err, service.ErrUserNotFound) {
  487. return nil, nil
  488. }
  489. return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
  490. }
  491. return userEntity, nil
  492. }
  493. func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
  494. c *gin.Context,
  495. identity service.PendingAuthIdentityKey,
  496. suggestedEmail string,
  497. resolvedEmail string,
  498. redirectTo string,
  499. browserSessionKey string,
  500. upstreamClaims map[string]any,
  501. compatEmail string,
  502. compatEmailUser *dbent.User,
  503. forceEmailOnSignup bool,
  504. ) error {
  505. suggestionEmail := strings.TrimSpace(suggestedEmail)
  506. canonicalEmail := strings.TrimSpace(resolvedEmail)
  507. if suggestionEmail == "" {
  508. suggestionEmail = canonicalEmail
  509. }
  510. completionResponse := map[string]any{
  511. "step": oauthPendingChoiceStep,
  512. "adoption_required": true,
  513. "redirect": strings.TrimSpace(redirectTo),
  514. "email": suggestionEmail,
  515. "resolved_email": canonicalEmail,
  516. "existing_account_email": "",
  517. "existing_account_bindable": false,
  518. "create_account_allowed": true,
  519. "force_email_on_signup": forceEmailOnSignup,
  520. "choice_reason": "third_party_signup",
  521. }
  522. if strings.TrimSpace(compatEmail) != "" {
  523. completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
  524. }
  525. if compatEmailUser != nil {
  526. completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
  527. completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
  528. completionResponse["existing_account_bindable"] = true
  529. completionResponse["choice_reason"] = "compat_email_match"
  530. }
  531. if forceEmailOnSignup && compatEmailUser == nil {
  532. completionResponse["choice_reason"] = "force_email_on_signup"
  533. }
  534. resolvedChoiceEmail := suggestionEmail
  535. if compatEmailUser != nil {
  536. resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
  537. }
  538. var targetUserID *int64
  539. if compatEmailUser != nil && compatEmailUser.ID > 0 {
  540. targetUserID = &compatEmailUser.ID
  541. }
  542. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  543. Intent: oauthIntentLogin,
  544. Identity: identity,
  545. TargetUserID: targetUserID,
  546. ResolvedEmail: resolvedChoiceEmail,
  547. RedirectTo: redirectTo,
  548. BrowserSessionKey: browserSessionKey,
  549. UpstreamIdentityClaims: upstreamClaims,
  550. CompletionResponse: completionResponse,
  551. })
  552. }
  553. type completeOIDCOAuthRequest struct {
  554. InvitationCode string `json:"invitation_code" binding:"required"`
  555. AffCode string `json:"aff_code,omitempty"`
  556. AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
  557. AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
  558. }
  559. // CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating
  560. // the invitation code and creating the user account.
  561. // POST /api/v1/auth/oauth/oidc/complete-registration
  562. func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
  563. var req completeOIDCOAuthRequest
  564. if err := c.ShouldBindJSON(&req); err != nil {
  565. c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
  566. return
  567. }
  568. secureCookie := isRequestHTTPS(c)
  569. sessionToken, err := readOAuthPendingSessionCookie(c)
  570. if err != nil {
  571. clearOAuthPendingSessionCookie(c, secureCookie)
  572. clearOAuthPendingBrowserCookie(c, secureCookie)
  573. response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
  574. return
  575. }
  576. browserSessionKey, err := readOAuthPendingBrowserCookie(c)
  577. if err != nil {
  578. clearOAuthPendingSessionCookie(c, secureCookie)
  579. clearOAuthPendingBrowserCookie(c, secureCookie)
  580. response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
  581. return
  582. }
  583. pendingSvc, err := h.pendingIdentityService()
  584. if err != nil {
  585. response.ErrorFrom(c, err)
  586. return
  587. }
  588. session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
  589. if err != nil {
  590. clearOAuthPendingSessionCookie(c, secureCookie)
  591. clearOAuthPendingBrowserCookie(c, secureCookie)
  592. response.ErrorFrom(c, err)
  593. return
  594. }
  595. if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
  596. response.ErrorFrom(c, err)
  597. return
  598. }
  599. if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
  600. response.ErrorFrom(c, err)
  601. return
  602. } else if handled {
  603. c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
  604. return
  605. } else {
  606. session = updatedSession
  607. }
  608. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  609. response.ErrorFrom(c, err)
  610. return
  611. }
  612. email := strings.TrimSpace(session.ResolvedEmail)
  613. username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
  614. if email == "" || username == "" {
  615. response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
  616. return
  617. }
  618. client := h.entClient()
  619. if client == nil {
  620. response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
  621. return
  622. }
  623. if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
  624. respondPendingOAuthBindingApplyError(c, err)
  625. return
  626. }
  627. decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
  628. AdoptDisplayName: req.AdoptDisplayName,
  629. AdoptAvatar: req.AdoptAvatar,
  630. })
  631. if err != nil {
  632. response.ErrorFrom(c, err)
  633. return
  634. }
  635. tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
  636. c.Request.Context(),
  637. email,
  638. username,
  639. req.InvitationCode,
  640. req.AffCode,
  641. pendingOAuthPromoCode(session),
  642. "oidc",
  643. )
  644. if err != nil {
  645. response.ErrorFrom(c, err)
  646. return
  647. }
  648. if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
  649. respondPendingOAuthBindingApplyError(c, err)
  650. return
  651. }
  652. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  653. clearOAuthPendingSessionCookie(c, secureCookie)
  654. clearOAuthPendingBrowserCookie(c, secureCookie)
  655. c.JSON(http.StatusOK, gin.H{
  656. "access_token": tokenPair.AccessToken,
  657. "refresh_token": tokenPair.RefreshToken,
  658. "expires_in": tokenPair.ExpiresIn,
  659. "token_type": "Bearer",
  660. })
  661. }
  662. func (h *AuthHandler) getOIDCOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
  663. if h != nil && h.settingSvc != nil {
  664. return h.settingSvc.GetOIDCConnectOAuthConfig(ctx)
  665. }
  666. if h == nil || h.cfg == nil {
  667. return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  668. }
  669. if !h.cfg.OIDC.Enabled {
  670. return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
  671. }
  672. return h.cfg.OIDC, nil
  673. }
  674. func oidcExchangeCode(
  675. ctx context.Context,
  676. cfg config.OIDCConnectConfig,
  677. code string,
  678. redirectURI string,
  679. codeVerifier string,
  680. ) (*oidcTokenResponse, error) {
  681. client := req.C().SetTimeout(30 * time.Second)
  682. form := url.Values{}
  683. form.Set("grant_type", "authorization_code")
  684. form.Set("client_id", cfg.ClientID)
  685. form.Set("code", code)
  686. form.Set("redirect_uri", redirectURI)
  687. if strings.TrimSpace(codeVerifier) != "" {
  688. form.Set("code_verifier", codeVerifier)
  689. }
  690. r := client.R().
  691. SetContext(ctx).
  692. SetHeader("Accept", "application/json")
  693. switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) {
  694. case "", "client_secret_post":
  695. form.Set("client_secret", cfg.ClientSecret)
  696. case "client_secret_basic":
  697. r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
  698. case "none":
  699. default:
  700. return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
  701. }
  702. resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
  703. if err != nil {
  704. return nil, fmt.Errorf("request token: %w", err)
  705. }
  706. body := strings.TrimSpace(resp.String())
  707. if !resp.IsSuccessState() {
  708. providerErr, providerDesc := parseOAuthProviderError(body)
  709. return nil, &oidcTokenExchangeError{
  710. StatusCode: resp.StatusCode,
  711. ProviderError: providerErr,
  712. ProviderDescription: providerDesc,
  713. Body: body,
  714. }
  715. }
  716. tokenResp, ok := oidcParseTokenResponse(body)
  717. if !ok {
  718. return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body}
  719. }
  720. if strings.TrimSpace(tokenResp.TokenType) == "" {
  721. tokenResp.TokenType = "Bearer"
  722. }
  723. if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" {
  724. return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body}
  725. }
  726. return tokenResp, nil
  727. }
  728. func oidcParseTokenResponse(body string) (*oidcTokenResponse, bool) {
  729. body = strings.TrimSpace(body)
  730. if body == "" {
  731. return nil, false
  732. }
  733. accessToken := strings.TrimSpace(getGJSON(body, "access_token"))
  734. idToken := strings.TrimSpace(getGJSON(body, "id_token"))
  735. if accessToken != "" || idToken != "" {
  736. tokenType := strings.TrimSpace(getGJSON(body, "token_type"))
  737. refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token"))
  738. scope := strings.TrimSpace(getGJSON(body, "scope"))
  739. expiresIn := gjson.Get(body, "expires_in").Int()
  740. return &oidcTokenResponse{
  741. AccessToken: accessToken,
  742. TokenType: tokenType,
  743. ExpiresIn: expiresIn,
  744. RefreshToken: refreshToken,
  745. Scope: scope,
  746. IDToken: idToken,
  747. }, true
  748. }
  749. values, err := url.ParseQuery(body)
  750. if err != nil {
  751. return nil, false
  752. }
  753. accessToken = strings.TrimSpace(values.Get("access_token"))
  754. idToken = strings.TrimSpace(values.Get("id_token"))
  755. if accessToken == "" && idToken == "" {
  756. return nil, false
  757. }
  758. expiresIn := int64(0)
  759. if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" {
  760. if v, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
  761. expiresIn = v
  762. }
  763. }
  764. return &oidcTokenResponse{
  765. AccessToken: accessToken,
  766. TokenType: strings.TrimSpace(values.Get("token_type")),
  767. ExpiresIn: expiresIn,
  768. RefreshToken: strings.TrimSpace(values.Get("refresh_token")),
  769. Scope: strings.TrimSpace(values.Get("scope")),
  770. IDToken: idToken,
  771. }, true
  772. }
  773. func oidcFetchUserInfo(
  774. ctx context.Context,
  775. cfg config.OIDCConnectConfig,
  776. token *oidcTokenResponse,
  777. ) (*oidcUserInfoClaims, error) {
  778. if strings.TrimSpace(cfg.UserInfoURL) == "" {
  779. return &oidcUserInfoClaims{}, nil
  780. }
  781. if token == nil || strings.TrimSpace(token.AccessToken) == "" {
  782. return nil, errors.New("missing access_token for userinfo request")
  783. }
  784. client := req.C().SetTimeout(30 * time.Second)
  785. authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
  786. if err != nil {
  787. return nil, fmt.Errorf("invalid token for userinfo request: %w", err)
  788. }
  789. resp, err := client.R().
  790. SetContext(ctx).
  791. SetHeader("Accept", "application/json").
  792. SetHeader("Authorization", authorization).
  793. Get(cfg.UserInfoURL)
  794. if err != nil {
  795. return nil, fmt.Errorf("request userinfo: %w", err)
  796. }
  797. if !resp.IsSuccessState() {
  798. return nil, fmt.Errorf("userinfo status=%d", resp.StatusCode)
  799. }
  800. return oidcParseUserInfo(resp.String(), cfg), nil
  801. }
  802. func oidcParseUserInfo(body string, cfg config.OIDCConnectConfig) *oidcUserInfoClaims {
  803. claims := &oidcUserInfoClaims{}
  804. claims.Email = firstNonEmpty(
  805. getGJSON(body, cfg.UserInfoEmailPath),
  806. getGJSON(body, "email"),
  807. getGJSON(body, "user.email"),
  808. getGJSON(body, "data.email"),
  809. getGJSON(body, "attributes.email"),
  810. )
  811. claims.Username = firstNonEmpty(
  812. getGJSON(body, cfg.UserInfoUsernamePath),
  813. getGJSON(body, "preferred_username"),
  814. getGJSON(body, "username"),
  815. getGJSON(body, "name"),
  816. getGJSON(body, "user.username"),
  817. getGJSON(body, "user.name"),
  818. )
  819. claims.Subject = firstNonEmpty(
  820. getGJSON(body, cfg.UserInfoIDPath),
  821. getGJSON(body, "sub"),
  822. getGJSON(body, "id"),
  823. getGJSON(body, "user_id"),
  824. getGJSON(body, "uid"),
  825. getGJSON(body, "user.id"),
  826. )
  827. if verified, ok := getGJSONBool(body, "email_verified"); ok {
  828. claims.EmailVerified = &verified
  829. }
  830. claims.DisplayName = firstNonEmpty(
  831. getGJSON(body, "name"),
  832. getGJSON(body, "nickname"),
  833. getGJSON(body, "display_name"),
  834. getGJSON(body, "preferred_username"),
  835. getGJSON(body, "username"),
  836. )
  837. claims.AvatarURL = firstNonEmpty(
  838. getGJSON(body, "picture"),
  839. getGJSON(body, "avatar_url"),
  840. getGJSON(body, "avatar"),
  841. getGJSON(body, "profile_image_url"),
  842. getGJSON(body, "user.avatar"),
  843. getGJSON(body, "user.avatar_url"),
  844. )
  845. claims.Email = strings.TrimSpace(claims.Email)
  846. claims.Username = strings.TrimSpace(claims.Username)
  847. claims.Subject = strings.TrimSpace(claims.Subject)
  848. claims.DisplayName = strings.TrimSpace(claims.DisplayName)
  849. claims.AvatarURL = strings.TrimSpace(claims.AvatarURL)
  850. return claims
  851. }
  852. func getGJSONBool(body string, path string) (bool, bool) {
  853. path = strings.TrimSpace(path)
  854. if path == "" {
  855. return false, false
  856. }
  857. res := gjson.Get(body, path)
  858. if !res.Exists() {
  859. return false, false
  860. }
  861. return res.Bool(), true
  862. }
  863. func buildOIDCAuthorizeURL(cfg config.OIDCConnectConfig, state, nonce, codeChallenge, redirectURI string) (string, error) {
  864. u, err := url.Parse(cfg.AuthorizeURL)
  865. if err != nil {
  866. return "", fmt.Errorf("parse authorize_url: %w", err)
  867. }
  868. q := u.Query()
  869. q.Set("response_type", "code")
  870. q.Set("client_id", cfg.ClientID)
  871. q.Set("redirect_uri", redirectURI)
  872. if strings.TrimSpace(cfg.Scopes) != "" {
  873. q.Set("scope", cfg.Scopes)
  874. }
  875. q.Set("state", state)
  876. if strings.TrimSpace(nonce) != "" {
  877. q.Set("nonce", nonce)
  878. }
  879. if strings.TrimSpace(codeChallenge) != "" {
  880. q.Set("code_challenge", codeChallenge)
  881. q.Set("code_challenge_method", "S256")
  882. }
  883. u.RawQuery = q.Encode()
  884. return u.String(), nil
  885. }
  886. func oidcParseAndValidateIDToken(ctx context.Context, cfg config.OIDCConnectConfig, idToken string, expectedNonce string) (*oidcIDTokenClaims, error) {
  887. idToken = strings.TrimSpace(idToken)
  888. if idToken == "" {
  889. return nil, errors.New("missing id_token")
  890. }
  891. allowed := oidcAllowedSigningAlgs(cfg.AllowedSigningAlgs)
  892. if len(allowed) == 0 {
  893. return nil, errors.New("empty allowed signing algorithms")
  894. }
  895. jwks, err := oidcFetchJWKSet(ctx, cfg.JWKSURL)
  896. if err != nil {
  897. return nil, err
  898. }
  899. leeway := time.Duration(cfg.ClockSkewSeconds) * time.Second
  900. claims := &oidcIDTokenClaims{}
  901. parsed, err := jwt.ParseWithClaims(
  902. idToken,
  903. claims,
  904. func(token *jwt.Token) (any, error) {
  905. alg := strings.TrimSpace(token.Method.Alg())
  906. if !containsString(allowed, alg) {
  907. return nil, fmt.Errorf("unexpected signing algorithm: %s", alg)
  908. }
  909. kid, _ := token.Header["kid"].(string)
  910. return oidcFindPublicKey(jwks, strings.TrimSpace(kid), alg)
  911. },
  912. jwt.WithValidMethods(allowed),
  913. jwt.WithAudience(cfg.ClientID),
  914. jwt.WithIssuer(cfg.IssuerURL),
  915. jwt.WithLeeway(leeway),
  916. )
  917. if err != nil {
  918. return nil, err
  919. }
  920. if !parsed.Valid {
  921. return nil, errors.New("id_token invalid")
  922. }
  923. if strings.TrimSpace(claims.Subject) == "" {
  924. return nil, errors.New("id_token missing sub")
  925. }
  926. if expectedNonce != "" && strings.TrimSpace(claims.Nonce) != strings.TrimSpace(expectedNonce) {
  927. return nil, errors.New("id_token nonce mismatch")
  928. }
  929. if len(claims.Audience) > 1 {
  930. if strings.TrimSpace(claims.Azp) == "" || strings.TrimSpace(claims.Azp) != strings.TrimSpace(cfg.ClientID) {
  931. return nil, errors.New("id_token azp mismatch")
  932. }
  933. }
  934. return claims, nil
  935. }
  936. func oidcAllowedSigningAlgs(raw string) []string {
  937. if strings.TrimSpace(raw) == "" {
  938. return []string{"RS256", "ES256", "PS256"}
  939. }
  940. seen := make(map[string]struct{})
  941. out := make([]string, 0, 4)
  942. for _, part := range strings.Split(raw, ",") {
  943. alg := strings.ToUpper(strings.TrimSpace(part))
  944. if alg == "" {
  945. continue
  946. }
  947. if _, ok := seen[alg]; ok {
  948. continue
  949. }
  950. seen[alg] = struct{}{}
  951. out = append(out, alg)
  952. }
  953. return out
  954. }
  955. func oidcFetchJWKSet(ctx context.Context, jwksURL string) (*oidcJWKSet, error) {
  956. jwksURL = strings.TrimSpace(jwksURL)
  957. if jwksURL == "" {
  958. return nil, errors.New("missing jwks_url")
  959. }
  960. resp, err := req.C().
  961. SetTimeout(30*time.Second).
  962. R().
  963. SetContext(ctx).
  964. SetHeader("Accept", "application/json").
  965. Get(jwksURL)
  966. if err != nil {
  967. return nil, fmt.Errorf("request jwks: %w", err)
  968. }
  969. if !resp.IsSuccessState() {
  970. return nil, fmt.Errorf("jwks status=%d", resp.StatusCode)
  971. }
  972. set := &oidcJWKSet{}
  973. if err := json.Unmarshal(resp.Bytes(), set); err != nil {
  974. return nil, fmt.Errorf("parse jwks: %w", err)
  975. }
  976. if len(set.Keys) == 0 {
  977. return nil, errors.New("jwks empty keys")
  978. }
  979. return set, nil
  980. }
  981. func oidcFindPublicKey(set *oidcJWKSet, kid, alg string) (any, error) {
  982. if set == nil {
  983. return nil, errors.New("jwks not loaded")
  984. }
  985. alg = strings.ToUpper(strings.TrimSpace(alg))
  986. kid = strings.TrimSpace(kid)
  987. var lastErr error
  988. for i := range set.Keys {
  989. k := set.Keys[i]
  990. if strings.TrimSpace(k.Use) != "" && !strings.EqualFold(strings.TrimSpace(k.Use), "sig") {
  991. continue
  992. }
  993. if kid != "" && strings.TrimSpace(k.Kid) != kid {
  994. continue
  995. }
  996. if strings.TrimSpace(k.Alg) != "" && !strings.EqualFold(strings.TrimSpace(k.Alg), alg) {
  997. continue
  998. }
  999. pk, err := k.publicKey()
  1000. if err != nil {
  1001. lastErr = err
  1002. continue
  1003. }
  1004. if pk != nil {
  1005. return pk, nil
  1006. }
  1007. }
  1008. if lastErr != nil {
  1009. return nil, lastErr
  1010. }
  1011. if kid != "" {
  1012. return nil, fmt.Errorf("jwk not found for kid=%s", kid)
  1013. }
  1014. return nil, errors.New("jwk not found")
  1015. }
  1016. func (k oidcJWK) publicKey() (any, error) {
  1017. switch strings.ToUpper(strings.TrimSpace(k.Kty)) {
  1018. case "RSA":
  1019. n, err := decodeBase64URLBigInt(k.N)
  1020. if err != nil {
  1021. return nil, fmt.Errorf("decode rsa n: %w", err)
  1022. }
  1023. eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(k.E))
  1024. if err != nil {
  1025. return nil, fmt.Errorf("decode rsa e: %w", err)
  1026. }
  1027. if len(eBytes) == 0 {
  1028. return nil, errors.New("empty rsa e")
  1029. }
  1030. e := 0
  1031. for _, b := range eBytes {
  1032. e = (e << 8) | int(b)
  1033. }
  1034. if e <= 0 {
  1035. return nil, errors.New("invalid rsa exponent")
  1036. }
  1037. if n.Sign() <= 0 {
  1038. return nil, errors.New("invalid rsa modulus")
  1039. }
  1040. return &rsa.PublicKey{N: n, E: e}, nil
  1041. case "EC":
  1042. var curve elliptic.Curve
  1043. switch strings.TrimSpace(k.Crv) {
  1044. case "P-256":
  1045. curve = elliptic.P256()
  1046. case "P-384":
  1047. curve = elliptic.P384()
  1048. case "P-521":
  1049. curve = elliptic.P521()
  1050. default:
  1051. return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv)
  1052. }
  1053. x, err := decodeBase64URLBigInt(k.X)
  1054. if err != nil {
  1055. return nil, fmt.Errorf("decode ec x: %w", err)
  1056. }
  1057. y, err := decodeBase64URLBigInt(k.Y)
  1058. if err != nil {
  1059. return nil, fmt.Errorf("decode ec y: %w", err)
  1060. }
  1061. if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移
  1062. return nil, errors.New("ec point is not on curve")
  1063. }
  1064. return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上
  1065. default:
  1066. return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty)
  1067. }
  1068. }
  1069. func decodeBase64URLBigInt(raw string) (*big.Int, error) {
  1070. buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
  1071. if err != nil {
  1072. return nil, err
  1073. }
  1074. if len(buf) == 0 {
  1075. return nil, errors.New("empty value")
  1076. }
  1077. return new(big.Int).SetBytes(buf), nil
  1078. }
  1079. func containsString(values []string, target string) bool {
  1080. target = strings.TrimSpace(target)
  1081. for _, v := range values {
  1082. if strings.EqualFold(strings.TrimSpace(v), target) {
  1083. return true
  1084. }
  1085. }
  1086. return false
  1087. }
  1088. func oidcIdentityKey(issuer, subject string) string {
  1089. issuer = strings.TrimSpace(strings.ToLower(issuer))
  1090. subject = strings.TrimSpace(subject)
  1091. return issuer + "\x1f" + subject
  1092. }
  1093. func oidcSyntheticEmailFromIdentityKey(identityKey string) string {
  1094. identityKey = strings.TrimSpace(identityKey)
  1095. if identityKey == "" {
  1096. return ""
  1097. }
  1098. sum := sha256.Sum256([]byte(identityKey))
  1099. return "oidc-" + hex.EncodeToString(sum[:16]) + service.OIDCConnectSyntheticEmailDomain
  1100. }
  1101. func oidcFallbackUsername(subject string) string {
  1102. subject = strings.TrimSpace(subject)
  1103. if subject == "" {
  1104. return "oidc_user"
  1105. }
  1106. sum := sha256.Sum256([]byte(subject))
  1107. return "oidc_" + hex.EncodeToString(sum[:])[:12]
  1108. }
  1109. func oidcSetCookie(c *gin.Context, name, value string, maxAgeSec int, secure bool) {
  1110. http.SetCookie(c.Writer, &http.Cookie{
  1111. Name: name,
  1112. Value: value,
  1113. Path: oidcOAuthCookiePath,
  1114. MaxAge: maxAgeSec,
  1115. HttpOnly: true,
  1116. Secure: secure,
  1117. SameSite: http.SameSiteLaxMode,
  1118. })
  1119. }
  1120. func oidcClearCookie(c *gin.Context, name string, secure bool) {
  1121. http.SetCookie(c.Writer, &http.Cookie{
  1122. Name: name,
  1123. Value: "",
  1124. Path: oidcOAuthCookiePath,
  1125. MaxAge: -1,
  1126. HttpOnly: true,
  1127. Secure: secure,
  1128. SameSite: http.SameSiteLaxMode,
  1129. })
  1130. }
  1131. // tryOIDCVerifiedEmailFastPath 在 OIDC 上游已返回已验证邮箱时尝试跳过 choice/pending 页。
  1132. // 返回 true 表示已经写出重定向响应;返回 false 表示调用方应继续回退到常规 choice 流程。
  1133. func (h *AuthHandler) tryOIDCVerifiedEmailFastPath(
  1134. c *gin.Context,
  1135. frontendCallback string,
  1136. redirectTo string,
  1137. identity service.PendingAuthIdentityKey,
  1138. compatEmail string,
  1139. username string,
  1140. upstreamClaims map[string]any,
  1141. ) bool {
  1142. if h == nil || h.authService == nil || h.settingSvc == nil {
  1143. return false
  1144. }
  1145. ctx := c.Request.Context()
  1146. if h.isForceEmailOnThirdPartySignup(ctx) {
  1147. return false
  1148. }
  1149. if h.settingSvc.IsInvitationCodeEnabled(ctx) {
  1150. return false
  1151. }
  1152. if err := h.ensureBackendModeAllowsNewUserLogin(ctx); err != nil {
  1153. log.Printf("[OIDC OAuth] verified-email fast path blocked by backend mode: reason=%s", infraerrors.Reason(err))
  1154. clearOAuthPendingSessionCookie(c, isRequestHTTPS(c))
  1155. clearOAuthPendingBrowserCookie(c, isRequestHTTPS(c))
  1156. redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err))
  1157. return true
  1158. }
  1159. verifiedEmail := strings.TrimSpace(strings.ToLower(compatEmail))
  1160. upstreamMetadata := make(map[string]any, len(upstreamClaims)+1)
  1161. for k, v := range upstreamClaims {
  1162. upstreamMetadata[k] = v
  1163. }
  1164. if syntheticEmail := pendingSessionStringValue(upstreamClaims, "email"); syntheticEmail != "" && !strings.EqualFold(syntheticEmail, verifiedEmail) {
  1165. upstreamMetadata["synthetic_email"] = syntheticEmail
  1166. }
  1167. upstreamMetadata["email"] = verifiedEmail
  1168. input := service.EmailOAuthIdentityInput{
  1169. ProviderType: strings.TrimSpace(identity.ProviderType),
  1170. ProviderKey: strings.TrimSpace(identity.ProviderKey),
  1171. ProviderSubject: strings.TrimSpace(identity.ProviderSubject),
  1172. Email: verifiedEmail,
  1173. EmailVerified: true,
  1174. Username: strings.TrimSpace(username),
  1175. DisplayName: pendingSessionStringValue(upstreamClaims, "suggested_display_name"),
  1176. AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"),
  1177. UpstreamMetadata: upstreamMetadata,
  1178. }
  1179. tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
  1180. ctx,
  1181. input,
  1182. "",
  1183. "",
  1184. readOAuthPromoCode(c),
  1185. )
  1186. if err != nil {
  1187. log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err))
  1188. return false
  1189. }
  1190. fragment := url.Values{}
  1191. fragment.Set("access_token", tokenPair.AccessToken)
  1192. fragment.Set("refresh_token", tokenPair.RefreshToken)
  1193. fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
  1194. fragment.Set("token_type", "Bearer")
  1195. fragment.Set("redirect", redirectTo)
  1196. clearOAuthPendingSessionCookie(c, isRequestHTTPS(c))
  1197. clearOAuthPendingBrowserCookie(c, isRequestHTTPS(c))
  1198. redirectWithFragment(c, frontendCallback, fragment)
  1199. return true
  1200. }