diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 98b20154..6a2feb87 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -196,6 +196,9 @@ export interface OAuthTokenResponse { export interface PendingOAuthBindLoginResponse extends Partial { redirect?: string error?: string + requires_2fa?: boolean + temp_token?: string + user_email_masked?: string adoption_required?: boolean suggested_display_name?: string suggested_avatar_url?: string diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index 00b73868..ce7f92ef 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -12,7 +12,13 @@
+ +
@@ -226,6 +266,7 @@ import { exchangePendingOAuthCompletion, getOAuthCompletionKind, isOAuthLoginCompletion, + login2FA, persistOAuthTokenContext, type OAuthAdoptionDecision, type PendingOAuthExchangeResponse @@ -260,6 +301,11 @@ const bindLoginPassword = ref('') const accountActionError = ref('') const canReturnToCreateAccount = ref(false) const bindSuccessMessage = t('profile.authBindings.bindSuccess') +const needsTotpChallenge = ref(false) +const totpTempToken = ref('') +const totpCode = ref('') +const totpError = ref('') +const totpUserEmailMasked = ref('') const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account') const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login') @@ -346,6 +392,11 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) { const action = resolvePendingAccountAction(completion) pendingAccountAction.value = action accountActionError.value = '' + needsTotpChallenge.value = false + totpTempToken.value = '' + totpCode.value = '' + totpError.value = '' + totpUserEmailMasked.value = '' const email = extractPendingAccountEmail(completion) if (action === 'create_account') { @@ -364,6 +415,23 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) { canReturnToCreateAccount.value = false } +function applyTotpChallenge(completion: LinuxDoPendingActionResponse): boolean { + if (completion.requires_2fa !== true || !completion.temp_token) { + return false + } + + pendingAccountAction.value = 'none' + needsInvitation.value = false + needsAdoptionConfirmation.value = false + needsTotpChallenge.value = true + totpTempToken.value = completion.temp_token + totpCode.value = '' + totpError.value = '' + totpUserEmailMasked.value = completion.user_email_masked || '' + isProcessing.value = false + return true +} + function switchToBindLoginMode() { pendingAccountAction.value = 'bind_login' bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim() @@ -412,6 +480,10 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe return } + if (applyTotpChallenge(completion)) { + return + } + applyPendingAccountAction(completion) if (pendingAccountAction.value !== 'none') { needsInvitation.value = false @@ -501,6 +573,27 @@ async function handleBindLogin() { } } +async function handleSubmitTotpChallenge() { + totpError.value = '' + const code = totpCode.value.trim() + if (!totpTempToken.value || code.length !== 6) return + + isSubmitting.value = true + try { + const completion = await login2FA({ + temp_token: totpTempToken.value, + totp_code: code + }) + await authStore.setToken(completion.access_token) + appStore.showSuccess(t('auth.loginSuccess')) + await router.replace(redirectTo.value) + } catch (e: unknown) { + totpError.value = getRequestErrorMessage(e, t('auth.loginFailed')) + } finally { + isSubmitting.value = false + } +} + onMounted(async () => { const params = parseFragmentParams() const error = params.get('error') @@ -527,6 +620,10 @@ onMounted(async () => { return } + if (applyTotpChallenge(completion as LinuxDoPendingActionResponse)) { + return + } + applyPendingAccountAction(completion as LinuxDoPendingActionResponse) if (pendingAccountAction.value !== 'none') { isProcessing.value = false diff --git a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts index 30422791..8b2482fa 100644 --- a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts @@ -9,6 +9,7 @@ const showError = vi.fn() const setToken = vi.fn() const exchangePendingOAuthCompletion = vi.fn() const completeLinuxDoOAuthRegistration = vi.fn() +const login2FA = vi.fn() const apiClientPost = vi.fn() vi.mock('vue-router', () => ({ @@ -51,7 +52,8 @@ vi.mock('@/api/auth', async () => { return { ...actual, exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args), - completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args) + completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args), + login2FA: (...args: any[]) => login2FA(...args) } }) @@ -63,6 +65,7 @@ describe('LinuxDoCallbackView', () => { setToken.mockReset() exchangePendingOAuthCompletion.mockReset() completeLinuxDoOAuthRegistration.mockReset() + login2FA.mockReset() apiClientPost.mockReset() }) @@ -344,4 +347,57 @@ describe('LinuxDoCallbackView', () => { expect(setToken).toHaveBeenCalledWith('bind-access-token') expect(replace).toHaveBeenCalledWith('/profile/security') }) + + it('handles bind-login 2FA challenge before redirecting', async () => { + exchangePendingOAuthCompletion.mockResolvedValue({ + error: 'bind_login_required', + redirect: '/profile', + email: 'existing@example.com', + adoption_required: true, + suggested_display_name: 'LinuxDo Nick', + suggested_avatar_url: 'https://cdn.example/linuxdo.png' + }) + apiClientPost.mockResolvedValue({ + data: { + requires_2fa: true, + temp_token: 'temp-123', + user_email_masked: 'o***g@example.com' + } + }) + login2FA.mockResolvedValue({ + access_token: '2fa-access-token' + }) + setToken.mockResolvedValue({}) + + const wrapper = mount(LinuxDoCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false + } + } + }) + + await flushPromises() + + await wrapper.get('[data-testid="linuxdo-bind-login-password"]').setValue('secret-password') + await wrapper.get('[data-testid="linuxdo-bind-login-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('o***g@example.com') + expect(login2FA).not.toHaveBeenCalled() + + await wrapper.get('[data-testid="linuxdo-bind-login-totp"]').setValue('123456') + await wrapper.get('[data-testid="linuxdo-bind-login-totp-submit"]').trigger('click') + await flushPromises() + + expect(login2FA).toHaveBeenCalledWith({ + temp_token: 'temp-123', + totp_code: '123456' + }) + expect(setToken).toHaveBeenCalledWith('2fa-access-token') + expect(replace).toHaveBeenCalledWith('/profile') + }) })