fix: restore wechat oauth legacy callback compatibility

This commit is contained in:
IanShaw027
2026-04-21 13:36:19 +08:00
parent 0934f737d5
commit 12f1e19d68
2 changed files with 208 additions and 20 deletions

View File

@@ -300,6 +300,7 @@ import {
persistOAuthTokenContext, persistOAuthTokenContext,
resolveWeChatOAuthStartStrict, resolveWeChatOAuthStartStrict,
type OAuthAdoptionDecision, type OAuthAdoptionDecision,
type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
@@ -328,6 +329,7 @@ const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none
const pendingAccountEmail = ref('') const pendingAccountEmail = ref('')
const bindLoginEmail = ref('') const bindLoginEmail = ref('')
const bindLoginPassword = ref('') const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('') const accountActionError = ref('')
const canReturnToCreateAccount = ref(false) const canReturnToCreateAccount = ref(false)
const needsTotpChallenge = ref(false) const needsTotpChallenge = ref(false)
@@ -354,12 +356,49 @@ type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
user_email_masked?: string user_email_masked?: string
} }
function persistPendingAuthSession(redirect?: string) {
authStore.setPendingAuthSession({
token: '',
token_field: 'pending_oauth_token',
provider: 'wechat',
redirect: sanitizeRedirectPath(redirect || redirectTo.value)
})
}
function clearPendingAuthSession() {
authStore.clearPendingAuthSession()
}
function parseFragmentParams(): URLSearchParams { function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : '' const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash) return new URLSearchParams(hash)
} }
function readLegacyFragmentLogin(params: URLSearchParams): OAuthTokenResponse | null {
const accessToken = params.get('access_token')?.trim() || ''
if (!accessToken) {
return null
}
const completion: OAuthTokenResponse = {
access_token: accessToken
}
const refreshToken = params.get('refresh_token')?.trim() || ''
if (refreshToken) {
completion.refresh_token = refreshToken
}
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
if (Number.isFinite(expiresIn) && expiresIn > 0) {
completion.expires_in = expiresIn
}
const tokenType = params.get('token_type')?.trim() || ''
if (tokenType) {
completion.token_type = tokenType
}
return completion
}
function sanitizeRedirectPath(path: string | null | undefined): string { function sanitizeRedirectPath(path: string | null | undefined): string {
if (!path) return '/dashboard' if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard' if (!path.startsWith('/')) return '/dashboard'
@@ -672,6 +711,7 @@ function isCreateAccountRecoveryError(error: unknown): boolean {
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
@@ -689,16 +729,19 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
async function finalizePendingAccountResponse(completion: PendingWeChatCompletion) { async function finalizePendingAccountResponse(completion: PendingWeChatCompletion) {
applyAdoptionSuggestionState(completion) applyAdoptionSuggestionState(completion)
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
if (completion.error === 'invitation_required') { if (completion.error === 'invitation_required') {
pendingAccountAction.value = 'none' pendingAccountAction.value = 'none'
needsInvitation.value = true needsInvitation.value = true
needsAdoptionConfirmation.value = false needsAdoptionConfirmation.value = false
isProcessing.value = false isProcessing.value = false
persistPendingAuthSession(redirect)
return return
} }
if (applyTotpChallenge(completion)) { if (applyTotpChallenge(completion)) {
persistPendingAuthSession(redirect)
return return
} }
@@ -707,10 +750,10 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
needsInvitation.value = false needsInvitation.value = false
needsAdoptionConfirmation.value = false needsAdoptionConfirmation.value = false
isProcessing.value = false isProcessing.value = false
persistPendingAuthSession(redirect)
return return
} }
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
await finalizeCompletion(completion, redirect) await finalizeCompletion(completion, redirect)
} }
@@ -720,10 +763,18 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const tokenData = await completeWeChatOAuthRegistration( const tokenData = legacyPendingOAuthToken.value
invitationCode.value.trim(), ? (
currentAdoptionDecision() await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
) pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
})
).data
: await completeWeChatOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData) persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token) await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
@@ -864,48 +915,74 @@ onMounted(async () => {
} }
const params = parseFragmentParams() const params = parseFragmentParams()
const legacyLogin = readLegacyFragmentLogin(params)
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
const error = params.get('error') const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || '' const errorDesc = params.get('error_description') || params.get('error_message') || ''
const redirect = sanitizeRedirectPath(
if (error) { params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
errorMessage.value = errorDesc || error )
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
try { try {
const completion = await exchangePendingOAuthCompletion() as PendingWeChatCompletion if (legacyLogin) {
const redirect = sanitizeRedirectPath( persistOAuthTokenContext(legacyLogin)
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard' await authStore.setToken(legacyLogin.access_token)
) appStore.showSuccess(t('auth.loginSuccess'))
applyAdoptionSuggestionState(completion) await router.replace(redirect)
redirectTo.value = redirect return
}
if (completion.error === 'invitation_required') { if (error === 'invitation_required' && legacyPendingToken) {
legacyPendingOAuthToken.value = legacyPendingToken
redirectTo.value = redirect
needsInvitation.value = true needsInvitation.value = true
isProcessing.value = false isProcessing.value = false
return return
} }
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
const completion = await exchangePendingOAuthCompletion() as PendingWeChatCompletion
const completionRedirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = completionRedirect
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return
}
if (applyTotpChallenge(completion)) { if (applyTotpChallenge(completion)) {
persistPendingAuthSession(completionRedirect)
return return
} }
applyPendingAccountAction(completion) applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') { if (pendingAccountAction.value !== 'none') {
isProcessing.value = false isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return return
} }
if (adoptionRequired.value && hasSuggestedProfile(completion)) { if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true needsAdoptionConfirmation.value = true
isProcessing.value = false isProcessing.value = false
persistPendingAuthSession(completionRedirect)
return return
} }
await finalizeCompletion(completion, redirect) await finalizeCompletion(completion, completionRedirect)
} catch (e: unknown) { } catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed')) errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
isProcessing.value = false isProcessing.value = false

