VYPR
High severity7.4NVD Advisory· Published Apr 10, 2026· Updated Apr 20, 2026

CVE-2026-34727

CVE-2026-34727

Description

Vikunja is an open-source self-hosted task management platform. Prior to 2.3.0, the OIDC callback handler issues a full JWT token without checking whether the matched user has TOTP two-factor authentication enabled. When a local user with TOTP enrolled is matched via the OIDC email fallback mechanism, the second factor is completely skipped. This vulnerability is fixed in 2.3.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
code.vikunja.io/apiGo
< 2.3.02.3.0

Affected products

1

Patches

1
b642b2a4536a

feat(auth): prompt for TOTP code in the OIDC callback flow

https://github.com/go-vikunja/vikunjakolaenteApr 9, 2026via ghsa
2 files changed · +83 3
  • frontend/src/i18n/lang/en.json+2 0 modified
    @@ -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",
    
  • frontend/src/views/user/OpenIdAuth.vue+81 3 modified
    @@ -13,9 +13,38 @@
     		>
     			{{ errorMessageFromQuery }}
     		</Message>
    -		<Message v-if="loading">
    +		<Message v-if="loading && !needsTotp">
     			{{ $t('user.auth.authenticating') }}
     		</Message>
    +
    +		<form
    +			v-if="needsTotp"
    +			@submit.prevent="submitTotpAndRestart"
    +		>
    +			<Message class="mbe-2">
    +				{{ $t('user.auth.openIdTotpRequired') }}
    +			</Message>
    +			<FormField
    +				id="openIdTotpPasscode"
    +				ref="totpInput"
    +				v-model="totpPasscode"
    +				v-focus
    +				:label="$t('user.auth.totpTitle')"
    +				autocomplete="one-time-code"
    +				:placeholder="$t('user.auth.totpPlaceholder')"
    +				required
    +				type="text"
    +				inputmode="numeric"
    +			/>
    +			<XButton
    +				:loading="loading"
    +				:disabled="!totpPasscode"
    +				class="mbs-2"
    +				@click="submitTotpAndRestart"
    +			>
    +				{{ $t('user.auth.openIdTotpSubmit') }}
    +			</XButton>
    +		</form>
     	</div>
     </template>
     
    @@ -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())
     </script>
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.