feat(auth): add SSO-only authentication mode

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

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