auth_email_oauth.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "net/url"
  9. "strings"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. "github.com/imroc/req/v3"
  18. "github.com/tidwall/gjson"
  19. )
  20. const (
  21. emailOAuthCookiePath = "/api/v1/auth/oauth"
  22. emailOAuthStateCookieName = "email_oauth_state"
  23. emailOAuthRedirectCookie = "email_oauth_redirect"
  24. emailOAuthProviderCookie = "email_oauth_provider"
  25. emailOAuthAffiliateCookie = "email_oauth_affiliate"
  26. emailOAuthCookieMaxAgeSec = 10 * 60
  27. emailOAuthDefaultRedirect = "/dashboard"
  28. )
  29. type emailOAuthTokenResponse struct {
  30. AccessToken string `json:"access_token"`
  31. TokenType string `json:"token_type"`
  32. Scope string `json:"scope,omitempty"`
  33. }
  34. type emailOAuthProfile struct {
  35. Subject string
  36. Email string
  37. EmailVerified bool
  38. Username string
  39. DisplayName string
  40. AvatarURL string
  41. Metadata map[string]any
  42. }
  43. func (h *AuthHandler) GitHubOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "github") }
  44. func (h *AuthHandler) GoogleOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "google") }
  45. func (h *AuthHandler) GitHubOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "github") }
  46. func (h *AuthHandler) GoogleOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "google") }
  47. func (h *AuthHandler) CompleteGitHubOAuthRegistration(c *gin.Context) {
  48. h.completeEmailOAuthRegistration(c, "github")
  49. }
  50. func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
  51. h.completeEmailOAuthRegistration(c, "google")
  52. }
  53. func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
  54. if !h.requireActionCaptchaForOAuthLoginStart(c) {
  55. return
  56. }
  57. cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
  58. if err != nil {
  59. response.ErrorFrom(c, err)
  60. return
  61. }
  62. state, err := oauth.GenerateState()
  63. if err != nil {
  64. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  65. return
  66. }
  67. redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
  68. if redirectTo == "" {
  69. redirectTo = emailOAuthDefaultRedirect
  70. }
  71. secureCookie := isRequestHTTPS(c)
  72. emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
  73. emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
  74. emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
  75. captureOAuthPromoCode(c, secureCookie)
  76. if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
  77. emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
  78. } else {
  79. emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
  80. }
  81. authURL, err := buildEmailOAuthAuthorizeURL(cfg, state)
  82. if err != nil {
  83. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
  84. return
  85. }
  86. respondOAuthStart(c, authURL)
  87. }
  88. func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
  89. cfg, cfgErr := h.getEmailOAuthConfig(c.Request.Context(), provider)
  90. if cfgErr != nil {
  91. response.ErrorFrom(c, cfgErr)
  92. return
  93. }
  94. frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
  95. if frontendCallback == "" {
  96. frontendCallback = "/auth/oauth/callback"
  97. }
  98. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  99. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  100. return
  101. }
  102. code := strings.TrimSpace(c.Query("code"))
  103. state := strings.TrimSpace(c.Query("state"))
  104. if code == "" || state == "" {
  105. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  106. return
  107. }
  108. secureCookie := isRequestHTTPS(c)
  109. defer func() {
  110. emailOAuthClearCookie(c, emailOAuthStateCookieName, secureCookie)
  111. emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
  112. emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
  113. emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
  114. clearOAuthPromoCodeCookie(c, secureCookie)
  115. }()
  116. expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
  117. if err != nil || expectedState == "" || expectedState != state {
  118. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
  119. return
  120. }
  121. expectedProvider, _ := readCookieDecoded(c, emailOAuthProviderCookie)
  122. if !strings.EqualFold(strings.TrimSpace(expectedProvider), provider) {
  123. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth provider", "")
  124. return
  125. }
  126. redirectTo, _ := readCookieDecoded(c, emailOAuthRedirectCookie)
  127. redirectTo = sanitizeFrontendRedirectPath(redirectTo)
  128. if redirectTo == "" {
  129. redirectTo = emailOAuthDefaultRedirect
  130. }
  131. tokenResp, err := exchangeEmailOAuthCode(c.Request.Context(), cfg, code)
  132. if err != nil {
  133. redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(err.Error()))
  134. return
  135. }
  136. profile, err := fetchEmailOAuthProfile(c.Request.Context(), provider, cfg, tokenResp)
  137. if err != nil {
  138. redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch verified email", singleLine(err.Error()))
  139. return
  140. }
  141. h.emailOAuthCallbackWithProfile(c, provider, cfg, frontendCallback, redirectTo, profile)
  142. }
  143. func (h *AuthHandler) emailOAuthCallbackWithProfile(
  144. c *gin.Context,
  145. provider string,
  146. cfg config.EmailOAuthProviderConfig,
  147. frontendCallback string,
  148. redirectTo string,
  149. profile *emailOAuthProfile,
  150. ) {
  151. input := service.EmailOAuthIdentityInput{
  152. ProviderType: provider,
  153. ProviderKey: provider,
  154. ProviderSubject: profile.Subject,
  155. Email: profile.Email,
  156. EmailVerified: profile.EmailVerified,
  157. Username: profile.Username,
  158. DisplayName: profile.DisplayName,
  159. AvatarURL: profile.AvatarURL,
  160. UpstreamMetadata: profile.Metadata,
  161. }
  162. affiliateCode := h.emailOAuthAffiliateCode(c)
  163. if shouldCreate, err := h.emailOAuthShouldCreatePendingRegistration(c.Request.Context(), input); err != nil {
  164. redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
  165. return
  166. } else if shouldCreate {
  167. if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
  168. redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
  169. return
  170. }
  171. redirectToFrontendCallback(c, frontendCallback)
  172. return
  173. }
  174. tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
  175. c.Request.Context(),
  176. input,
  177. "",
  178. affiliateCode,
  179. readOAuthPromoCode(c),
  180. )
  181. if err != nil {
  182. if errors.Is(err, service.ErrOAuthInvitationRequired) {
  183. if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
  184. redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
  185. return
  186. }
  187. redirectToFrontendCallback(c, frontendCallback)
  188. return
  189. }
  190. redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
  191. return
  192. }
  193. if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
  194. redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err))
  195. return
  196. }
  197. fragment := url.Values{}
  198. fragment.Set("access_token", tokenPair.AccessToken)
  199. fragment.Set("refresh_token", tokenPair.RefreshToken)
  200. fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
  201. fragment.Set("token_type", "Bearer")
  202. fragment.Set("redirect", redirectTo)
  203. redirectWithFragment(c, frontendCallback, fragment)
  204. }
  205. func (h *AuthHandler) emailOAuthShouldCreatePendingRegistration(ctx context.Context, input service.EmailOAuthIdentityInput) (bool, error) {
  206. client := h.entClient()
  207. if client == nil {
  208. return false, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  209. }
  210. identityUser, err := h.findOAuthIdentityUser(ctx, service.PendingAuthIdentityKey{
  211. ProviderType: strings.TrimSpace(input.ProviderType),
  212. ProviderKey: strings.TrimSpace(input.ProviderKey),
  213. ProviderSubject: strings.TrimSpace(input.ProviderSubject),
  214. })
  215. if err != nil {
  216. return false, err
  217. }
  218. email := strings.TrimSpace(strings.ToLower(input.Email))
  219. if identityUser != nil {
  220. if !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
  221. return false, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
  222. }
  223. return false, nil
  224. }
  225. if _, err := findUserByNormalizedEmail(ctx, client, email); err != nil {
  226. if errors.Is(err, service.ErrUserNotFound) {
  227. return true, nil
  228. }
  229. return false, err
  230. }
  231. return false, nil
  232. }
  233. func (h *AuthHandler) emailOAuthAffiliateCode(c *gin.Context) string {
  234. if c == nil {
  235. return ""
  236. }
  237. if code, err := readCookieDecoded(c, emailOAuthAffiliateCookie); err == nil {
  238. return strings.TrimSpace(code)
  239. }
  240. return ""
  241. }
  242. func (h *AuthHandler) createEmailOAuthRegistrationPendingSession(
  243. c *gin.Context,
  244. provider string,
  245. frontendCallback string,
  246. redirectTo string,
  247. profile *emailOAuthProfile,
  248. ) error {
  249. if h == nil || profile == nil {
  250. return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  251. }
  252. browserSessionKey, err := generateOAuthPendingBrowserSession()
  253. if err != nil {
  254. return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
  255. }
  256. setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c))
  257. email := strings.TrimSpace(strings.ToLower(profile.Email))
  258. username := strings.TrimSpace(profile.Username)
  259. affiliateCode := h.emailOAuthAffiliateCode(c)
  260. upstreamClaims := map[string]any{
  261. "email": email,
  262. "email_verified": profile.EmailVerified,
  263. "username": username,
  264. "provider": provider,
  265. "provider_key": provider,
  266. "provider_subject": strings.TrimSpace(profile.Subject),
  267. }
  268. if strings.TrimSpace(profile.DisplayName) != "" {
  269. upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName)
  270. }
  271. if strings.TrimSpace(profile.AvatarURL) != "" {
  272. upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL)
  273. }
  274. if affiliateCode != "" {
  275. upstreamClaims["aff_code"] = affiliateCode
  276. }
  277. for key, value := range profile.Metadata {
  278. if _, exists := upstreamClaims[key]; !exists {
  279. upstreamClaims[key] = value
  280. }
  281. }
  282. invitationRequired := h != nil && h.settingSvc != nil && h.settingSvc.IsInvitationCodeEnabled(c.Request.Context())
  283. pendingError := "registration_completion_required"
  284. choiceReason := "registration_completion_required"
  285. if invitationRequired {
  286. pendingError = "invitation_required"
  287. choiceReason = "invitation_required"
  288. }
  289. completionResponse := map[string]any{
  290. "step": oauthPendingChoiceStep,
  291. "error": pendingError,
  292. "choice_reason": choiceReason,
  293. "adoption_required": false,
  294. "create_account_allowed": true,
  295. "existing_account_bindable": false,
  296. "force_email_on_signup": true,
  297. "invitation_required": invitationRequired,
  298. "email": email,
  299. "resolved_email": email,
  300. "provider": provider,
  301. "redirect": redirectTo,
  302. }
  303. if strings.TrimSpace(frontendCallback) != "" {
  304. completionResponse["frontend_callback"] = strings.TrimSpace(frontendCallback)
  305. }
  306. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  307. Intent: oauthIntentLogin,
  308. Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)},
  309. ResolvedEmail: email,
  310. RedirectTo: redirectTo,
  311. BrowserSessionKey: browserSessionKey,
  312. UpstreamIdentityClaims: upstreamClaims,
  313. CompletionResponse: completionResponse,
  314. })
  315. }
  316. type completeEmailOAuthRequest struct {
  317. Password string `json:"password" binding:"required,min=6"`
  318. InvitationCode string `json:"invitation_code,omitempty"`
  319. AffCode string `json:"aff_code,omitempty"`
  320. }
  321. func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider string) {
  322. var req completeEmailOAuthRequest
  323. if err := c.ShouldBindJSON(&req); err != nil {
  324. response.BadRequest(c, "Invalid request: "+err.Error())
  325. return
  326. }
  327. _, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
  328. if err != nil {
  329. response.ErrorFrom(c, err)
  330. return
  331. }
  332. if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
  333. response.ErrorFrom(c, err)
  334. return
  335. }
  336. if !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
  337. response.BadRequest(c, "Pending oauth session provider mismatch")
  338. return
  339. }
  340. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  341. response.ErrorFrom(c, err)
  342. return
  343. }
  344. affiliateCode := strings.TrimSpace(req.AffCode)
  345. if affiliateCode == "" {
  346. affiliateCode = pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code")
  347. }
  348. tokenPair, user, err := h.authService.RegisterVerifiedOAuthEmailAccount(
  349. c.Request.Context(),
  350. strings.TrimSpace(session.ResolvedEmail),
  351. req.Password,
  352. strings.TrimSpace(req.InvitationCode),
  353. strings.TrimSpace(session.ProviderType),
  354. )
  355. if err != nil {
  356. response.ErrorFrom(c, err)
  357. return
  358. }
  359. client := h.entClient()
  360. if client == nil {
  361. response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
  362. return
  363. }
  364. tx, err := client.Tx(c.Request.Context())
  365. if err != nil {
  366. response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
  367. return
  368. }
  369. defer func() { _ = tx.Rollback() }()
  370. txCtx := dbent.NewTxContext(c.Request.Context(), tx)
  371. sessionForBinding := *session
  372. sessionForBinding.UpstreamIdentityClaims = clonePendingMap(session.UpstreamIdentityClaims)
  373. if strings.TrimSpace(req.InvitationCode) != "" {
  374. sessionForBinding.UpstreamIdentityClaims["invitation_code"] = strings.TrimSpace(req.InvitationCode)
  375. }
  376. decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{})
  377. if err != nil {
  378. _ = tx.Rollback()
  379. _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
  380. response.ErrorFrom(c, err)
  381. return
  382. }
  383. if err := applyPendingOAuthBinding(txCtx, client, h.authService, h.userService, &sessionForBinding, decision, &user.ID, true, false); err != nil {
  384. _ = tx.Rollback()
  385. _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
  386. respondPendingOAuthBindingApplyError(c, err)
  387. return
  388. }
  389. if err := h.authService.FinalizeOAuthEmailAccount(
  390. txCtx,
  391. user,
  392. strings.TrimSpace(req.InvitationCode),
  393. strings.TrimSpace(session.ProviderType),
  394. affiliateCode,
  395. ); err != nil {
  396. _ = tx.Rollback()
  397. _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
  398. response.ErrorFrom(c, err)
  399. return
  400. }
  401. if err := consumePendingOAuthBrowserSessionTx(c.Request.Context(), tx, session); err != nil {
  402. _ = tx.Rollback()
  403. _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
  404. clearCookies()
  405. response.ErrorFrom(c, err)
  406. return
  407. }
  408. if err := tx.Commit(); err != nil {
  409. _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
  410. response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
  411. return
  412. }
  413. h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
  414. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  415. clearCookies()
  416. writeOAuthTokenPairResponse(c, tokenPair)
  417. }
  418. func (h *AuthHandler) getEmailOAuthConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
  419. if h != nil && h.settingSvc != nil {
  420. return h.settingSvc.GetEmailOAuthProviderConfig(ctx, provider)
  421. }
  422. return config.EmailOAuthProviderConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  423. }
  424. func buildEmailOAuthAuthorizeURL(cfg config.EmailOAuthProviderConfig, state string) (string, error) {
  425. u, err := url.Parse(cfg.AuthorizeURL)
  426. if err != nil {
  427. return "", fmt.Errorf("parse authorize_url: %w", err)
  428. }
  429. q := u.Query()
  430. q.Set("response_type", "code")
  431. q.Set("client_id", cfg.ClientID)
  432. q.Set("redirect_uri", cfg.RedirectURL)
  433. q.Set("state", state)
  434. if strings.TrimSpace(cfg.Scopes) != "" {
  435. q.Set("scope", cfg.Scopes)
  436. }
  437. u.RawQuery = q.Encode()
  438. return u.String(), nil
  439. }
  440. func exchangeEmailOAuthCode(ctx context.Context, cfg config.EmailOAuthProviderConfig, code string) (*emailOAuthTokenResponse, error) {
  441. resp, err := req.C().
  442. R().
  443. SetContext(ctx).
  444. SetHeader("Accept", "application/json").
  445. SetFormData(map[string]string{
  446. "grant_type": "authorization_code",
  447. "client_id": cfg.ClientID,
  448. "client_secret": cfg.ClientSecret,
  449. "code": code,
  450. "redirect_uri": cfg.RedirectURL,
  451. }).
  452. Post(cfg.TokenURL)
  453. if err != nil {
  454. return nil, err
  455. }
  456. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  457. return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
  458. }
  459. var tokenResp emailOAuthTokenResponse
  460. if err := json.Unmarshal(resp.Bytes(), &tokenResp); err != nil {
  461. return nil, err
  462. }
  463. if strings.TrimSpace(tokenResp.AccessToken) == "" {
  464. return nil, errors.New("missing access_token")
  465. }
  466. return &tokenResp, nil
  467. }
  468. func fetchEmailOAuthProfile(ctx context.Context, provider string, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse) (*emailOAuthProfile, error) {
  469. resp, err := req.C().
  470. R().
  471. SetContext(ctx).
  472. SetBearerAuthToken(token.AccessToken).
  473. SetHeader("Accept", "application/json").
  474. Get(cfg.UserInfoURL)
  475. if err != nil {
  476. return nil, err
  477. }
  478. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  479. return nil, fmt.Errorf("userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
  480. }
  481. switch strings.ToLower(strings.TrimSpace(provider)) {
  482. case "github":
  483. return parseGitHubOAuthProfile(ctx, cfg, token, resp.String())
  484. case "google":
  485. return parseGoogleOAuthProfile(resp.String())
  486. default:
  487. return nil, errors.New("unsupported oauth provider")
  488. }
  489. }
  490. func parseGitHubOAuthProfile(ctx context.Context, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse, body string) (*emailOAuthProfile, error) {
  491. subject := strings.TrimSpace(gjson.Get(body, "id").String())
  492. if subject == "" {
  493. return nil, errors.New("github user id is missing")
  494. }
  495. email := ""
  496. emailsURL := strings.TrimSpace(cfg.EmailsURL)
  497. if emailsURL == "" {
  498. return nil, errors.New("github verified email is missing")
  499. }
  500. verifiedEmail, err := fetchGitHubPrimaryVerifiedEmail(ctx, emailsURL, token.AccessToken)
  501. if err != nil {
  502. return nil, err
  503. }
  504. email = verifiedEmail
  505. if email == "" {
  506. return nil, errors.New("github verified email is missing")
  507. }
  508. login := strings.TrimSpace(gjson.Get(body, "login").String())
  509. name := strings.TrimSpace(gjson.Get(body, "name").String())
  510. return &emailOAuthProfile{
  511. Subject: subject,
  512. Email: email,
  513. EmailVerified: true,
  514. Username: firstNonEmpty(login, name, "github_"+subject),
  515. DisplayName: firstNonEmpty(name, login),
  516. AvatarURL: strings.TrimSpace(gjson.Get(body, "avatar_url").String()),
  517. Metadata: map[string]any{
  518. "login": login,
  519. },
  520. }, nil
  521. }
  522. func fetchGitHubPrimaryVerifiedEmail(ctx context.Context, emailsURL string, accessToken string) (string, error) {
  523. resp, err := req.C().
  524. R().
  525. SetContext(ctx).
  526. SetBearerAuthToken(accessToken).
  527. SetHeader("Accept", "application/json").
  528. Get(emailsURL)
  529. if err != nil {
  530. return "", err
  531. }
  532. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  533. return "", fmt.Errorf("github emails endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
  534. }
  535. items := gjson.Parse(resp.String()).Array()
  536. for _, item := range items {
  537. if item.Get("primary").Bool() && item.Get("verified").Bool() {
  538. if email := strings.TrimSpace(item.Get("email").String()); email != "" {
  539. return email, nil
  540. }
  541. }
  542. }
  543. for _, item := range items {
  544. if item.Get("verified").Bool() {
  545. if email := strings.TrimSpace(item.Get("email").String()); email != "" {
  546. return email, nil
  547. }
  548. }
  549. }
  550. return "", errors.New("github verified email is missing")
  551. }
  552. func parseGoogleOAuthProfile(body string) (*emailOAuthProfile, error) {
  553. subject := strings.TrimSpace(gjson.Get(body, "sub").String())
  554. email := strings.TrimSpace(gjson.Get(body, "email").String())
  555. verified := gjson.Get(body, "email_verified").Bool()
  556. if subject == "" {
  557. return nil, errors.New("google subject is missing")
  558. }
  559. if email == "" || !verified {
  560. return nil, errors.New("google verified email is missing")
  561. }
  562. name := strings.TrimSpace(gjson.Get(body, "name").String())
  563. return &emailOAuthProfile{
  564. Subject: subject,
  565. Email: email,
  566. EmailVerified: true,
  567. Username: firstNonEmpty(strings.TrimSpace(gjson.Get(body, "given_name").String()), name, email),
  568. DisplayName: name,
  569. AvatarURL: strings.TrimSpace(gjson.Get(body, "picture").String()),
  570. Metadata: map[string]any{
  571. "email_verified": true,
  572. },
  573. }, nil
  574. }
  575. func emailOAuthSetCookie(c *gin.Context, name, value string, secure bool) {
  576. http.SetCookie(c.Writer, &http.Cookie{
  577. Name: name,
  578. Value: value,
  579. Path: emailOAuthCookiePath,
  580. MaxAge: emailOAuthCookieMaxAgeSec,
  581. HttpOnly: true,
  582. Secure: secure,
  583. SameSite: http.SameSiteLaxMode,
  584. })
  585. }
  586. func emailOAuthClearCookie(c *gin.Context, name string, secure bool) {
  587. http.SetCookie(c.Writer, &http.Cookie{
  588. Name: name,
  589. Value: "",
  590. Path: emailOAuthCookiePath,
  591. MaxAge: -1,
  592. HttpOnly: true,
  593. Secure: secure,
  594. SameSite: http.SameSiteLaxMode,
  595. })
  596. }