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