mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
6769bf4b0f
The lockout was checked before the password was verified, so a correct password on a locked account returned the same generic invalid-credentials error as a wrong one. Users had no way to tell the two apart, and the natural response was to keep retrying a password that was already right. Completing a password reset did not clear the lock either, so the one recovery path the UI advertises appeared to fail for up to fifteen minutes after a successful reset. Verify the password first and report the lockout, with the remaining wait, only when the supplied password was correct. Someone guessing wrong passwords still gets the generic failure, so account existence is not leaked. Clear the lock whenever a password is reset or changed, and give admins an explicit unlock action for the case where waiting is not acceptable.
794 lines
28 KiB
TypeScript
794 lines
28 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 { 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 });
|
|
});
|
|
|
|
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('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);
|
|
});
|
|
});
|