View File

@@ -14,6 +14,8 @@ const {
getAuthTokenMock, getAuthTokenMock,
replaceMock, replaceMock,
setTokenMock, setTokenMock,
setPendingAuthSessionMock,
clearPendingAuthSessionMock,
showSuccessMock, showSuccessMock,
showErrorMock, showErrorMock,
fetchPublicSettingsMock, fetchPublicSettingsMock,
@@ -32,6 +34,8 @@ const {
getAuthTokenMock: vi.fn(), getAuthTokenMock: vi.fn(),
replaceMock: vi.fn(), replaceMock: vi.fn(),
setTokenMock: vi.fn(), setTokenMock: vi.fn(),
setPendingAuthSessionMock: vi.fn(),
clearPendingAuthSessionMock: vi.fn(),
showSuccessMock: vi.fn(), showSuccessMock: vi.fn(),
showErrorMock: vi.fn(), showErrorMock: vi.fn(),
fetchPublicSettingsMock: vi.fn(), fetchPublicSettingsMock: vi.fn(),
@@ -111,6 +115,8 @@ vi.mock('vue-i18n', () => ({
vi.mock('@/stores', () => ({ vi.mock('@/stores', () => ({
useAuthStore: () => ({ useAuthStore: () => ({
setToken: setTokenMock, setToken: setTokenMock,
setPendingAuthSession: setPendingAuthSessionMock,
clearPendingAuthSession: clearPendingAuthSessionMock,
}), }),
useAppStore: () => ({ useAppStore: () => ({
...appStoreState, ...appStoreState,
@@ -152,6 +158,8 @@ describe('WechatCallbackView', () => {
getPublicSettingsMock.mockReset() getPublicSettingsMock.mockReset()
replaceMock.mockReset() replaceMock.mockReset()
setTokenMock.mockReset() setTokenMock.mockReset()
setPendingAuthSessionMock.mockReset()
clearPendingAuthSessionMock.mockReset()
showSuccessMock.mockReset() showSuccessMock.mockReset()
showErrorMock.mockReset() showErrorMock.mockReset()
prepareOAuthBindAccessTokenCookieMock.mockReset() prepareOAuthBindAccessTokenCookieMock.mockReset()
@@ -269,6 +277,81 @@ describe('WechatCallbackView', () => {
expect(locationState.current.href).toContain('mode=open') expect(locationState.current.href).toContain('mode=open')
}) })
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
locationState.current.hash =
'#access_token=legacy-access-token&refresh_token=legacy-refresh-token&expires_in=3600&token_type=Bearer&redirect=%2Flegacy-dashboard'
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
setTokenMock.mockResolvedValue({})
mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled()
expect(setTokenMock).toHaveBeenCalledWith('legacy-access-token')
expect(localStorage.getItem('refresh_token')).toBe('legacy-refresh-token')
expect(localStorage.getItem('token_expires_at')).not.toBeNull()
expect(showSuccessMock).toHaveBeenCalledWith('Login success')
expect(replaceMock).toHaveBeenCalledWith('/legacy-dashboard')
})
it('accepts the legacy pending oauth invitation fragment without pending-session exchange', async () => {
locationState.current.hash =
'#error=invitation_required&pending_oauth_token=legacy-pending-token&redirect=%2Flegacy-invite'
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
apiClientPostMock.mockResolvedValue({
data: {
access_token: 'legacy-access-token',
refresh_token: 'legacy-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
},
})
setTokenMock.mockResolvedValue({})
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: 'legacy-pending-token',
invitation_code: 'invite-code',
adopt_display_name: true,
adopt_avatar: true,
})
expect(setTokenMock).toHaveBeenCalledWith('legacy-access-token')
expect(replaceMock).toHaveBeenCalledWith('/legacy-invite')
})
it('does not send adoption decisions during the initial exchange', async () => { it('does not send adoption decisions during the initial exchange', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({ exchangePendingOAuthCompletionMock.mockResolvedValue({
access_token: 'access-token', access_token: 'access-token',
@@ -382,6 +465,7 @@ describe('WechatCallbackView', () => {
adoptAvatar: true, adoptAvatar: true,
}) })
expect(setTokenMock).not.toHaveBeenCalled() expect(setTokenMock).not.toHaveBeenCalled()
expect(clearPendingAuthSessionMock).toHaveBeenCalledTimes(1)
expect(showSuccessMock).toHaveBeenCalledWith('profile.authBindings.bindSuccess') expect(showSuccessMock).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
expect(replaceMock).toHaveBeenCalledWith('/profile/connections') expect(replaceMock).toHaveBeenCalledWith('/profile/connections')
}) })
@@ -548,6 +632,33 @@ describe('WechatCallbackView', () => {
expect(replaceMock).toHaveBeenCalledWith('/welcome') expect(replaceMock).toHaveBeenCalledWith('/welcome')
}) })
it('persists a pending auth session when the oauth flow still needs account creation', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required',
redirect: '/welcome',
})
mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(setPendingAuthSessionMock).toHaveBeenCalledWith({
token: '',
token_field: 'pending_oauth_token',
provider: 'wechat',
redirect: '/welcome',
})
})
it('switches to bind-login when create-account returns EMAIL_EXISTS', async () => { it('switches to bind-login when create-account returns EMAIL_EXISTS', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({ exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'email_required', error: 'email_required',