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 @@ 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 row ?? null;
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 row ?? null;
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');
+17 -17
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> {
const hash = await this.userService.findPasswordHashById(userId);
if (!hash) throw new BadRequestException('User not found');
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 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
.delete(schema.oidcIdentities)
.where(and(eq(schema.oidcIdentities.userId, userId), eq(schema.oidcIdentities.providerId, providerId)))
.returning();
return row ?? null;
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();
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",