feat: fix OAuth email completion flow

This commit is contained in:
haruka
2026-06-30 01:11:10 +08:00
parent d3acd8e96e
commit 260fda19b3
7 changed files with 177 additions and 9 deletions
@@ -383,6 +383,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
upstreamClaims,
compatEmail,
compatEmailUser,
emailVerificationRequired,
forceEmailOnSignup,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
@@ -433,6 +434,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
emailVerificationRequired bool,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
@@ -467,6 +469,17 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
if forceEmailOnSignup && compatEmailUser == nil {
completionResponse["choice_reason"] = "force_email_on_signup"
}
if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
completionResponse["step"] = "create_account_required"
completionResponse["email_binding_required"] = true
completionResponse["force_email_on_signup"] = true
if emailVerificationRequired {
completionResponse["choice_reason"] = "email_verification_required"
}
delete(completionResponse, "email")
delete(completionResponse, "resolved_email")
resolvedChoiceEmail = ""
}
var targetUserID *int64
if compatEmailUser != nil && compatEmailUser.ID > 0 {
@@ -631,6 +631,108 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
require.Equal(t, "third_party_signup", completion["choice_reason"])
}
func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
case "/userinfo":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", config.LinuxDoConnectConfig{
Enabled: true,
ClientID: "linuxdo-client",
ClientSecret: "linuxdo-secret",
AuthorizeURL: upstream.URL + "/authorize",
TokenURL: upstream.URL + "/token",
UserInfoURL: upstream.URL + "/userinfo",
Scopes: "read",
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
FrontendRedirectURL: "/auth/linuxdo/callback",
TokenAuthMethod: "client_secret_post",
UsePKCE: true,
})
t.Cleanup(func() { _ = client.Close() })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-email&state=state-email", nil)
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email"))
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email"))
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email"))
c.Request = req
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
require.NotNil(t, sessionCookie)
ctx := context.Background()
session, err := client.PendingAuthSession.Query().
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
Only(ctx)
require.NoError(t, err)
require.Equal(t, oauthIntentLogin, session.Intent)
require.Nil(t, session.TargetUserID)
require.Empty(t, session.ResolvedEmail)
require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"])
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
require.True(t, ok)
require.Equal(t, "create_account_required", completion["step"])
require.Equal(t, true, completion["email_binding_required"])
require.Equal(t, true, completion["force_email_on_signup"])
require.Equal(t, "email_verification_required", completion["choice_reason"])
require.NotContains(t, completion, "email")
require.NotContains(t, completion, "resolved_email")
createRecorder := httptest.NewRecorder()
createCtx, _ := gin.CreateTestContext(createRecorder)
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body)
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(sessionCookie)
createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")})
createCtx.Request = createReq
handler.CreatePendingOAuthAccount(createCtx)
require.Equal(t, http.StatusOK, createRecorder.Code)
responseData := decodeJSONBody(t, createRecorder)
require.NotEmpty(t, responseData["access_token"])
userEntity, err := client.User.Query().
Where(dbuser.EmailEQ("fresh@example.com")).
Only(ctx)
require.NoError(t, err)
require.Equal(t, "linuxdo", userEntity.SignupSource)
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("email-verify-123"),
).
Only(ctx)
require.NoError(t, err)
require.Equal(t, userEntity.ID, identity.UserID)
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, storedSession.ConsumedAt)
}
func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -1103,6 +1205,24 @@ func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg c
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func newLinuxDoOAuthHandlerAndClientWithEmailVerification(
t *testing.T,
invitationEnabled bool,
email string,
code string,
oauthCfg config.LinuxDoConnectConfig,
) (*AuthHandler, *dbent.Client) {
t.Helper()
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code)
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
return handler, client
}
func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) {
handler.settingSvc = nil
handler.cfg = &config.Config{
JWT: config.JWTConfig{
@@ -1113,5 +1233,4 @@ func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauth
},
LinuxDo: oauthCfg,
}
return handler, client
}
@@ -43,6 +43,8 @@ func backendModeAllowsAuthPath(path string) bool {
"/auth/oauth/github/callback",
"/auth/oauth/google/callback",
"/auth/oauth/dingtalk/callback",
"/auth/oauth/github/complete-registration",
"/auth/oauth/google/complete-registration",
"/auth/oauth/linuxdo/complete-registration",
"/auth/oauth/wechat/complete-registration",
"/auth/oauth/oidc/complete-registration",
@@ -258,6 +258,12 @@ func TestBackendModeAuthGuard(t *testing.T) {
path: "/api/v1/auth/oauth/github/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_github_complete_registration",
enabled: "true",
path: "/api/v1/auth/oauth/github/complete-registration",
wantStatus: http.StatusOK,
},
{
name: "enabled_blocks_google_oauth_start",
enabled: "true",
@@ -270,6 +276,12 @@ func TestBackendModeAuthGuard(t *testing.T) {
path: "/api/v1/auth/oauth/google/callback",
wantStatus: http.StatusOK,
},
{
name: "enabled_allows_google_complete_registration",
enabled: "true",
path: "/api/v1/auth/oauth/google/complete-registration",
wantStatus: http.StatusOK,
},
{
name: "enabled_blocks_dingtalk_oauth_start",
enabled: "true",
+13
View File
@@ -20,11 +20,24 @@ describe('API Client', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
// --- 请求拦截器 ---
describe('请求拦截器', () => {
it('规范化相对 API base,避免在回调页拼出相对 v1 路径', async () => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE_URL', 'api/v1')
const mod = await import('@/api/client')
expect(mod.apiClient.defaults.baseURL).toBe('/api/v1')
expect(mod.buildApiUrl('/auth/oauth/github/callback?code=abc')).toBe(
'/api/v1/auth/oauth/github/callback?code=abc'
)
})
it('自动附加 Authorization 头', async () => {
localStorage.setItem('auth_token', 'my-jwt-token')
+15 -5
View File
@@ -1,20 +1,30 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const DEFAULT_API_BASE_URL = '/api/v1'
const API_BASE_URL = normalizeAPIBaseURL(import.meta.env.VITE_API_BASE_URL)
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
function normalizeAPIBaseURL(value: unknown): string {
const raw = String(value || DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL
const withoutTrailingSlash = raw.replace(/\/+$/, '')
if (/^[a-z][a-z\d+.-]*:\/\//i.test(withoutTrailingSlash) || withoutTrailingSlash.startsWith('//')) {
return withoutTrailingSlash
}
return normalizePath(withoutTrailingSlash)
}
export function getAPIBaseURL(): string {
return String(API_BASE_URL || '/api/v1')
return API_BASE_URL
}
export function buildApiUrl(path: string): string {
const base = getAPIBaseURL().replace(/\/+$/, '')
let suffix = normalizePath(path)
if (suffix === '/api/v1') {
if (suffix === DEFAULT_API_BASE_URL) {
suffix = ''
} else if (suffix.startsWith('/api/v1/')) {
suffix = suffix.slice('/api/v1'.length)
} else if (suffix.startsWith(`${DEFAULT_API_BASE_URL}/`)) {
suffix = suffix.slice(DEFAULT_API_BASE_URL.length)
}
return `${base}${suffix}`
}
@@ -152,6 +152,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore, useAuthStore } from '@/stores'
import { apiClient } from '@/api/client'
import { buildApiUrl } from '@/api/url'
import {
exchangePendingOAuthCompletion,
persistOAuthTokenContext,
@@ -256,8 +257,6 @@ function readPendingEmailOAuthProvider(): 'github' | 'google' | null {
function redirectProviderCallbackToBackend(provider: 'github' | 'google'): void {
if (typeof window === 'undefined') return
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const params = new URLSearchParams()
for (const [key, value] of Object.entries(route.query)) {
if (Array.isArray(value)) {
@@ -269,7 +268,7 @@ function redirectProviderCallbackToBackend(provider: 'github' | 'google'): void
}
}
const suffix = params.toString() ? `?${params.toString()}` : ''
window.location.href = `${normalized}/auth/oauth/${provider}/callback${suffix}`
window.location.href = buildApiUrl(`/auth/oauth/${provider}/callback${suffix}`)
}
async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {