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
This commit is contained in:
kolaente 2026-04-09 13:44:27 +02:00 committed by kolaente
parent 546db0dc21
commit b642b2a453
2 changed files with 83 additions and 3 deletions

View File

@ -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",

View File

@ -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>