fix: restore wechat oauth legacy callback compatibility
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user