Commit bffcc204 authored by IanShaw027's avatar IanShaw027
Browse files

fix: complete oidc pending auth callback flows

parent 724f8e89
...@@ -18,9 +18,10 @@ ...@@ -18,9 +18,10 @@
<div <div
v-if=" v-if="
needsInvitation || needsInvitation ||
needsEmailCollection || needsAdoptionConfirmation ||
needsExistingAccountBinding || needsCreateAccount ||
needsAdoptionConfirmation needsBindLogin ||
needsTotpChallenge
" "
class="space-y-4" class="space-y-4"
> >
...@@ -108,46 +109,131 @@ ...@@ -108,46 +109,131 @@
</button> </button>
</template> </template>
<template v-else-if="needsEmailCollection"> <template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Continue with email to finish setting up your {{ providerName }} sign-in. Review the {{ providerName }} profile details before continuing.
</p> </p>
<div> <button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
{{ isSubmitting ? t('common.processing') : 'Continue' }}
</button>
</template>
<template v-else-if="needsCreateAccount">
<p class="text-sm text-gray-700 dark:text-gray-300">
Enter an email address to create your account and continue.
</p>
<div class="space-y-3">
<input <input
v-model="pendingEmail" v-model="pendingAccountEmail"
data-testid="oidc-create-account-email"
type="email" type="email"
class="input w-full" class="input w-full"
placeholder="you@example.com" placeholder="you@example.com"
:disabled="isSubmitting" :disabled="isSubmitting"
@keyup.enter="handleContinueWithEmail" @keyup.enter="handleCreateAccount"
/> />
<button
data-testid="oidc-create-account-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || !pendingAccountEmail.trim()"
@click="handleCreateAccount"
>
{{ isSubmitting ? t('common.processing') : 'Create account' }}
</button>
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode"
>
I already have an account
</button>
</div> </div>
<button <transition name="fade">
class="btn btn-primary w-full" <p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
:disabled="isSubmitting || !pendingEmail.trim()" {{ accountActionError }}
@click="handleContinueWithEmail" </p>
> </transition>
Continue with email
</button>
</template> </template>
<template v-else-if="needsExistingAccountBinding"> <template v-else-if="needsBindLogin">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Sign in to bind {{ providerName }} to the existing account for Log in to an existing account to bind this {{ providerName }} sign-in.
<span class="font-medium text-gray-900 dark:text-white">{{ pendingEmail }}</span>.
</p> </p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueToLogin"> <div class="space-y-3">
Sign in to bind <input
</button> v-model="bindLoginEmail"
data-testid="oidc-bind-login-email"
type="email"
class="input w-full"
placeholder="you@example.com"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<input
v-model="bindLoginPassword"
data-testid="oidc-bind-login-password"
type="password"
class="input w-full"
placeholder="Password"
:disabled="isSubmitting"
@keyup.enter="handleBindLogin"
/>
<button
data-testid="oidc-bind-login-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@click="handleBindLogin"
>
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
</button>
<button
v-if="canReturnToCreateAccount"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Use a different email
</button>
</div>
<transition name="fade">
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
{{ accountActionError }}
</p>
</transition>
</template> </template>
<template v-else-if="needsAdoptionConfirmation"> <template v-else-if="needsTotpChallenge">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing. Enter the 6-digit verification code for
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
to finish binding this {{ providerName }} sign-in.
</p> </p>
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin"> <div class="space-y-3">
{{ isSubmitting ? t('common.processing') : 'Continue' }} <input
</button> v-model="totpCode"
data-testid="oidc-bind-login-totp"
type="text"
inputmode="numeric"
maxlength="6"
class="input w-full"
placeholder="123456"
:disabled="isSubmitting"
@keyup.enter="handleSubmitTotpChallenge"
/>
<button
data-testid="oidc-bind-login-totp-submit"
class="btn btn-primary w-full"
:disabled="isSubmitting || totpCode.trim().length !== 6"
@click="handleSubmitTotpChallenge"
>
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
</button>
</div>
<transition name="fade">
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
{{ totpError }}
</p>
</transition>
</template> </template>
</div> </div>
</transition> </transition>
...@@ -177,11 +263,12 @@ ...@@ -177,11 +263,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { apiClient } from '@/api/client'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { import {
completeOIDCOAuthRegistration, completeOIDCOAuthRegistration,
...@@ -189,6 +276,7 @@ import { ...@@ -189,6 +276,7 @@ import {
getOAuthCompletionKind, getOAuthCompletionKind,
getPublicSettings, getPublicSettings,
isOAuthLoginCompletion, isOAuthLoginCompletion,
login2FA,
persistOAuthTokenContext, persistOAuthTokenContext,
type OAuthAdoptionDecision, type OAuthAdoptionDecision,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
...@@ -203,7 +291,6 @@ const appStore = useAppStore() ...@@ -203,7 +291,6 @@ const appStore = useAppStore()
const isProcessing = ref(true) const isProcessing = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const needsInvitation = ref(false) const needsInvitation = ref(false)
const invitationCode = ref('') const invitationCode = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
...@@ -215,11 +302,22 @@ const suggestedDisplayName = ref('') ...@@ -215,11 +302,22 @@ const suggestedDisplayName = ref('')
const suggestedAvatarUrl = ref('') const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true) const adoptDisplayName = ref(true)
const adoptAvatar = ref(true) const adoptAvatar = ref(true)
const pendingEmail = ref('')
const needsEmailCollection = ref(false)
const needsExistingAccountBinding = ref(false)
const needsAdoptionConfirmation = ref(false) const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('')
const bindLoginEmail = ref('')
const bindLoginPassword = ref('')
const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const bindSuccessMessage = t('profile.authBindings.bindSuccess') 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')
type PendingOidcCompletion = PendingOAuthExchangeResponse & { type PendingOidcCompletion = PendingOAuthExchangeResponse & {
step?: string step?: string
...@@ -229,6 +327,9 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & { ...@@ -229,6 +327,9 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
email?: string email?: string
provider_fallback?: string provider_fallback?: string
intent?: string intent?: string
requires_2fa?: boolean
temp_token?: string
user_email_masked?: string
} }
function parseFragmentParams(): URLSearchParams { function parseFragmentParams(): URLSearchParams {
...@@ -258,34 +359,6 @@ async function loadProviderName() { ...@@ -258,34 +359,6 @@ async function loadProviderName() {
} }
} }
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function resolvePendingEmail(completion: PendingOidcCompletion): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.resolved_email ||
completion.email ||
''
).trim()
}
function requiresEmailCollection(completion: PendingOidcCompletion): boolean {
const state = normalizedPendingState(completion.step || completion.error)
return state === 'email_required'
}
function requiresExistingAccountBinding(completion: PendingOidcCompletion): boolean {
const state = normalizedPendingState(completion.step || completion.error || completion.intent)
return (
state === 'existing_account_binding_required' ||
state === 'existing_account_required' ||
state === 'adopt_existing_user_by_email'
)
}
function currentAdoptionDecision(): OAuthAdoptionDecision { function currentAdoptionDecision(): OAuthAdoptionDecision {
return { return {
adoptDisplayName: adoptDisplayName.value, adoptDisplayName: adoptDisplayName.value,
...@@ -293,6 +366,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision { ...@@ -293,6 +366,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
} }
} }
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
const payload: Record<string, boolean> = {}
if (typeof decision.adoptDisplayName === 'boolean') {
payload.adopt_display_name = decision.adoptDisplayName
}
if (typeof decision.adoptAvatar === 'boolean') {
payload.adopt_avatar = decision.adoptAvatar
}
return payload
}
function applyAdoptionSuggestionState(completion: { function applyAdoptionSuggestionState(completion: {
adoption_required?: boolean adoption_required?: boolean
suggested_display_name?: string suggested_display_name?: string
...@@ -317,6 +401,100 @@ function hasSuggestedProfile(completion: { ...@@ -317,6 +401,100 @@ function hasSuggestedProfile(completion: {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url) return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
} }
function normalizedPendingState(value: string | null | undefined): string {
return value?.trim().toLowerCase() || ''
}
function extractPendingAccountEmail(completion: PendingOidcCompletion): string {
return (
completion.pending_email ||
completion.existing_account_email ||
completion.resolved_email ||
completion.email ||
''
).trim()
}
function resolvePendingAccountAction(completion: PendingOidcCompletion): 'none' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
if (
raw === 'bind_login_required' ||
raw === 'bind_login' ||
raw === 'existing_account_binding_required' ||
raw === 'existing_account_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'bind_login'
}
return 'none'
}
function applyPendingAccountAction(completion: PendingOidcCompletion) {
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') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
return
}
if (action === 'bind_login') {
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
canReturnToCreateAccount.value = false
}
function applyTotpChallenge(completion: PendingOidcCompletion): 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()
bindLoginPassword.value = ''
accountActionError.value = ''
canReturnToCreateAccount.value = true
}
function switchToCreateAccountMode() {
pendingAccountAction.value = 'create_account'
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
accountActionError.value = ''
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
...@@ -335,6 +513,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -335,6 +513,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
await router.replace(redirect) await router.replace(redirect)
} }
async function finalizePendingAccountResponse(completion: PendingOidcCompletion) {
applyAdoptionSuggestionState(completion)
if (completion.error === 'invitation_required') {
pendingAccountAction.value = 'none'
needsInvitation.value = true
needsAdoptionConfirmation.value = false
isProcessing.value = false
return
}
if (applyTotpChallenge(completion)) {
return
}
applyPendingAccountAction(completion)
if (pendingAccountAction.value !== 'none') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
return
}
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
await finalizeCompletion(completion, redirect)
}
async function handleSubmitInvitation() { async function handleSubmitInvitation() {
invitationError.value = '' invitationError.value = ''
if (!invitationCode.value.trim()) return if (!invitationCode.value.trim()) return
...@@ -364,12 +569,7 @@ async function handleContinueLogin() { ...@@ -364,12 +569,7 @@ async function handleContinueLogin() {
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision()) const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
await finalizeCompletion(completion, redirectTo.value) await finalizeCompletion(completion, redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } } errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
errorMessage.value =
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
t('auth.loginFailed')
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
needsAdoptionConfirmation.value = false needsAdoptionConfirmation.value = false
} finally { } finally {
...@@ -377,33 +577,65 @@ async function handleContinueLogin() { ...@@ -377,33 +577,65 @@ async function handleContinueLogin() {
} }
} }
async function handleContinueWithEmail() { async function handleCreateAccount() {
const email = pendingEmail.value.trim() accountActionError.value = ''
if (!email) { const email = pendingAccountEmail.value.trim()
return if (!email) return
}
await router.replace({ isSubmitting.value = true
path: '/register', try {
query: { const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/create-account', {
email, email,
redirect: redirectTo.value, ...serializeAdoptionDecision(currentAdoptionDecision())
provider: providerName.value })
} await finalizePendingAccountResponse(data)
}) } catch (e: unknown) {
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
} }
async function handleContinueToLogin() { async function handleBindLogin() {
const email = pendingEmail.value.trim() accountActionError.value = ''
const email = bindLoginEmail.value.trim()
const password = bindLoginPassword.value
if (!email || !password) return
await router.replace({ isSubmitting.value = true
path: '/login', try {
query: { const { data } = await apiClient.post<PendingOidcCompletion>('/auth/oauth/pending/bind-login', {
email, email,
redirect: redirectTo.value, password,
provider: providerName.value ...serializeAdoptionDecision(currentAdoptionDecision())
} })
}) await finalizePendingAccountResponse(data)
} catch (e: unknown) {
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally {
isSubmitting.value = false
}
}
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 () => { onMounted(async () => {
...@@ -427,7 +659,6 @@ onMounted(async () => { ...@@ -427,7 +659,6 @@ onMounted(async () => {
) )
applyAdoptionSuggestionState(completion) applyAdoptionSuggestionState(completion)
redirectTo.value = redirect redirectTo.value = redirect
pendingEmail.value = resolvePendingEmail(completion)
if (completion.error === 'invitation_required') { if (completion.error === 'invitation_required') {
needsInvitation.value = true needsInvitation.value = true
...@@ -435,14 +666,12 @@ onMounted(async () => { ...@@ -435,14 +666,12 @@ onMounted(async () => {
return return
} }
if (requiresEmailCollection(completion)) { if (applyTotpChallenge(completion)) {
needsEmailCollection.value = true
isProcessing.value = false
return return
} }
if (requiresExistingAccountBinding(completion)) { applyPendingAccountAction(completion)
needsExistingAccountBinding.value = true if (pendingAccountAction.value !== 'none') {
isProcessing.value = false isProcessing.value = false
return return
} }
...@@ -455,12 +684,7 @@ onMounted(async () => { ...@@ -455,12 +684,7 @@ onMounted(async () => {
await finalizeCompletion(completion, redirect) await finalizeCompletion(completion, redirect)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } } errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
errorMessage.value =
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
t('auth.loginFailed')
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
isProcessing.value = false isProcessing.value = false
} }
......
...@@ -10,6 +10,8 @@ const setToken = vi.fn() ...@@ -10,6 +10,8 @@ const setToken = vi.fn()
const exchangePendingOAuthCompletion = vi.fn() const exchangePendingOAuthCompletion = vi.fn()
const completeOIDCOAuthRegistration = vi.fn() const completeOIDCOAuthRegistration = vi.fn()
const getPublicSettings = vi.fn() const getPublicSettings = vi.fn()
const login2FA = vi.fn()
const apiClientPost = vi.fn()
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({ useRoute: () => ({
...@@ -45,13 +47,20 @@ vi.mock('@/stores', () => ({ ...@@ -45,13 +47,20 @@ vi.mock('@/stores', () => ({
}) })
})) }))
vi.mock('@/api/client', () => ({
apiClient: {
post: (...args: any[]) => apiClientPost(...args)
}
}))
vi.mock('@/api/auth', async () => { vi.mock('@/api/auth', async () => {
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth') const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
return { return {
...actual, ...actual,
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args), exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args), completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
getPublicSettings: (...args: any[]) => getPublicSettings(...args) getPublicSettings: (...args: any[]) => getPublicSettings(...args),
login2FA: (...args: any[]) => login2FA(...args)
} }
}) })
...@@ -64,6 +73,8 @@ describe('OidcCallbackView', () => { ...@@ -64,6 +73,8 @@ describe('OidcCallbackView', () => {
exchangePendingOAuthCompletion.mockReset() exchangePendingOAuthCompletion.mockReset()
completeOIDCOAuthRegistration.mockReset() completeOIDCOAuthRegistration.mockReset()
getPublicSettings.mockReset() getPublicSettings.mockReset()
login2FA.mockReset()
apiClientPost.mockReset()
getPublicSettings.mockResolvedValue({ getPublicSettings.mockResolvedValue({
oidc_oauth_provider_name: 'ExampleID' oidc_oauth_provider_name: 'ExampleID'
}) })
...@@ -132,9 +143,7 @@ describe('OidcCallbackView', () => { ...@@ -132,9 +143,7 @@ describe('OidcCallbackView', () => {
const checkboxes = wrapper.findAll('input[type="checkbox"]') const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(false) await checkboxes[0].setValue(false)
const buttons = wrapper.findAll('button') await wrapper.findAll('button')[0].trigger('click')
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises() await flushPromises()
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2) expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
...@@ -184,12 +193,21 @@ describe('OidcCallbackView', () => { ...@@ -184,12 +193,21 @@ describe('OidcCallbackView', () => {
expect(replace).toHaveBeenCalledWith('/profile') expect(replace).toHaveBeenCalledWith('/profile')
}) })
it('renders pending email collection ui and routes to register with the entered email', async () => { it('renders adoption choices for invitation flow and submits the selected values', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({ exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required', error: 'invitation_required',
redirect: '/profile', redirect: '/dashboard',
provider_fallback: 'ExampleID' adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
}) })
completeOIDCOAuthRegistration.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'Bearer'
})
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, { const wrapper = mount(OidcCallbackView, {
global: { global: {
...@@ -204,28 +222,35 @@ describe('OidcCallbackView', () => { ...@@ -204,28 +222,35 @@ describe('OidcCallbackView', () => {
await flushPromises() await flushPromises()
expect(setToken).not.toHaveBeenCalled() const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(wrapper.text()).toContain('Continue with email') expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
await wrapper.get('input[type="email"]').setValue('alice@example.com') await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.get('button').trigger('click') await wrapper.find('button').trigger('click')
expect(replace).toHaveBeenCalledWith({ expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
path: '/register', adoptDisplayName: true,
query: { adoptAvatar: false
email: 'alice@example.com',
redirect: '/profile',
provider: 'ExampleID'
}
}) })
}) })
it('renders existing-account binding ui and routes to login', async () => { it('collects email for pending oauth account creation and submits adoption decisions', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({ exchangePendingOAuthCompletion.mockResolvedValue({
error: 'existing_account_binding_required', error: 'email_required',
redirect: '/profile', redirect: '/welcome',
existing_account_email: 'alice@example.com' adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
})
apiClientPost.mockResolvedValue({
data: {
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'Bearer'
}
}) })
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, { const wrapper = mount(OidcCallbackView, {
global: { global: {
...@@ -240,34 +265,90 @@ describe('OidcCallbackView', () => { ...@@ -240,34 +265,90 @@ describe('OidcCallbackView', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('alice@example.com') const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(wrapper.text()).toContain('Sign in to bind') expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue(' new@example.com ')
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
await flushPromises()
await wrapper.get('button').trigger('click') expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
email: 'new@example.com',
adopt_display_name: true,
adopt_avatar: false
})
expect(setToken).toHaveBeenCalledWith('new-access-token')
expect(replace).toHaveBeenCalledWith('/welcome')
})
expect(replace).toHaveBeenCalledWith({ it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
path: '/login', exchangePendingOAuthCompletion.mockResolvedValue({
query: { error: 'adopt_existing_user_by_email',
email: 'alice@example.com', redirect: '/profile/security',
redirect: '/profile', email: 'existing@example.com',
provider: 'ExampleID' adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
})
apiClientPost.mockResolvedValue({
data: {
access_token: 'bind-access-token',
refresh_token: 'bind-refresh-token',
expires_in: 3600,
token_type: 'Bearer'
}
})
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
} }
}) })
await flushPromises()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(2)
await checkboxes[0].setValue(false)
await wrapper.get('[data-testid="oidc-bind-login-email"]').setValue('existing@example.com')
await wrapper.get('[data-testid="oidc-bind-login-password"]').setValue('secret-password')
await wrapper.get('[data-testid="oidc-bind-login-submit"]').trigger('click')
await flushPromises()
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/bind-login', {
email: 'existing@example.com',
password: 'secret-password',
adopt_display_name: false,
adopt_avatar: true
})
expect(setToken).toHaveBeenCalledWith('bind-access-token')
expect(replace).toHaveBeenCalledWith('/profile/security')
}) })
it('renders adoption choices for invitation flow and submits the selected values', async () => { it('handles bind-login 2FA challenge before redirecting', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({ exchangePendingOAuthCompletion.mockResolvedValue({
error: 'invitation_required', error: 'adopt_existing_user_by_email',
redirect: '/dashboard', redirect: '/profile',
email: 'existing@example.com',
adoption_required: true, adoption_required: true,
suggested_display_name: 'OIDC Nick', suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png' suggested_avatar_url: 'https://cdn.example/oidc.png'
}) })
completeOIDCOAuthRegistration.mockResolvedValue({ apiClientPost.mockResolvedValue({
access_token: 'access-token', data: {
refresh_token: 'refresh-token', requires_2fa: true,
expires_in: 3600, temp_token: 'temp-123',
token_type: 'Bearer' user_email_masked: 'o***g@example.com'
}
})
login2FA.mockResolvedValue({
access_token: '2fa-access-token'
}) })
setToken.mockResolvedValue({}) setToken.mockResolvedValue({})
...@@ -284,20 +365,22 @@ describe('OidcCallbackView', () => { ...@@ -284,20 +365,22 @@ describe('OidcCallbackView', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('OIDC Nick') await wrapper.get('[data-testid="oidc-bind-login-password"]').setValue('secret-password')
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1) await wrapper.get('[data-testid="oidc-bind-login-submit"]').trigger('click')
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith() await flushPromises()
const checkboxes = wrapper.findAll('input[type="checkbox"]') expect(wrapper.text()).toContain('o***g@example.com')
expect(checkboxes).toHaveLength(2) expect(login2FA).not.toHaveBeenCalled()
await checkboxes[1].setValue(false) await wrapper.get('[data-testid="oidc-bind-login-totp"]').setValue('123456')
await wrapper.find('input[type="text"]').setValue('invite-code') await wrapper.get('[data-testid="oidc-bind-login-totp-submit"]').trigger('click')
await wrapper.find('button').trigger('click') await flushPromises()
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', { expect(login2FA).toHaveBeenCalledWith({
adoptDisplayName: true, temp_token: 'temp-123',
adoptAvatar: false totp_code: '123456'
}) })
expect(setToken).toHaveBeenCalledWith('2fa-access-token')
expect(replace).toHaveBeenCalledWith('/profile')
}) })
}) })
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment