From b642b2a4536a3846e627a78dce2fdd1be425e6a1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 9 Apr 2026 13:44:27 +0200 Subject: [PATCH] feat(auth): prompt for TOTP code in the OIDC callback flow When the backend reports that 2FA is required (412/1017), the OIDC callback view now shows a TOTP input and restarts the OIDC dance with the typed passcode stashed in localStorage so it can be submitted alongside a fresh authorization code. Refs GHSA-8jvc-mcx6-r4cg --- frontend/src/i18n/lang/en.json | 2 + frontend/src/views/user/OpenIdAuth.vue | 84 +++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 551349ef7..4f3ac11b2 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -81,6 +81,8 @@ "authenticating": "Authenticating…", "openIdStateError": "State does not match, refusing to continue!", "openIdGeneralError": "An error occurred while authenticating against the third party.", + "openIdTotpRequired": "Your account requires two-factor authentication. Enter your TOTP code and sign in again.", + "openIdTotpSubmit": "Continue", "oauthMissingParams": "Missing required OAuth parameters: {params}", "oauthRedirectedToApp": "You have been redirected to the app. You can close this tab now.", "desktopTryDemo": "Try the Demo", diff --git a/frontend/src/views/user/OpenIdAuth.vue b/frontend/src/views/user/OpenIdAuth.vue index e581aaee2..b163ba4e5 100644 --- a/frontend/src/views/user/OpenIdAuth.vue +++ b/frontend/src/views/user/OpenIdAuth.vue @@ -13,9 +13,38 @@ > {{ errorMessageFromQuery }} - + {{ $t('user.auth.authenticating') }} + +
+ + {{ $t('user.auth.openIdTotpRequired') }} + + + + {{ $t('user.auth.openIdTotpSubmit') }} + + @@ -27,9 +56,13 @@ import {useI18n} from 'vue-i18n' import {getErrorText} from '@/message' import Message from '@/components/misc/Message.vue' +import FormField from '@/components/input/FormField.vue' import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited' +import {redirectToProvider} from '@/helpers/redirectToProvider' import {useAuthStore} from '@/stores/auth' +import {useConfigStore} from '@/stores/config' +import type {IProvider} from '@/types/IProvider' defineOptions({name: 'Auth'}) @@ -39,11 +72,23 @@ const route = useRoute() const {redirectIfSaved} = useRedirectToLastVisited() const authStore = useAuthStore() +const configStore = useConfigStore() const loading = computed(() => authStore.isLoading) const errorMessage = ref('') const errorMessageFromQuery = computed(() => route.query.error) +const needsTotp = ref(false) +const totpPasscode = ref('') + +function pendingTotpKey(provider: string): string { + return `openid_pending_totp_${provider}` +} + +function findProvider(providerKey: string): IProvider | undefined { + return configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === providerKey) +} + async function authenticateWithCode() { // This component gets mounted twice: The first time when the actual auth request hits the frontend, // the second time after that auth request succeeded and the outer component "content-no-auth" isn't used @@ -60,8 +105,11 @@ async function authenticateWithCode() { errorMessage.value = '' + const providerKey = route.params.provider as string + if (typeof route.query.error !== 'undefined') { localStorage.removeItem('authenticating') + sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = typeof route.query.message !== 'undefined' ? route.query.message as string : t('user.auth.openIdGeneralError') @@ -71,23 +119,53 @@ async function authenticateWithCode() { const state = localStorage.getItem('state') if (typeof route.query.state === 'undefined' || route.query.state !== state) { localStorage.removeItem('authenticating') + sessionStorage.removeItem(pendingTotpKey(providerKey)) errorMessage.value = t('user.auth.openIdStateError') return } + // sessionStorage (not localStorage): per-tab, cleared on tab close. + const pendingPasscode = sessionStorage.getItem(pendingTotpKey(providerKey)) ?? undefined + if (pendingPasscode) { + sessionStorage.removeItem(pendingTotpKey(providerKey)) + } + try { await authStore.openIdAuth({ - provider: route.params.provider, - code: route.query.code, + provider: providerKey, + code: route.query.code as string, + totpPasscode: pendingPasscode, }) redirectIfSaved() } catch (e) { + const err = e as {response?: {data?: {code?: number}}} + if (err?.response?.data?.code === 1017) { + needsTotp.value = true + return + } errorMessage.value = getErrorText(e) } finally { localStorage.removeItem('authenticating') } } +async function submitTotpAndRestart() { + if (!totpPasscode.value) { + return + } + + const providerKey = route.params.provider as string + const provider = findProvider(providerKey) + if (!provider) { + errorMessage.value = t('user.auth.openIdGeneralError') + return + } + + sessionStorage.setItem(pendingTotpKey(providerKey), totpPasscode.value) + // The auth code is single-use; restart the OIDC flow so the next callback reads the stashed passcode. + redirectToProvider(provider) +} + onMounted(() => authenticateWithCode())