Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
12f1e19d
"frontend/src/api/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "9742796ee727e678fabf1e4028640a764cbac895"
Commit
12f1e19d
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix: restore wechat oauth legacy callback compatibility
parent
0934f737
Changes
2
Show whitespace changes
Inline
Side-by-side
frontend/src/views/auth/WechatCallbackView.vue
View file @
12f1e19d
...
@@ -300,6 +300,7 @@ import {
...
@@ -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
...
@@ -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 & {
...
@@ -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 {
...
@@ -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
...
@@ -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
...
@@ -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,7 +763,15 @@ async function handleSubmitInvitation() {
...
@@ -720,7 +763,15 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
isSubmitting
.
value
=
true
try
{
try
{
const
tokenData
=
await
completeWeChatOAuthRegistration
(
const
tokenData
=
legacyPendingOAuthToken
.
value
?
(
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
(),
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
currentAdoptionDecision
()
)
)
...
@@ -864,8 +915,30 @@ onMounted(async () => {
...
@@ -864,8 +915,30 @@ 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
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
try
{
if
(
legacyLogin
)
{
persistOAuthTokenContext
(
legacyLogin
)
await
authStore
.
setToken
(
legacyLogin
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
return
}
if
(
error
===
'
invitation_required
'
&&
legacyPendingToken
)
{
legacyPendingOAuthToken
.
value
=
legacyPendingToken
redirectTo
.
value
=
redirect
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
return
}
if
(
error
)
{
if
(
error
)
{
errorMessage
.
value
=
errorDesc
||
error
errorMessage
.
value
=
errorDesc
||
error
...
@@ -874,38 +947,42 @@ onMounted(async () => {
...
@@ -874,38 +947,42 @@ onMounted(async () => {
return
return
}
}
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
as
PendingWeChatCompletion
const
completion
=
await
exchangePendingOAuthCompletion
()
as
PendingWeChatCompletion
const
r
edirect
=
sanitizeRedirectPath
(
const
completionR
edirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
)
applyAdoptionSuggestionState
(
completion
)
applyAdoptionSuggestionState
(
completion
)
redirectTo
.
value
=
r
edirect
redirectTo
.
value
=
completionR
edirect
if
(
completion
.
error
===
'
invitation_required
'
)
{
if
(
completion
.
error
===
'
invitation_required
'
)
{
needsInvitation
.
value
=
true
needsInvitation
.
value
=
true
isProcessing
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
completionRedirect
)
return
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
,
r
edirect
)
await
finalizeCompletion
(
completion
,
completionR
edirect
)
}
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
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
12f1e19d
...
@@ -14,6 +14,8 @@ const {
...
@@ -14,6 +14,8 @@ const {
getAuthTokenMock
,
getAuthTokenMock
,
replaceMock
,
replaceMock
,
setTokenMock
,
setTokenMock
,
setPendingAuthSessionMock
,
clearPendingAuthSessionMock
,
showSuccessMock
,
showSuccessMock
,
showErrorMock
,
showErrorMock
,
fetchPublicSettingsMock
,
fetchPublicSettingsMock
,
...
@@ -32,6 +34,8 @@ const {
...
@@ -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', () => ({
...
@@ -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', () => {
...
@@ -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', () => {
...
@@ -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', () => {
...
@@ -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', () => {
...
@@ -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
'
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment