fix: restore legacy oauth callback fragment compatibility

This commit is contained in:
IanShaw027
2026-04-21 11:00:18 +08:00
parent f398650166
commit 33b208ab6f
4 changed files with 282 additions and 36 deletions

View File

@@ -249,6 +249,7 @@ import {
login2FA,
persistOAuthTokenContext,
type OAuthAdoptionDecision,
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
@@ -278,6 +279,7 @@ const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
@@ -315,6 +317,30 @@ function parseFragmentParams(): URLSearchParams {
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 {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
@@ -521,10 +547,18 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const tokenData = await completeLinuxDoOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
const tokenData = legacyPendingOAuthToken.value
? (
await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
})
).data
: await completeLinuxDoOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
@@ -621,51 +655,72 @@ async function handleSubmitTotpChallenge() {
onMounted(async () => {
const params = parseFragmentParams()
const legacyLogin = readLegacyFragmentLogin(params)
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
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) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
const completion = await exchangePendingOAuthCompletion()
const redirect = sanitizeRedirectPath(
const completionRedirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = redirect
redirectTo.value = completionRedirect
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
if (applyTotpChallenge(completion as LinuxDoPendingActionResponse)) {
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
applyPendingAccountAction(completion as LinuxDoPendingActionResponse)
if (pendingAccountAction.value !== 'none') {
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
await finalizeCompletion(completion, redirect)
await finalizeCompletion(completion, completionRedirect)
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))

View File

@@ -259,6 +259,7 @@ import {
login2FA,
persistOAuthTokenContext,
type OAuthAdoptionDecision,
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
@@ -287,6 +288,7 @@ const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
@@ -331,6 +333,30 @@ function parseFragmentParams(): URLSearchParams {
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 {
if (!path) return '/dashboard'
if (!path.startsWith('/')) return '/dashboard'
@@ -565,10 +591,18 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const tokenData = await completeOIDCOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
const tokenData = legacyPendingOAuthToken.value
? (
await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
})
).data
: await completeOIDCOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
@@ -667,51 +701,72 @@ onMounted(async () => {
void loadProviderName()
const params = parseFragmentParams()
const legacyLogin = readLegacyFragmentLogin(params)
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
const error = params.get('error')
const errorDesc = params.get('error_description') || params.get('error_message') || ''
if (error) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
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) {
errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
const completion = await exchangePendingOAuthCompletion() as PendingOidcCompletion
const redirect = sanitizeRedirectPath(
const completionRedirect = sanitizeRedirectPath(
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
)
applyAdoptionSuggestionState(completion)
redirectTo.value = redirect
redirectTo.value = completionRedirect
if (completion.error === 'invitation_required') {
needsInvitation.value = true
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
if (applyTotpChallenge(completion)) {
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') {
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
needsAdoptionConfirmation.value = true
isProcessing.value = false
persistPendingAuthSession(redirect)
persistPendingAuthSession(completionRedirect)
return
}
await finalizeCompletion(completion, redirect)
await finalizeCompletion(completion, completionRedirect)
} catch (e: unknown) {
clearPendingAuthSession()
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))

View File

@@ -86,6 +86,74 @@ describe('LinuxDoCallbackView', () => {
turnstile_enabled: false,
turnstile_site_key: ''
})
window.location.hash = ''
localStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
window.location.hash =
'#access_token=legacy-access-token&refresh_token=legacy-refresh-token&expires_in=3600&token_type=Bearer&redirect=%2Flegacy-dashboard'
setToken.mockResolvedValue({})
mount(LinuxDoCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(exchangePendingOAuthCompletion).not.toHaveBeenCalled()
expect(setToken).toHaveBeenCalledWith('legacy-access-token')
expect(localStorage.getItem('refresh_token')).toBe('legacy-refresh-token')
expect(localStorage.getItem('token_expires_at')).not.toBeNull()
expect(showSuccess).toHaveBeenCalledWith('auth.loginSuccess')
expect(replace).toHaveBeenCalledWith('/legacy-dashboard')
})
it('accepts the legacy pending oauth invitation fragment without pending-session exchange', async () => {
window.location.hash = '#error=invitation_required&pending_oauth_token=legacy-pending-token&redirect=%2Flegacy-invite'
apiClientPost.mockResolvedValue({
data: {
access_token: 'legacy-access-token',
refresh_token: 'legacy-refresh-token',
expires_in: 3600,
token_type: 'Bearer'
}
})
setToken.mockResolvedValue({})
const wrapper = mount(LinuxDoCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(exchangePendingOAuthCompletion).not.toHaveBeenCalled()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
adopt_display_name: true,
adopt_avatar: true,
pending_oauth_token: 'legacy-pending-token',
invitation_code: 'invite-code'
})
expect(setToken).toHaveBeenCalledWith('legacy-access-token')
expect(replace).toHaveBeenCalledWith('/legacy-invite')
})
it('does not send adoption decisions during the initial exchange', async () => {

View File

@@ -92,6 +92,74 @@ describe('OidcCallbackView', () => {
turnstile_enabled: false,
turnstile_site_key: ''
})
window.location.hash = ''
localStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
window.location.hash =
'#access_token=legacy-access-token&refresh_token=legacy-refresh-token&expires_in=3600&token_type=Bearer&redirect=%2Flegacy-dashboard'
setToken.mockResolvedValue({})
mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(exchangePendingOAuthCompletion).not.toHaveBeenCalled()
expect(setToken).toHaveBeenCalledWith('legacy-access-token')
expect(localStorage.getItem('refresh_token')).toBe('legacy-refresh-token')
expect(localStorage.getItem('token_expires_at')).not.toBeNull()
expect(showSuccess).toHaveBeenCalledWith('auth.loginSuccess')
expect(replace).toHaveBeenCalledWith('/legacy-dashboard')
})
it('accepts the legacy pending oauth invitation fragment without pending-session exchange', async () => {
window.location.hash = '#error=invitation_required&pending_oauth_token=legacy-pending-token&redirect=%2Flegacy-invite'
apiClientPost.mockResolvedValue({
data: {
access_token: 'legacy-access-token',
refresh_token: 'legacy-refresh-token',
expires_in: 3600,
token_type: 'Bearer'
}
})
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(exchangePendingOAuthCompletion).not.toHaveBeenCalled()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
adopt_display_name: true,
adopt_avatar: true,
pending_oauth_token: 'legacy-pending-token',
invitation_code: 'invite-code'
})
expect(setToken).toHaveBeenCalledWith('legacy-access-token')
expect(replace).toHaveBeenCalledWith('/legacy-invite')
})
it('does not send adoption decisions during the initial exchange', async () => {