mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
cb8d1e3b2b
Keep dashboard layouts browser-local by default and let users opt in to account storage. Group library selection and shelf sync in one dropdown, preserve legacy client settings, and block sync changes for demo-restricted accounts. Fixes #1646
885 lines
32 KiB
TypeScript
885 lines
32 KiB
TypeScript
import { createHash, randomUUID } from 'crypto';
|
|
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import { Test } from '@nestjs/testing';
|
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
import { hash } from 'bcryptjs';
|
|
import { Permission } from '@bookorbit/types';
|
|
|
|
import { AppModule } from '../src/app.module';
|
|
import { DB } from '../src/db';
|
|
import * as schema from '../src/db/schema';
|
|
import { MetadataService } from '../src/modules/metadata/metadata.service';
|
|
import { makeMetadataNoopMock } from './e2e/app-harness';
|
|
import type { Db } from './e2e/app-harness';
|
|
|
|
type CookieJar = Map<string, string>;
|
|
|
|
interface AuthE2EContext {
|
|
app: NestFastifyApplication;
|
|
db: Db;
|
|
}
|
|
|
|
interface LocalUserCredentials {
|
|
userId: number;
|
|
username: string;
|
|
password: string;
|
|
email: string;
|
|
}
|
|
|
|
interface LoginResult {
|
|
userId: number;
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
jar: CookieJar;
|
|
}
|
|
|
|
const ADMIN_SETUP_DTO = {
|
|
username: 'auth-e2e-admin',
|
|
name: 'Auth E2E Admin',
|
|
email: 'auth-e2e-admin@example.com',
|
|
password: 'AuthAdmin123',
|
|
};
|
|
|
|
function sha256(value: string): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function getSetCookieLines(headers: Record<string, string | string[] | undefined>): string[] {
|
|
const raw = headers['set-cookie'];
|
|
if (!raw) return [];
|
|
return Array.isArray(raw) ? raw : [raw];
|
|
}
|
|
|
|
function parseCookiePair(setCookieLine: string): { name: string; value: string } | null {
|
|
const firstSegment = setCookieLine.split(';', 1)[0];
|
|
if (!firstSegment) return null;
|
|
const eqIndex = firstSegment.indexOf('=');
|
|
if (eqIndex <= 0) return null;
|
|
return {
|
|
name: firstSegment.slice(0, eqIndex),
|
|
value: firstSegment.slice(eqIndex + 1),
|
|
};
|
|
}
|
|
|
|
function findCookieLine(setCookieLines: string[], cookieName: string): string | undefined {
|
|
return setCookieLines.find((line) => line.startsWith(`${cookieName}=`));
|
|
}
|
|
|
|
function cookieValue(setCookieLines: string[], cookieName: string): string | null {
|
|
const line = findCookieLine(setCookieLines, cookieName);
|
|
if (!line) return null;
|
|
const parsed = parseCookiePair(line);
|
|
return parsed?.value ?? null;
|
|
}
|
|
|
|
function cookieAttribute(setCookieLine: string, attribute: string): string | null {
|
|
const segments = setCookieLine.split(';').map((segment) => segment.trim());
|
|
const target = attribute.toLowerCase();
|
|
for (const segment of segments.slice(1)) {
|
|
const [key, ...rest] = segment.split('=');
|
|
if (key.toLowerCase() !== target) continue;
|
|
return rest.length > 0 ? rest.join('=') : '';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void {
|
|
for (const line of setCookieLines) {
|
|
const parsed = parseCookiePair(line);
|
|
if (!parsed) continue;
|
|
if (parsed.value === '') {
|
|
jar.delete(parsed.name);
|
|
} else {
|
|
jar.set(parsed.name, parsed.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cookieHeader(jar: CookieJar): string {
|
|
return Array.from(jar.entries())
|
|
.map(([name, value]) => `${name}=${value}`)
|
|
.join('; ');
|
|
}
|
|
|
|
async function createAuthE2EContext(): Promise<AuthE2EContext> {
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(MetadataService)
|
|
.useValue(makeMetadataNoopMock())
|
|
.compile();
|
|
|
|
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
app.setGlobalPrefix('api/v1');
|
|
await app.register(fastifyCookie as never);
|
|
await app.init();
|
|
await app.getHttpAdapter().getInstance().ready();
|
|
|
|
const db = app.get<Db>(DB);
|
|
return { app, db };
|
|
}
|
|
|
|
async function createLocalUser(db: Db, overrides?: Partial<Omit<LocalUserCredentials, 'userId'>>): Promise<LocalUserCredentials> {
|
|
const suffix = randomUUID().replace(/-/g, '');
|
|
const password = overrides?.password ?? 'AuthUser123';
|
|
const username = overrides?.username ?? `auth-user-${suffix}`;
|
|
const email = overrides?.email ?? `${username}@example.com`;
|
|
const passwordHash = await hash(password, 12);
|
|
|
|
const [created] = await db
|
|
.insert(schema.users)
|
|
.values({
|
|
username,
|
|
name: `Auth User ${suffix}`,
|
|
email,
|
|
passwordHash,
|
|
isDefaultPassword: false,
|
|
provisioningMethod: 'local',
|
|
})
|
|
.returning({ id: schema.users.id });
|
|
|
|
return {
|
|
userId: created.id,
|
|
username,
|
|
password,
|
|
email,
|
|
};
|
|
}
|
|
|
|
async function findRefreshTokenByRawToken(db: Db, rawToken: string) {
|
|
return db.query.refreshTokens.findFirst({
|
|
where: eq(schema.refreshTokens.tokenHash, sha256(rawToken)),
|
|
});
|
|
}
|
|
|
|
async function activeSessionCount(db: Db, userId: number): Promise<number> {
|
|
const rows = await db
|
|
.select({ id: schema.refreshTokens.id })
|
|
.from(schema.refreshTokens)
|
|
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())));
|
|
return rows.length;
|
|
}
|
|
|
|
async function login(app: NestFastifyApplication, username: string, password: string): Promise<LoginResult> {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { username, password },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = response.json() as { accessToken: string; user: { id: number } };
|
|
const setCookieLines = getSetCookieLines(response.headers);
|
|
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
|
|
expect(refreshToken).toBeTruthy();
|
|
|
|
const jar: CookieJar = new Map();
|
|
mergeCookieJar(jar, setCookieLines);
|
|
|
|
return {
|
|
userId: body.user.id,
|
|
accessToken: body.accessToken,
|
|
refreshToken: refreshToken!,
|
|
jar,
|
|
};
|
|
}
|
|
|
|
describe('Auth session security (e2e)', () => {
|
|
let context: AuthE2EContext;
|
|
|
|
beforeAll(async () => {
|
|
context = await createAuthE2EContext();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await context.app.close();
|
|
});
|
|
|
|
describe('setup + login', () => {
|
|
it('completes initial setup and issues auth cookies', async () => {
|
|
const statusBefore = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/setup-status',
|
|
});
|
|
|
|
expect(statusBefore.statusCode).toBe(200);
|
|
const beforeBody = statusBefore.json() as { needsSetup: boolean };
|
|
if (!beforeBody.needsSetup) {
|
|
return;
|
|
}
|
|
|
|
const setupResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/setup',
|
|
payload: ADMIN_SETUP_DTO,
|
|
});
|
|
|
|
expect(setupResponse.statusCode).toBe(201);
|
|
const setupBody = setupResponse.json() as { accessToken: string; user: { username: string; email: string } };
|
|
expect(setupBody.accessToken).toEqual(expect.any(String));
|
|
expect(setupBody.user).toMatchObject({
|
|
username: ADMIN_SETUP_DTO.username,
|
|
email: ADMIN_SETUP_DTO.email,
|
|
});
|
|
|
|
const setupCookies = getSetCookieLines(setupResponse.headers);
|
|
const refreshCookieLine = findCookieLine(setupCookies, 'refresh_token');
|
|
const accessCookieLine = findCookieLine(setupCookies, 'access_token');
|
|
expect(refreshCookieLine).toBeDefined();
|
|
expect(accessCookieLine).toBeDefined();
|
|
expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth');
|
|
expect(cookieAttribute(refreshCookieLine!, 'httponly')).toBe('');
|
|
expect(cookieAttribute(refreshCookieLine!, 'samesite')).toBe('Strict');
|
|
expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api');
|
|
expect(cookieAttribute(accessCookieLine!, 'httponly')).toBe('');
|
|
expect(cookieAttribute(accessCookieLine!, 'samesite')).toBe('Lax');
|
|
|
|
const statusAfter = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/setup-status',
|
|
});
|
|
|
|
expect(statusAfter.statusCode).toBe(200);
|
|
expect(statusAfter.json()).toEqual({ needsSetup: false, allowRegistration: false });
|
|
});
|
|
|
|
it('rejects setup when already completed', async () => {
|
|
const setupAgain = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/setup',
|
|
payload: {
|
|
...ADMIN_SETUP_DTO,
|
|
username: 'another-admin',
|
|
email: 'another-admin@example.com',
|
|
},
|
|
});
|
|
|
|
expect(setupAgain.statusCode).toBe(409);
|
|
});
|
|
|
|
it('allows admin login and rejects invalid credentials', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'AuthUserLogin123' });
|
|
|
|
const loginResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: {
|
|
username: credentials.username,
|
|
password: credentials.password,
|
|
},
|
|
});
|
|
|
|
expect(loginResponse.statusCode).toBe(200);
|
|
expect(loginResponse.json()).toMatchObject({
|
|
user: {
|
|
username: credentials.username,
|
|
},
|
|
});
|
|
|
|
const loginCookies = getSetCookieLines(loginResponse.headers);
|
|
expect(cookieValue(loginCookies, 'refresh_token')).toEqual(expect.any(String));
|
|
expect(cookieValue(loginCookies, 'access_token')).toEqual(expect.any(String));
|
|
|
|
const invalidLoginResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: {
|
|
username: credentials.username,
|
|
password: 'WrongPass123',
|
|
},
|
|
});
|
|
|
|
expect(invalidLoginResponse.statusCode).toBe(401);
|
|
expect(getSetCookieLines(invalidLoginResponse.headers)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('dashboard shelf settings', () => {
|
|
it.each([false, true])(
|
|
'rejects shelf sync writes for demo-restricted accounts with isSuperuser=%s',
|
|
async (isSuperuser) => {
|
|
const demo = await createLocalUser(context.db);
|
|
await context.db.update(schema.users).set({ isSuperuser }).where(eq(schema.users.id, demo.userId));
|
|
await context.db.insert(schema.userPermissions).values({ userId: demo.userId, permissionName: Permission.DemoRestricted });
|
|
const session = await login(context.app, demo.username, demo.password);
|
|
const headers = { authorization: `Bearer ${session.accessToken}` };
|
|
|
|
for (const dashboardShelfConfig of [{ syncAcrossSessions: true }, { syncAcrossSessions: false }, null]) {
|
|
const response = await context.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/users/me/settings',
|
|
headers,
|
|
payload: { settings: { dashboardShelfConfig } },
|
|
});
|
|
expect(response.statusCode).toBe(403);
|
|
}
|
|
|
|
const legacySave = await context.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/users/me/settings',
|
|
headers,
|
|
payload: { settings: { dashboardConfig: { readingGoal: 24 } } },
|
|
});
|
|
expect(legacySave.statusCode).toBe(200);
|
|
expect(legacySave.json().settings.dashboardConfig.readingGoal).toBe(24);
|
|
expect(legacySave.json().settings.dashboardShelfConfig).toBeUndefined();
|
|
},
|
|
15_000,
|
|
);
|
|
|
|
it('persists opt-in shelves across sessions, preserves them through legacy saves, and isolates accounts', async () => {
|
|
const owner = await createLocalUser(context.db);
|
|
const session = await login(context.app, owner.username, owner.password);
|
|
const shelfConfig = {
|
|
syncAcrossSessions: true,
|
|
shelfLayout: 'two-columns',
|
|
scrollers: [{ id: '1', type: 'random', label: 'Discover Something New', enabled: true, order: 1, limit: 20, rows: 2 }],
|
|
};
|
|
const save = await context.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/users/me/settings',
|
|
headers: { authorization: `Bearer ${session.accessToken}` },
|
|
payload: { settings: { dashboardShelfConfig: shelfConfig } },
|
|
});
|
|
expect(save.statusCode).toBe(200);
|
|
|
|
const legacySave = await context.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/users/me/settings',
|
|
headers: { authorization: `Bearer ${session.accessToken}` },
|
|
payload: { settings: { dashboardConfig: { readingGoal: 24, widgets: [], libraryIds: [1] } } },
|
|
});
|
|
expect(legacySave.statusCode).toBe(200);
|
|
expect(legacySave.json().settings.dashboardShelfConfig).toEqual(shelfConfig);
|
|
|
|
const fresh = await login(context.app, owner.username, owner.password);
|
|
const me = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: { authorization: `Bearer ${fresh.accessToken}` },
|
|
});
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.json().settings.dashboardShelfConfig).toEqual(shelfConfig);
|
|
expect(me.json().settings.dashboardConfig.readingGoal).toBe(24);
|
|
|
|
const other = await createLocalUser(context.db);
|
|
const otherSession = await login(context.app, other.username, other.password);
|
|
const otherMe = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: { authorization: `Bearer ${otherSession.accessToken}` },
|
|
});
|
|
expect(otherMe.statusCode).toBe(200);
|
|
expect(otherMe.json().settings.dashboardShelfConfig).toBeUndefined();
|
|
|
|
const optOut = await context.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/v1/users/me/settings',
|
|
headers: { authorization: `Bearer ${fresh.accessToken}` },
|
|
payload: { settings: { dashboardShelfConfig: { ...shelfConfig, syncAcrossSessions: false } } },
|
|
});
|
|
expect(optOut.statusCode).toBe(200);
|
|
expect(optOut.json().settings.dashboardShelfConfig.syncAcrossSessions).toBe(false);
|
|
expect(optOut.json().settings.dashboardConfig.readingGoal).toBe(24);
|
|
}, 15_000);
|
|
});
|
|
|
|
describe('login lockout', () => {
|
|
async function attemptLogin(username: string, password: string) {
|
|
return context.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password } });
|
|
}
|
|
|
|
async function lockAccount(credentials: LocalUserCredentials) {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
const response = await attemptLogin(credentials.username, 'DefinitelyWrong123');
|
|
expect(response.statusCode).toBe(401);
|
|
}
|
|
const locked = await context.db.query.users.findFirst({ where: eq(schema.users.id, credentials.userId) });
|
|
expect(locked?.lockedUntil).toBeInstanceOf(Date);
|
|
}
|
|
|
|
it('tells the owner of a correct password that the account is locked, with a retry window', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'LockoutPass123' });
|
|
await lockAccount(credentials);
|
|
|
|
const response = await attemptLogin(credentials.username, credentials.password);
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
const body = response.json() as { errorCode?: string; retryAfterSeconds?: number };
|
|
expect(body.errorCode).toBe('account_locked');
|
|
expect(body.retryAfterSeconds).toBeGreaterThan(0);
|
|
expect(body.retryAfterSeconds).toBeLessThanOrEqual(15 * 60);
|
|
expect(getSetCookieLines(response.headers)).toHaveLength(0);
|
|
});
|
|
|
|
it('does not reveal the lockout to someone who does not know the password', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'LockoutPass123' });
|
|
await lockAccount(credentials);
|
|
|
|
const response = await attemptLogin(credentials.username, 'StillWrong123');
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json()).not.toHaveProperty('errorCode');
|
|
expect(response.json()).not.toHaveProperty('retryAfterSeconds');
|
|
});
|
|
|
|
it('lets a completed password reset unlock the account immediately', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'LockoutPass123' });
|
|
await lockAccount(credentials);
|
|
|
|
const rawToken = randomUUID();
|
|
await context.db.insert(schema.passwordResetTokens).values({
|
|
userId: credentials.userId,
|
|
tokenHash: sha256(rawToken),
|
|
expiresAt: new Date(Date.now() + 15 * 60_000),
|
|
});
|
|
|
|
const resetResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/reset-password',
|
|
payload: { token: rawToken, newPassword: 'UnlockedPass123' },
|
|
});
|
|
expect(resetResponse.statusCode).toBe(204);
|
|
|
|
const afterReset = await attemptLogin(credentials.username, 'UnlockedPass123');
|
|
expect(afterReset.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('refresh failure paths', () => {
|
|
it('rejects refresh without cookie', async () => {
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
|
|
it('rejects refresh with unknown token', async () => {
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: 'refresh_token=unknown-token',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
|
|
it('rejects refresh with expired token and clears the refresh cookie', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
|
expect(tokenRow).toBeDefined();
|
|
|
|
await context.db
|
|
.update(schema.refreshTokens)
|
|
.set({ createdAt: new Date(Date.now() - 120_000), expiresAt: new Date(Date.now() - 60_000) })
|
|
.where(eq(schema.refreshTokens.id, tokenRow!.id));
|
|
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${loginResult.refreshToken}`,
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
const responseCookies = getSetCookieLines(response.headers);
|
|
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(responseCookies, 'access_token')).toBeNull();
|
|
});
|
|
|
|
it('rejects refresh for disabled users and clears both auth cookies', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
|
|
await context.db.update(schema.users).set({ active: false }).where(eq(schema.users.id, credentials.userId));
|
|
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: cookieHeader(loginResult.jar),
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
const responseCookies = getSetCookieLines(response.headers);
|
|
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
|
|
|
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
|
expect(tokenRow?.revokedAt).toBeNull();
|
|
|
|
const meResponse = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: {
|
|
authorization: `Bearer ${loginResult.accessToken}`,
|
|
},
|
|
});
|
|
expect(meResponse.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('refresh rotation', () => {
|
|
it('rotates refresh tokens and revokes the previous token', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
|
|
|
const refreshResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: cookieHeader(loginResult.jar),
|
|
},
|
|
});
|
|
|
|
expect(refreshResponse.statusCode).toBe(200);
|
|
const refreshBody = refreshResponse.json() as { accessToken: string };
|
|
expect(refreshBody.accessToken).toEqual(expect.any(String));
|
|
|
|
const refreshCookies = getSetCookieLines(refreshResponse.headers);
|
|
const rotatedRefreshToken = cookieValue(refreshCookies, 'refresh_token');
|
|
expect(rotatedRefreshToken).toBeTruthy();
|
|
expect(rotatedRefreshToken).not.toBe(loginResult.refreshToken);
|
|
|
|
mergeCookieJar(loginResult.jar, refreshCookies);
|
|
|
|
const oldTokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
|
expect(oldTokenRow?.revokedAt).not.toBeNull();
|
|
|
|
const newTokenRow = await findRefreshTokenByRawToken(context.db, rotatedRefreshToken!);
|
|
expect(newTokenRow?.userId).toBe(credentials.userId);
|
|
expect(newTokenRow?.revokedAt).toBeNull();
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('revoked refresh token reuse', () => {
|
|
it('treats immediate rotated token reuse as benign and keeps the replacement session active', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const initialLogin = await login(context.app, credentials.username, credentials.password);
|
|
const revokedRefreshToken = initialLogin.refreshToken;
|
|
|
|
const rotateResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: cookieHeader(initialLogin.jar),
|
|
},
|
|
});
|
|
|
|
expect(rotateResponse.statusCode).toBe(200);
|
|
mergeCookieJar(initialLogin.jar, getSetCookieLines(rotateResponse.headers));
|
|
|
|
const reuseResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${revokedRefreshToken}`,
|
|
},
|
|
});
|
|
|
|
expect(reuseResponse.statusCode).toBe(200);
|
|
const replayAccessToken = (reuseResponse.json() as { accessToken: string }).accessToken;
|
|
expect(replayAccessToken).toEqual(expect.any(String));
|
|
|
|
const reuseCookies = getSetCookieLines(reuseResponse.headers);
|
|
expect(cookieValue(reuseCookies, 'refresh_token')).toBeNull();
|
|
expect(cookieValue(reuseCookies, 'access_token')).toEqual(expect.any(String));
|
|
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
|
|
|
const [userAfterReuse] = await context.db
|
|
.select({ tokenVersion: schema.users.tokenVersion })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.id, credentials.userId))
|
|
.limit(1);
|
|
expect(userAfterReuse?.tokenVersion).toBe(1);
|
|
|
|
const meResponse = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: {
|
|
authorization: `Bearer ${replayAccessToken}`,
|
|
},
|
|
});
|
|
expect(meResponse.statusCode).toBe(200);
|
|
|
|
const nextRefreshResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: cookieHeader(initialLogin.jar),
|
|
},
|
|
});
|
|
|
|
expect(nextRefreshResponse.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('clears auth cookies even when no refresh cookie is present', async () => {
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({});
|
|
const responseCookies = getSetCookieLines(response.headers);
|
|
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
|
});
|
|
|
|
it('does not revoke active sessions when logout receives an unknown refresh token', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
|
|
const response = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
headers: {
|
|
cookie: 'refresh_token=unknown-token',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({});
|
|
const responseCookies = getSetCookieLines(response.headers);
|
|
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(responseCookies, 'access_token')).toBe('');
|
|
|
|
const refreshResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${loginResult.refreshToken}`,
|
|
},
|
|
});
|
|
expect(refreshResponse.statusCode).toBe(200);
|
|
});
|
|
|
|
it('clears cookies, revokes refresh token, and blocks refresh/access reuse', async () => {
|
|
const credentials = await createLocalUser(context.db);
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
|
|
const logoutResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
headers: {
|
|
cookie: cookieHeader(loginResult.jar),
|
|
},
|
|
});
|
|
|
|
expect(logoutResponse.statusCode).toBe(200);
|
|
const logoutCookies = getSetCookieLines(logoutResponse.headers);
|
|
expect(cookieValue(logoutCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(logoutCookies, 'access_token')).toBe('');
|
|
|
|
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
|
|
expect(tokenRow?.revokedAt).not.toBeNull();
|
|
|
|
const refreshAfterLogout = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${loginResult.refreshToken}`,
|
|
},
|
|
});
|
|
expect(refreshAfterLogout.statusCode).toBe(401);
|
|
|
|
const meAfterLogout = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: {
|
|
authorization: `Bearer ${loginResult.accessToken}`,
|
|
},
|
|
});
|
|
expect(meAfterLogout.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('change password', () => {
|
|
it('revokes active sessions and requires new credentials', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'OldPass123' });
|
|
const sessionA = await login(context.app, credentials.username, credentials.password);
|
|
const sessionB = await login(context.app, credentials.username, credentials.password);
|
|
const nextPassword = 'NewPass123';
|
|
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(2);
|
|
|
|
const changePasswordResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/change-password',
|
|
headers: {
|
|
authorization: `Bearer ${sessionA.accessToken}`,
|
|
cookie: cookieHeader(sessionA.jar),
|
|
},
|
|
payload: {
|
|
currentPassword: credentials.password,
|
|
newPassword: nextPassword,
|
|
},
|
|
});
|
|
|
|
expect(changePasswordResponse.statusCode).toBe(204);
|
|
const changeCookies = getSetCookieLines(changePasswordResponse.headers);
|
|
expect(cookieValue(changeCookies, 'refresh_token')).toBe('');
|
|
expect(cookieValue(changeCookies, 'access_token')).toBe('');
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(0);
|
|
|
|
const oldPasswordLogin = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: {
|
|
username: credentials.username,
|
|
password: credentials.password,
|
|
},
|
|
});
|
|
expect(oldPasswordLogin.statusCode).toBe(401);
|
|
|
|
const newPasswordLogin = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: {
|
|
username: credentials.username,
|
|
password: nextPassword,
|
|
},
|
|
});
|
|
expect(newPasswordLogin.statusCode).toBe(200);
|
|
|
|
const refreshAfterPasswordChange = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${sessionB.refreshToken}`,
|
|
},
|
|
});
|
|
expect(refreshAfterPasswordChange.statusCode).toBe(401);
|
|
}, 15_000);
|
|
|
|
it('keeps sessions active when current password is invalid', async () => {
|
|
const credentials = await createLocalUser(context.db, { password: 'OldPass123' });
|
|
const loginResult = await login(context.app, credentials.username, credentials.password);
|
|
|
|
const changePasswordResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/change-password',
|
|
headers: {
|
|
authorization: `Bearer ${loginResult.accessToken}`,
|
|
cookie: cookieHeader(loginResult.jar),
|
|
},
|
|
payload: {
|
|
currentPassword: 'WrongOldPass123',
|
|
newPassword: 'NewPass123',
|
|
},
|
|
});
|
|
|
|
expect(changePasswordResponse.statusCode).toBe(401);
|
|
expect(await activeSessionCount(context.db, credentials.userId)).toBe(1);
|
|
|
|
const refreshResponse = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${loginResult.refreshToken}`,
|
|
},
|
|
});
|
|
expect(refreshResponse.statusCode).toBe(200);
|
|
}, 15_000);
|
|
});
|
|
|
|
describe('session revocation endpoint', () => {
|
|
it('revokes target session only and enforces ownership', async () => {
|
|
const owner = await createLocalUser(context.db, { password: 'OwnerPass123' });
|
|
const sessionA = await login(context.app, owner.username, owner.password);
|
|
const sessionB = await login(context.app, owner.username, owner.password);
|
|
|
|
const sessionARow = await findRefreshTokenByRawToken(context.db, sessionA.refreshToken);
|
|
const sessionBRow = await findRefreshTokenByRawToken(context.db, sessionB.refreshToken);
|
|
expect(sessionARow).toBeDefined();
|
|
expect(sessionBRow).toBeDefined();
|
|
|
|
const sessionsResponse = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/sessions',
|
|
headers: {
|
|
authorization: `Bearer ${sessionA.accessToken}`,
|
|
},
|
|
});
|
|
|
|
expect(sessionsResponse.statusCode).toBe(200);
|
|
const sessions = sessionsResponse.json() as Array<{ id: number }>;
|
|
expect(sessions.map((session) => session.id)).toEqual(expect.arrayContaining([sessionARow!.id, sessionBRow!.id]));
|
|
|
|
const revokeResponse = await context.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/auth/sessions/${sessionARow!.id}`,
|
|
headers: {
|
|
authorization: `Bearer ${sessionB.accessToken}`,
|
|
},
|
|
});
|
|
expect(revokeResponse.statusCode).toBe(204);
|
|
|
|
const sessionsAfterRevoke = await context.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/sessions',
|
|
headers: {
|
|
authorization: `Bearer ${sessionB.accessToken}`,
|
|
},
|
|
});
|
|
expect(sessionsAfterRevoke.statusCode).toBe(200);
|
|
const activeSessionIds = (sessionsAfterRevoke.json() as Array<{ id: number }>).map((session) => session.id);
|
|
expect(activeSessionIds).toContain(sessionBRow!.id);
|
|
expect(activeSessionIds).not.toContain(sessionARow!.id);
|
|
|
|
const otherUser = await createLocalUser(context.db, { password: 'OtherPass123' });
|
|
const otherSession = await login(context.app, otherUser.username, otherUser.password);
|
|
const forbiddenResponse = await context.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/auth/sessions/${sessionBRow!.id}`,
|
|
headers: {
|
|
authorization: `Bearer ${otherSession.accessToken}`,
|
|
},
|
|
});
|
|
expect(forbiddenResponse.statusCode).toBe(403);
|
|
|
|
const missingSessionResponse = await context.app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/v1/auth/sessions/999999',
|
|
headers: {
|
|
authorization: `Bearer ${sessionB.accessToken}`,
|
|
},
|
|
});
|
|
expect(missingSessionResponse.statusCode).toBe(404);
|
|
|
|
const activeSessionRefresh = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${sessionB.refreshToken}`,
|
|
},
|
|
});
|
|
expect(activeSessionRefresh.statusCode).toBe(200);
|
|
|
|
const revokedSessionRefresh = await context.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/refresh',
|
|
headers: {
|
|
cookie: `refresh_token=${sessionA.refreshToken}`,
|
|
},
|
|
});
|
|
expect(revokedSessionRefresh.statusCode).toBe(401);
|
|
}, 15_000);
|
|
});
|
|
});
|