mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -64,6 +64,10 @@ NODE_MAX_OLD_SPACE_SIZE=2048
|
||||
# Default false. Enable only in trusted self-hosted networks.
|
||||
# OIDC_ALLOW_LOCAL_ISSUERS=false
|
||||
|
||||
# Disable username/password sign-in after at least one active administrator has linked an enabled
|
||||
# OIDC provider. Set false and restart to recover access if the identity provider is unavailable.
|
||||
# DISABLE_LOCAL_AUTH=false
|
||||
|
||||
# Optional: allow Cloudflare Web Analytics beacon when using automatic setup.
|
||||
# Keep unset/false to preserve strict CSP (`script-src 'self'`).
|
||||
# CSP_ALLOW_CLOUDFLARE_INSIGHTS=false
|
||||
|
||||
@@ -95,6 +95,8 @@ docker compose up -d
|
||||
|
||||
Open `http://your-server-ip:3000` and complete setup using your `SETUP_BOOTSTRAP_TOKEN`.
|
||||
|
||||
After configuring OIDC and linking at least one active administrator, you can set `DISABLE_LOCAL_AUTH=true` and restart BookOrbit to remove and reject password sign-in. BookOrbit refuses to start if that would leave no usable OIDC administrator. Set it back to `false` and restart to recover access during an identity-provider outage.
|
||||
|
||||
For the full installation guide including reverse proxy setup, file permissions on NAS, external databases, and environment variable reference, see **[bookorbit.app/installation](https://bookorbit.app/installation)**.
|
||||
|
||||
## KOReader Plugin
|
||||
|
||||
@@ -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,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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '/' }
|
||||
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-auto}
|
||||
BOOK_DOCK_PATH: ${BOOK_DOCK_PATH:-}
|
||||
LIBRARY_BROWSE_ROOT: ${LIBRARY_BROWSE_ROOT:-}
|
||||
DISABLE_LOCAL_AUTH: ${DISABLE_LOCAL_AUTH:-false}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -234,6 +234,7 @@ These are the variables you are most likely to need during development. See `ser
|
||||
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
|
||||
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
|
||||
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
|
||||
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
export enum LoginErrorCode {
|
||||
ACCOUNT_LOCKED = "account_locked",
|
||||
PASSWORD_AUTH_DISABLED = "password_auth_disabled",
|
||||
}
|
||||
|
||||
export const AuthenticationMethod = {
|
||||
Password: "password",
|
||||
Oidc: "oidc",
|
||||
MagicLink: "magic_link",
|
||||
Setup: "setup",
|
||||
Legacy: "legacy",
|
||||
} as const;
|
||||
|
||||
export type AuthenticationMethod = (typeof AuthenticationMethod)[keyof typeof AuthenticationMethod];
|
||||
|
||||
export enum OidcErrorCode {
|
||||
STATE_EXPIRED = "oidc_state_expired",
|
||||
PRIVATE_ISSUER_ADDRESS = "oidc_private_issuer_address",
|
||||
@@ -48,6 +59,7 @@ export interface AuthUser {
|
||||
settings: UserSettings;
|
||||
avatarUrl?: string | null;
|
||||
provisioningMethod: ProvisioningMethod;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@@ -69,6 +81,12 @@ export interface OidcProviderPublic {
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
export interface LoginOptionsResponse {
|
||||
passwordLoginEnabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
oidcProviders: OidcProviderPublic[];
|
||||
}
|
||||
|
||||
export interface OidcProviderConfig {
|
||||
id: number;
|
||||
slug: string;
|
||||
|
||||
@@ -64,6 +64,10 @@ APP_DATA_PATH=../local/data
|
||||
# Default false. Keep false unless you intentionally trust your private network.
|
||||
# OIDC_ALLOW_LOCAL_ISSUERS=false
|
||||
|
||||
# Disable username/password sign-in after at least one active administrator has linked an enabled
|
||||
# OIDC provider. Set false and restart to recover access if the identity provider is unavailable.
|
||||
# DISABLE_LOCAL_AUTH=false
|
||||
|
||||
# App URL (used for building password reset links in emails)
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PermissionService } from './services/permission.service';
|
||||
import { AuthenticationPolicyService } from './services/authentication-policy.service';
|
||||
|
||||
@Module({
|
||||
providers: [PermissionService],
|
||||
exports: [PermissionService],
|
||||
providers: [PermissionService, AuthenticationPolicyService],
|
||||
exports: [PermissionService, AuthenticationPolicyService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
authenticationMethod: 'password',
|
||||
permissions: [],
|
||||
...overrides,
|
||||
|
||||
@@ -88,4 +89,13 @@ describe('JwtAuthGuard', () => {
|
||||
const result = guard.handleRequest(undefined, user, undefined, makeContext());
|
||||
expect(result).toBe(user);
|
||||
});
|
||||
|
||||
it('does not force a password change for an OIDC-authenticated session', () => {
|
||||
const reflector = { getAllAndOverride: vi.fn() };
|
||||
const guard = new JwtAuthGuard(reflector as never);
|
||||
|
||||
expect(() =>
|
||||
guard.handleRequest(undefined, makeUser({ isDefaultPassword: true, authenticationMethod: 'oidc' }), undefined, makeContext()),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { ALLOW_DEFAULT_PASSWORD_KEY } from '../decorators/allow-default-password.decorator';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { RequestUser } from '../types/request-user';
|
||||
import { AuthenticationMethod } from '@bookorbit/types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
@@ -23,7 +24,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (user.isDefaultPassword) {
|
||||
if (user.isDefaultPassword && user.authenticationMethod !== AuthenticationMethod.Oidc) {
|
||||
const allowDefaultPassword = this.reflector.getAllAndOverride<boolean>(ALLOW_DEFAULT_PASSWORD_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (!allowDefaultPassword) {
|
||||
throw new ForbiddenException('Password change required');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { LoginErrorCode } from '@bookorbit/types';
|
||||
|
||||
import { AuthenticationPolicyService } from './authentication-policy.service';
|
||||
|
||||
function makeService(passwordLoginEnabled: boolean) {
|
||||
const tx = { execute: vi.fn() };
|
||||
const db = { transaction: vi.fn(async (callback: (value: typeof tx) => Promise<unknown>) => callback(tx)) };
|
||||
const config = { get: vi.fn().mockReturnValue(passwordLoginEnabled) };
|
||||
return { service: new AuthenticationPolicyService(config as never, db as never), db, tx };
|
||||
}
|
||||
|
||||
describe('AuthenticationPolicyService', () => {
|
||||
it('returns a stable error code when password authentication is disabled', () => {
|
||||
const { service } = makeService(false);
|
||||
|
||||
expect(() => service.assertPasswordLoginEnabled()).toThrow(ForbiddenException);
|
||||
try {
|
||||
service.assertPasswordLoginEnabled();
|
||||
} catch (error) {
|
||||
expect((error as ForbiddenException).getResponse()).toEqual({
|
||||
message: 'Password authentication is disabled',
|
||||
errorCode: LoginErrorCode.PASSWORD_AUTH_DISABLED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not query administrator availability while password recovery is enabled', async () => {
|
||||
const { service, db } = makeService(true);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts in SSO-only mode when an active superuser has an enabled OIDC identity', async () => {
|
||||
const { service, db } = makeService(false);
|
||||
vi.spyOn(service, 'hasUsableOidcSuperuser').mockResolvedValue(true);
|
||||
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
expect(db.transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses to start in SSO-only mode without a usable OIDC administrator', async () => {
|
||||
const { service } = makeService(false);
|
||||
vi.spyOn(service, 'hasUsableOidcSuperuser').mockResolvedValue(false);
|
||||
|
||||
await expect(service.onApplicationBootstrap()).rejects.toThrow(
|
||||
'DISABLE_LOCAL_AUTH requires at least one active superuser linked to an enabled OIDC provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ConflictException, ForbiddenException, Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { and, eq, ne, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { LoginErrorCode } from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
export type AuthPolicyTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
|
||||
const ADMIN_AUTH_AVAILABILITY_LOCK_KEY = 'bookorbit:administrator-auth-availability';
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationPolicyService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {}
|
||||
|
||||
isPasswordLoginEnabled(): boolean {
|
||||
return this.config.get<boolean>('auth.passwordLoginEnabled') !== false;
|
||||
}
|
||||
|
||||
assertPasswordLoginEnabled(): void {
|
||||
if (this.isPasswordLoginEnabled()) return;
|
||||
throw new ForbiddenException({
|
||||
message: 'Password authentication is disabled',
|
||||
errorCode: LoginErrorCode.PASSWORD_AUTH_DISABLED,
|
||||
});
|
||||
}
|
||||
|
||||
async lockAdministratorAvailability(tx: AuthPolicyTransaction): Promise<void> {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${ADMIN_AUTH_AVAILABILITY_LOCK_KEY})::bigint)`);
|
||||
}
|
||||
|
||||
async hasUsableOidcSuperuser(tx: AuthPolicyTransaction, excludedUserId?: number): Promise<boolean> {
|
||||
const exclusion = excludedUserId === undefined ? sql`true` : sql`${schema.users.id} <> ${excludedUserId}`;
|
||||
const rows = await tx
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.innerJoin(schema.oidcIdentities, eq(schema.oidcIdentities.userId, schema.users.id))
|
||||
.innerJoin(schema.oidcProviders, eq(schema.oidcProviders.id, schema.oidcIdentities.providerId))
|
||||
.where(and(eq(schema.users.active, true), eq(schema.users.isSuperuser, true), eq(schema.oidcProviders.enabled, true), exclusion))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasUsableOidcSuperuserOutsideProvider(tx: AuthPolicyTransaction, providerId: number): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.innerJoin(schema.oidcIdentities, eq(schema.oidcIdentities.userId, schema.users.id))
|
||||
.innerJoin(schema.oidcProviders, eq(schema.oidcProviders.id, schema.oidcIdentities.providerId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.users.active, true),
|
||||
eq(schema.users.isSuperuser, true),
|
||||
eq(schema.oidcProviders.enabled, true),
|
||||
ne(schema.oidcProviders.id, providerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasEnabledOidcIdentityForUser(tx: AuthPolicyTransaction, userId: number): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ id: schema.oidcIdentities.id })
|
||||
.from(schema.oidcIdentities)
|
||||
.innerJoin(schema.oidcProviders, eq(schema.oidcProviders.id, schema.oidcIdentities.providerId))
|
||||
.where(and(eq(schema.oidcIdentities.userId, userId), eq(schema.oidcProviders.enabled, true)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async assertProviderAuthenticationCanChange(tx: AuthPolicyTransaction, providerId: number): Promise<void> {
|
||||
if (this.isPasswordLoginEnabled() || (await this.hasUsableOidcSuperuserOutsideProvider(tx, providerId))) return;
|
||||
throw new ConflictException('Cannot change the only OIDC provider available to administrators while password authentication is disabled');
|
||||
}
|
||||
|
||||
async assertUsableOidcSuperuser(tx: AuthPolicyTransaction): Promise<void> {
|
||||
if (this.isPasswordLoginEnabled() || (await this.hasUsableOidcSuperuser(tx))) return;
|
||||
throw new ConflictException('Cannot remove the last administrator sign-in method while password authentication is disabled');
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (this.isPasswordLoginEnabled()) return;
|
||||
const ready = await this.db.transaction(async (tx) => {
|
||||
await this.lockAdministratorAvailability(tx);
|
||||
return this.hasUsableOidcSuperuser(tx);
|
||||
});
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
'DISABLE_LOCAL_AUTH requires at least one active superuser linked to an enabled OIDC provider. Set DISABLE_LOCAL_AUTH=false, restart BookOrbit, configure and link OIDC, then enable it again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||
avatarSource: 'none',
|
||||
avatarVersion: 0,
|
||||
provisioningMethod: 'local',
|
||||
authenticationMethod: 'password',
|
||||
permissions: [],
|
||||
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||
...overrides,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { AuthenticationMethod } from '@bookorbit/types';
|
||||
import type { ContentFilterRules } from '@bookorbit/types';
|
||||
|
||||
export interface RequestUser {
|
||||
@@ -15,6 +16,7 @@ export interface RequestUser {
|
||||
avatarSource?: 'none' | 'external' | 'uploaded' | null;
|
||||
avatarVersion?: number | null;
|
||||
provisioningMethod: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
permissions: Permission[];
|
||||
contentFilters: ContentFilterRules;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ function resetEnv(): void {
|
||||
delete process.env.JWT_EXPIRES_IN;
|
||||
delete process.env.JWT_REFRESH_EXPIRES_IN;
|
||||
delete process.env.SETUP_BOOTSTRAP_TOKEN;
|
||||
delete process.env.DISABLE_LOCAL_AUTH;
|
||||
delete process.env.APP_DATA_PATH;
|
||||
delete process.env.BOOK_DOCK_PATH;
|
||||
delete process.env.LIBRARY_BROWSE_ROOT;
|
||||
@@ -93,11 +94,23 @@ describe('config', () => {
|
||||
jwtRefreshExpiresIn: '7d',
|
||||
setupBootstrapToken: '',
|
||||
refreshRotationGraceMs: 30_000,
|
||||
passwordLoginEnabled: true,
|
||||
});
|
||||
expect(emailConfig().encryptionKey).toBe('');
|
||||
expect(migrationConfig()).toEqual({ encryptionKey: '', importRoot: undefined });
|
||||
});
|
||||
|
||||
it('disables password login only for an enabled DISABLE_LOCAL_AUTH flag', () => {
|
||||
for (const value of ['true', '1', 'yes', 'on']) {
|
||||
process.env.DISABLE_LOCAL_AUTH = value;
|
||||
expect(authConfig().passwordLoginEnabled).toBe(false);
|
||||
}
|
||||
for (const value of ['false', '0', 'no', 'off', 'invalid']) {
|
||||
process.env.DISABLE_LOCAL_AUTH = value;
|
||||
expect(authConfig().passwordLoginEnabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the configured migration import root', () => {
|
||||
process.env.MIGRATION_IMPORT_ROOT = './imports/audiobookshelf';
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const authConfig = registerAs('auth', () => ({
|
||||
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
setupBootstrapToken: process.env.SETUP_BOOTSTRAP_TOKEN ?? '',
|
||||
refreshRotationGraceMs: parsePositiveInteger(process.env.AUTH_REFRESH_ROTATION_GRACE_MS, 30_000),
|
||||
passwordLoginEnabled: !parseBooleanFlag(process.env.DISABLE_LOCAL_AUTH, false),
|
||||
}));
|
||||
|
||||
export const storageConfig = registerAs('storage', () => {
|
||||
|
||||
@@ -79,6 +79,16 @@ describe('validateEnv', () => {
|
||||
).toThrow('OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off');
|
||||
});
|
||||
|
||||
it('accepts boolean-like values for DISABLE_LOCAL_AUTH', () => {
|
||||
for (const DISABLE_LOCAL_AUTH of ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']) {
|
||||
expect(() => validateEnv({ ...BASE_ENV, DISABLE_LOCAL_AUTH })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid DISABLE_LOCAL_AUTH values', () => {
|
||||
expect(() => validateEnv({ ...BASE_ENV, DISABLE_LOCAL_AUTH: 'maybe' })).toThrow('DISABLE_LOCAL_AUTH must be one of true/false/1/0/yes/no/on/off');
|
||||
});
|
||||
|
||||
it('accepts boolean-like values for SWAGGER_ENABLED', () => {
|
||||
for (const SWAGGER_ENABLED of ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']) {
|
||||
expect(() =>
|
||||
|
||||
@@ -56,6 +56,7 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
|
||||
DISABLE_LOCAL_AUTH: booleanEnvFlag('DISABLE_LOCAL_AUTH'),
|
||||
APP_DATA_PATH: z.string().default('/data'),
|
||||
BOOK_DOCK_PATH: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "refresh_tokens" ADD COLUMN "authentication_method" varchar(20) DEFAULT 'legacy' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_authentication_method_chk" CHECK ("refresh_tokens"."authentication_method" in ('password', 'oidc', 'magic_link', 'setup', 'legacy'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -596,6 +596,13 @@
|
||||
"when": 1788206528114,
|
||||
"tag": "0084_add_scan_state_version",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1788321273908,
|
||||
"tag": "0085_add_authentication_method",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export const refreshTokens = pgTable(
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
authenticationMethod: varchar('authentication_method', { length: 20 }).notNull().default('legacy'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
rotatedAt: timestamp('rotated_at', { withTimezone: true }),
|
||||
@@ -123,6 +124,7 @@ export const refreshTokens = pgTable(
|
||||
index('refresh_tokens_user_id_idx').on(t.userId),
|
||||
index('refresh_tokens_expires_at_idx').on(t.expiresAt),
|
||||
check('refresh_tokens_expires_after_created_chk', sql`${t.expiresAt} > ${t.createdAt}`),
|
||||
check('refresh_tokens_authentication_method_chk', sql`${t.authenticationMethod} in ('password', 'oidc', 'magic_link', 'setup', 'legacy')`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
|
||||
import { AppSettingsController } from './app-settings.controller';
|
||||
import { AppSettingsRepository } from './app-settings.repository';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
@@ -8,6 +10,7 @@ import { OidcProviderRepository } from './oidc-provider.repository';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonModule],
|
||||
controllers: [AppSettingsController],
|
||||
providers: [AppSettingsRepository, AppSettingsService, OidcGroupMappingAdminService, OidcProviderRepository, OidcProviderService],
|
||||
exports: [AppSettingsService, OidcProviderService],
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { OidcProviderRepository } from './oidc-provider.repository';
|
||||
|
||||
const PROVIDER = {
|
||||
id: 1,
|
||||
slug: 'sso',
|
||||
displayName: 'SSO',
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid',
|
||||
iconUrl: null,
|
||||
claimMapping: {},
|
||||
autoProvision: {},
|
||||
displayOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeRepository(linkedIdentityCount = 0) {
|
||||
const updateReturning = vi.fn().mockResolvedValue([PROVIDER]);
|
||||
const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning });
|
||||
const deleteReturning = vi.fn().mockResolvedValue([PROVIDER]);
|
||||
const deleteWhere = vi.fn().mockReturnValue({ returning: deleteReturning });
|
||||
const countWhere = vi.fn().mockResolvedValue([{ count: linkedIdentityCount }]);
|
||||
const tx = {
|
||||
query: { oidcProviders: { findFirst: vi.fn().mockResolvedValue(PROVIDER) } },
|
||||
update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: updateWhere }) }),
|
||||
delete: vi.fn().mockReturnValue({ where: deleteWhere }),
|
||||
select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: countWhere }) }),
|
||||
};
|
||||
const db = { transaction: vi.fn(async (callback: (value: typeof tx) => Promise<unknown>) => callback(tx)) };
|
||||
const policy = {
|
||||
lockAdministratorAvailability: vi.fn(),
|
||||
assertProviderAuthenticationCanChange: vi.fn(),
|
||||
assertUsableOidcSuperuser: vi.fn(),
|
||||
};
|
||||
return { repository: new OidcProviderRepository(db as never, policy as never), policy };
|
||||
}
|
||||
|
||||
describe('OidcProviderRepository administrator availability', () => {
|
||||
it('allows non-authentication edits when the form resubmits unchanged authentication fields', async () => {
|
||||
const { repository, policy } = makeRepository();
|
||||
|
||||
await repository.update(1, {
|
||||
displayName: 'Renamed',
|
||||
enabled: PROVIDER.enabled,
|
||||
issuerUri: PROVIDER.issuerUri,
|
||||
clientId: PROVIDER.clientId,
|
||||
scopes: PROVIDER.scopes,
|
||||
});
|
||||
|
||||
expect(policy.assertProviderAuthenticationCanChange).not.toHaveBeenCalled();
|
||||
expect(policy.assertUsableOidcSuperuser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('checks administrator availability before changing an authentication-critical field', async () => {
|
||||
const { repository, policy } = makeRepository();
|
||||
|
||||
await repository.update(1, { enabled: false });
|
||||
|
||||
expect(policy.assertProviderAuthenticationCanChange).toHaveBeenCalledWith(expect.anything(), 1);
|
||||
});
|
||||
|
||||
it('refuses provider deletion when identities are linked inside the same locked transaction', async () => {
|
||||
const { repository, policy } = makeRepository(2);
|
||||
|
||||
await expect(repository.remove(1)).rejects.toThrow(BadRequestException);
|
||||
expect(policy.lockAdministratorAvailability).toHaveBeenCalledOnce();
|
||||
expect(policy.assertUsableOidcSuperuser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks administrator availability after deleting an unlinked provider', async () => {
|
||||
const { repository, policy } = makeRepository();
|
||||
|
||||
await expect(repository.remove(1)).resolves.toMatchObject({ id: 1 });
|
||||
expect(policy.assertUsableOidcSuperuser).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcProviderRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
return this.db.query.oidcProviders.findMany({
|
||||
@@ -48,13 +52,33 @@ export class OidcProviderRepository {
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Omit<typeof schema.oidcProviders.$inferInsert, 'id'>>) {
|
||||
const [row] = await this.db.update(schema.oidcProviders).set(data).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const current = await tx.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, id) });
|
||||
if (!current) return null;
|
||||
if (this.changesAuthenticationConfiguration(current, data)) {
|
||||
await this.authenticationPolicy.assertProviderAuthenticationCanChange(tx, id);
|
||||
}
|
||||
const [row] = await tx.update(schema.oidcProviders).set(data).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const [row] = await this.db.delete(schema.oidcProviders).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const [{ count }] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.oidcIdentities)
|
||||
.where(eq(schema.oidcIdentities.providerId, id));
|
||||
if ((count ?? 0) > 0) {
|
||||
throw new BadRequestException(`Cannot delete provider: ${count} user(s) have linked identities. Unlink all users first.`);
|
||||
}
|
||||
const [row] = await tx.delete(schema.oidcProviders).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async reorder(orderedIds: number[]) {
|
||||
@@ -101,4 +125,13 @@ export class OidcProviderRepository {
|
||||
.returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private changesAuthenticationConfiguration(
|
||||
current: typeof schema.oidcProviders.$inferSelect,
|
||||
data: Partial<Omit<typeof schema.oidcProviders.$inferInsert, 'id'>>,
|
||||
): boolean {
|
||||
return (['enabled', 'issuerUri', 'clientId', 'clientSecret', 'scopes'] as const).some(
|
||||
(key) => Object.prototype.hasOwnProperty.call(data, key) && data[key] !== current[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('AuthController', () => {
|
||||
await controller.oidcGenerateLinkState({ id: 7 } as never, 'keycloak');
|
||||
await controller.oidcGeneratePreviewState('keycloak');
|
||||
await controller.oidcGetIdentities({ id: 7 } as never);
|
||||
await controller.oidcUnlinkIdentity({ id: 7 } as never, 3, { password: 'Secret1!' });
|
||||
await controller.oidcUnlinkIdentity({ id: 7, authenticationMethod: 'oidc' } as never, 3, { password: 'Secret1!' });
|
||||
|
||||
expect(oidcService.generateState).toHaveBeenCalledWith('keycloak');
|
||||
expect(oidcService.handleCallback).toHaveBeenCalledWith({ code: 'abc' }, reply);
|
||||
@@ -99,7 +99,7 @@ describe('AuthController', () => {
|
||||
expect(oidcService.generateLinkState).toHaveBeenCalledWith(7, 'keycloak');
|
||||
expect(oidcService.generatePreviewState).toHaveBeenCalledWith('keycloak');
|
||||
expect(oidcService.getLinkedIdentities).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 3, 'Secret1!');
|
||||
expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 3, 'Secret1!', 'oidc');
|
||||
});
|
||||
|
||||
it('delegates magic link endpoints to MagicLinkService', async () => {
|
||||
|
||||
@@ -63,6 +63,12 @@ export class AuthController {
|
||||
return this.authService.setupStatus();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('login-options')
|
||||
loginOptions() {
|
||||
return this.authService.loginOptions();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 3, ttl: ONE_MINUTE_MS } })
|
||||
@Post('setup')
|
||||
@@ -194,7 +200,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
|
||||
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Param('providerId', ParseIntPipe) providerId: number, @Body() dto: OidcUnlinkDto) {
|
||||
return this.oidcService.unlinkIdentity(user.id, providerId, dto.password);
|
||||
return this.oidcService.unlinkIdentity(user.id, providerId, dto.password, user.authenticationMethod);
|
||||
}
|
||||
|
||||
@Post('magic-links')
|
||||
|
||||
@@ -108,6 +108,11 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
hasActiveByUserId: vi.fn().mockResolvedValue(true),
|
||||
countActiveByUserId: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
const oidcProviderService = { findEnabled: vi.fn().mockResolvedValue([]) };
|
||||
const authenticationPolicy = {
|
||||
assertPasswordLoginEnabled: vi.fn(),
|
||||
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const service = new AuthService(
|
||||
userService as never,
|
||||
@@ -119,10 +124,25 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
{ emit: vi.fn() } as never,
|
||||
magicLinkRepo as never,
|
||||
appSettings as never,
|
||||
oidcProviderService as never,
|
||||
authenticationPolicy as never,
|
||||
db,
|
||||
);
|
||||
|
||||
return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery, magicLinkRepo };
|
||||
return {
|
||||
service,
|
||||
db,
|
||||
userService,
|
||||
jwtService,
|
||||
config,
|
||||
systemMailService,
|
||||
appSettings,
|
||||
oidcSessionRepo,
|
||||
oidcDiscovery,
|
||||
magicLinkRepo,
|
||||
oidcProviderService,
|
||||
authenticationPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -185,6 +205,30 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginOptions', () => {
|
||||
it('returns password and registration policy with the enabled OIDC providers atomically', async () => {
|
||||
const { service, appSettings, oidcProviderService } = makeService();
|
||||
appSettings.getValue.mockResolvedValue('true');
|
||||
oidcProviderService.findEnabled.mockResolvedValue([
|
||||
{ slug: 'sso', displayName: 'SSO', enabled: true, iconUrl: null, clientId: 'client', scopes: 'openid', issuerUri: 'secret' },
|
||||
]);
|
||||
|
||||
await expect(service.loginOptions()).resolves.toEqual({
|
||||
passwordLoginEnabled: true,
|
||||
allowRegistration: true,
|
||||
oidcProviders: [{ slug: 'sso', displayName: 'SSO', enabled: true, iconUrl: null, clientId: 'client', scopes: 'openid' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('never advertises registration when password authentication is disabled', async () => {
|
||||
const { service, appSettings, authenticationPolicy } = makeService();
|
||||
appSettings.getValue.mockResolvedValue('true');
|
||||
authenticationPolicy.isPasswordLoginEnabled.mockReturnValue(false);
|
||||
|
||||
await expect(service.loginOptions()).resolves.toMatchObject({ passwordLoginEnabled: false, allowRegistration: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('throws ForbiddenException when setup token is invalid in production', async () => {
|
||||
const { service, config } = makeService();
|
||||
@@ -392,6 +436,17 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('rejects a disabled password login before lookup or lockout mutation', async () => {
|
||||
const { service, db, userService, authenticationPolicy } = makeService();
|
||||
authenticationPolicy.assertPasswordLoginEnabled.mockImplementation(() => {
|
||||
throw new ForbiddenException({ message: 'Password authentication is disabled', errorCode: LoginErrorCode.PASSWORD_AUTH_DISABLED });
|
||||
});
|
||||
|
||||
await expect(service.login({ username: 'jdoe', password: 'wrong' }, makeReply())).rejects.toThrow(ForbiddenException);
|
||||
expect(userService.findByUsername).not.toHaveBeenCalled();
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when user not found', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByUsername.mockResolvedValue(null);
|
||||
@@ -639,6 +694,27 @@ describe('AuthService', () => {
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'unknown-token' }), makeReply())).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('revokes a password refresh token when password authentication is disabled', async () => {
|
||||
const { service, db, authenticationPolicy } = makeService();
|
||||
const reply = makeReply();
|
||||
authenticationPolicy.isPasswordLoginEnabled.mockReturnValue(false);
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({
|
||||
id: 9,
|
||||
userId: 5,
|
||||
authenticationMethod: 'password',
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'password-session' }), reply)).rejects.toThrow(UnauthorizedException);
|
||||
expect((db as unknown as Record<string, vi.Mock>).set).toHaveBeenCalledWith({ revokedAt: expect.any(Date) });
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns access-only success for a recently rotated token reuse', async () => {
|
||||
const { service, db, jwtService } = makeService();
|
||||
const reply = makeReply();
|
||||
@@ -667,7 +743,7 @@ describe('AuthService', () => {
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'recently-rotated-token' }), reply)).resolves.toEqual({ accessToken: 'signed-jwt' });
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 7 });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 7, amr: 'legacy' });
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect((db as unknown as Record<string, vi.Mock>).set).toHaveBeenCalledWith(expect.objectContaining({ lastAuthenticatedAt: expect.any(Date) }));
|
||||
expect(db.delete).not.toHaveBeenCalled();
|
||||
@@ -885,7 +961,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'oldest-token' }), reply)).resolves.toEqual({ accessToken: 'signed-jwt' });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 9 });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 9, amr: 'legacy' });
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
const setCookieCalls = (reply as unknown as { setCookie: vi.Mock }).setCookie.mock.calls;
|
||||
expect(setCookieCalls.map(([name]) => name)).toEqual(['access_token']);
|
||||
@@ -959,7 +1035,7 @@ describe('AuthService', () => {
|
||||
(db.where as vi.Mock).mockResolvedValueOnce({ rowCount: 0 });
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'racing-token' }), reply)).resolves.toEqual({ accessToken: 'signed-jwt' });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 3 });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 3, amr: 'legacy' });
|
||||
// No refresh cookie was set on the race-loser path
|
||||
const setCookieCalls = (reply as unknown as { setCookie: vi.Mock }).setCookie.mock.calls;
|
||||
expect(setCookieCalls.map(([name]) => name)).toEqual(['access_token']);
|
||||
@@ -1045,7 +1121,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'widened-grace-token' }), reply)).resolves.toEqual({ accessToken: 'signed-jwt' });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 4 });
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({ sub: 5, ver: 4, amr: 'legacy' });
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when token is expired', async () => {
|
||||
@@ -1089,6 +1165,24 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('validateUser', () => {
|
||||
it('rejects password and legacy access tokens in SSO-only mode before loading the user', async () => {
|
||||
const { service, userService, authenticationPolicy } = makeService();
|
||||
authenticationPolicy.isPasswordLoginEnabled.mockReturnValue(false);
|
||||
|
||||
await expect(service.validateUser(1, 1, 'password')).rejects.toThrow(UnauthorizedException);
|
||||
await expect(service.validateUser(1, 1, 'legacy')).rejects.toThrow(UnauthorizedException);
|
||||
expect(userService.findByIdWithPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['oidc', 'magic_link'] as const)('accepts an active %s session in SSO-only mode', async (authenticationMethod) => {
|
||||
const { service, userService, authenticationPolicy } = makeService();
|
||||
authenticationPolicy.isPasswordLoginEnabled.mockReturnValue(false);
|
||||
const user = makeFullUser({ tokenVersion: 2 });
|
||||
userService.findByIdWithPermissions.mockResolvedValue(user);
|
||||
|
||||
await expect(service.validateUser(1, 2, authenticationMethod)).resolves.toEqual({ ...user, authenticationMethod });
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when user not found', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByIdWithPermissions.mockResolvedValue(null);
|
||||
|
||||
@@ -19,7 +19,8 @@ import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { AuditAction, LoginErrorCode } from '@bookorbit/types';
|
||||
import { AuditAction, AuthenticationMethod, LoginErrorCode } from '@bookorbit/types';
|
||||
import type { AuthenticationMethod as AuthenticationMethodValue, LoginOptionsResponse } from '@bookorbit/types';
|
||||
|
||||
import { APP_SETTING_KEYS } from '../../common/constants/app-settings.constants';
|
||||
import { DB } from '../../db/db.module';
|
||||
@@ -40,6 +41,8 @@ import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
|
||||
import { OidcSessionRepository } from './oidc/oidc-session.repository';
|
||||
import { MagicLinkRepository } from './magic-link.repository';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { OidcProviderService } from '../app-settings/oidc-provider.service';
|
||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
const match = duration.match(/^(\d+)([smhd])$/);
|
||||
@@ -92,6 +95,8 @@ export class AuthService {
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly magicLinkRepo: MagicLinkRepository,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly oidcProviderService: OidcProviderService,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
@Inject(DB) private readonly db: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
@@ -100,6 +105,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
if (!(await this.isRegistrationOpen())) {
|
||||
throw new ForbiddenException('Registration is not open');
|
||||
}
|
||||
@@ -163,10 +169,28 @@ export class AuthService {
|
||||
|
||||
async setupStatus(): Promise<{ needsSetup: boolean; allowRegistration: boolean }> {
|
||||
const [count, allowRegistration] = await Promise.all([this.db.$count(schema.users), this.isRegistrationOpen()]);
|
||||
return { needsSetup: count === 0, allowRegistration };
|
||||
return { needsSetup: count === 0, allowRegistration: this.authenticationPolicy.isPasswordLoginEnabled() && allowRegistration };
|
||||
}
|
||||
|
||||
async loginOptions(): Promise<LoginOptionsResponse> {
|
||||
const [allowRegistration, providers] = await Promise.all([this.isRegistrationOpen(), this.oidcProviderService.findEnabled()]);
|
||||
const passwordLoginEnabled = this.authenticationPolicy.isPasswordLoginEnabled();
|
||||
return {
|
||||
passwordLoginEnabled,
|
||||
allowRegistration: passwordLoginEnabled && allowRegistration,
|
||||
oidcProviders: providers.map((provider) => ({
|
||||
slug: provider.slug,
|
||||
displayName: provider.displayName,
|
||||
enabled: provider.enabled,
|
||||
iconUrl: provider.iconUrl,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async setup(dto: SetupDto, setupToken: string | undefined, reply: FastifyReply) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
this.assertSetupToken(setupToken);
|
||||
const passwordHash = await hash(dto.password, 12);
|
||||
|
||||
@@ -219,10 +243,11 @@ export class AuthService {
|
||||
return user;
|
||||
});
|
||||
|
||||
return this.issueTokensForUser(created.id, reply);
|
||||
return this.issueTokensForUser(created.id, reply, AuthenticationMethod.Setup);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, reply: FastifyReply, ip?: string) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
const user = await this.userService.findByUsername(dto.username);
|
||||
const now = new Date();
|
||||
|
||||
@@ -277,7 +302,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const fullUser = await this.userService.findByIdWithPermissions(user.id);
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion);
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion, AuthenticationMethod.Password);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
|
||||
@@ -291,10 +316,10 @@ export class AuthService {
|
||||
ip,
|
||||
});
|
||||
|
||||
return { accessToken, user: this.buildUserResponse(fullUser!) };
|
||||
return { accessToken, user: this.buildUserResponse(fullUser!, AuthenticationMethod.Password) };
|
||||
}
|
||||
|
||||
buildUserResponse(user: RequestUser) {
|
||||
buildUserResponse(user: RequestUser, authenticationMethod: AuthenticationMethodValue = user.authenticationMethod ?? AuthenticationMethod.Legacy) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -306,18 +331,19 @@ export class AuthService {
|
||||
settings: user.settings,
|
||||
avatarUrl: resolveUserAvatarUrl(user),
|
||||
provisioningMethod: user.provisioningMethod,
|
||||
authenticationMethod,
|
||||
permissions: user.isSuperuser ? ['*'] : user.permissions,
|
||||
};
|
||||
}
|
||||
|
||||
async issueTokensForUser(userId: number, reply: FastifyReply) {
|
||||
async issueTokensForUser(userId: number, reply: FastifyReply, authenticationMethod: AuthenticationMethodValue) {
|
||||
const user = await this.userService.findByIdWithPermissions(userId);
|
||||
if (!user || !user.active) throw new UnauthorizedException();
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(userId, user.tokenVersion);
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt } = await this.issueTokenPair(userId, user.tokenVersion, authenticationMethod);
|
||||
await this.oidcSessionRepo.touchActiveByUserId(userId, refreshExpiresAt);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
return { accessToken, user: this.buildUserResponse(user) };
|
||||
return { accessToken, user: this.buildUserResponse(user, authenticationMethod) };
|
||||
}
|
||||
|
||||
async refresh(req: FastifyRequest, reply: FastifyReply) {
|
||||
@@ -334,9 +360,17 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const authenticationMethod = this.normalizeAuthenticationMethod(row.authenticationMethod);
|
||||
if (!this.isAuthenticationMethodAllowed(authenticationMethod)) {
|
||||
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
|
||||
this.clearRefreshCookie(reply);
|
||||
this.clearAccessCookie(reply);
|
||||
throw new UnauthorizedException('Password-authenticated session is no longer allowed');
|
||||
}
|
||||
|
||||
if (row.revokedAt) {
|
||||
if (await this.isRecentRefreshRotationReuse(row)) {
|
||||
return this.issueAccessOnlyForRefreshReuse(row.userId, reply);
|
||||
return this.issueAccessOnlyForRefreshReuse(row.userId, authenticationMethod, reply);
|
||||
}
|
||||
|
||||
// Reuse of a revoked token outside a fresh rotation indicates possible theft.
|
||||
@@ -367,7 +401,11 @@ export class AuthService {
|
||||
// Rotate: revoke old, issue new. Guard against the SPA race where two requests
|
||||
// present the same (still-valid) refresh: the loser's UPDATE matches zero rows
|
||||
// and we treat it as a benign reuse of an already-rotated token.
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt, refreshTokenHash } = this.createTokenPair(row.userId, userForToken.tokenVersion);
|
||||
const { accessToken, rawRefreshToken, refreshExpiresAt, refreshTokenHash } = this.createTokenPair(
|
||||
row.userId,
|
||||
userForToken.tokenVersion,
|
||||
authenticationMethod,
|
||||
);
|
||||
|
||||
const rotatedAt = new Date();
|
||||
try {
|
||||
@@ -379,7 +417,9 @@ export class AuthService {
|
||||
if ((updateResult.rowCount ?? 0) === 0) {
|
||||
throw new ConcurrentRotationError();
|
||||
}
|
||||
await tx.insert(schema.refreshTokens).values({ userId: row.userId, tokenHash: refreshTokenHash, expiresAt: refreshExpiresAt });
|
||||
await tx
|
||||
.insert(schema.refreshTokens)
|
||||
.values({ userId: row.userId, tokenHash: refreshTokenHash, expiresAt: refreshExpiresAt, authenticationMethod });
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ lastAuthenticatedAt: rotatedAt, updatedAt: sql`${schema.users.updatedAt}` })
|
||||
@@ -396,7 +436,7 @@ export class AuthService {
|
||||
});
|
||||
if (refreshedRow && (await this.isRecentRefreshRotationReuse(refreshedRow))) {
|
||||
this.logger.log(`[auth.refresh] [end] userId=${row.userId} reason="rotation-race-lost" - access-only refresh issued`);
|
||||
return this.issueAccessOnlyForRefreshReuse(row.userId, reply);
|
||||
return this.issueAccessOnlyForRefreshReuse(row.userId, authenticationMethod, reply);
|
||||
}
|
||||
// Concurrent non-rotation revoke = the user (logout) or an admin (disable/security)
|
||||
// just killed this specific session. Honor that intent: fail this refresh and clear
|
||||
@@ -494,14 +534,15 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateUser(userId: number, tokenVersion: number) {
|
||||
async validateUser(userId: number, tokenVersion: number, authenticationMethod: AuthenticationMethodValue) {
|
||||
if (!this.isAuthenticationMethodAllowed(authenticationMethod)) throw new UnauthorizedException();
|
||||
const user = await this.userService.findByIdWithPermissions(userId);
|
||||
if (!user || !user.active) throw new UnauthorizedException();
|
||||
// Before the shared-link lookup, so a stale token is refused without a second query.
|
||||
if (user.tokenVersion !== tokenVersion) throw new UnauthorizedException();
|
||||
if (!(await this.canActNow(user))) throw new UnauthorizedException();
|
||||
|
||||
return user;
|
||||
return { ...user, authenticationMethod };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,7 +585,7 @@ export class AuthService {
|
||||
const rows = await this.db.query.refreshTokens.findMany({
|
||||
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())),
|
||||
});
|
||||
return rows.map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
|
||||
return rows.map(({ id, createdAt, expiresAt, authenticationMethod }) => ({ id, createdAt, expiresAt, authenticationMethod }));
|
||||
}
|
||||
|
||||
async revokeSession(userId: number, sessionId: number) {
|
||||
@@ -557,6 +598,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
if (!(await this.systemMailService.isConfigured())) {
|
||||
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
|
||||
}
|
||||
@@ -598,6 +640,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async resetPassword(dto: ResetPasswordDto, ip?: string): Promise<void> {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
const tokenHash = sha256(dto.token);
|
||||
|
||||
const row = await this.db.query.passwordResetTokens.findFirst({
|
||||
@@ -645,6 +688,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
const user = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
});
|
||||
@@ -697,12 +741,14 @@ export class AuthService {
|
||||
return new Date(baseDate.getTime() + parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d'));
|
||||
}
|
||||
|
||||
private async issueTokenPair(userId: number, tokenVersion: number) {
|
||||
const pair = this.createTokenPair(userId, tokenVersion);
|
||||
private async issueTokenPair(userId: number, tokenVersion: number, authenticationMethod: AuthenticationMethodValue) {
|
||||
const pair = this.createTokenPair(userId, tokenVersion, authenticationMethod);
|
||||
const authenticatedAt = new Date();
|
||||
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx.insert(schema.refreshTokens).values({ userId, tokenHash: pair.refreshTokenHash, expiresAt: pair.refreshExpiresAt });
|
||||
await tx
|
||||
.insert(schema.refreshTokens)
|
||||
.values({ userId, tokenHash: pair.refreshTokenHash, expiresAt: pair.refreshExpiresAt, authenticationMethod });
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ lastLoginAt: authenticatedAt, lastAuthenticatedAt: authenticatedAt, updatedAt: sql`${schema.users.updatedAt}` })
|
||||
@@ -712,8 +758,8 @@ export class AuthService {
|
||||
return pair;
|
||||
}
|
||||
|
||||
private createTokenPair(userId: number, tokenVersion: number) {
|
||||
const accessToken = this.jwtService.sign({ sub: userId, ver: tokenVersion });
|
||||
private createTokenPair(userId: number, tokenVersion: number, authenticationMethod: AuthenticationMethodValue) {
|
||||
const accessToken = this.jwtService.sign({ sub: userId, ver: tokenVersion, amr: authenticationMethod });
|
||||
|
||||
const rawRefreshToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = sha256(rawRefreshToken);
|
||||
@@ -747,7 +793,13 @@ export class AuthService {
|
||||
const next: typeof schema.refreshTokens.$inferSelect | undefined = await this.db.query.refreshTokens.findFirst({
|
||||
where: eq(schema.refreshTokens.tokenHash, nextHash),
|
||||
});
|
||||
if (!next || next.userId !== row.userId || next.expiresAt < new Date()) return false;
|
||||
if (
|
||||
!next ||
|
||||
next.userId !== row.userId ||
|
||||
next.expiresAt < new Date() ||
|
||||
this.normalizeAuthenticationMethod(next.authenticationMethod) !== this.normalizeAuthenticationMethod(row.authenticationMethod)
|
||||
)
|
||||
return false;
|
||||
if (!next.revokedAt) return true;
|
||||
// Revoked-but-rotated link: keep walking. Revoked-without-rotation means logout/security revoke.
|
||||
if (!next.rotatedAt || !next.replacedByTokenHash) return false;
|
||||
@@ -778,9 +830,9 @@ export class AuthService {
|
||||
return userForToken;
|
||||
}
|
||||
|
||||
private async issueAccessOnlyForRefreshReuse(userId: number, reply: FastifyReply) {
|
||||
private async issueAccessOnlyForRefreshReuse(userId: number, authenticationMethod: AuthenticationMethodValue, reply: FastifyReply) {
|
||||
const userForToken = await this.assertUserCanRefresh(userId, reply);
|
||||
const accessToken = this.jwtService.sign({ sub: userId, ver: userForToken.tokenVersion });
|
||||
const accessToken = this.jwtService.sign({ sub: userId, ver: userForToken.tokenVersion, amr: authenticationMethod });
|
||||
await this.db
|
||||
.update(schema.users)
|
||||
.set({ lastAuthenticatedAt: new Date(), updatedAt: sql`${schema.users.updatedAt}` })
|
||||
@@ -790,6 +842,17 @@ export class AuthService {
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
private normalizeAuthenticationMethod(value: string): AuthenticationMethodValue {
|
||||
return Object.values(AuthenticationMethod).includes(value as AuthenticationMethodValue)
|
||||
? (value as AuthenticationMethodValue)
|
||||
: AuthenticationMethod.Legacy;
|
||||
}
|
||||
|
||||
private isAuthenticationMethodAllowed(authenticationMethod: AuthenticationMethodValue): boolean {
|
||||
if (this.authenticationPolicy.isPasswordLoginEnabled()) return true;
|
||||
return authenticationMethod === AuthenticationMethod.Oidc || authenticationMethod === AuthenticationMethod.MagicLink;
|
||||
}
|
||||
|
||||
private assertSetupToken(setupToken: string | undefined) {
|
||||
const isDevelopment = this.config.get<string>('app.nodeEnv') === 'development';
|
||||
if (isDevelopment) return;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class OidcUnlinkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,16 @@ describe('JwtStrategy.validate', () => {
|
||||
|
||||
const result = await strategy.validate({ sub: 1, ver: 2 });
|
||||
expect(result).toEqual(user);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(1, 2);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(1, 2, 'legacy');
|
||||
});
|
||||
|
||||
it('passes the authentication method through to user validation', async () => {
|
||||
const { strategy, authService } = makeStrategy();
|
||||
authService.validateUser.mockResolvedValue({ id: 1 });
|
||||
|
||||
await strategy.validate({ sub: 1, ver: 2, amr: 'oidc' });
|
||||
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(1, 2, 'oidc');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when validateUser returns null', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import type { AuthenticationMethod } from '@bookorbit/types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -18,8 +19,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: number; ver: number }) {
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
async validate(payload: { sub: number; ver: number; amr?: AuthenticationMethod }) {
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('MagicLinkService', () => {
|
||||
const result = await service.loginWithToken('valid-token', reply, '10.0.0.1');
|
||||
|
||||
expect(magicLinkRepo.updateUsage).toHaveBeenCalledWith(5);
|
||||
expect(authService.issueTokensForUser).toHaveBeenCalledWith(2, reply);
|
||||
expect(authService.issueTokensForUser).toHaveBeenCalledWith(2, reply, 'magic_link');
|
||||
expect(result).toEqual({ accessToken: 'jwt', user: {} });
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.login' }));
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundEx
|
||||
import { createHash } from 'crypto';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
import { AuditAction, AuditResource, AuthenticationMethod } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
|
||||
@@ -115,7 +115,7 @@ export class MagicLinkService {
|
||||
}
|
||||
|
||||
await this.magicLinkRepo.updateUsage(row.id);
|
||||
const result = await this.authService.issueTokensForUser(row.userId, reply);
|
||||
const result = await this.authService.issueTokensForUser(row.userId, reply, AuthenticationMethod.MagicLink);
|
||||
|
||||
this.logger.log(`[magic_link.login] [end] tokenId=${row.id} userId=${row.userId} ip=${ip ?? 'unknown'} - magic link login completed`);
|
||||
|
||||
|
||||
@@ -90,6 +90,9 @@ function makeService() {
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
const authenticationPolicy = {
|
||||
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const service = new OidcService(
|
||||
providerService as never,
|
||||
@@ -106,6 +109,7 @@ function makeService() {
|
||||
authService as never,
|
||||
auditEvents as never,
|
||||
configService as never,
|
||||
authenticationPolicy as never,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -121,6 +125,7 @@ function makeService() {
|
||||
tokenValidator,
|
||||
identityRepo,
|
||||
groupMapping,
|
||||
authenticationPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,6 +249,29 @@ describe('OidcService', () => {
|
||||
});
|
||||
|
||||
describe('unlinkIdentity', () => {
|
||||
it('lets an OIDC-authenticated session unlink without sending a password', async () => {
|
||||
const { service, userService, identityRepo } = makeService();
|
||||
userService.findById.mockResolvedValue({ id: 5, provisioningMethod: 'local' });
|
||||
identityRepo.findByUserAndProvider.mockResolvedValue({ id: 1, oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
|
||||
await service.unlinkIdentity(5, 1, undefined, 'oidc');
|
||||
|
||||
expect(userService.findPasswordHashById).not.toHaveBeenCalled();
|
||||
expect(identityRepo.remove).toHaveBeenCalledWith(5, 1);
|
||||
});
|
||||
|
||||
it('lets an SSO-only session unlink without a password but leaves final-method checks to the repository', async () => {
|
||||
const { service, userService, identityRepo, authenticationPolicy } = makeService();
|
||||
authenticationPolicy.isPasswordLoginEnabled.mockReturnValue(false);
|
||||
userService.findById.mockResolvedValue({ id: 5, provisioningMethod: 'local' });
|
||||
identityRepo.findByUserAndProvider.mockResolvedValue({ id: 1, oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
|
||||
await service.unlinkIdentity(5, 1, undefined, 'magic_link');
|
||||
|
||||
expect(userService.findPasswordHashById).not.toHaveBeenCalled();
|
||||
expect(identityRepo.remove).toHaveBeenCalledWith(5, 1);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when password is incorrect', async () => {
|
||||
const { service, userService } = makeService();
|
||||
const bcrypt = await import('bcryptjs');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { compare } from 'bcryptjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { AuditAction, AuditResource, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types';
|
||||
import { AuditAction, AuditResource, AuthenticationMethod, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types';
|
||||
import type { OidcAutoProvision, OidcClaimMapping } from '@bookorbit/types';
|
||||
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../../audit/audit-events.service';
|
||||
@@ -18,6 +18,7 @@ import { OidcSessionRepository } from './oidc-session.repository';
|
||||
import { OidcStateService } from './oidc-state.service';
|
||||
import { OidcTokenClientService } from './oidc-token-client.service';
|
||||
import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||
import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
@@ -61,6 +62,7 @@ export class OidcService {
|
||||
private readonly authService: AuthService,
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
) {
|
||||
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
}
|
||||
@@ -112,26 +114,24 @@ export class OidcService {
|
||||
return this.identityRepo.findByUser(userId);
|
||||
}
|
||||
|
||||
async unlinkIdentity(userId: number, providerId: number, password: string): Promise<void> {
|
||||
async unlinkIdentity(
|
||||
userId: number,
|
||||
providerId: number,
|
||||
password: string | undefined,
|
||||
authenticationMethod: AuthenticationMethod = AuthenticationMethod.Legacy,
|
||||
): Promise<void> {
|
||||
if (this.authenticationPolicy.isPasswordLoginEnabled() && authenticationMethod !== AuthenticationMethod.Oidc) {
|
||||
if (!password) throw new BadRequestException('Password confirmation is required');
|
||||
const hash = await this.userService.findPasswordHashById(userId);
|
||||
if (!hash) throw new BadRequestException('User not found');
|
||||
|
||||
const valid = await compare(password, hash);
|
||||
if (!valid) throw new BadRequestException('Incorrect password');
|
||||
}
|
||||
|
||||
const identity = await this.identityRepo.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) throw new NotFoundException('No identity linked to this provider');
|
||||
|
||||
// D4: Check if user has local password OR another linked identity
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) throw new BadRequestException('User not found');
|
||||
|
||||
const identityCount = await this.identityRepo.countByUser(userId);
|
||||
const hasLocalAuth = user.provisioningMethod !== 'oidc';
|
||||
if (!hasLocalAuth && identityCount <= 1) {
|
||||
throw new BadRequestException('Cannot unlink: this is your only authentication method. Set a password first.');
|
||||
}
|
||||
|
||||
await this.identityRepo.remove(userId, providerId);
|
||||
|
||||
// Clean up provider-scoped permission grants
|
||||
@@ -272,7 +272,7 @@ export class OidcService {
|
||||
description: `User logged in via OIDC (issuer: ${disc.issuer})`,
|
||||
});
|
||||
|
||||
const authResult = await this.authService.issueTokensForUser(user.id, reply);
|
||||
const authResult = await this.authService.issueTokensForUser(user.id, reply, AuthenticationMethod.Oidc);
|
||||
return { mode: 'login' as const, ...authResult } as OidcCallbackResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('AuthorEnrichmentGateway', () => {
|
||||
|
||||
await gateway.handleConnection(client);
|
||||
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(11, 2);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(11, 2, 'legacy');
|
||||
expect(client.emit).toHaveBeenCalledWith(AUTHOR_ENRICHMENT_STATUS_EVENT, {
|
||||
queued: 3,
|
||||
processing: 1,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/commo
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Permission, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
|
||||
import { Permission, type AuthenticationMethod, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
@@ -47,8 +47,8 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
|
||||
this.assertCanViewStatus(user);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('BookDockGateway', () => {
|
||||
|
||||
await gateway.handleConnection(client);
|
||||
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(8, 3);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(8, 3, 'legacy');
|
||||
expect(client.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import type { AuthenticationMethod } from '@bookorbit/types';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||
import { wsCorsOrigin } from '../../common/utils/ws-cors.utils';
|
||||
@@ -21,8 +23,8 @@ export class BookDockGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
try {
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
(client.data as Record<string, unknown>).user = user;
|
||||
this.logger.debug(`WS connected: user=${user.id} socket=${client.id}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Permission, type BookMetadataFetchStatusEvent } from '@bookorbit/types';
|
||||
import { Permission, type AuthenticationMethod, type BookMetadataFetchStatusEvent } from '@bookorbit/types';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
@@ -32,8 +32,8 @@ export class BookMetadataFetchGateway implements OnGatewayConnection, OnGatewayD
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
|
||||
this.assertCanViewStatus(user);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { Permission, type AuthenticationMethod } from '@bookorbit/types';
|
||||
import type { BookRequestProgressEvent } from '@bookorbit/types';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
@@ -43,8 +43,8 @@ export class BookRequestGateway implements OnGatewayConnection, OnGatewayDisconn
|
||||
try {
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
if (!user.isSuperuser && !user.permissions.includes(Permission.BookRequestAccess)) {
|
||||
throw new UnauthorizedException('Missing book request access');
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('MigrationProgressGateway', () => {
|
||||
await gateway.handleConnection(client as never);
|
||||
|
||||
expect(jwtService.verify).toHaveBeenCalledWith('token-1', { algorithms: ['HS256'] });
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(7, 1);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(7, 1, 'legacy');
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
expect((client.data as { user?: RequestUser }).user?.id).toBe(7);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { Permission, type MigrationProgressEvent, type MigrationRunState } from '@bookorbit/types';
|
||||
import { Permission, type AuthenticationMethod, type MigrationProgressEvent, type MigrationRunState } from '@bookorbit/types';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
@@ -40,8 +40,8 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
|
||||
try {
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
this.assertCanViewMigrationProgress(user);
|
||||
(client.data as Record<string, unknown>).user = user;
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('handleConnection', () => {
|
||||
await gateway.handleConnection(client);
|
||||
|
||||
expect(jwtService.verify).toHaveBeenCalledWith('valid', { algorithms: ['HS256'] });
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(42, 3);
|
||||
expect(authService.validateUser).toHaveBeenCalledWith(42, 3, 'legacy');
|
||||
expect(client.data.user).toEqual({ id: 42, username: 'reader' });
|
||||
expect(join).toHaveBeenCalledWith('user:42');
|
||||
expect(disconnect).not.toHaveBeenCalled();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Server, Socket } from 'socket.io';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
import type {
|
||||
AuthenticationMethod,
|
||||
BookMissingEvent,
|
||||
BookMovedEvent,
|
||||
BookProgressChangedEvent,
|
||||
@@ -65,8 +66,8 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
try {
|
||||
const token = client.handshake.auth?.token as string | undefined;
|
||||
if (!token) throw new UnauthorizedException('No token provided');
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
||||
const payload = this.jwtService.verify<{ sub: number; ver: number; amr?: AuthenticationMethod }>(token, { algorithms: ['HS256'] });
|
||||
const user = await this.authService.validateUser(payload.sub, payload.ver, payload.amr ?? 'legacy');
|
||||
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
||||
(client.data as Record<string, unknown>).user = user;
|
||||
await client.join(`user:${user.id}`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { OidcIdentityRepository } from './oidc-identity.repository';
|
||||
|
||||
function makeRepository(options: { passwordLoginEnabled: boolean; userProvisioningMethod?: string; hasEnabledIdentity: boolean }) {
|
||||
const returning = vi.fn().mockResolvedValue([{ id: 4, userId: 7, providerId: 2 }]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const tx = {
|
||||
delete: vi.fn().mockReturnValue({ where }),
|
||||
query: { users: { findFirst: vi.fn().mockResolvedValue({ id: 7, provisioningMethod: options.userProvisioningMethod ?? 'local' }) } },
|
||||
};
|
||||
const db = { transaction: vi.fn(async (callback: (value: typeof tx) => Promise<unknown>) => callback(tx)) };
|
||||
const policy = {
|
||||
lockAdministratorAvailability: vi.fn(),
|
||||
isPasswordLoginEnabled: vi.fn().mockReturnValue(options.passwordLoginEnabled),
|
||||
hasEnabledOidcIdentityForUser: vi.fn().mockResolvedValue(options.hasEnabledIdentity),
|
||||
assertUsableOidcSuperuser: vi.fn(),
|
||||
};
|
||||
return { repository: new OidcIdentityRepository(db as never, policy as never), policy };
|
||||
}
|
||||
|
||||
describe('OidcIdentityRepository', () => {
|
||||
it('rolls back unlinking the final enabled sign-in method in SSO-only mode', async () => {
|
||||
const { repository, policy } = makeRepository({ passwordLoginEnabled: false, hasEnabledIdentity: false });
|
||||
|
||||
await expect(repository.remove(7, 2)).rejects.toThrow(ConflictException);
|
||||
expect(policy.lockAdministratorAvailability).toHaveBeenCalledOnce();
|
||||
expect(policy.assertUsableOidcSuperuser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows unlinking when another enabled identity remains and rechecks administrator availability', async () => {
|
||||
const { repository, policy } = makeRepository({ passwordLoginEnabled: false, hasEnabledIdentity: true });
|
||||
|
||||
await expect(repository.remove(7, 2)).resolves.toMatchObject({ id: 4 });
|
||||
expect(policy.assertUsableOidcSuperuser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('protects an OIDC-provisioned account from losing its final identity even when passwords are enabled', async () => {
|
||||
const { repository } = makeRepository({ passwordLoginEnabled: true, userProvisioningMethod: 'oidc', hasEnabledIdentity: false });
|
||||
|
||||
await expect(repository.remove(7, 2)).rejects.toThrow('Cannot unlink the last enabled sign-in provider for this account');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcIdentityRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
) {}
|
||||
|
||||
async findByProviderAndSubject(providerId: number, oidcSubject: string) {
|
||||
return this.db.query.oidcIdentities.findFirst({
|
||||
@@ -52,11 +56,22 @@ export class OidcIdentityRepository {
|
||||
}
|
||||
|
||||
async remove(userId: number, providerId: number) {
|
||||
const [row] = await this.db
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const [row] = await tx
|
||||
.delete(schema.oidcIdentities)
|
||||
.where(and(eq(schema.oidcIdentities.userId, userId), eq(schema.oidcIdentities.providerId, providerId)))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
if (!row) return null;
|
||||
|
||||
const user = await tx.query.users.findFirst({ where: eq(schema.users.id, userId) });
|
||||
const needsOidcMethod = !this.authenticationPolicy.isPasswordLoginEnabled() || user?.provisioningMethod === 'oidc';
|
||||
if (needsOidcMethod && !(await this.authenticationPolicy.hasEnabledOidcIdentityForUser(tx, userId))) {
|
||||
throw new ConflictException('Cannot unlink the last enabled sign-in provider for this account');
|
||||
}
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async removeAllForUser(userId: number) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { UserStatisticsModule } from '../user-statistics/user-statistics.module';
|
||||
import { ContentFilterRepository } from './content-filter.repository';
|
||||
@@ -12,7 +13,7 @@ import { UserRepository } from './user.repository';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppSettingsModule, UserStatisticsModule],
|
||||
imports: [CommonModule, AppSettingsModule, UserStatisticsModule],
|
||||
controllers: [UserController],
|
||||
providers: [
|
||||
UserService,
|
||||
|
||||
@@ -64,7 +64,15 @@ describe('UserRepository', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
repo = new UserRepository(db as any);
|
||||
repo = new UserRepository(
|
||||
db as any,
|
||||
{
|
||||
lockAdministratorAvailability: vi.fn((tx: { execute: () => unknown }) => tx.execute()),
|
||||
assertUsableOidcSuperuser: vi.fn(),
|
||||
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
|
||||
hasEnabledOidcIdentityForUser: vi.fn().mockResolvedValue(true),
|
||||
} as any,
|
||||
);
|
||||
|
||||
db.update.mockImplementation(() => ({ set: updateSet }));
|
||||
db.insert.mockImplementation(() => ({ values: insertValues }));
|
||||
|
||||
@@ -10,14 +10,13 @@ import type { UserAttentionReason, UserListSortDirection, UserListSortField, Use
|
||||
import { RequestUser } from '../../common/types/request-user';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
|
||||
const SUPERUSER_LIFECYCLE_LOCK_KEY = 'bookorbit:superuser-lifecycle';
|
||||
|
||||
export type ManagedUserMutationStatus = 'updated' | 'target_not_found' | 'requester_not_superuser' | 'last_superuser';
|
||||
export type SuperuserTransitionStatus = ManagedUserMutationStatus | 'unchanged' | 'self_target' | 'shared_target';
|
||||
export type SuperuserTransitionStatus = ManagedUserMutationStatus | 'unchanged' | 'self_target' | 'shared_target' | 'target_no_oidc';
|
||||
|
||||
/**
|
||||
* An account needs an administrator's attention when it is locked out, is still on the
|
||||
@@ -64,7 +63,10 @@ export interface UserListQuery {
|
||||
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
) {}
|
||||
|
||||
async findAll(query: UserListQuery) {
|
||||
const { page, pageSize } = query;
|
||||
@@ -407,7 +409,7 @@ export class UserRepository {
|
||||
data: Partial<Pick<typeof schema.users.$inferInsert, 'name' | 'email' | 'active'>>,
|
||||
): Promise<{ status: ManagedUserMutationStatus; user?: Awaited<ReturnType<UserRepository['update']>> }> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.lockSuperuserLifecycle(tx);
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
|
||||
if (!target) return { status: 'target_not_found' };
|
||||
if (target.isSuperuser && !this.isActiveSuperuser(requester)) return { status: 'requester_not_superuser' };
|
||||
@@ -430,6 +432,7 @@ export class UserRepository {
|
||||
createdAt: schema.users.createdAt,
|
||||
updatedAt: schema.users.updatedAt,
|
||||
});
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return user ? { status: 'updated', user } : { status: 'target_not_found' };
|
||||
});
|
||||
}
|
||||
@@ -440,7 +443,7 @@ export class UserRepository {
|
||||
|
||||
async deleteManagedUser(requestingUserId: number, targetUserId: number, beforeDelete?: () => Promise<void>): Promise<ManagedUserMutationStatus> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.lockSuperuserLifecycle(tx);
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
|
||||
if (!target) return 'target_not_found';
|
||||
if (target.isSuperuser && !this.isActiveSuperuser(requester)) return 'requester_not_superuser';
|
||||
@@ -448,6 +451,7 @@ export class UserRepository {
|
||||
|
||||
if (beforeDelete) await beforeDelete();
|
||||
await tx.delete(schema.users).where(eq(schema.users.id, targetUserId));
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return 'updated';
|
||||
});
|
||||
}
|
||||
@@ -471,13 +475,20 @@ export class UserRepository {
|
||||
|
||||
async setSuperuser(requestingUserId: number, targetUserId: number, isSuperuser: boolean): Promise<SuperuserTransitionStatus> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
await this.lockSuperuserLifecycle(tx);
|
||||
await this.authenticationPolicy.lockAdministratorAvailability(tx);
|
||||
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
|
||||
if (requestingUserId === targetUserId) return 'self_target';
|
||||
if (!this.isActiveSuperuser(requester)) return 'requester_not_superuser';
|
||||
if (!target) return 'target_not_found';
|
||||
if (isSuperuser && target.provisioningMethod === 'shared') return 'shared_target';
|
||||
if (target.isSuperuser === isSuperuser) return 'unchanged';
|
||||
if (
|
||||
isSuperuser &&
|
||||
!this.authenticationPolicy.isPasswordLoginEnabled() &&
|
||||
!(await this.authenticationPolicy.hasEnabledOidcIdentityForUser(tx, targetUserId))
|
||||
) {
|
||||
return 'target_no_oidc';
|
||||
}
|
||||
if (!isSuperuser && (await this.countOtherActiveSuperusers(tx, targetUserId)) === 0) return 'last_superuser';
|
||||
|
||||
const now = new Date();
|
||||
@@ -497,6 +508,7 @@ export class UserRepository {
|
||||
.update(schema.passwordResetTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(and(eq(schema.passwordResetTokens.userId, targetUserId), isNull(schema.passwordResetTokens.usedAt)));
|
||||
await this.authenticationPolicy.assertUsableOidcSuperuser(tx);
|
||||
return 'updated';
|
||||
});
|
||||
}
|
||||
@@ -509,10 +521,6 @@ export class UserRepository {
|
||||
return Number(total);
|
||||
}
|
||||
|
||||
private async lockSuperuserLifecycle(tx: Tx): Promise<void> {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${SUPERUSER_LIFECYCLE_LOCK_KEY})::bigint)`);
|
||||
}
|
||||
|
||||
private async findLifecycleUsers(tx: Tx, requestingUserId: number, targetUserId: number) {
|
||||
const rows = await tx
|
||||
.select({
|
||||
|
||||
@@ -68,7 +68,9 @@ describe('UserService', () => {
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
events = new UserEventsService();
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any, userStatistics as any, events);
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any, userStatistics as any, events, {
|
||||
assertPasswordLoginEnabled: vi.fn(),
|
||||
} as any);
|
||||
|
||||
mockHash.mockResolvedValue('hashed-secret');
|
||||
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
|
||||
@@ -567,6 +569,14 @@ describe('UserService', () => {
|
||||
await expect(service.setSuperuser(2, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('setSuperuser rejects promotion without an enabled OIDC identity in SSO-only mode', async () => {
|
||||
userRepo.setSuperuser.mockResolvedValue('target_no_oidc');
|
||||
|
||||
await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).rejects.toThrow(
|
||||
'An administrator must link an enabled OIDC provider while password authentication is disabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('setSuperuser writes the target superuser flag when allowed', async () => {
|
||||
await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).resolves.toBeUndefined();
|
||||
expect(userRepo.setSuperuser).toHaveBeenCalledWith(1, 2, true);
|
||||
@@ -782,6 +792,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
|
||||
appSettingsService as any,
|
||||
userStatistics as any,
|
||||
new UserEventsService(),
|
||||
{ assertPasswordLoginEnabled: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { USER_DELETING, UserEventsService, type UserDeletingEvent } from './user
|
||||
import { UserRepository, type UserListQuery } from './user.repository';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { UserStatisticsService } from '../user-statistics/user-statistics.service';
|
||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||
|
||||
/** The band is a to-do list, not a second roster. */
|
||||
const ATTENTION_BAND_LIMIT = 8;
|
||||
@@ -37,6 +38,7 @@ export class UserService {
|
||||
private readonly appSettingsService: AppSettingsService,
|
||||
private readonly userStatistics: UserStatisticsService,
|
||||
private readonly events: UserEventsService,
|
||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||
) {}
|
||||
|
||||
findByUsername(username: string) {
|
||||
@@ -130,6 +132,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
async createUser(dto: CreateUserDto) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
const existing = await this.userRepo.findByUsername(dto.username);
|
||||
if (existing) throw new ConflictException('Username already taken');
|
||||
const existingEmail = await this.userRepo.findByEmail(dto.email);
|
||||
@@ -399,6 +402,9 @@ export class UserService {
|
||||
if (result === 'requester_not_superuser') throw new ForbiddenException('Only administrators can change superuser status');
|
||||
if (result === 'target_not_found') throw new NotFoundException('User not found');
|
||||
if (result === 'shared_target') throw new BadRequestException('Shared accounts cannot be made superuser');
|
||||
if (result === 'target_no_oidc') {
|
||||
throw new ConflictException('An administrator must link an enabled OIDC provider while password authentication is disabled');
|
||||
}
|
||||
if (result === 'last_superuser') throw new ConflictException('Cannot remove the last administrator');
|
||||
}
|
||||
|
||||
@@ -421,6 +427,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
async adminResetPassword(targetUserId: number, requestingUser: RequestUser) {
|
||||
this.authenticationPolicy.assertPasswordLoginEnabled();
|
||||
const target = await this.userRepo.findByIdWithPermissions(targetUserId);
|
||||
if (!target) throw new NotFoundException('User not found');
|
||||
if (target.isSuperuser && !requestingUser.isSuperuser) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"totalRoutes": 452,
|
||||
"totalRoutes": 453,
|
||||
"byPermission": {
|
||||
"(none)": 167,
|
||||
"(none)": 168,
|
||||
"Permission.ManageMetadataConfig": 34,
|
||||
"Permission.EmailSend": 30,
|
||||
"Permission.LibraryEditMetadata": 34,
|
||||
|
||||
@@ -299,6 +299,18 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/auth/auth.controller.ts",
|
||||
"className": "AuthController",
|
||||
"methodName": "loginOptions",
|
||||
"httpMethod": "GET",
|
||||
"path": "/auth/login-options",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": true,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/auth/auth.controller.ts",
|
||||
"className": "AuthController",
|
||||
|
||||
Reference in New Issue
Block a user