feat: 增加 GitHub 和 Google 邮箱快捷登录

This commit is contained in:
lyen1688 2026-05-06 16:06:11 +08:00
parent a1106e8167
commit af550fa64e
35 changed files with 2656 additions and 74 deletions

View File

@ -16,6 +16,8 @@ import (
var authProviderTypes = map[string]struct{}{
"email": {},
"github": {},
"google": {},
"linuxdo": {},
"oidc": {},
"wechat": {},

View File

@ -77,10 +77,10 @@ func (User) Fields() []ent.Field {
field.String("signup_source").
Validate(func(value string) error {
switch value {
case "email", "linuxdo", "wechat", "oidc":
case "email", "linuxdo", "wechat", "oidc", "github", "google":
return nil
default:
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc")
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google")
}
}).
Default("email"),

View File

@ -72,6 +72,8 @@ type Config struct {
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
Default DefaultConfig `mapstructure:"default"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Pricing PricingConfig `mapstructure:"pricing"`
@ -240,6 +242,19 @@ type OIDCConnectConfig struct {
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
}
type EmailOAuthProviderConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
AuthorizeURL string `mapstructure:"authorize_url"`
TokenURL string `mapstructure:"token_url"`
UserInfoURL string `mapstructure:"userinfo_url"`
EmailsURL string `mapstructure:"emails_url"`
Scopes string `mapstructure:"scopes"`
RedirectURL string `mapstructure:"redirect_url"`
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
}
const (
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"

View File

@ -169,6 +169,16 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GitHubOAuthClientID: settings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
GoogleOAuthClientID: settings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
@ -368,6 +378,17 @@ type UpdateSettingsRequest struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GitHubOAuthClientID string `json:"github_oauth_client_id"`
GitHubOAuthClientSecret string `json:"github_oauth_client_secret"`
GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
GoogleOAuthClientID string `json:"google_oauth_client_id"`
GoogleOAuthClientSecret string `json:"google_oauth_client_secret"`
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
// OEM设置
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
@ -413,6 +434,16 @@ type UpdateSettingsRequest struct {
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
// Model fallback configuration
@ -1200,6 +1231,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: req.GitHubOAuthEnabled,
GitHubOAuthClientID: req.GitHubOAuthClientID,
GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: req.GoogleOAuthEnabled,
GoogleOAuthClientID: req.GoogleOAuthClientID,
GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
SiteName: req.SiteName,
SiteLogo: req.SiteLogo,
SiteSubtitle: req.SiteSubtitle,
@ -1396,6 +1437,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
},
GitHub: service.ProviderDefaultGrantSettings{
Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance),
Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency),
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions),
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind),
},
Google: service.ProviderDefaultGrantSettings{
Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance),
Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency),
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions),
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind),
},
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
}
if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
@ -1538,6 +1593,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
SiteName: updatedSettings.SiteName,
SiteLogo: updatedSettings.SiteLogo,
SiteSubtitle: updatedSettings.SiteSubtitle,
@ -2027,6 +2092,8 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
{name: "oidc", before: before.OIDC, after: after.OIDC},
{name: "wechat", before: before.WeChat, after: after.WeChat},
{name: "github", before: before.GitHub, after: after.GitHub},
{name: "google", before: before.Google, after: after.Google},
}
for _, field := range fields {
if field.before.Balance != field.after.Balance {
@ -2141,6 +2208,16 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults
data["auth_source_default_wechat_subscriptions"] = authSourceDefaults.WeChat.Subscriptions
data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup
data["auth_source_default_wechat_grant_on_first_bind"] = authSourceDefaults.WeChat.GrantOnFirstBind
data["auth_source_default_github_balance"] = authSourceDefaults.GitHub.Balance
data["auth_source_default_github_concurrency"] = authSourceDefaults.GitHub.Concurrency
data["auth_source_default_github_subscriptions"] = authSourceDefaults.GitHub.Subscriptions
data["auth_source_default_github_grant_on_signup"] = authSourceDefaults.GitHub.GrantOnSignup
data["auth_source_default_github_grant_on_first_bind"] = authSourceDefaults.GitHub.GrantOnFirstBind
data["auth_source_default_google_balance"] = authSourceDefaults.Google.Balance
data["auth_source_default_google_concurrency"] = authSourceDefaults.Google.Concurrency
data["auth_source_default_google_subscriptions"] = authSourceDefaults.Google.Subscriptions
data["auth_source_default_google_grant_on_signup"] = authSourceDefaults.Google.GrantOnSignup
data["auth_source_default_google_grant_on_first_bind"] = authSourceDefaults.Google.GrantOnFirstBind
data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup
return data

View File

@ -0,0 +1,549 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
const (
emailOAuthCookiePath = "/api/v1/auth/oauth"
emailOAuthStateCookieName = "email_oauth_state"
emailOAuthRedirectCookie = "email_oauth_redirect"
emailOAuthProviderCookie = "email_oauth_provider"
emailOAuthAffiliateCookie = "email_oauth_affiliate"
emailOAuthCookieMaxAgeSec = 10 * 60
emailOAuthDefaultRedirect = "/dashboard"
)
type emailOAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
}
type emailOAuthProfile struct {
Subject string
Email string
EmailVerified bool
Username string
DisplayName string
AvatarURL string
Metadata map[string]any
}
func (h *AuthHandler) GitHubOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "github") }
func (h *AuthHandler) GoogleOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "google") }
func (h *AuthHandler) GitHubOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "github") }
func (h *AuthHandler) GoogleOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "google") }
func (h *AuthHandler) CompleteGitHubOAuthRegistration(c *gin.Context) {
h.completeEmailOAuthRegistration(c, "github")
}
func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
h.completeEmailOAuthRegistration(c, "google")
}
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
if err != nil {
response.ErrorFrom(c, err)
return
}
state, err := oauth.GenerateState()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
return
}
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
if redirectTo == "" {
redirectTo = emailOAuthDefaultRedirect
}
secureCookie := isRequestHTTPS(c)
emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
} else {
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
}
authURL, err := buildEmailOAuthAuthorizeURL(cfg, state)
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
}
func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
cfg, cfgErr := h.getEmailOAuthConfig(c.Request.Context(), provider)
if cfgErr != nil {
response.ErrorFrom(c, cfgErr)
return
}
frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
if frontendCallback == "" {
frontendCallback = "/auth/oauth/callback"
}
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
return
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
return
}
secureCookie := isRequestHTTPS(c)
defer func() {
emailOAuthClearCookie(c, emailOAuthStateCookieName, secureCookie)
emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
}()
expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
if err != nil || expectedState == "" || expectedState != state {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
return
}
expectedProvider, _ := readCookieDecoded(c, emailOAuthProviderCookie)
if !strings.EqualFold(strings.TrimSpace(expectedProvider), provider) {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth provider", "")
return
}
redirectTo, _ := readCookieDecoded(c, emailOAuthRedirectCookie)
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
if redirectTo == "" {
redirectTo = emailOAuthDefaultRedirect
}
tokenResp, err := exchangeEmailOAuthCode(c.Request.Context(), cfg, code)
if err != nil {
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(err.Error()))
return
}
profile, err := fetchEmailOAuthProfile(c.Request.Context(), provider, cfg, tokenResp)
if err != nil {
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch verified email", singleLine(err.Error()))
return
}
h.emailOAuthCallbackWithProfile(c, provider, cfg, frontendCallback, redirectTo, profile)
}
func (h *AuthHandler) emailOAuthCallbackWithProfile(
c *gin.Context,
provider string,
cfg config.EmailOAuthProviderConfig,
frontendCallback string,
redirectTo string,
profile *emailOAuthProfile,
) {
input := service.EmailOAuthIdentityInput{
ProviderType: provider,
ProviderKey: provider,
ProviderSubject: profile.Subject,
Email: profile.Email,
EmailVerified: profile.EmailVerified,
Username: profile.Username,
DisplayName: profile.DisplayName,
AvatarURL: profile.AvatarURL,
UpstreamMetadata: profile.Metadata,
}
affiliateCode := h.emailOAuthAffiliateCode(c)
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode)
if err != nil {
if errors.Is(err, service.ErrOAuthInvitationRequired) {
if pendingErr := h.createEmailOAuthInvitationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
return
}
if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err))
return
}
fragment := url.Values{}
fragment.Set("access_token", tokenPair.AccessToken)
fragment.Set("refresh_token", tokenPair.RefreshToken)
fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
fragment.Set("token_type", "Bearer")
fragment.Set("redirect", redirectTo)
redirectWithFragment(c, frontendCallback, fragment)
}
func (h *AuthHandler) emailOAuthAffiliateCode(c *gin.Context) string {
if c == nil {
return ""
}
if code, err := readCookieDecoded(c, emailOAuthAffiliateCookie); err == nil {
return strings.TrimSpace(code)
}
return ""
}
func (h *AuthHandler) createEmailOAuthInvitationPendingSession(
c *gin.Context,
provider string,
frontendCallback string,
redirectTo string,
profile *emailOAuthProfile,
) error {
if h == nil || profile == nil {
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
}
browserSessionKey, err := generateOAuthPendingBrowserSession()
if err != nil {
return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
}
setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c))
email := strings.TrimSpace(strings.ToLower(profile.Email))
username := strings.TrimSpace(profile.Username)
affiliateCode := h.emailOAuthAffiliateCode(c)
upstreamClaims := map[string]any{
"email": email,
"email_verified": profile.EmailVerified,
"username": username,
"provider": provider,
"provider_key": provider,
"provider_subject": strings.TrimSpace(profile.Subject),
}
if strings.TrimSpace(profile.DisplayName) != "" {
upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName)
}
if strings.TrimSpace(profile.AvatarURL) != "" {
upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL)
}
if affiliateCode != "" {
upstreamClaims["aff_code"] = affiliateCode
}
for key, value := range profile.Metadata {
if _, exists := upstreamClaims[key]; !exists {
upstreamClaims[key] = value
}
}
completionResponse := map[string]any{
"step": oauthPendingChoiceStep,
"error": "invitation_required",
"choice_reason": "invitation_required",
"adoption_required": false,
"create_account_allowed": true,
"existing_account_bindable": false,
"force_email_on_signup": true,
"email": email,
"resolved_email": email,
"provider": provider,
"redirect": redirectTo,
}
if strings.TrimSpace(frontendCallback) != "" {
completionResponse["frontend_callback"] = strings.TrimSpace(frontendCallback)
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin,
Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)},
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
type completeEmailOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
}
func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider string) {
var req completeEmailOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
_, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
response.ErrorFrom(c, err)
return
}
if !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
response.BadRequest(c, "Pending oauth session provider mismatch")
return
}
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
affiliateCode := strings.TrimSpace(req.AffCode)
if affiliateCode == "" {
affiliateCode = pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code")
}
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(
c.Request.Context(),
service.EmailOAuthIdentityInput{
ProviderType: strings.TrimSpace(session.ProviderType),
ProviderKey: strings.TrimSpace(session.ProviderKey),
ProviderSubject: strings.TrimSpace(session.ProviderSubject),
Email: strings.TrimSpace(session.ResolvedEmail),
EmailVerified: true,
Username: pendingSessionStringValue(session.UpstreamIdentityClaims, "username"),
DisplayName: pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name"),
AvatarURL: pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url"),
UpstreamMetadata: clonePendingMap(session.UpstreamIdentityClaims),
},
strings.TrimSpace(req.InvitationCode),
affiliateCode,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
client := h.entClient()
if client == nil {
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
return
}
tx, err := client.Tx(c.Request.Context())
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
defer func() { _ = tx.Rollback() }()
if err := consumePendingOAuthBrowserSessionTx(c.Request.Context(), tx, session); err != nil {
_ = tx.Rollback()
clearCookies()
response.ErrorFrom(c, err)
return
}
if err := tx.Commit(); err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
}
func (h *AuthHandler) getEmailOAuthConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
if h != nil && h.settingSvc != nil {
return h.settingSvc.GetEmailOAuthProviderConfig(ctx, provider)
}
return config.EmailOAuthProviderConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
func buildEmailOAuthAuthorizeURL(cfg config.EmailOAuthProviderConfig, state string) (string, error) {
u, err := url.Parse(cfg.AuthorizeURL)
if err != nil {
return "", fmt.Errorf("parse authorize_url: %w", err)
}
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", cfg.ClientID)
q.Set("redirect_uri", cfg.RedirectURL)
q.Set("state", state)
if strings.TrimSpace(cfg.Scopes) != "" {
q.Set("scope", cfg.Scopes)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func exchangeEmailOAuthCode(ctx context.Context, cfg config.EmailOAuthProviderConfig, code string) (*emailOAuthTokenResponse, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetHeader("Accept", "application/json").
SetFormData(map[string]string{
"grant_type": "authorization_code",
"client_id": cfg.ClientID,
"client_secret": cfg.ClientSecret,
"code": code,
"redirect_uri": cfg.RedirectURL,
}).
Post(cfg.TokenURL)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
var tokenResp emailOAuthTokenResponse
if err := json.Unmarshal(resp.Bytes(), &tokenResp); err != nil {
return nil, err
}
if strings.TrimSpace(tokenResp.AccessToken) == "" {
return nil, errors.New("missing access_token")
}
return &tokenResp, nil
}
func fetchEmailOAuthProfile(ctx context.Context, provider string, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse) (*emailOAuthProfile, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetBearerAuthToken(token.AccessToken).
SetHeader("Accept", "application/json").
Get(cfg.UserInfoURL)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
return parseGitHubOAuthProfile(ctx, cfg, token, resp.String())
case "google":
return parseGoogleOAuthProfile(resp.String())
default:
return nil, errors.New("unsupported oauth provider")
}
}
func parseGitHubOAuthProfile(ctx context.Context, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse, body string) (*emailOAuthProfile, error) {
subject := strings.TrimSpace(gjson.Get(body, "id").String())
if subject == "" {
return nil, errors.New("github user id is missing")
}
email := strings.TrimSpace(gjson.Get(body, "email").String())
emailVerified := false
if email != "" {
emailVerified = true
}
if strings.TrimSpace(cfg.EmailsURL) != "" {
if verifiedEmail, err := fetchGitHubPrimaryVerifiedEmail(ctx, cfg.EmailsURL, token.AccessToken); err == nil && verifiedEmail != "" {
email = verifiedEmail
emailVerified = true
} else if email == "" && err != nil {
return nil, err
}
}
if email == "" || !emailVerified {
return nil, errors.New("github verified email is missing")
}
login := strings.TrimSpace(gjson.Get(body, "login").String())
name := strings.TrimSpace(gjson.Get(body, "name").String())
return &emailOAuthProfile{
Subject: subject,
Email: email,
EmailVerified: true,
Username: firstNonEmpty(login, name, "github_"+subject),
DisplayName: firstNonEmpty(name, login),
AvatarURL: strings.TrimSpace(gjson.Get(body, "avatar_url").String()),
Metadata: map[string]any{
"login": login,
},
}, nil
}
func fetchGitHubPrimaryVerifiedEmail(ctx context.Context, emailsURL string, accessToken string) (string, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetBearerAuthToken(accessToken).
SetHeader("Accept", "application/json").
Get(emailsURL)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("github emails endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
items := gjson.Parse(resp.String()).Array()
for _, item := range items {
if item.Get("primary").Bool() && item.Get("verified").Bool() {
if email := strings.TrimSpace(item.Get("email").String()); email != "" {
return email, nil
}
}
}
for _, item := range items {
if item.Get("verified").Bool() {
if email := strings.TrimSpace(item.Get("email").String()); email != "" {
return email, nil
}
}
}
return "", errors.New("github verified email is missing")
}
func parseGoogleOAuthProfile(body string) (*emailOAuthProfile, error) {
subject := strings.TrimSpace(gjson.Get(body, "sub").String())
email := strings.TrimSpace(gjson.Get(body, "email").String())
verified := gjson.Get(body, "email_verified").Bool()
if subject == "" {
return nil, errors.New("google subject is missing")
}
if email == "" || !verified {
return nil, errors.New("google verified email is missing")
}
name := strings.TrimSpace(gjson.Get(body, "name").String())
return &emailOAuthProfile{
Subject: subject,
Email: email,
EmailVerified: true,
Username: firstNonEmpty(strings.TrimSpace(gjson.Get(body, "given_name").String()), name, email),
DisplayName: name,
AvatarURL: strings.TrimSpace(gjson.Get(body, "picture").String()),
Metadata: map[string]any{
"email_verified": true,
},
}, nil
}
func emailOAuthSetCookie(c *gin.Context, name, value string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: emailOAuthCookiePath,
MaxAge: emailOAuthCookieMaxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func emailOAuthClearCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: emailOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}

View File

@ -0,0 +1,333 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestEmailOAuthCallbackRequiresPendingRegistrationWhenInvitationEnabled(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandler(t, true)
ctx := context.Background()
state := "github-oauth-state"
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback?code=code-1&state="+url.QueryEscape(state), nil)
req.AddCookie(&http.Cookie{Name: emailOAuthStateCookieName, Value: encodeCookieValue(state)})
req.AddCookie(&http.Cookie{Name: emailOAuthRedirectCookie, Value: encodeCookieValue("/dashboard")})
req.AddCookie(&http.Cookie{Name: emailOAuthProviderCookie, Value: encodeCookieValue("github")})
c.Request = req
profile := &emailOAuthProfile{
Subject: "github-123",
Email: "fresh@example.com",
EmailVerified: true,
Username: "fresh",
DisplayName: "Fresh User",
AvatarURL: "https://cdn.example/fresh.png",
Metadata: map[string]any{
"login": "fresh",
},
}
handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "github-client",
ClientSecret: "github-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", profile)
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.Contains(t, location, "/auth/oauth/callback")
require.NotContains(t, location, "access_token=")
userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
require.NoError(t, err)
require.Zero(t, userCount)
session, err := client.PendingAuthSession.Query().Only(ctx)
require.NoError(t, err)
require.Equal(t, "github", session.ProviderType)
require.Equal(t, "github", session.ProviderKey)
require.Equal(t, "github-123", session.ProviderSubject)
require.Equal(t, "fresh@example.com", session.ResolvedEmail)
require.Equal(t, "/dashboard", session.RedirectTo)
require.Nil(t, session.TargetUserID)
completion, ok := readCompletionResponse(session.LocalFlowState)
require.True(t, ok)
require.Equal(t, oauthPendingChoiceStep, completion["step"])
require.Equal(t, "invitation_required", completion["error"])
require.Equal(t, "fresh@example.com", completion["email"])
require.Equal(t, "fresh@example.com", completion["resolved_email"])
require.Equal(t, true, completion["create_account_allowed"])
require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName))
require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName))
}
func TestEmailOAuthCallbackExistingEmailLogsInWhenInvitationEnabled(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandler(t, true)
ctx := context.Background()
user, err := client.User.Create().
SetEmail("existing@example.com").
SetUsername("existing").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
Save(ctx)
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/google/callback", nil)
handler.emailOAuthCallbackWithProfile(c, "google", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "google-client",
ClientSecret: "google-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/google/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "google-123",
Email: "existing@example.com",
EmailVerified: true,
Username: "existing",
})
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.Contains(t, location, "access_token=")
require.Contains(t, location, "redirect=%252Fdashboard")
sessionCount, err := client.PendingAuthSession.Query().Count(ctx)
require.NoError(t, err)
require.Zero(t, sessionCount)
identityCount, err := client.AuthIdentity.Query().Where(
authidentity.ProviderTypeEQ("google"),
authidentity.ProviderSubjectEQ("google-123"),
).Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, identityCount)
_ = user
}
func TestEmailOAuthCallbackAutoRegistrationAppliesAffiliateCode(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF123": 1001})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
settingValues: map[string]string{
service.SettingKeyAffiliateEnabled: "true",
},
affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
},
})
ctx := context.Background()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
req.AddCookie(&http.Cookie{Name: emailOAuthAffiliateCookie, Value: encodeCookieValue("AFF123")})
c.Request = req
handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "github-client",
ClientSecret: "github-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "github-aff-user",
Email: "aff-user@example.com",
EmailVerified: true,
Username: "aff-user",
})
require.Equal(t, http.StatusFound, recorder.Code)
require.Contains(t, recorder.Header().Get("Location"), "access_token=")
user, err := client.User.Query().Where(dbuser.EmailEQ("aff-user@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, []int64{user.ID, user.ID}, affiliateRepo.ensureUserIDs)
require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 1001}}, affiliateRepo.bindCalls)
}
func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
invitationEnabled: true,
settingValues: map[string]string{
service.SettingKeyAffiliateEnabled: "true",
},
affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
},
})
ctx := context.Background()
invitation, err := client.RedeemCode.Create().
SetCode("INVITE456").
SetType(service.RedeemTypeInvitation).
SetStatus(service.StatusUnused).
SetValue(0).
Save(ctx)
require.NoError(t, err)
session, err := client.PendingAuthSession.Create().
SetSessionToken("email-oauth-aff-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("google").
SetProviderKey("google").
SetProviderSubject("google-aff-user").
SetResolvedEmail("pending-aff@example.com").
SetRedirectTo("/dashboard").
SetBrowserSessionKey("browser-aff-key").
SetUpstreamIdentityClaims(map[string]any{
"email": "pending-aff@example.com",
"email_verified": true,
"username": "pending-aff",
"provider": "google",
"provider_key": "google",
"provider_subject": "google-aff-user",
"aff_code": "AFF456",
}).
SetLocalFlowState(map[string]any{
"step": oauthPendingChoiceStep,
"error": "invitation_required",
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/google/complete-registration", strings.NewReader(`{"invitation_code":"INVITE456"}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-aff-key")})
c.Request = req
handler.completeEmailOAuthRegistration(c, "google")
require.Equal(t, http.StatusOK, recorder.Code)
user, err := client.User.Query().Where(dbuser.EmailEQ("pending-aff@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 2002}}, affiliateRepo.bindCalls)
storedInvitation, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(invitation.ID)).Only(ctx)
require.NoError(t, err)
require.NotNil(t, storedInvitation.UsedBy)
require.Equal(t, user.ID, *storedInvitation.UsedBy)
}
type oauthEmailAffiliateBindCall struct {
userID int64
inviterID int64
}
type oauthEmailAffiliateRepoStub struct {
codeOwners map[string]int64
ensureUserIDs []int64
bindCalls []oauthEmailAffiliateBindCall
}
func newOAuthEmailAffiliateRepoStub(codeOwners map[string]int64) *oauthEmailAffiliateRepoStub {
return &oauthEmailAffiliateRepoStub{codeOwners: codeOwners}
}
func (r *oauthEmailAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*service.AffiliateSummary, error) {
r.ensureUserIDs = append(r.ensureUserIDs, userID)
return &service.AffiliateSummary{UserID: userID, AffCode: "SELF"}, nil
}
func (r *oauthEmailAffiliateRepoStub) GetAffiliateByCode(_ context.Context, code string) (*service.AffiliateSummary, error) {
userID, ok := r.codeOwners[strings.ToUpper(strings.TrimSpace(code))]
if !ok {
return nil, service.ErrAffiliateProfileNotFound
}
return &service.AffiliateSummary{UserID: userID, AffCode: strings.ToUpper(strings.TrimSpace(code))}, nil
}
func (r *oauthEmailAffiliateRepoStub) BindInviter(_ context.Context, userID, inviterID int64) (bool, error) {
r.bindCalls = append(r.bindCalls, oauthEmailAffiliateBindCall{userID: userID, inviterID: inviterID})
return true, nil
}
func (r *oauthEmailAffiliateRepoStub) AccrueQuota(context.Context, int64, int64, float64, int, *int64) (bool, error) {
panic("unexpected AccrueQuota call")
}
func (r *oauthEmailAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
panic("unexpected GetAccruedRebateFromInvitee call")
}
func (r *oauthEmailAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
panic("unexpected ThawFrozenQuota call")
}
func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
panic("unexpected TransferQuotaToBalance call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
func (r *oauthEmailAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
panic("unexpected UpdateUserAffCode call")
}
func (r *oauthEmailAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
panic("unexpected ResetUserAffCode call")
}
func (r *oauthEmailAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
panic("unexpected SetUserRebateRate call")
}
func (r *oauthEmailAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
panic("unexpected BatchSetUserRebateRate call")
}
func (r *oauthEmailAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
panic("unexpected ListUsersWithCustomSettings call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
panic("unexpected ListAffiliateInviteRecords call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
panic("unexpected ListAffiliateRebateRecords call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
panic("unexpected ListAffiliateTransferRecords call")
}
func (r *oauthEmailAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*service.AffiliateUserOverview, error) {
panic("unexpected GetAffiliateUserOverview call")
}
func findSetCookieValue(cookies []*http.Cookie, name string) string {
for _, cookie := range cookies {
if cookie != nil && strings.EqualFold(cookie.Name, name) && cookie.MaxAge >= 0 {
return cookie.Value
}
}
return ""
}

View File

@ -2121,6 +2121,8 @@ type oauthPendingFlowTestHandlerOptions struct {
emailCache service.EmailCache
settingValues map[string]string
defaultSubAssigner service.DefaultSubscriptionAssigner
affiliateService *service.AffiliateService
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
totpCache service.TotpCache
totpEncryptor service.SecretEncryptor
userRepoOptions oauthPendingFlowUserRepoOptions
@ -2160,6 +2162,21 @@ CREATE TABLE IF NOT EXISTS user_avatars (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS user_affiliates (
user_id INTEGER PRIMARY KEY,
aff_code TEXT NOT NULL UNIQUE,
aff_code_custom BOOLEAN NOT NULL DEFAULT false,
aff_rebate_rate_percent REAL NULL,
inviter_id INTEGER NULL,
aff_count INTEGER NOT NULL DEFAULT 0,
aff_quota REAL NOT NULL DEFAULT 0,
aff_frozen_quota REAL NOT NULL DEFAULT 0,
aff_history_quota REAL NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
require.NoError(t, err)
drv := entsql.OpenDB(dialect.SQLite, db)
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
@ -2177,14 +2194,19 @@ CREATE TABLE IF NOT EXISTS user_avatars (
},
}
settingValues := map[string]string{
service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
}
for key, value := range options.settingValues {
settingValues[key] = value
}
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
affiliateService := options.affiliateService
if affiliateService == nil && options.affiliateFactory != nil {
affiliateService = options.affiliateFactory(client, settingSvc)
}
userRepo := &oauthPendingFlowUserRepo{
client: client,
options: options.userRepoOptions,
@ -2210,7 +2232,7 @@ CREATE TABLE IF NOT EXISTS user_avatars (
nil,
nil,
options.defaultSubAssigner,
nil,
affiliateService,
)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
var totpSvc *service.TotpService

View File

@ -91,6 +91,17 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GitHubOAuthClientID string `json:"github_oauth_client_id"`
GitHubOAuthClientSecretConfigured bool `json:"github_oauth_client_secret_configured"`
GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
GoogleOAuthClientID string `json:"google_oauth_client_id"`
GoogleOAuthClientSecretConfigured bool `json:"google_oauth_client_secret_configured"`
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
@ -241,6 +252,8 @@ type PublicSettings struct {
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`

View File

@ -63,6 +63,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,

View File

@ -40,6 +40,8 @@ func backendModeAllowsAuthPath(path string) bool {
"/auth/oauth/wechat/callback",
"/auth/oauth/wechat/payment/callback",
"/auth/oauth/oidc/callback",
"/auth/oauth/github/callback",
"/auth/oauth/google/callback",
"/auth/oauth/linuxdo/complete-registration",
"/auth/oauth/wechat/complete-registration",
"/auth/oauth/oidc/complete-registration",

View File

@ -246,6 +246,30 @@ func TestBackendModeAuthGuard(t *testing.T) {
path: "/api/v1/auth/oauth/oidc/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_blocks_github_oauth_start",
enabled: "true",
path: "/api/v1/auth/oauth/github/start",
wantStatus: http.StatusForbidden,
},
{
name: "enabled_allows_github_oauth_callback",
enabled: "true",
path: "/api/v1/auth/oauth/github/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_blocks_google_oauth_start",
enabled: "true",
path: "/api/v1/auth/oauth/google/start",
wantStatus: http.StatusForbidden,
},
{
name: "enabled_allows_google_oauth_callback",
enabled: "true",
path: "/api/v1/auth/oauth/google/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_oauth_pending_exchange",
enabled: "true",

View File

@ -63,6 +63,22 @@ func RegisterAuthRoutes(
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ResetPassword)
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
auth.POST("/oauth/github/complete-registration",
rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteGitHubOAuthRegistration,
)
auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
auth.POST("/oauth/google/complete-registration",
rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteGoogleOAuthRegistration,
)
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")

View File

@ -0,0 +1,274 @@
package service
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
type EmailOAuthIdentityInput struct {
ProviderType string
ProviderKey string
ProviderSubject string
Email string
EmailVerified bool
Username string
DisplayName string
AvatarURL string
UpstreamMetadata map[string]any
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "")
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
ctx context.Context,
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
}
func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
ctx context.Context,
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
) (*TokenPair, *User, error) {
if s == nil || s.userRepo == nil || s.entClient == nil {
return nil, nil, ErrServiceUnavailable
}
providerType := normalizeOAuthSignupSource(input.ProviderType)
if providerType != "github" && providerType != "google" {
return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
}
providerKey := strings.TrimSpace(input.ProviderKey)
if providerKey == "" {
providerKey = providerType
}
providerSubject := strings.TrimSpace(input.ProviderSubject)
if providerSubject == "" {
return nil, nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing")
}
if !input.EmailVerified {
return nil, nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified")
}
email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" || len(email) > 255 {
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
}
if isReservedEmail(email) {
return nil, nil, ErrEmailReserved
}
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return nil, nil, err
}
identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject)
if err != nil {
return nil, nil, err
}
if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
return nil, nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
}
user := identityUser
created := false
if user == nil {
user, err = s.userRepo.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode)
if err != nil {
return nil, nil, err
}
created = true
} else {
logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err)
return nil, nil, ErrServiceUnavailable
}
}
}
if !user.IsActive() {
return nil, nil, ErrUserNotActive
}
if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{
ProviderType: providerType,
ProviderKey: providerKey,
ProviderSubject: providerSubject,
Email: email,
EmailVerified: input.EmailVerified,
Username: input.Username,
DisplayName: input.DisplayName,
AvatarURL: input.AvatarURL,
UpstreamMetadata: input.UpstreamMetadata,
}); err != nil {
return nil, nil, err
}
if user.Username == "" && strings.TrimSpace(input.Username) != "" {
user.Username = strings.TrimSpace(input.Username)
if err := s.userRepo.Update(ctx, user); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err)
}
}
if !created {
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err)
}
}
s.RecordSuccessfulLogin(ctx, user.ID)
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
if err != nil {
return nil, nil, fmt.Errorf("generate token pair: %w", err)
}
return tokenPair, user, nil
}
func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) {
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return nil, ErrRegDisabled
}
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
if err != nil {
if errors.Is(err, ErrInvitationCodeRequired) {
return nil, ErrOAuthInvitationRequired
}
return nil, err
}
randomPassword, err := randomHexString(32)
if err != nil {
return nil, ErrServiceUnavailable
}
hashedPassword, err := s.HashPassword(randomPassword)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
grantPlan := s.resolveSignupGrantPlan(ctx, providerType)
var defaultRPMLimit int
if s.settingService != nil {
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
}
user := &User{
Email: email,
Username: strings.TrimSpace(username),
PasswordHash: hashedPassword,
Role: RoleUser,
Balance: grantPlan.Balance,
Concurrency: grantPlan.Concurrency,
RPMLimit: defaultRPMLimit,
Status: StatusActive,
SignupSource: providerType,
}
if err := s.userRepo.Create(ctx, user); err != nil {
if errors.Is(err, ErrEmailExists) {
existing, loadErr := s.userRepo.GetByEmail(ctx, email)
if loadErr != nil {
return nil, ErrServiceUnavailable
}
return existing, nil
}
return nil, ErrServiceUnavailable
}
s.postAuthUserBootstrap(ctx, user, providerType, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
if invitationRedeemCode != nil {
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode)
return nil, ErrInvitationCodeInvalid
}
}
return user, nil
}
func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) {
identity, err := s.entClient.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ(providerType),
authidentity.ProviderKeyEQ(providerKey),
authidentity.ProviderSubjectEQ(providerSubject),
).
Only(ctx)
if err != nil {
if dbent.IsNotFound(err) {
return nil, nil
}
return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
}
user, err := s.userRepo.GetByID(ctx, identity.UserID)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, nil
}
return nil, ErrServiceUnavailable
}
return user, nil
}
func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error {
metadata := map[string]any{
"email": strings.TrimSpace(strings.ToLower(input.Email)),
"email_verified": input.EmailVerified,
}
for key, value := range input.UpstreamMetadata {
metadata[key] = value
}
if strings.TrimSpace(input.Username) != "" {
metadata["username"] = strings.TrimSpace(input.Username)
}
if strings.TrimSpace(input.DisplayName) != "" {
metadata["display_name"] = strings.TrimSpace(input.DisplayName)
}
if strings.TrimSpace(input.AvatarURL) != "" {
metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL)
}
providerType := normalizeOAuthSignupSource(input.ProviderType)
providerKey := strings.TrimSpace(input.ProviderKey)
providerSubject := strings.TrimSpace(input.ProviderSubject)
identity, err := s.entClient.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ(providerType),
authidentity.ProviderKeyEQ(providerKey),
authidentity.ProviderSubjectEQ(providerSubject),
).
Only(ctx)
if err != nil && !dbent.IsNotFound(err) {
return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
}
if identity != nil {
if identity.UserID != userID {
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
}
_, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID).
SetMetadata(metadata).
Save(ctx)
return err
}
_, err = s.entClient.AuthIdentity.Create().
SetUserID(userID).
SetProviderType(providerType).
SetProviderKey(providerKey).
SetProviderSubject(providerSubject).
SetMetadata(metadata).
Save(ctx)
return err
}

View File

@ -17,7 +17,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
switch signupSource {
case "", "email":
return "email"
case "linuxdo", "wechat", "oidc":
case "linuxdo", "wechat", "oidc", "github", "google":
return signupSource
default:
return "email"

View File

@ -775,6 +775,10 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
return defaults.OIDC, true
case "wechat":
return defaults.WeChat, true
case "github":
return defaults.GitHub, true
case "google":
return defaults.Google, true
default:
return ProviderDefaultGrantSettings{}, false
}

View File

@ -173,6 +173,18 @@ const (
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
// GitHub / Google 邮箱快捷登录设置
SettingKeyGitHubOAuthEnabled = "github_oauth_enabled"
SettingKeyGitHubOAuthClientID = "github_oauth_client_id"
SettingKeyGitHubOAuthClientSecret = "github_oauth_client_secret"
SettingKeyGitHubOAuthRedirectURL = "github_oauth_redirect_url"
SettingKeyGitHubOAuthFrontendRedirectURL = "github_oauth_frontend_redirect_url"
SettingKeyGoogleOAuthEnabled = "google_oauth_enabled"
SettingKeyGoogleOAuthClientID = "google_oauth_client_id"
SettingKeyGoogleOAuthClientSecret = "google_oauth_client_secret"
SettingKeyGoogleOAuthRedirectURL = "google_oauth_redirect_url"
SettingKeyGoogleOAuthFrontendRedirectURL = "google_oauth_frontend_redirect_url"
// OEM设置
SettingKeySiteName = "site_name" // 网站名称
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
@ -216,6 +228,16 @@ const (
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
// 管理员 API Key

View File

@ -129,6 +129,8 @@ type AuthSourceDefaultSettings struct {
LinuxDo ProviderDefaultGrantSettings
OIDC ProviderDefaultGrantSettings
WeChat ProviderDefaultGrantSettings
GitHub ProviderDefaultGrantSettings
Google ProviderDefaultGrantSettings
ForceEmailOnThirdPartySignup bool
}
@ -169,6 +171,20 @@ var (
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
}
gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
balance: SettingKeyAuthSourceDefaultGitHubBalance,
concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
}
googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
balance: SettingKeyAuthSourceDefaultGoogleBalance,
concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
}
)
const (
@ -177,6 +193,17 @@ const (
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"
defaultWeChatConnectFrontend = "/auth/wechat/callback"
defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
defaultGitHubOAuthUserInfo = "https://api.github.com/user"
defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
defaultGitHubOAuthScopes = "read:user user:email"
defaultGitHubOAuthFrontend = "/auth/oauth/callback"
defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
defaultGoogleOAuthScopes = "openid email profile"
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
)
func normalizeWeChatConnectModeSetting(raw string) string {
@ -448,6 +475,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
SettingKeyGitHubOAuthEnabled,
SettingKeyGitHubOAuthClientID,
SettingKeyGitHubOAuthClientSecret,
SettingKeyGoogleOAuthEnabled,
SettingKeyGoogleOAuthClientID,
SettingKeyGoogleOAuthClientSecret,
SettingKeyBalanceLowNotifyEnabled,
SettingKeyBalanceLowNotifyThreshold,
SettingKeyBalanceLowNotifyRechargeURL,
@ -482,6 +515,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if oidcProviderName == "" {
oidcProviderName = "OIDC"
}
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
// Password reset requires email verification to be enabled
@ -534,6 +569,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
GitHubOAuthEnabled: gitHubEnabled,
GoogleOAuthEnabled: googleEnabled,
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
@ -677,6 +714,8 @@ type PublicSettingsInjectionPayload struct {
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
@ -733,6 +772,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: s.version,
@ -806,6 +847,98 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string
return openReady || mpReady, openReady, mpReady, mobileReady
}
func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
cfg := config.EmailOAuthProviderConfig{
AuthorizeURL: defaultGitHubOAuthAuthorize,
TokenURL: defaultGitHubOAuthToken,
UserInfoURL: defaultGitHubOAuthUserInfo,
EmailsURL: defaultGitHubOAuthEmails,
Scopes: defaultGitHubOAuthScopes,
FrontendRedirectURL: defaultGitHubOAuthFrontend,
}
if s != nil && s.cfg != nil {
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
}
return cfg
case "google":
cfg := config.EmailOAuthProviderConfig{
AuthorizeURL: defaultGoogleOAuthAuthorize,
TokenURL: defaultGoogleOAuthToken,
UserInfoURL: defaultGoogleOAuthUserInfo,
Scopes: defaultGoogleOAuthScopes,
FrontendRedirectURL: defaultGoogleOAuthFrontend,
}
if s != nil && s.cfg != nil {
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
}
return cfg
default:
return config.EmailOAuthProviderConfig{}
}
}
func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
base.Enabled = override.Enabled
if strings.TrimSpace(override.ClientID) != "" {
base.ClientID = strings.TrimSpace(override.ClientID)
}
if strings.TrimSpace(override.ClientSecret) != "" {
base.ClientSecret = strings.TrimSpace(override.ClientSecret)
}
if strings.TrimSpace(override.AuthorizeURL) != "" {
base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
}
if strings.TrimSpace(override.TokenURL) != "" {
base.TokenURL = strings.TrimSpace(override.TokenURL)
}
if strings.TrimSpace(override.UserInfoURL) != "" {
base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
}
if strings.TrimSpace(override.EmailsURL) != "" {
base.EmailsURL = strings.TrimSpace(override.EmailsURL)
}
if strings.TrimSpace(override.Scopes) != "" {
base.Scopes = strings.TrimSpace(override.Scopes)
}
if strings.TrimSpace(override.RedirectURL) != "" {
base.RedirectURL = strings.TrimSpace(override.RedirectURL)
}
if strings.TrimSpace(override.FrontendRedirectURL) != "" {
base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
}
return base
}
func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
cfg := s.effectiveEmailOAuthConfig(settings, provider)
return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
}
func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
cfg := s.emailOAuthBaseConfig(provider)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
cfg.Enabled = raw == "true"
}
cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
case "google":
if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
cfg.Enabled = raw == "true"
}
cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
}
return cfg
}
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
// array string, returning only items with visibility != "admin".
func filterUserVisibleMenuItems(raw string) json.RawMessage {
@ -1052,6 +1185,16 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if settings.WeChatConnectFrontendRedirectURL == "" {
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
}
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
if settings.GitHubOAuthFrontendRedirectURL == "" {
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
}
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
if settings.GoogleOAuthFrontendRedirectURL == "" {
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
}
updates := make(map[string]string)
@ -1121,6 +1264,22 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
}
// GitHub / Google 邮箱快捷登录
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
if settings.GitHubOAuthClientSecret != "" {
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
}
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
if settings.GoogleOAuthClientSecret != "" {
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
}
// WeChat Connect OAuth 登录
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
@ -1273,17 +1432,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett
settings.LinuxDo.Subscriptions,
settings.OIDC.Subscriptions,
settings.WeChat.Subscriptions,
settings.GitHub.Subscriptions,
settings.Google.Subscriptions,
} {
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
return nil, err
}
}
updates := make(map[string]string, 21)
updates := make(map[string]string, 31)
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
return updates, nil
}
@ -1362,6 +1525,61 @@ func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context,
return nil
}
func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider != "github" && provider != "google" {
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
}
keys := []string{
SettingKeyGitHubOAuthEnabled,
SettingKeyGitHubOAuthClientID,
SettingKeyGitHubOAuthClientSecret,
SettingKeyGitHubOAuthRedirectURL,
SettingKeyGitHubOAuthFrontendRedirectURL,
SettingKeyGoogleOAuthEnabled,
SettingKeyGoogleOAuthClientID,
SettingKeyGoogleOAuthClientSecret,
SettingKeyGoogleOAuthRedirectURL,
SettingKeyGoogleOAuthFrontendRedirectURL,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
}
cfg := s.effectiveEmailOAuthConfig(settings, provider)
if !cfg.Enabled {
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
}
if strings.TrimSpace(cfg.ClientSecret) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
}
for label, rawURL := range map[string]string{
"authorize": cfg.AuthorizeURL,
"token": cfg.TokenURL,
"userinfo": cfg.UserInfoURL,
"redirect": cfg.RedirectURL,
} {
if strings.TrimSpace(rawURL) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
}
}
if strings.TrimSpace(cfg.EmailsURL) != "" {
if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
}
}
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
}
return cfg, nil
}
// IsRegistrationEnabled 检查是否开放注册
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
@ -1711,6 +1929,16 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
SettingKeyAuthSourceDefaultWeChatSubscriptions,
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
SettingKeyAuthSourceDefaultGitHubBalance,
SettingKeyAuthSourceDefaultGitHubConcurrency,
SettingKeyAuthSourceDefaultGitHubSubscriptions,
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
SettingKeyAuthSourceDefaultGoogleBalance,
SettingKeyAuthSourceDefaultGoogleConcurrency,
SettingKeyAuthSourceDefaultGoogleSubscriptions,
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
SettingKeyForceEmailOnThirdPartySignup,
}
@ -1724,6 +1952,8 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
}, nil
}
@ -1824,6 +2054,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyGitHubOAuthEnabled: "false",
SettingKeyGitHubOAuthClientID: "",
SettingKeyGitHubOAuthClientSecret: "",
SettingKeyGitHubOAuthRedirectURL: "",
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
SettingKeyGoogleOAuthEnabled: "false",
SettingKeyGoogleOAuthClientID: "",
SettingKeyGoogleOAuthClientSecret: "",
SettingKeyGoogleOAuthRedirectURL: "",
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
@ -1874,6 +2114,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGitHubBalance: "0",
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGoogleBalance: "0",
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
@ -2173,6 +2423,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
result.GitHubOAuthEnabled = gitHubEffective.Enabled
result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
result.GoogleOAuthEnabled = googleEffective.Enabled
result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
// WeChat Connect 设置:
// - 优先读取 DB 系统设置
// - 缺失时回退到 config/env保持升级兼容

View File

@ -89,6 +89,20 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string
OIDCConnectUserInfoUsernamePath string
// GitHub / Google 邮箱快捷登录
GitHubOAuthEnabled bool
GitHubOAuthClientID string
GitHubOAuthClientSecret string
GitHubOAuthClientSecretConfigured bool
GitHubOAuthRedirectURL string
GitHubOAuthFrontendRedirectURL string
GoogleOAuthEnabled bool
GoogleOAuthClientID string
GoogleOAuthClientSecret string
GoogleOAuthClientSecretConfigured bool
GoogleOAuthRedirectURL string
GoogleOAuthFrontendRedirectURL string
SiteName string
SiteLogo string
SiteSubtitle string
@ -217,6 +231,8 @@ type PublicSettings struct {
PaymentEnabled bool
OIDCOAuthEnabled bool
OIDCOAuthProviderName string
GitHubOAuthEnabled bool
GoogleOAuthEnabled bool
Version string
BalanceLowNotifyEnabled bool

View File

@ -11,7 +11,13 @@ export interface DefaultSubscriptionSetting {
validity_days: number;
}
export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat";
export type AuthSourceType =
| "email"
| "linuxdo"
| "oidc"
| "wechat"
| "github"
| "google";
export interface AuthSourceDefaultsValue {
balance: number;
@ -51,6 +57,8 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [
"linuxdo",
"oidc",
"wechat",
"github",
"google",
];
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
@ -335,6 +343,16 @@ export interface SystemSettings {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_github_grant_on_signup?: boolean;
auth_source_default_github_grant_on_first_bind?: boolean;
auth_source_default_google_balance?: number;
auth_source_default_google_concurrency?: number;
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_google_grant_on_signup?: boolean;
auth_source_default_google_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean;
// OEM settings
site_name: string;
@ -410,6 +428,16 @@ export interface SystemSettings {
oidc_connect_userinfo_email_path: string;
oidc_connect_userinfo_id_path: string;
oidc_connect_userinfo_username_path: string;
github_oauth_enabled: boolean;
github_oauth_client_id: string;
github_oauth_client_secret_configured: boolean;
github_oauth_redirect_url: string;
github_oauth_frontend_redirect_url: string;
google_oauth_enabled: boolean;
google_oauth_client_id: string;
google_oauth_client_secret_configured: boolean;
google_oauth_redirect_url: string;
google_oauth_frontend_redirect_url: string;
// Model fallback configuration
enable_model_fallback: boolean;
@ -527,6 +555,16 @@ export interface UpdateSettingsRequest {
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean;
auth_source_default_github_balance?: number;
auth_source_default_github_concurrency?: number;
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_github_grant_on_signup?: boolean;
auth_source_default_github_grant_on_first_bind?: boolean;
auth_source_default_google_balance?: number;
auth_source_default_google_concurrency?: number;
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_google_grant_on_signup?: boolean;
auth_source_default_google_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean;
site_name?: string;
site_logo?: string;
@ -593,6 +631,16 @@ export interface UpdateSettingsRequest {
oidc_connect_userinfo_email_path?: string;
oidc_connect_userinfo_id_path?: string;
oidc_connect_userinfo_username_path?: string;
github_oauth_enabled?: boolean;
github_oauth_client_id?: string;
github_oauth_client_secret?: string;
github_oauth_redirect_url?: string;
github_oauth_frontend_redirect_url?: string;
google_oauth_enabled?: boolean;
google_oauth_client_id?: string;
google_oauth_client_secret?: string;
google_oauth_redirect_url?: string;
google_oauth_frontend_redirect_url?: string;
enable_model_fallback?: boolean;
fallback_model_anthropic?: string;
fallback_model_openai?: string;

View File

@ -0,0 +1,85 @@
<template>
<div v-if="hasProviders" class="space-y-4">
<div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
<div :class="providerGridClass">
<button
v-for="provider in visibleProviders"
:key="provider"
type="button"
:disabled="disabled"
class="btn btn-secondary h-12 w-full justify-center gap-2"
@click="startLogin(provider)"
>
<GitHubMark v-if="provider === 'github'" class="h-5 w-5 text-gray-800 dark:text-gray-100" />
<GoogleMark v-else class="h-5 w-5" />
<span class="font-medium">{{ providerLabel(provider) }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import GitHubMark from './GitHubMark.vue'
import GoogleMark from './GoogleMark.vue'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
type EmailOAuthProvider = 'github' | 'google'
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
githubEnabled?: boolean
googleEnabled?: boolean
showDivider?: boolean
}>(), {
showDivider: true
})
const route = useRoute()
const { t } = useI18n()
const visibleProviders = computed<EmailOAuthProvider[]>(() => {
const providers: EmailOAuthProvider[] = []
if (props.githubEnabled) providers.push('github')
if (props.googleEnabled) providers.push('google')
return providers
})
const hasProviders = computed(() => visibleProviders.value.length > 0)
const hasMultipleProviders = computed(() => visibleProviders.value.length > 1)
const providerGridClass = computed(() => [
'grid',
'grid-cols-1',
'gap-3',
hasMultipleProviders.value ? 'sm:grid-cols-2' : ''
])
function providerLabel(provider: EmailOAuthProvider): string {
const name = provider === 'github' ? 'GitHub' : 'Google'
return hasMultipleProviders.value ? name : t('auth.emailOAuth.signIn', { providerName: name })
}
function startLogin(provider: EmailOAuthProvider): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)
storeOAuthAffiliateCode(affiliateCode)
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const params = new URLSearchParams({ redirect: redirectTo })
if (affiliateCode) {
params.set('aff_code', affiliateCode)
}
const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
window.location.href = startURL
}
</script>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.61 7.61 0 0 1 8 3.86c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
/>
</svg>
</template>

View File

@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1A6.61 6.61 0 0 1 5.5 12c0-.73.12-1.43.34-2.1V7.06H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.94l3.66-2.84z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06L5.84 9.9C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
</template>

View File

@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const locationState = vi.hoisted(() => ({
current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.emailOAuth.signIn') {
return `使用 ${params?.providerName ?? ''} 登录`
}
return key
},
}),
}))
describe('EmailOAuthButtons', () => {
beforeEach(() => {
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
locationState.current = { href: 'http://localhost/register?aff=AFF123' }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
window.localStorage.clear()
window.sessionStorage.clear()
})
it('passes the affiliate code to the email oauth start URL', async () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: true,
googleEnabled: false,
},
global: {
stubs: {
GitHubMark: true,
GoogleMark: true,
},
},
})
await wrapper.get('button').trigger('click')
expect(locationState.current.href).toBe(
'/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
)
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
})
it('uses a full-width descriptive button when only GitHub is enabled', () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: true,
googleEnabled: false,
},
global: {
stubs: {
GitHubMark: true,
GoogleMark: true,
},
},
})
expect(wrapper.find('.grid').classes()).not.toContain('sm:grid-cols-2')
expect(wrapper.get('button').text()).toContain('使用 GitHub 登录')
})
it('uses compact labels and two columns when GitHub and Google are both enabled', () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: true,
googleEnabled: true,
},
global: {
stubs: {
GitHubMark: true,
GoogleMark: true,
},
},
})
expect(wrapper.find('.grid').classes()).toContain('sm:grid-cols-2')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].text()).toContain('GitHub')
expect(buttons[0].text()).not.toContain('使用 GitHub 登录')
expect(buttons[1].text()).toContain('Google')
expect(buttons[1].text()).not.toContain('使用 Google 登录')
})
})

View File

@ -263,7 +263,9 @@ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
email: t('profile.authBindings.providers.email'),
linuxdo: t('profile.authBindings.providers.linuxdo'),
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
wechat: t('profile.authBindings.providers.wechat')
wechat: t('profile.authBindings.providers.wechat'),
github: 'GitHub',
google: 'Google'
}))
function formatCurrency(value: number): string {
@ -272,7 +274,13 @@ function formatCurrency(value: number): string {
function normalizeProvider(value: string): UserAuthProvider | null {
const normalized = value.trim().toLowerCase()
if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
if (
normalized === 'email' ||
normalized === 'linuxdo' ||
normalized === 'wechat' ||
normalized === 'github' ||
normalized === 'google'
) {
return normalized
}
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {

View File

@ -472,6 +472,9 @@ export default {
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
emailOAuth: {
signIn: 'Continue with {providerName}'
},
oidc: {
signIn: 'Continue with {providerName}',
callbackTitle: 'Signing you in with {providerName}',
@ -531,6 +534,8 @@ export default {
oauth: {
callbackTitle: 'OAuth Callback',
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
invalidCallbackTitle: 'Invalid sign-in callback',
invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.',
code: 'Code',
state: 'State',
fullUrl: 'Full URL'

View File

@ -471,6 +471,9 @@ export default {
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
emailOAuth: {
signIn: '使用 {providerName} 登录'
},
oidc: {
signIn: '使用 {providerName} 登录',
callbackTitle: '正在完成 {providerName} 登录',
@ -529,6 +532,8 @@ export default {
oauth: {
callbackTitle: 'OAuth 回调',
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
invalidCallbackTitle: '无效的登录回调',
invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。',
code: '授权码',
state: '状态',
fullUrl: '完整URL'

View File

@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/auth/callback',
name: 'OAuthCallback',
alias: '/auth/oauth/callback',
component: () => import('@/views/auth/OAuthCallbackView.vue'),
meta: {
requiresAuth: false,

View File

@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => {
wechat_oauth_mobile_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
github_oauth_enabled: false,
google_oauth_enabled: false,
backend_mode_enabled: false,
version: siteVersion.value,
balance_low_notify_enabled: false,

View File

@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
// ==================== User & Auth Types ====================
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
export interface UserAuthBindingStatus {
bound?: boolean
@ -208,6 +208,8 @@ export interface PublicSettings {
wechat_oauth_mobile_enabled?: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
github_oauth_enabled: boolean
google_oauth_enabled: boolean
backend_mode_enabled: boolean
version: string
balance_low_notify_enabled: boolean

View File

@ -1752,6 +1752,232 @@
</div>
</div>
<!-- GitHub / Google 邮箱快捷登录 -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ localText("邮箱快捷登录", "Email OAuth Sign-in") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。",
"After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.",
)
}}
</p>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
GitHub
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。",
"GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.",
)
}}
</p>
</div>
<Toggle v-model="form.github_oauth_enabled" />
</div>
<div v-if="form.github_oauth_enabled" class="mt-4 space-y-4">
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
<template v-if="isZhLocale">
开通引导GitHub Settings Developer settings
<a
data-testid="github-oauth-apps-guide-link"
href="https://github.com/settings/developers"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>OAuth Apps</a>
New OAuth AppHomepage URL 填站点域名Authorization callback URL 填下面的后端回调地址
</template>
<template v-else>
Setup guide: GitHub Settings Developer settings
<a
data-testid="github-oauth-apps-guide-link"
href="https://github.com/settings/developers"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>OAuth Apps</a>
New OAuth App. Use your site origin as Homepage URL and the backend callback URL below as Authorization callback URL.
</template>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input
v-model="form.github_oauth_client_id"
type="text"
class="input font-mono text-sm"
placeholder="GitHub OAuth Client ID"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input
v-model="form.github_oauth_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.github_oauth_client_secret_configured
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
: 'GitHub OAuth Client Secret'
"
/>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("后端回调地址", "Backend Callback URL") }}
</label>
<input
v-model="form.github_oauth_redirect_url"
type="url"
class="input font-mono text-sm"
placeholder="https://your-domain.com/api/v1/auth/oauth/github/callback"
/>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="button"
class="btn btn-secondary btn-sm w-fit"
@click="setAndCopyEmailOAuthRedirectUrl('github')"
>
{{ localText("生成并复制", "Generate and copy") }}
</button>
<code
v-if="githubOAuthRedirectUrlSuggestion"
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{ githubOAuthRedirectUrlSuggestion }}
</code>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("前端回跳地址", "Frontend Callback URL") }}
</label>
<input
v-model="form.github_oauth_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
placeholder="/auth/oauth/callback"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
Google
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。",
"Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.",
)
}}
</p>
</div>
<Toggle v-model="form.google_oauth_enabled" />
</div>
<div v-if="form.google_oauth_enabled" class="mt-4 space-y-4">
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
{{
localText(
"开通引导Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕Credentials → Create Credentials → OAuth client ID类型选择 Web application并把下面地址加入 Authorized redirect URIs。",
"Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.",
)
}}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input
v-model="form.google_oauth_client_id"
type="text"
class="input font-mono text-sm"
placeholder="Google OAuth Client ID"
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input
v-model="form.google_oauth_client_secret"
type="password"
class="input font-mono text-sm"
:placeholder="
form.google_oauth_client_secret_configured
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
: 'Google OAuth Client Secret'
"
/>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("后端回调地址", "Backend Callback URL") }}
</label>
<input
v-model="form.google_oauth_redirect_url"
type="url"
class="input font-mono text-sm"
placeholder="https://your-domain.com/api/v1/auth/oauth/google/callback"
/>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="button"
class="btn btn-secondary btn-sm w-fit"
@click="setAndCopyEmailOAuthRedirectUrl('google')"
>
{{ localText("生成并复制", "Generate and copy") }}
</button>
<code
v-if="googleOAuthRedirectUrlSuggestion"
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{ googleOAuthRedirectUrlSuggestion }}
</code>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("前端回跳地址", "Frontend Callback URL") }}
</label>
<input
v-model="form.google_oauth_frontend_redirect_url"
type="text"
class="input font-mono text-sm"
placeholder="/auth/oauth/callback"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- WeChat Connect OAuth 登录 -->
<div class="card">
<div
@ -5646,9 +5872,10 @@ import {
const { t, locale } = useI18n();
const appStore = useAppStore();
const adminSettingsStore = useAdminSettingsStore();
const isZhLocale = computed(() => locale.value.startsWith("zh"));
function localText(zh: string, en: string): string {
return locale.value.startsWith("zh") ? zh : en;
return isZhLocale.value ? zh : en;
}
const paymentGuideHref = computed(() =>
@ -5796,6 +6023,8 @@ type SettingsForm = Omit<
wechat_connect_mp_enabled: boolean;
wechat_connect_mobile_enabled: boolean;
oidc_connect_client_secret: string;
github_oauth_client_secret: string;
google_oauth_client_secret: string;
force_email_on_third_party_signup: boolean;
openai_advanced_scheduler_enabled: boolean;
};
@ -5926,6 +6155,19 @@ const form = reactive<SettingsForm>({
oidc_connect_userinfo_email_path: "",
oidc_connect_userinfo_id_path: "",
oidc_connect_userinfo_username_path: "",
// GitHub / Google
github_oauth_enabled: false,
github_oauth_client_id: "",
github_oauth_client_secret: "",
github_oauth_client_secret_configured: false,
github_oauth_redirect_url: "",
github_oauth_frontend_redirect_url: "/auth/oauth/callback",
google_oauth_enabled: false,
google_oauth_client_id: "",
google_oauth_client_secret: "",
google_oauth_client_secret_configured: false,
google_oauth_redirect_url: "",
google_oauth_frontend_redirect_url: "/auth/oauth/callback",
// Model fallback
enable_model_fallback: false,
fallback_model_anthropic: "claude-3-5-sonnet-20241022",
@ -5991,6 +6233,22 @@ const authSourceDefaultsMeta = computed(() => [
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
},
{
source: "github" as AuthSourceType,
title: "GitHub",
description: localText(
"通过 GitHub 已验证邮箱首次注册或首次绑定时应用。",
"Applied on first signup or first bind through a verified GitHub email.",
),
},
{
source: "google" as AuthSourceType,
title: "Google",
description: localText(
"通过 Google 已验证邮箱首次注册或首次绑定时应用。",
"Applied on first signup or first bind through a verified Google email.",
),
},
]);
// Proxies for web search emulation ProxySelector
@ -6298,6 +6556,42 @@ async function setAndCopyLinuxdoRedirectUrl() {
);
}
type EmailOAuthProvider = "github" | "google";
const githubOAuthRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/github/callback`;
});
const googleOAuthRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
return `${origin}/api/v1/auth/oauth/google/callback`;
});
async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) {
const url =
provider === "github"
? githubOAuthRedirectUrlSuggestion.value
: googleOAuthRedirectUrlSuggestion.value;
if (!url) return;
if (provider === "github") {
form.github_oauth_redirect_url = url;
} else {
form.google_oauth_redirect_url = url;
}
await copyToClipboard(
url,
localText("回调地址已写入并复制。", "Callback URL set and copied."),
);
}
const wechatRedirectUrlSuggestion = computed(() => {
if (typeof window === "undefined") return "";
const origin =
@ -6488,6 +6782,8 @@ async function loadSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";
@ -6846,6 +7142,20 @@ async function saveSettings() {
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
oidc_connect_userinfo_username_path:
form.oidc_connect_userinfo_username_path,
github_oauth_enabled: form.github_oauth_enabled,
github_oauth_client_id: form.github_oauth_client_id,
github_oauth_client_secret:
form.github_oauth_client_secret || undefined,
github_oauth_redirect_url: form.github_oauth_redirect_url,
github_oauth_frontend_redirect_url:
form.github_oauth_frontend_redirect_url,
google_oauth_enabled: form.google_oauth_enabled,
google_oauth_client_id: form.google_oauth_client_id,
google_oauth_client_secret:
form.google_oauth_client_secret || undefined,
google_oauth_redirect_url: form.google_oauth_redirect_url,
google_oauth_frontend_redirect_url:
form.google_oauth_frontend_redirect_url,
enable_model_fallback: form.enable_model_fallback,
fallback_model_anthropic: form.fallback_model_anthropic,
fallback_model_openai: form.fallback_model_openai,
@ -6960,6 +7270,8 @@ async function saveSettings() {
smtpPasswordManuallyEdited.value = false;
form.turnstile_secret_key = "";
form.linuxdo_connect_client_secret = "";
form.github_oauth_client_secret = "";
form.google_oauth_client_secret = "";
form.wechat_connect_app_secret = "";
form.wechat_connect_open_app_secret = "";
form.wechat_connect_mp_app_secret = "";

View File

@ -817,6 +817,24 @@ describe("admin SettingsView wechat connect controls", () => {
).toBe("/auth/wechat/callback");
});
it("links GitHub OAuth Apps guide to GitHub developer settings", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
github_oauth_enabled: true,
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]');
expect(link.text()).toContain("OAuth Apps");
expect(link.attributes("href")).toBe("https://github.com/settings/developers");
expect(link.attributes("target")).toBe("_blank");
expect(link.attributes("rel")).toContain("noopener");
});
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
const wrapper = mountView();

View File

@ -10,33 +10,6 @@
{{ t('auth.signInToAccount') }}
</p>
</div>
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input -->
@ -144,6 +117,40 @@
<Icon v-else name="login" size="md" class="mr-2" />
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
</button>
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
<EmailOAuthButtons
:disabled="isLoading"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
:show-divider="false"
/>
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
</div>
</form>
</div>
@ -180,6 +187,7 @@ import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
@ -210,6 +218,8 @@ const wechatOAuthEnabled = ref<boolean>(false)
const backendModeEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
// Turnstile
@ -237,6 +247,16 @@ const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
(linuxdoOAuthEnabled.value ||
wechatOAuthEnabled.value ||
oidcOAuthEnabled.value ||
githubOAuthEnabled.value ||
googleOAuthEnabled.value)
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
@ -263,6 +283,8 @@ onMounted(async () => {
backendModeEnabled.value = settings.backend_mode_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
githubOAuthEnabled.value = settings.github_oauth_enabled
googleOAuthEnabled.value = settings.google_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled
} catch (error) {

View File

@ -1,7 +1,60 @@
<template>
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
<div class="mx-auto max-w-2xl">
<div class="card p-6">
<div v-if="isProcessing" class="card p-6 text-center">
<div class="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
<h1 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oauth.callbackTitle') }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('auth.oauth.callbackHint') }}
</p>
</div>
<div v-else-if="needsInvitation" class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oidc.callbackTitle', { providerName }) }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('auth.oidc.invitationRequired', { providerName }) }}
</p>
<div class="mt-6 space-y-4">
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitInvitation"
/>
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
</p>
<button
class="btn btn-primary w-full"
type="button"
:disabled="isSubmitting || !invitationCode.trim()"
@click="handleSubmitInvitation"
>
{{ isSubmitting ? t('common.processing') : t('auth.oidc.completeRegistration') }}
</button>
</div>
</div>
<div v-else-if="invalidCallback" class="card p-6 text-center">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oauth.invalidCallbackTitle') }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('auth.oauth.invalidCallbackHint') }}
</p>
<button class="btn btn-primary mt-6" type="button" @click="router.replace('/login')">
{{ t('auth.backToLogin') }}
</button>
</div>
<div v-else class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oauth.callbackTitle') }}
</h1>
@ -56,16 +109,43 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
import { useAppStore, useAuthStore } from '@/stores'
import { apiClient } from '@/api/client'
import {
exchangePendingOAuthCompletion,
persistOAuthTokenContext,
type OAuthTokenResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
const appStore = useAppStore()
const authStore = useAuthStore()
const isProcessing = ref(false)
const isSubmitting = ref(false)
const needsInvitation = ref(false)
const invitationCode = ref('')
const invitationError = ref('')
const pendingProvider = ref<'github' | 'google'>('github')
const redirectTo = ref('/dashboard')
const invalidCallback = ref(false)
type EmailOAuthPendingCompletion = Partial<OAuthTokenResponse> & {
error?: string
provider?: string
redirect?: string
}
const code = computed(() => (route.query.code as string) || '')
const state = computed(() => (route.query.state as string) || '')
@ -77,6 +157,137 @@ const fullUrl = computed(() => {
if (typeof window === 'undefined') return ''
return window.location.href
})
const providerName = computed(() =>
pendingProvider.value === 'google' ? 'Google' : 'GitHub'
)
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function readTokenResponse(params: URLSearchParams): OAuthTokenResponse | null {
const accessToken = params.get('access_token')?.trim() || ''
if (!accessToken) return null
const response: OAuthTokenResponse = { access_token: accessToken }
const refreshToken = params.get('refresh_token')?.trim() || ''
if (refreshToken) response.refresh_token = refreshToken
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
if (Number.isFinite(expiresIn) && expiresIn > 0) response.expires_in = expiresIn
const tokenType = params.get('token_type')?.trim() || ''
if (tokenType) response.token_type = tokenType
return response
}
function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
if (path.startsWith('//')) return '/dashboard'
if (path.includes('://')) return '/dashboard'
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
return path
}
async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {
persistOAuthTokenContext(tokenResponse)
await authStore.setToken(tokenResponse.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(sanitizeRedirectPath(redirect))
}
function hasOAuthTokenResponse(value: Partial<OAuthTokenResponse>): value is OAuthTokenResponse {
return typeof value.access_token === 'string' && value.access_token.trim() !== ''
}
async function resumePendingEmailOAuth() {
isProcessing.value = true
try {
const completion = await exchangePendingOAuthCompletion() as EmailOAuthPendingCompletion
const completionRedirect = completion.redirect || '/dashboard'
if (hasOAuthTokenResponse(completion)) {
await finalizeTokenResponse(completion, completionRedirect)
return
}
const provider = String(completion.provider || '').toLowerCase()
if (provider === 'github' || provider === 'google') {
pendingProvider.value = provider
}
redirectTo.value = sanitizeRedirectPath(completionRedirect)
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
return
}
appStore.showError(completion.error || t('auth.loginFailed'))
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
const message = err.response?.data?.message || err.message || t('auth.loginFailed')
appStore.showError(message)
invalidCallback.value = true
} finally {
if (!needsInvitation.value) {
isProcessing.value = false
}
}
}
async function handleSubmitInvitation() {
invitationError.value = ''
const code = invitationCode.value.trim()
if (!code) return
isSubmitting.value = true
try {
const { data } = await apiClient.post<OAuthTokenResponse>(
`/auth/oauth/${pendingProvider.value}/complete-registration`,
{
invitation_code: code,
...oauthAffiliatePayload(loadOAuthAffiliateCode())
}
)
await finalizeTokenResponse(data, redirectTo.value)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
const params = parseFragmentParams()
const tokenResponse = readTokenResponse(params)
const fragmentError = params.get('error') || ''
const fragmentErrorDescription =
params.get('error_description') || params.get('error_message') || ''
if (fragmentError) {
appStore.showError(fragmentErrorDescription || fragmentError)
return
}
if (!tokenResponse) {
if (route.path === '/auth/oauth/callback') {
await resumePendingEmailOAuth()
}
return
}
isProcessing.value = true
try {
await finalizeTokenResponse(tokenResponse, params.get('redirect') || '/dashboard')
} catch (error: unknown) {
const message = (error as { message?: string })?.message || t('auth.loginFailed')
appStore.showError(message)
isProcessing.value = false
}
})
watch(
error,

View File

@ -11,35 +11,6 @@
</p>
</div>
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<!-- Registration Disabled Message -->
<div
v-if="!registrationEnabled && settingsLoaded"
@ -256,7 +227,46 @@
: t('auth.createAccount')
}}
</button>
</form>
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
<EmailOAuthButtons
:disabled="isLoading"
:aff-code="formData.aff_code"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
:show-divider="false"
/>
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
/>
</div>
</div>
<!-- Footer -->
@ -282,6 +292,7 @@ import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
@ -330,6 +341,8 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
const wechatOAuthEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile
@ -380,6 +393,15 @@ const validationToastMessage = computed(() =>
''
)
const showOAuthLogin = computed(
() =>
linuxdoOAuthEnabled.value ||
wechatOAuthEnabled.value ||
oidcOAuthEnabled.value ||
githubOAuthEnabled.value ||
googleOAuthEnabled.value
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
@ -412,6 +434,8 @@ onMounted(async () => {
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
githubOAuthEnabled.value = settings.github_oauth_enabled
googleOAuthEnabled.value = settings.google_oauth_enabled
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)

View File

@ -2,16 +2,34 @@ import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
const { routeState, showErrorMock, copyToClipboardMock } = vi.hoisted(() => ({
const {
routeState,
routerReplaceMock,
showErrorMock,
showSuccessMock,
setTokenMock,
copyToClipboardMock,
exchangePendingOAuthCompletionMock,
apiPostMock,
} = vi.hoisted(() => ({
routeState: {
path: '/auth/callback',
query: {} as Record<string, unknown>,
},
routerReplaceMock: vi.fn(),
showErrorMock: vi.fn(),
showSuccessMock: vi.fn(),
setTokenMock: vi.fn(),
copyToClipboardMock: vi.fn(),
exchangePendingOAuthCompletionMock: vi.fn(),
apiPostMock: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({
replace: (...args: any[]) => routerReplaceMock(...args),
}),
}))
vi.mock('vue-i18n', () => ({
@ -21,11 +39,30 @@ vi.mock('vue-i18n', () => ({
}))
vi.mock('@/stores', () => ({
useAuthStore: () => ({
setToken: (...args: any[]) => setTokenMock(...args),
}),
useAppStore: () => ({
showError: (...args: any[]) => showErrorMock(...args),
showSuccess: (...args: any[]) => showSuccessMock(...args),
}),
}))
vi.mock('@/api/client', () => ({
apiClient: {
post: (...args: any[]) => apiPostMock(...args),
},
}))
vi.mock('@/api/auth', async () => {
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
return {
...actual,
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
persistOAuthTokenContext: vi.fn(),
}
})
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
@ -34,9 +71,17 @@ vi.mock('@/composables/useClipboard', () => ({
describe('OAuthCallbackView', () => {
beforeEach(() => {
routeState.path = '/auth/callback'
routeState.query = {}
window.location.hash = ''
routerReplaceMock.mockReset()
showErrorMock.mockReset()
showSuccessMock.mockReset()
setTokenMock.mockReset()
copyToClipboardMock.mockReset()
exchangePendingOAuthCompletionMock.mockReset()
apiPostMock.mockReset()
window.sessionStorage.clear()
})
it('renders localized callback copy actions', () => {
@ -65,4 +110,44 @@ describe('OAuthCallbackView', () => {
expect(wrapper.text()).not.toContain('oauth failed')
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
})
it('does not render manual copy fields for direct email oauth callback visits', async () => {
routeState.path = '/auth/oauth/callback'
exchangePendingOAuthCompletionMock.mockRejectedValue(new Error('pending session not found'))
const wrapper = mount(OAuthCallbackView)
await vi.dynamicImportSettled()
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackTitle')
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackHint')
expect(wrapper.find('input[readonly]').exists()).toBe(false)
})
it('submits stored affiliate code when completing invited email oauth registration', async () => {
routeState.path = '/auth/oauth/callback'
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'invitation_required',
provider: 'google',
redirect: '/dashboard',
})
apiPostMock.mockResolvedValue({
data: {
access_token: 'token-1',
},
})
window.sessionStorage.setItem('oauth_aff_code', 'AFF456')
const wrapper = mount(OAuthCallbackView)
await vi.dynamicImportSettled()
const input = wrapper.find('input[type="text"]')
await input.setValue('INVITE456')
await wrapper.findAll('button').at(0)?.trigger('click')
expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/google/complete-registration', {
invitation_code: 'INVITE456',
aff_code: 'AFF456',
})
expect(setTokenMock).toHaveBeenCalledWith('token-1')
})
})