diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index 735c6582..a775075d 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -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('/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')) diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue index 019cab54..e15e752f 100644 --- a/frontend/src/views/auth/OidcCallbackView.vue +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -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('/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')) diff --git a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts index f612681a..0daf5d9a 100644 --- a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts @@ -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: '
' }, + Icon: true, + RouterLink: { template: '' }, + 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: '
' }, + Icon: true, + RouterLink: { template: '' }, + 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 () => { diff --git a/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts b/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts index 0edcb931..18128f17 100644 --- a/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts @@ -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: '
' }, + Icon: true, + RouterLink: { template: '' }, + 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: '
' }, + Icon: true, + RouterLink: { template: '' }, + 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 () => {