feat(auth): add SSO-only authentication mode

Require an enabled OIDC-linked superuser before disabling password flows. Track session authentication methods and fail closed across the API, web client, WebSockets, and administrative mutations.

Closes #1002
This commit is contained in:
neon
2026-09-02 05:04:18 +00:00
parent 6edde6c64d
commit 9165dbed1d
69 changed files with 19353 additions and 156 deletions
+13 -1
View File
@@ -37,6 +37,7 @@ const props = defineProps<{
canManageSuperuser?: boolean
currentUserId?: number
canDelete?: boolean
passwordLoginEnabled?: boolean
}>()
const emit = defineEmits<{
@@ -203,6 +204,14 @@ watch(
},
)
watch(
[() => props.user, () => props.passwordLoginEnabled],
([user, passwordLoginEnabled]) => {
if (!user && passwordLoginEnabled !== true) isSharedAccount.value = true
},
{ immediate: true },
)
function selectSection(section: UserFormSection) {
activeSection.value = section
}
@@ -339,7 +348,10 @@ async function handleSubmit() {
</ul>
<section v-else-if="activeSection === 'profile'" class="space-y-5">
<label v-if="!isEdit" class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5">
<label
v-if="!isEdit && passwordLoginEnabled"
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5"
>
<input v-model="isSharedAccount" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary" />
<span>
<span class="block text-sm font-medium text-foreground">{{ t('adminFeature.userForm.sharedAccount') }}</span>
+10 -4
View File
@@ -10,6 +10,7 @@ import { api } from '@/lib/api'
import { formatNumber } from '@/i18n/formatters'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
import { useUsers, type UserRow } from './composables/useUsers'
import { useSelfRegistration } from './composables/useSelfRegistration'
import UserFormDrawer from './UserFormDrawer.vue'
@@ -24,6 +25,8 @@ const HEADER_ACTIONS_TARGET = '#settings-header-actions'
const { t } = useI18n()
const { isSuperuser, hasPermission } = usePermissions()
const { user: currentUser } = useAuth()
const { loginOptions, fetchLoginOptions } = useLoginOptions()
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
const {
users,
@@ -119,7 +122,7 @@ const superuserConfirmLabel = computed(() => {
onMounted(async () => {
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
await Promise.all([reload(), loadStatic()])
await Promise.all([reload(), loadStatic(), fetchLoginOptions().catch(() => undefined)])
if (canManageAppSettings.value) await loadSelfRegistration()
})
@@ -186,7 +189,7 @@ function canManage(user: UserRow): boolean {
}
function isPasswordResettable(user: UserRow): boolean {
return user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared'
return passwordLoginEnabled.value && user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared'
}
function isLocked(user: UserRow): boolean {
@@ -363,7 +366,7 @@ async function saveDefaultLibraryAccess() {
<p class="hidden text-sm text-muted-foreground lg:block">{{ pageSubtitle }}</p>
<Button size="sm" type="button" @click="openCreate">
<UserPlus :size="14" aria-hidden="true" />
{{ t('adminFeature.usersPage.createUser') }}
{{ passwordLoginEnabled ? t('adminFeature.usersPage.createUser') : t('adminFeature.userForm.createSharedAccount') }}
</Button>
</Teleport>
@@ -375,6 +378,7 @@ async function saveDefaultLibraryAccess() {
:items="attention"
:total="summary.attention"
:busy-user-id="busyUserId"
:password-login-enabled="passwordLoginEnabled"
@unlock="handleUnlock"
@send-reset-link="handleResetPassword"
@open="openById"
@@ -468,6 +472,7 @@ async function saveDefaultLibraryAccess() {
:is-locked="isLocked"
:is-resettable="isPasswordResettable"
:needs-attention="needsAttention"
:password-login-enabled="passwordLoginEnabled"
@sort="handleSort"
@edit="openEdit"
@unlock="unlockUser"
@@ -500,7 +505,7 @@ async function saveDefaultLibraryAccess() {
<NewAccountDefaults
v-if="showDefaults"
:show-self-registration="canManageAppSettings"
:show-self-registration="canManageAppSettings && passwordLoginEnabled"
:show-library-defaults="canManageUserDefaults"
:allow-registration="allowRegistration"
:saving-self-registration="savingSelfRegistration"
@@ -523,6 +528,7 @@ async function saveDefaultLibraryAccess() {
:can-manage-superuser="isSuperuser"
:current-user-id="currentUser?.id"
:can-delete="canDeleteEditingUser"
:password-login-enabled="passwordLoginEnabled"
@close="closeDrawer"
@saved="onSaved"
@request-superuser-change="requestSuperuserChange"
@@ -68,7 +68,7 @@ type Drawer = ReturnType<typeof mount>
async function mountDrawer(props: Record<string, unknown> = {}): Promise<Drawer> {
const wrapper = mount(UserFormDrawer, {
props: { user: USER, libraries: LIBRARIES, defaultLibraryIds: [], ...props },
props: { user: USER, libraries: LIBRARIES, defaultLibraryIds: [], passwordLoginEnabled: true, ...props },
})
await flushPromises()
return wrapper
@@ -4,10 +4,11 @@ import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import UsersPage from '../UsersPage.vue'
import UserRosterTable from '../components/UserRosterTable.vue'
const { apiMock, authState, permState } = vi.hoisted(() => ({
const { apiMock, authState, permState, policyState } = vi.hoisted(() => ({
apiMock: vi.fn<(input: string, init?: RequestInit) => Promise<unknown>>(),
authState: { userId: 1 },
permState: { isSuperuser: true, denied: [] as string[] },
policyState: { passwordLoginEnabled: true },
}))
vi.mock('@/lib/api', () => ({ api: apiMock }))
@@ -32,6 +33,21 @@ vi.mock('@/features/auth/composables/useAuth', () => ({
}),
}))
vi.mock('@/features/auth/composables/useLoginOptions', () => ({
useLoginOptions: () => ({
loginOptions: computed(() => ({
passwordLoginEnabled: policyState.passwordLoginEnabled,
allowRegistration: false,
oidcProviders: [],
})),
fetchLoginOptions: vi.fn<() => Promise<{ passwordLoginEnabled: boolean; allowRegistration: boolean; oidcProviders: never[] }>>(async () => ({
passwordLoginEnabled: policyState.passwordLoginEnabled,
allowRegistration: false,
oidcProviders: [],
})),
}),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
@@ -122,6 +138,7 @@ beforeEach(() => {
permState.isSuperuser = true
permState.denied = []
authState.userId = 1
policyState.passwordLoginEnabled = true
stubApi()
})
@@ -9,7 +9,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
import { formatNumber } from '@/i18n/formatters'
import { relativeTimestamp } from '../lib/relative-time'
const props = defineProps<{ items: UserAttentionItem[]; total: number; busyUserId: number | null }>()
const props = defineProps<{ items: UserAttentionItem[]; total: number; busyUserId: number | null; passwordLoginEnabled: boolean }>()
const emit = defineEmits<{ unlock: [id: number]; sendResetLink: [id: number]; open: [id: number] }>()
const { t } = useI18n()
@@ -18,7 +18,7 @@ const collapsed = ref(false)
/** OIDC and shared accounts have no password here, so there is no link to send. */
function isResettable(item: UserAttentionItem): boolean {
return item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared'
return props.passwordLoginEnabled && item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared'
}
function detail(item: UserAttentionItem): string {
@@ -41,6 +41,9 @@ function actionLabel(item: UserAttentionItem): string {
}
function unavailableHint(item: UserAttentionItem): string {
if (!props.passwordLoginEnabled && item.provisioningMethod !== 'oidc' && item.provisioningMethod !== 'shared') {
return t('adminFeature.usersPage.resetPasswordDisabledHintFull')
}
return item.provisioningMethod === 'oidc'
? t('adminFeature.usersPage.resetPasswordOidcHintFull')
: t('adminFeature.usersPage.resetPasswordSharedHintFull')
@@ -22,6 +22,7 @@ const props = defineProps<{
isLocked: (user: UserRow) => boolean
isResettable: (user: UserRow) => boolean
needsAttention: (user: UserRow) => boolean
passwordLoginEnabled: boolean
}>()
const emit = defineEmits<{
@@ -47,6 +48,9 @@ function lastActiveLabel(user: UserRow): string {
}
function resetHint(user: UserRow): string {
if (!props.passwordLoginEnabled && user.provisioningMethod !== 'oidc' && user.provisioningMethod !== 'shared') {
return t('adminFeature.usersPage.resetPasswordDisabledHintFull')
}
if (user.provisioningMethod === 'oidc') return t('adminFeature.usersPage.resetPasswordOidcHintFull')
return t('adminFeature.usersPage.resetPasswordSharedHintFull')
}
+22 -8
View File
@@ -8,6 +8,7 @@ import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThe
import { LoginError, useAuth } from './composables/useAuth'
import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
import { useLoginOptions } from './composables/useLoginOptions'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const { t } = useI18n()
@@ -52,8 +53,11 @@ function closeAll() {
}
const { login } = useAuth()
const { getPublicProviders, initiateLogin } = useOidc()
const { allowRegistration, setupStatusError } = useSetupStatus()
const { initiateLogin } = useOidc()
const { setupStatusError } = useSetupStatus()
const { loginOptions, loginOptionsError, fetchLoginOptions } = useLoginOptions()
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
const allowRegistration = computed(() => loginOptions.value?.allowRegistration === true)
const route = useRoute()
const justRegistered = computed(() => route.query.registered === '1')
@@ -68,6 +72,10 @@ const oidcLoadingSlug = ref<string | null>(null)
function resolveLoginError(err: unknown): string {
if (!(err instanceof Error)) return t('auth.login.errors.invalidCredentials')
if (err instanceof LoginError && err.errorCode === LoginErrorCode.PASSWORD_AUTH_DISABLED) {
return t('auth.login.errors.passwordDisabled')
}
if (err instanceof LoginError && err.errorCode === LoginErrorCode.ACCOUNT_LOCKED) {
const minutes = Math.max(1, Math.ceil((err.retryAfterSeconds ?? 0) / 60))
return t('auth.login.errors.accountLocked', { minutes })
@@ -85,7 +93,11 @@ function resolveLoginError(err: unknown): string {
}
onMounted(async () => {
oidcProviders.value = await getPublicProviders()
try {
oidcProviders.value = (await fetchLoginOptions()).oidcProviders
} catch {
error.value = t('auth.login.errors.optionsUnavailable')
}
})
async function handleSubmit() {
@@ -251,7 +263,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
{{ t('auth.login.registeredNotice') }}
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<form v-if="passwordLoginEnabled" @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5 animate-fade-up" style="animation-delay: 80ms">
<label for="username" class="text-sm font-medium text-foreground">{{ t('auth.fields.username') }}</label>
<input
@@ -277,7 +289,9 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
</div>
<div v-if="error" role="alert" class="text-sm text-destructive animate-shake">{{ error }}</div>
<div v-if="setupStatusError" class="text-sm text-destructive animate-shake">{{ setupStatusError }}</div>
<div v-if="setupStatusError || loginOptionsError" class="text-sm text-destructive animate-shake">
{{ t('auth.login.errors.optionsUnavailable') }}
</div>
<button
type="submit"
@@ -290,13 +304,13 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
</form>
<template v-if="oidcProviders.length > 0">
<div class="flex items-center gap-3 my-6">
<div v-if="passwordLoginEnabled" class="flex items-center gap-3 my-6">
<div class="flex-1 h-px bg-border" />
<span class="text-sm text-muted-foreground">{{ t('auth.login.orDivider') }}</span>
<div class="flex-1 h-px bg-border" />
</div>
<div class="space-y-3">
<div class="space-y-3" :class="passwordLoginEnabled ? '' : 'mt-2'">
<button
v-for="provider in oidcProviders"
:key="provider.slug"
@@ -311,7 +325,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
</div>
</template>
<p class="mt-6 text-center text-sm text-muted-foreground">
<p v-if="passwordLoginEnabled" class="mt-6 text-center text-sm text-muted-foreground">
<RouterLink to="/forgot-password" class="text-primary hover:underline">{{ t('auth.login.forgotPassword') }}</RouterLink>
</p>
@@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import LoginPage from '../LoginPage.vue'
const { statusState, routeState } = vi.hoisted(() => ({
statusState: { allowRegistration: false },
statusState: { allowRegistration: false, passwordLoginEnabled: true },
routeState: { query: {} as Record<string, unknown> },
}))
@@ -15,7 +15,22 @@ vi.mock('../composables/useAuth', () => ({
}))
vi.mock('../composables/useOidc', () => ({
useOidc: () => ({ getPublicProviders: vi.fn<() => Promise<unknown[]>>(async () => []), initiateLogin: vi.fn<() => void>() }),
useOidc: () => ({ initiateLogin: vi.fn<() => void>() }),
}))
vi.mock('../composables/useLoginOptions', () => ({
useLoginOptions: () => {
const options = {
passwordLoginEnabled: statusState.passwordLoginEnabled,
allowRegistration: statusState.passwordLoginEnabled && statusState.allowRegistration,
oidcProviders: [],
}
return {
loginOptions: ref(options),
loginOptionsError: ref(null),
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => options),
}
},
}))
vi.mock('../composables/useSetupStatus', () => ({
@@ -59,6 +74,7 @@ function mountPage() {
beforeEach(() => {
vi.clearAllMocks()
statusState.allowRegistration = false
statusState.passwordLoginEnabled = true
routeState.query = {}
})
@@ -87,6 +103,16 @@ describe('LoginPage sign-up affordance', () => {
expect(wrapper.findAll('a').some((a) => a.attributes('href') === '/forgot-password')).toBe(true)
})
it('omits every password affordance when password authentication is disabled', async () => {
statusState.passwordLoginEnabled = false
statusState.allowRegistration = true
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.findAll('a').some((a) => ['/register', '/forgot-password'].includes(a.attributes('href') ?? ''))).toBe(false)
})
})
describe('LoginPage post-registration notice', () => {
@@ -0,0 +1,34 @@
import { ref } from 'vue'
import type { LoginOptionsResponse } from '@bookorbit/types'
const loginOptions = ref<LoginOptionsResponse | null>(null)
const loginOptionsError = ref<string | null>(null)
let inFlight: Promise<LoginOptionsResponse> | null = null
export function useLoginOptions() {
async function fetchLoginOptions(force = false): Promise<LoginOptionsResponse> {
if (inFlight) return inFlight
if (!force && loginOptions.value) return loginOptions.value
inFlight = (async () => {
try {
const res = await fetch('/api/v1/auth/login-options', { credentials: 'include' })
if (!res.ok) throw new Error('Failed to load sign-in options')
const data = (await res.json()) as LoginOptionsResponse
loginOptions.value = data
loginOptionsError.value = null
return data
} catch (error) {
loginOptions.value = null
loginOptionsError.value = error instanceof Error ? error.message : 'Failed to load sign-in options'
throw error
} finally {
inFlight = null
}
})()
return inFlight
}
return { loginOptions, loginOptionsError, fetchLoginOptions }
}
@@ -38,16 +38,6 @@ export class OidcLoginError extends Error {
}
export function useOidc() {
async function getPublicProviders(): Promise<OidcProviderPublic[]> {
try {
const res = await fetch('/api/v1/app-settings/oidc/providers/public')
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
async function initiateLogin(provider: OidcProviderPublic): Promise<void> {
if (!provider.enabled) {
throw new OidcLoginError(undefined, 'OIDC provider is not enabled')
@@ -112,5 +102,5 @@ export function useOidc() {
return res.json() as Promise<OidcCallbackResponse>
}
return { getPublicProviders, initiateLogin, exchangeCode }
return { initiateLogin, exchangeCode }
}
@@ -16,6 +16,7 @@ import SettingsPageHeader from './SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -25,6 +26,7 @@ const { isDemoRestrictedAccount } = usePermissions()
const { open: openChangePassword } = useChangePasswordDialog()
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
const { resetTour } = useOnboardingTour()
const { loginOptions, fetchLoginOptions } = useLoginOptions()
const fileInput = ref<HTMLInputElement | null>(null)
const savingProfile = ref(false)
@@ -65,8 +67,13 @@ const nameChanged = computed(() => {
const profileChanged = computed(() => nameChanged.value || timezoneChanged.value)
const accountEditBlocked = computed(() => isDemoRestrictedAccount.value)
const canChangePassword = computed(
() => !accountEditBlocked.value && user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared',
() =>
loginOptions.value?.passwordLoginEnabled === true &&
!accountEditBlocked.value &&
user.value?.provisioningMethod !== 'oidc' &&
user.value?.provisioningMethod !== 'shared',
)
const needsUnlinkPassword = computed(() => loginOptions.value?.passwordLoginEnabled === true && user.value?.authenticationMethod !== 'oidc')
const accountTypeLabel = computed(() => {
if (user.value?.provisioningMethod === 'oidc') return t('settings.account.profile.accountTypeOidc')
@@ -249,12 +256,9 @@ const linkingSlug = ref<string | null>(null)
onMounted(async () => {
oidcIdentityLoading.value = true
try {
const [identitiesRes, providersRes] = await Promise.all([
api('/api/v1/auth/oidc/identities'),
fetch('/api/v1/app-settings/oidc/providers/public'),
])
const [identitiesRes, options] = await Promise.all([api('/api/v1/auth/oidc/identities'), fetchLoginOptions()])
if (identitiesRes.ok) linkedIdentities.value = await identitiesRes.json()
if (providersRes.ok) oidcProviders.value = await providersRes.json()
oidcProviders.value = options.oidcProviders
} finally {
oidcIdentityLoading.value = false
}
@@ -298,13 +302,13 @@ async function initiateOidcLink(provider: OidcProviderPublic) {
async function confirmUnlink() {
if (shouldBlockAccountEdit()) return
if (!unlinkPassword.value || !unlinkTarget.value) return
if (!unlinkTarget.value || (needsUnlinkPassword.value && !unlinkPassword.value)) return
unlinking.value = true
try {
const res = await api(`/api/v1/auth/oidc/identities/${unlinkTarget.value.providerId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: unlinkPassword.value }),
body: JSON.stringify(needsUnlinkPassword.value ? { password: unlinkPassword.value } : {}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
@@ -453,7 +457,10 @@ function closeUnlinkDialog() {
<h2 id="account-preferences-heading" class="settings-group-label">{{ t('settings.account.groups.preferences') }}</h2>
<div class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card shadow-xs">
<div class="flex flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5">
<div
v-if="loginOptions?.passwordLoginEnabled"
class="flex flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-5 md:py-5"
>
<div class="flex min-w-0 max-w-2xl items-start gap-2.5">
<Clock :size="16" class="mt-0.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div class="min-w-0">
@@ -618,11 +625,11 @@ function closeUnlinkDialog() {
:description="t('settings.account.connectedAccounts.unlinkDialog.description')"
:confirm-label="unlinking ? t('settings.account.connectedAccounts.unlinking') : t('settings.account.connectedAccounts.unlink')"
:busy="unlinking"
:confirm-disabled="!unlinkPassword || accountEditBlocked"
:confirm-disabled="(needsUnlinkPassword && !unlinkPassword) || accountEditBlocked"
@confirm="confirmUnlink"
@cancel="closeUnlinkDialog"
>
<div class="mt-3 space-y-1.5">
<div v-if="needsUnlinkPassword" class="mt-3 space-y-1.5">
<label for="account-unlink-password" class="settings-label">
{{ t('settings.account.connectedAccounts.unlinkDialog.currentPassword') }}
</label>
+3
View File
@@ -3640,6 +3640,7 @@
"resetPasswordSharedHint": "Shared accounts do not have passwords",
"resetPasswordOidcHintFull": "OIDC users reset passwords in their identity provider.",
"resetPasswordSharedHintFull": "Shared accounts do not have passwords.",
"resetPasswordDisabledHintFull": "Password authentication is disabled by the server configuration.",
"deleteUserAction": "Delete user",
"deleteDialogTitle": "Delete user?",
"deleteDialogBody": "Delete user \"{username}\". This action cannot be undone.",
@@ -4245,6 +4246,8 @@
"errors": {
"invalidCredentials": "Invalid username or password",
"tooManyAttempts": "Too many login attempts. Please wait a minute and try again.",
"passwordDisabled": "Password sign-in is disabled. Use a single sign-on provider.",
"optionsUnavailable": "Sign-in options could not be loaded. Please refresh the page or contact your administrator.",
"accountLocked": "Your password is correct, but this account is temporarily locked after too many failed sign-in attempts. Try again in {minutes, plural, one {# minute} other {# minutes}}, or reset your password to unlock it now."
}
},
+2 -1
View File
@@ -10,6 +10,7 @@ import { i18n } from './i18n'
import { useLocaleStore } from './stores/locale'
import { useAuth } from './features/auth/composables/useAuth'
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
import { useLoginOptions } from './features/auth/composables/useLoginOptions'
// Chrome 124+ blocks aria-hidden from being applied to an element that contains
// a focused descendant. Reka UI's dialog uses the aria-hidden package which sets
@@ -49,7 +50,7 @@ await localeStore.setLocale(localeStore.locale)
// app.use(router) triggers initial navigation and guard execution.
const { fetchSetupStatus, needsSetup } = useSetupStatus()
try {
await fetchSetupStatus()
await Promise.all([fetchSetupStatus(), useLoginOptions().fetchLoginOptions()])
} catch {
// If setup-status check fails, continue with normal auth bootstrap.
}
+47 -1
View File
@@ -6,7 +6,7 @@ import { registerAuthGuard } from '../guards/auth.guard'
const { authState, statusState, openChangePasswordMock } = vi.hoisted(() => ({
authState: { user: null as Record<string, unknown> | null },
statusState: { needsSetup: false, allowRegistration: false },
statusState: { needsSetup: false, allowRegistration: false, passwordLoginEnabled: true },
openChangePasswordMock: vi.fn<(force: boolean) => void>(),
}))
@@ -21,6 +21,20 @@ vi.mock('@/features/auth/composables/useSetupStatus', () => ({
}),
}))
vi.mock('@/features/auth/composables/useLoginOptions', () => ({
useLoginOptions: () => {
const options = {
passwordLoginEnabled: statusState.passwordLoginEnabled,
allowRegistration: statusState.passwordLoginEnabled && statusState.allowRegistration,
oidcProviders: [],
}
return {
loginOptions: ref(options),
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => options),
}
},
}))
vi.mock('@/composables/useChangePasswordDialog', () => ({
useChangePasswordDialog: () => ({ open: openChangePasswordMock }),
}))
@@ -35,6 +49,8 @@ function makeRouter() {
{ path: '/login', name: 'login', component: blank, meta: { public: true } },
{ path: '/register', name: 'register', component: blank, meta: { public: true } },
{ path: '/setup', name: 'setup', component: blank, meta: { public: true } },
{ path: '/forgot-password', name: 'forgot-password', component: blank, meta: { public: true } },
{ path: '/reset-password', name: 'reset-password', component: blank, meta: { public: true } },
],
})
registerAuthGuard(router)
@@ -46,6 +62,7 @@ beforeEach(() => {
authState.user = null
statusState.needsSetup = false
statusState.allowRegistration = false
statusState.passwordLoginEnabled = true
})
describe('auth guard: /register', () => {
@@ -76,6 +93,16 @@ describe('auth guard: /register', () => {
expect(router.currentRoute.value.path).toBe('/')
})
it('redirects a visitor to /login when password authentication is disabled', async () => {
statusState.allowRegistration = true
statusState.passwordLoginEnabled = false
const router = makeRouter()
await router.push('/register')
expect(router.currentRoute.value.path).toBe('/login')
})
it('sends a signed-in user home even when registration is closed', async () => {
authState.user = { id: 1, isDefaultPassword: false, settings: {} }
const router = makeRouter()
@@ -95,3 +122,22 @@ describe('auth guard: /register', () => {
expect(router.currentRoute.value.path).toBe('/setup')
})
})
describe('auth guard: password recovery', () => {
it.each(['/forgot-password', '/reset-password?token=existing-token'])('redirects %s when password authentication is disabled', async (path) => {
statusState.passwordLoginEnabled = false
const router = makeRouter()
await router.push(path)
expect(router.currentRoute.value.path).toBe('/login')
})
it.each(['/forgot-password', '/reset-password?token=existing-token'])('allows %s when password authentication is enabled', async (path) => {
const router = makeRouter()
await router.push(path)
expect(router.currentRoute.value.fullPath).toBe(path)
})
})
+18 -4
View File
@@ -2,6 +2,9 @@ import type { Router } from 'vue-router'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
const PASSWORD_ONLY_PUBLIC_PATHS = new Set(['/forgot-password', '/reset-password'])
function permissionFallback(name: string | undefined) {
return name ? { name } : { path: '/' }
@@ -9,10 +12,12 @@ function permissionFallback(name: string | undefined) {
export function registerAuthGuard(router: Router): void {
router.beforeEach(async (to) => {
const { fetchSetupStatus, allowRegistration } = useSetupStatus()
const { fetchSetupStatus } = useSetupStatus()
const { fetchLoginOptions, loginOptions } = useLoginOptions()
let requiresSetup = false
try {
requiresSetup = await fetchSetupStatus()
const [setup] = await Promise.all([fetchSetupStatus(), fetchLoginOptions()])
requiresSetup = setup
} catch {
// If setup-status cannot be loaded, fall back to normal auth checks.
}
@@ -28,7 +33,11 @@ export function registerAuthGuard(router: Router): void {
if (to.path === '/register') {
const { user } = useAuth()
if (user.value) return { path: '/' }
if (!allowRegistration.value) return { path: '/login' }
if (!loginOptions.value?.allowRegistration) return { path: '/login' }
}
if (PASSWORD_ONLY_PUBLIC_PATHS.has(to.path) && loginOptions.value?.passwordLoginEnabled !== true) {
return { path: '/login' }
}
if (to.meta.public) return true
@@ -39,7 +48,12 @@ export function registerAuthGuard(router: Router): void {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (user.value.isDefaultPassword && user.value.provisioningMethod !== 'shared') {
if (
user.value.isDefaultPassword &&
user.value.provisioningMethod !== 'shared' &&
user.value.authenticationMethod !== 'oidc' &&
loginOptions.value?.passwordLoginEnabled !== false
) {
useChangePasswordDialog().open(true)
// Allow navigation to '/' but block everything else
if (to.path !== '/') return { path: '/' }