test(auth): add end-to-end coverage for authentication flows

This commit is contained in:
neon
2026-03-30 03:58:27 -06:00
parent b5cc1ef370
commit 4d58418b93
14 changed files with 1841 additions and 72 deletions
+4 -8
View File
@@ -3,8 +3,8 @@ name: E2E Runner (reusable)
on:
workflow_call:
inputs:
suite-script:
description: "pnpm root script to run (e.g. test:e2e:scanner)"
suite-id:
description: "E2E suite id from scripts/test/e2e-suites.mjs (e.g. scanner)"
required: true
type: string
check-name:
@@ -15,10 +15,6 @@ on:
description: "Name for the uploaded artifact"
required: true
type: string
junit-glob:
description: "Glob pattern for the JUnit XML report"
required: true
type: string
job-timeout-minutes:
description: "Job timeout in minutes"
required: false
@@ -69,14 +65,14 @@ jobs:
- name: Run e2e suite
shell: bash
run: pnpm run ${{ inputs.suite-script }}
run: pnpm run test:e2e -- ${{ inputs.suite-id }}
- name: Publish test report annotations
if: always()
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: ${{ inputs.check-name }}
files: ${{ inputs.junit-glob }}
files: test-results/server/*-e2e-junit.xml
fail_on: nothing
comment_mode: off
+1 -2
View File
@@ -34,8 +34,7 @@ jobs:
scanner-file-ops-e2e:
uses: ./.github/workflows/e2e-runner.yml
with:
suite-script: test:e2e:scanner:file-ops
suite-id: scanner-file-ops
check-name: Server Scanner File Ops E2E
artifact-name: scanner-file-ops-e2e-report
junit-glob: test-results/server/scanner-file-ops-e2e-junit.xml
job-timeout-minutes: 60
+1 -2
View File
@@ -32,8 +32,7 @@ jobs:
scanner-e2e:
uses: ./.github/workflows/e2e-runner.yml
with:
suite-script: test:e2e:scanner
suite-id: scanner
check-name: Server Scanner E2E
artifact-name: scanner-e2e-report
junit-glob: test-results/server/scanner-e2e-junit.xml
job-timeout-minutes: 45
+39 -13
View File
@@ -114,9 +114,12 @@ Opens Drizzle Studio in your browser for browsing and editing tables directly.
pnpm run test # all tests (server + client)
pnpm run test:server # server unit tests only
pnpm run test:client # client unit tests only
pnpm run test:e2e:smoke # e2e smoke suite (requires running DB)
pnpm run test:e2e:scanner # scanner e2e suite (uses dedicated projectx_e2e DB)
pnpm run test:e2e:scanner:file-ops # scanner file operation e2e suite
pnpm run test:e2e -- all # run all e2e suites sequentially
pnpm run test:e2e -- smoke # e2e smoke suite (default DB)
pnpm run test:e2e -- scanner # scanner e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e -- scanner-file-ops # scanner file operation e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all"
pnpm run test:e2e:list # list all supported e2e suite ids
```
Watch mode while working on a specific area:
@@ -125,9 +128,23 @@ Watch mode while working on a specific area:
cd server && pnpm test:watch
```
Run a focused scanner scenario:
```bash
pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flattening
```
### E2E runner architecture
- All suite metadata lives in `scripts/test/e2e-suites.mjs` (suite id, spec file, JUnit output, DB prep mode).
- Local and CI use the same command: `pnpm run test:e2e -- <suite-id>`.
- Use `all` as a composite suite id to run every configured suite in order.
- Dedicated-db suites auto-start local PostgreSQL when `CI != true`, then reset and migrate the e2e database through `pnpm run db:prepare:e2e`.
- JUnit outputs are written to `test-results/server/`.
### Scanner e2e details
- `pnpm run test:e2e:scanner` prepares and migrates a dedicated e2e database (`projectx_e2e`) on each run.
- `pnpm run test:e2e -- scanner` prepares and migrates a dedicated e2e database (`projectx_e2e`) on each run.
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
- Results are written to `test-results/server/`:
- `scanner-e2e-junit.xml`
@@ -135,23 +152,32 @@ cd server && pnpm test:watch
### Scanner file operation e2e details
- `pnpm run test:e2e:scanner:file-ops` prepares and migrates the same dedicated e2e database (`projectx_e2e`) before running.
- `pnpm run test:e2e -- scanner-file-ops` prepares and migrates the same dedicated e2e database (`projectx_e2e`) before running.
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
- Results are written to `test-results/server/`:
- `scanner-file-ops-e2e-junit.xml`
- `scanner-file-ops-e2e-scenarios.json`
### Scanner e2e in CI (manual)
### E2E in CI (how to trigger)
- Open GitHub Actions and run the **Scanner E2E** workflow manually (`workflow_dispatch`).
- The workflow runs the full scanner scenario matrix against a service-container Postgres instance.
- It uploads `test-results/server/` as an artifact and publishes JUnit annotations.
Both workflows call the reusable `E2E Runner (reusable)` workflow, which runs:
### Scanner file operation e2e in CI
```bash
pnpm run test:e2e -- <suite-id>
```
- Open GitHub Actions and run the **Scanner File Ops E2E** workflow manually (`workflow_dispatch`) or wait for the nightly run.
- The workflow runs the full file operation matrix against a service-container Postgres instance.
- It uploads `test-results/server/` as an artifact and publishes JUnit annotations.
| Workflow | Suite id | Triggered by |
| -------------------- | ------------------ | -------------------------------------------------------------------------------- |
| Scanner E2E | `scanner` | `workflow_dispatch`, `push` (scanner-related paths), `pull_request` (same paths) |
| Scanner File Ops E2E | `scanner-file-ops` | `workflow_dispatch`, nightly schedule (`0 4 * * *`), `push`, `pull_request` |
Manual trigger steps:
1. Open GitHub Actions.
2. Select **Scanner E2E** or **Scanner File Ops E2E**.
3. Click **Run workflow**.
Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`.
---
+5 -5
View File
@@ -7,8 +7,8 @@
"dev:client": "cd client && npm run dev -- --host",
"dev:server": "cd server && npm run start:dev",
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e -- smoke",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e -- smoke",
"build:client": "pnpm --filter client build",
"build:server": "pnpm --filter server build",
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client",
@@ -22,7 +22,9 @@
"test": "pnpm run test:server && pnpm run test:client",
"test:server": "pnpm --filter server test",
"test:client": "pnpm --filter client test:unit --run",
"test:e2e:smoke": "pnpm --filter server test:e2e",
"test:e2e": "node ./scripts/test/run-e2e-suite.mjs",
"test:e2e:all": "node ./scripts/test/run-e2e-suite.mjs all",
"test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list",
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
"coverage:server": "pnpm --filter server test:cov",
"coverage:client": "pnpm --filter client test:cov --run",
@@ -33,8 +35,6 @@
"db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh",
"db:prepare:e2e": "bash ./scripts/db/prepare-e2e.sh",
"test:e2e:scanner": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner",
"test:e2e:scanner:file-ops": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops",
"prepare": "husky"
},
"engines": {
+52
View File
@@ -0,0 +1,52 @@
const TEST_RESULTS_DIR = "../test-results/server";
export const E2E_SUITES = Object.freeze({
smoke: {
id: "smoke",
description: "Server smoke suite",
vitestTarget: "test/app.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/smoke-e2e-junit.xml`,
prepareDedicatedDatabase: false,
useDedicatedDatabase: false,
},
scanner: {
id: "scanner",
description: "Scanner scenario matrix",
vitestTarget: "test/scanner.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/scanner-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
"scanner-file-ops": {
id: "scanner-file-ops",
description: "Scanner file operation matrix",
vitestTarget: "test/scanner-file-ops.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/scanner-file-ops-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
"auth-session-security": {
id: "auth-session-security",
description: "Auth session security suite",
vitestTarget: "test/auth-session-security.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/auth-session-security-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
"auth-recovery-oidc-logout": {
id: "auth-recovery-oidc-logout",
description: "Auth recovery and OIDC logout suite",
vitestTarget: "test/auth-recovery-oidc-logout.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/auth-recovery-oidc-logout-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
});
export function listE2ESuites() {
return Object.values(E2E_SUITES);
}
export function getE2ESuite(suiteId) {
return E2E_SUITES[suiteId] ?? null;
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { getE2ESuite, listE2ESuites } from "./e2e-suites.mjs";
const DEFAULT_E2E_DATABASE_URL = "postgres://projectx:projectx@localhost:5432/projectx_e2e";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = join(scriptDir, "..", "..");
const resultsDir = join(rootDir, "test-results", "server");
function isCiEnvironment() {
return (process.env.CI ?? "").toLowerCase() === "true";
}
function trimLeadingSeparator(args) {
if (args[0] === "--") {
return args.slice(1);
}
return args;
}
function printUsage() {
const usage = [
"Usage:",
" pnpm run test:e2e -- <suite-id> [vitest args...]",
"",
"Examples:",
" pnpm run test:e2e -- smoke",
" pnpm run test:e2e -- all",
" pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flattening",
" pnpm run test:e2e -- --list",
" pnpm run test:e2e:list",
];
console.log(usage.join("\n"));
}
function printSuiteList() {
const suites = listE2ESuites();
console.log("Available e2e suites:");
console.log("- all [composite] - Run all suites sequentially");
for (const suite of suites) {
const dbMode = suite.prepareDedicatedDatabase ? "dedicated-db" : "default-db";
console.log(`- ${suite.id} [${dbMode}] - ${suite.description}`);
}
}
async function runCommand(command, args, envOverrides = {}) {
await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: rootDir,
env: { ...process.env, ...envOverrides },
stdio: "inherit",
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolve(undefined);
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`Command failed (${reason}): ${command} ${args.join(" ")}`));
});
});
}
async function main() {
const cliArgs = trimLeadingSeparator(process.argv.slice(2));
if (cliArgs.length === 0 || cliArgs.includes("--help") || cliArgs.includes("-h")) {
printUsage();
process.exit(cliArgs.length === 0 ? 1 : 0);
}
if (cliArgs.includes("--list")) {
printSuiteList();
return;
}
const [suiteId, ...remainingArgs] = cliArgs;
const vitestArgs = trimLeadingSeparator(remainingArgs);
const suites = suiteId === "all" ? listE2ESuites() : [getE2ESuite(suiteId)];
if (suiteId !== "all" && !suites[0]) {
console.error(`Unknown e2e suite "${suiteId}".`);
printSuiteList();
process.exit(1);
}
if (suiteId === "all" && suites.length === 0) {
console.error("No e2e suites are configured.");
process.exit(1);
}
if (suiteId === "all" && process.env.JUNIT_OUTPUT) {
console.error("JUNIT_OUTPUT is only supported when running a single suite. Unset JUNIT_OUTPUT or run one suite id.");
process.exit(1);
}
mkdirSync(resultsDir, { recursive: true });
const e2eDatabaseUrl = process.env.E2E_DATABASE_URL ?? process.env.DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL;
if (!isCiEnvironment() && suites.some((suite) => suite.prepareDedicatedDatabase)) {
console.log("Starting local PostgreSQL (dev compose)...");
await runCommand("pnpm", ["run", "db:up"]);
}
for (const [index, suite] of suites.entries()) {
const runEnvironment = suite.useDedicatedDatabase ? { DATABASE_URL: e2eDatabaseUrl, E2E_DATABASE_URL: e2eDatabaseUrl } : {};
if (suite.prepareDedicatedDatabase) {
console.log(`Resetting and migrating dedicated e2e database for suite ${suite.id}...`);
await runCommand("pnpm", ["run", "db:prepare:e2e"], { E2E_DATABASE_URL: e2eDatabaseUrl });
}
const junitOutput = process.env.JUNIT_OUTPUT ?? suite.junitOutput;
if (suites.length > 1) {
console.log(`Running e2e suite [${index + 1}/${suites.length}]: ${suite.id}...`);
} else {
console.log(`Running e2e suite: ${suite.id}...`);
}
await runCommand(
"pnpm",
[
"--filter",
"server",
"exec",
"vitest",
"run",
suite.vitestTarget,
"--reporter=default",
"--reporter=junit",
`--outputFile.junit=${junitOutput}`,
...vitestArgs,
],
runEnvironment,
);
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+1 -30
View File
@@ -1,36 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: run-e2e-suite.sh <server-script-name> [extra vitest args...]
#
# Example:
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner -- --testNamePattern="book-per-folder-disc-folder-flattening"
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <server-script-name> [extra vitest args...]" >&2
exit 1
fi
SUITE_SCRIPT="$1"
shift
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
JUNIT_DIR="$ROOT_DIR/test-results/server"
mkdir -p "$JUNIT_DIR"
if [[ "${CI:-}" != "true" ]]; then
echo "Starting local PostgreSQL (dev compose)..."
pnpm run db:up
fi
echo "Resetting and migrating dedicated e2e database..."
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
echo "Running e2e suite: $SUITE_SCRIPT..."
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server "$SUITE_SCRIPT" "$@"
node ./scripts/test/run-e2e-suite.mjs "$@"
+9 -1
View File
@@ -50,10 +50,18 @@ pnpm db:studio # open Drizzle Studio (DB browser)
```bash
pnpm test # unit tests
pnpm test:watch # unit tests in watch mode
pnpm test:e2e # end-to-end tests
pnpm test:e2e # smoke end-to-end test from server package
pnpm test:cov # unit tests with coverage
```
For suite-based e2e runs (scanner, scanner-file-ops, auth suites), run from the repository root:
```bash
pnpm run test:e2e:list
pnpm run test:e2e -- all
pnpm run test:e2e -- scanner
```
## Quality
```bash
+2 -3
View File
@@ -16,6 +16,7 @@
"start:prod": "node dist/main",
"db:generate": "drizzle-kit generate --name",
"db:migrate": "drizzle-kit migrate",
"db:migrate:e2e": "DATABASE_URL=postgres://projectx:projectx@localhost:5432/projectx_e2e pnpm db:migrate",
"db:migrate:runtime": "node dist/scripts/migrate.js",
"db:prepare:e2e": "tsx src/scripts/db-prepare-e2e.ts",
"db:studio": "drizzle-kit studio",
@@ -28,9 +29,7 @@
"test": "vitest run --exclude test/scanner.e2e-spec.ts --exclude test/scanner-file-ops.e2e-spec.ts",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:e2e": "vitest run test/app.e2e-spec.ts",
"test:e2e:scanner": "vitest run test/scanner.e2e-spec.ts --reporter=default --reporter=junit --outputFile.junit=${JUNIT_OUTPUT:-../test-results/server/scanner-e2e-junit.xml}",
"test:e2e:scanner:file-ops": "vitest run test/scanner-file-ops.e2e-spec.ts --reporter=default --reporter=junit --outputFile.junit=${JUNIT_OUTPUT:-../test-results/server/scanner-file-ops-e2e-junit.xml}"
"test:e2e": "vitest run test/app.e2e-spec.ts"
},
"dependencies": {
"7z-wasm": "^1.2.0",
+80 -3
View File
@@ -304,6 +304,7 @@ describe('AuthService', () => {
it('throws UnauthorizedException and revokes all sessions when revoked token is reused', async () => {
const { service, db } = makeService();
const reply = makeReply();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({
id: 1,
userId: 5,
@@ -311,8 +312,19 @@ describe('AuthService', () => {
expiresAt: new Date(Date.now() + 100000),
});
await expect(service.refresh(makeRequest({ refresh_token: 'revoked-token' }), makeReply())).rejects.toThrow(UnauthorizedException);
await expect(service.refresh(makeRequest({ refresh_token: 'revoked-token' }), reply)).rejects.toThrow(UnauthorizedException);
expect(db.update).toHaveBeenCalled();
expect(db.delete).toHaveBeenCalled();
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'refresh_token',
'',
expect.objectContaining({ path: '/api/v1/auth', maxAge: 0 }),
);
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'access_token',
'',
expect.objectContaining({ path: '/api', maxAge: 0 }),
);
});
it('throws UnauthorizedException when token is expired', async () => {
@@ -595,14 +607,79 @@ describe('AuthService', () => {
expect(result).toEqual({});
});
it('returns empty object when OIDC is disabled', async () => {
const { service, db, userService, appSettings } = makeService();
it('revokes OIDC session and returns empty object when OIDC is disabled', async () => {
const { service, db, userService, appSettings, oidcSessionRepo } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 5 });
userService.incrementTokenVersion.mockResolvedValue(undefined);
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
idTokenHint: 'id-token-hint',
});
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
expect(result).toEqual({});
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(5);
});
it('revokes OIDC session even when there is no id token hint', async () => {
const { service, db, userService, oidcSessionRepo } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 6 });
userService.incrementTokenVersion.mockResolvedValue(undefined);
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
idTokenHint: null,
});
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
expect(result).toEqual({});
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(6);
});
it('returns logout URL when OIDC end-session endpoint is available', async () => {
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 7 });
userService.incrementTokenVersion.mockResolvedValue(undefined);
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
idTokenHint: 'id-token-hint',
});
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
authorizationEndpoint: 'https://issuer.example/auth',
tokenEndpoint: 'https://issuer.example/token',
userinfoEndpoint: 'https://issuer.example/userinfo',
jwksUri: 'https://issuer.example/jwks',
issuer: 'https://issuer.example',
endSessionEndpoint: 'https://issuer.example/logout',
});
const request = makeRequest({ refresh_token: 'some-token' });
request.headers.origin = 'http://localhost:5173';
const result = await service.logout(request, makeReply());
expect(result).toEqual({
logoutUrl: 'https://issuer.example/logout?id_token_hint=id-token-hint&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Flogin',
});
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(7);
});
it('revokes OIDC session and returns empty object when end-session endpoint is missing', async () => {
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 8 });
userService.incrementTokenVersion.mockResolvedValue(undefined);
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
idTokenHint: 'id-token-hint',
});
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
authorizationEndpoint: 'https://issuer.example/auth',
tokenEndpoint: 'https://issuer.example/token',
userinfoEndpoint: 'https://issuer.example/userinfo',
jwksUri: 'https://issuer.example/jwks',
issuer: 'https://issuer.example',
});
const result = await service.logout(makeRequest({ refresh_token: 'some-token' }), makeReply());
expect(result).toEqual({});
expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(8);
});
});
});
+14 -5
View File
@@ -237,11 +237,19 @@ export class AuthService {
throw new UnauthorizedException();
}
// Reuse of a revoked token → theft signal, revoke all user sessions
// Reuse of a revoked token indicates possible theft.
// Revoke refresh sessions and bump tokenVersion to invalidate active access tokens.
if (row.revokedAt) {
this.logger.warn(`[auth.refresh] [fail] user=${row.userId} reason="Token revoked - reuse attempt"`);
await this.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, row.userId));
await this.db.transaction(async (tx) => {
await tx
.update(schema.users)
.set({ tokenVersion: sql`${schema.users.tokenVersion} + 1` })
.where(eq(schema.users.id, row.userId));
await tx.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, row.userId));
});
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
throw new UnauthorizedException();
}
@@ -306,7 +314,10 @@ export class AuthService {
if (!userId) return {};
const oidcSession = await this.oidcSessionRepo.findActiveByUserId(userId);
if (!oidcSession?.idTokenHint) return {};
if (!oidcSession) return {};
await this.oidcSessionRepo.revokeByUserId(userId);
if (!oidcSession.idTokenHint) return {};
try {
const config = await this.appSettings.getOidcConfig();
@@ -321,8 +332,6 @@ export class AuthService {
const params = new URLSearchParams({ id_token_hint: oidcSession.idTokenHint });
if (postLogoutUri) params.set('post_logout_redirect_uri', postLogoutUri);
await this.oidcSessionRepo.revokeByUserId(userId);
return { logoutUrl: `${disc.endSessionEndpoint}?${params.toString()}` };
} catch {
return {};
@@ -0,0 +1,753 @@
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 { OidcDiscoveryService } from '../src/modules/auth/oidc/oidc-discovery.service';
import { SystemMailService } from '../src/modules/email/system-mail.service';
import type { Db } from './e2e/app-harness';
type CookieJar = Map<string, string>;
interface AuthRecoveryContext {
app: NestFastifyApplication;
db: Db;
systemMailMock: {
isConfigured: ReturnType<typeof vi.fn>;
sendPasswordReset: ReturnType<typeof vi.fn>;
};
oidcDiscoveryMock: {
getDiscoveryDoc: ReturnType<typeof vi.fn>;
};
}
interface LocalUserCredentials {
userId: number;
username: string;
password: string;
email: string;
}
interface LoginResult {
accessToken: string;
refreshToken: string;
jar: CookieJar;
}
const ADMIN_SETUP_DTO = {
username: 'recovery-e2e-admin',
name: 'Recovery E2E Admin',
email: 'recovery-e2e-admin@example.com',
password: 'RecoveryAdmin123',
};
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 cookieValue(setCookieLines: string[], cookieName: string): string | null {
const line = setCookieLines.find((entry) => entry.startsWith(`${cookieName}=`));
if (!line) return null;
const parsed = parseCookiePair(line);
return parsed?.value ?? 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('; ');
}
function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
return {
extractAndSave: () => Promise.resolve(undefined),
refreshCoverForBook: () => Promise.resolve(false),
extractAudioFileDuration: () => Promise.resolve(undefined),
aggregateAudioDuration: () => Promise.resolve(undefined),
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
};
}
function defaultDiscoveryDoc() {
return {
issuer: 'https://issuer.example',
authorizationEndpoint: 'https://issuer.example/auth',
tokenEndpoint: 'https://issuer.example/token',
jwksUri: 'https://issuer.example/jwks',
userinfoEndpoint: 'https://issuer.example/userinfo',
endSessionEndpoint: 'https://issuer.example/logout',
backchannelLogoutSupported: true,
};
}
async function createAuthRecoveryContext(): Promise<AuthRecoveryContext> {
const systemMailMock = {
isConfigured: vi.fn().mockResolvedValue(true),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
};
const oidcDiscoveryMock = {
getDiscoveryDoc: vi.fn().mockResolvedValue(defaultDiscoveryDoc()),
};
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MetadataService)
.useValue(makeMetadataNoopMock())
.overrideProvider(SystemMailService)
.useValue(systemMailMock)
.overrideProvider(OidcDiscoveryService)
.useValue(oidcDiscoveryMock)
.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, systemMailMock, oidcDiscoveryMock };
}
async function ensureInitialSetup(app: NestFastifyApplication): Promise<void> {
const statusResponse = await app.inject({
method: 'GET',
url: '/api/v1/auth/setup-status',
});
expect(statusResponse.statusCode).toBe(200);
const statusBody = statusResponse.json() as { needsSetup: boolean };
if (!statusBody.needsSetup) return;
const setupResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
expect(setupResponse.statusCode).toBe(201);
}
async function createUser(
db: Db,
options?: {
password?: string;
provisioningMethod?: 'local' | 'oidc';
active?: boolean;
},
): Promise<LocalUserCredentials> {
const suffix = randomUUID().replace(/-/g, '');
const password = options?.password ?? 'AuthUser123';
const username = `auth-recovery-${suffix}`;
const email = `${username}@example.com`;
const passwordHash = await hash(password, 12);
const [created] = await db
.insert(schema.users)
.values({
username,
name: `Auth Recovery ${suffix}`,
email,
passwordHash,
active: options?.active ?? true,
provisioningMethod: options?.provisioningMethod ?? 'local',
isDefaultPassword: false,
})
.returning({ id: schema.users.id });
return {
userId: created.id,
username,
password,
email,
};
}
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 };
const setCookieLines = getSetCookieLines(response.headers);
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
expect(refreshToken).toBeTruthy();
const jar: CookieJar = new Map();
mergeCookieJar(jar, setCookieLines);
return {
accessToken: body.accessToken,
refreshToken: refreshToken!,
jar,
};
}
async function waitForExpectation(assertion: () => void, timeoutMs = 4_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
throw lastError;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function latestResetTokenFromMail(context: AuthRecoveryContext): string {
const token = context.systemMailMock.sendPasswordReset.mock.calls.at(-1)?.[2];
if (typeof token !== 'string' || token.length === 0) {
throw new Error('Password reset token was not captured by the mail mock');
}
return token;
}
async function passwordResetTokenCount(db: Db): Promise<number> {
const rows = await db.select({ id: schema.passwordResetTokens.id }).from(schema.passwordResetTokens);
return rows.length;
}
async function waitForStablePasswordResetTokenCount(db: Db, expected: number, stableMs = 300, timeoutMs = 4_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let stableSince: number | null = null;
let latestCount = -1;
while (Date.now() < deadline) {
latestCount = await passwordResetTokenCount(db);
if (latestCount === expected) {
if (stableSince === null) {
stableSince = Date.now();
} else if (Date.now() - stableSince >= stableMs) {
return;
}
} else {
stableSince = null;
}
await sleep(50);
}
throw new Error(`Password reset token count was not stable at ${expected}. Last count: ${latestCount}`);
}
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 setOidcEnabledConfig(db: Db, issuerUri = 'https://issuer.example'): Promise<void> {
const value = JSON.stringify({
enabled: true,
providerName: 'Example OIDC',
issuerUri,
clientId: 'client-id',
clientSecret: 'client-secret',
scopes: 'openid profile email',
claimMapping: {
username: 'preferred_username',
name: 'name',
email: 'email',
groups: 'groups',
},
autoProvision: {
enabled: false,
allowLocalLinking: true,
defaultPermissionNames: [],
},
});
await db.insert(schema.appSettings).values({ key: 'oidc_config', value }).onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
}
describe('Auth recovery and OIDC logout hardening (e2e)', () => {
let context: AuthRecoveryContext;
beforeAll(async () => {
context = await createAuthRecoveryContext();
await ensureInitialSetup(context.app);
});
beforeEach(() => {
context.systemMailMock.isConfigured.mockReset();
context.systemMailMock.isConfigured.mockResolvedValue(true);
context.systemMailMock.sendPasswordReset.mockClear();
context.oidcDiscoveryMock.getDiscoveryDoc.mockReset();
context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(defaultDiscoveryDoc());
});
afterAll(async () => {
await context.app.close();
});
describe('forgot/reset password', () => {
it('issues reset token, resets password, and revokes active sessions', async () => {
const user = await createUser(context.db, { password: 'OldPass123' });
const session = await login(context.app, user.username, user.password);
const forgotResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: user.email },
});
expect(forgotResponse.statusCode).toBe(200);
await waitForExpectation(() => {
expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1);
});
const rawToken = latestResetTokenFromMail(context);
const tokenRowBeforeReset = await context.db.query.passwordResetTokens.findFirst({
where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)),
});
expect(tokenRowBeforeReset?.userId).toBe(user.userId);
expect(tokenRowBeforeReset?.usedAt).toBeNull();
const resetResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: rawToken,
newPassword: 'NewPass123',
},
});
expect(resetResponse.statusCode).toBe(204);
const oldPasswordLogin = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: {
username: user.username,
password: user.password,
},
});
expect(oldPasswordLogin.statusCode).toBe(401);
const newPasswordLogin = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: {
username: user.username,
password: 'NewPass123',
},
});
expect(newPasswordLogin.statusCode).toBe(200);
expect(await activeSessionCount(context.db, user.userId)).toBe(1);
const newAccessToken = (newPasswordLogin.json() as { accessToken: string }).accessToken;
const refreshAfterReset = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/refresh',
headers: {
cookie: `refresh_token=${session.refreshToken}`,
},
});
expect(refreshAfterReset.statusCode).toBe(401);
expect(await activeSessionCount(context.db, user.userId)).toBe(0);
const meAfterRevokedReuse = await context.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: {
authorization: `Bearer ${newAccessToken}`,
},
});
expect(meAfterRevokedReuse.statusCode).toBe(401);
const tokenRowAfterReset = await context.db.query.passwordResetTokens.findFirst({
where: eq(schema.passwordResetTokens.tokenHash, sha256(rawToken)),
});
expect(tokenRowAfterReset?.usedAt).not.toBeNull();
});
it('rejects reset token reuse after successful password reset', async () => {
const user = await createUser(context.db);
const forgotResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: user.email },
});
expect(forgotResponse.statusCode).toBe(200);
await waitForExpectation(() => {
expect(context.systemMailMock.sendPasswordReset).toHaveBeenCalledTimes(1);
});
const rawToken = latestResetTokenFromMail(context);
const firstReset = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: rawToken,
newPassword: 'ReuseCheck123',
},
});
expect(firstReset.statusCode).toBe(204);
const secondReset = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: rawToken,
newPassword: 'ReuseCheck456',
},
});
expect(secondReset.statusCode).toBe(400);
});
it('returns success but does not issue token for unknown or OIDC users', async () => {
const tokenCountBefore = await passwordResetTokenCount(context.db);
const unknownEmailResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: `missing-${randomUUID()}@example.com` },
});
expect(unknownEmailResponse.statusCode).toBe(200);
const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' });
const oidcForgotResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: oidcUser.email },
});
expect(oidcForgotResponse.statusCode).toBe(200);
await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore);
expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled();
const tokenCountAfter = await passwordResetTokenCount(context.db);
expect(tokenCountAfter).toBe(tokenCountBefore);
});
it('returns success but does not issue token for inactive local users', async () => {
const tokenCountBefore = await passwordResetTokenCount(context.db);
const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' });
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: inactiveUser.email },
});
expect(response.statusCode).toBe(200);
await waitForStablePasswordResetTokenCount(context.db, tokenCountBefore);
expect(context.systemMailMock.sendPasswordReset).not.toHaveBeenCalled();
const tokenRowsForUser = await context.db
.select({ id: schema.passwordResetTokens.id })
.from(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.userId, inactiveUser.userId));
expect(tokenRowsForUser).toHaveLength(0);
});
it('returns 503 when self-service mail is not configured', async () => {
context.systemMailMock.isConfigured.mockResolvedValueOnce(false);
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/forgot-password',
payload: { email: `config-check-${randomUUID()}@example.com` },
});
expect(response.statusCode).toBe(503);
});
it('rejects invalid and expired reset tokens', async () => {
const invalidTokenResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: `invalid-${randomUUID()}`,
newPassword: 'NewPass123',
},
});
expect(invalidTokenResponse.statusCode).toBe(400);
const user = await createUser(context.db);
const expiredToken = `expired-${randomUUID()}`;
await context.db.insert(schema.passwordResetTokens).values({
userId: user.userId,
tokenHash: sha256(expiredToken),
expiresAt: new Date(Date.now() - 60_000),
});
const expiredTokenResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: expiredToken,
newPassword: 'NewPass123',
},
});
expect(expiredTokenResponse.statusCode).toBe(400);
});
it('rejects reset tokens for inactive and OIDC users', async () => {
const inactiveUser = await createUser(context.db, { active: false, provisioningMethod: 'local' });
const inactiveUserToken = `inactive-${randomUUID()}`;
await context.db.insert(schema.passwordResetTokens).values({
userId: inactiveUser.userId,
tokenHash: sha256(inactiveUserToken),
expiresAt: new Date(Date.now() + 60_000),
});
const inactiveResetResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: inactiveUserToken,
newPassword: 'NewPass123',
},
});
expect(inactiveResetResponse.statusCode).toBe(400);
const oidcUser = await createUser(context.db, { provisioningMethod: 'oidc' });
const oidcUserToken = `oidc-${randomUUID()}`;
await context.db.insert(schema.passwordResetTokens).values({
userId: oidcUser.userId,
tokenHash: sha256(oidcUserToken),
expiresAt: new Date(Date.now() + 60_000),
});
const oidcResetResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: oidcUserToken,
newPassword: 'NewPass123',
},
});
expect(oidcResetResponse.statusCode).toBe(400);
const inactiveTokenRow = await context.db.query.passwordResetTokens.findFirst({
where: eq(schema.passwordResetTokens.tokenHash, sha256(inactiveUserToken)),
});
expect(inactiveTokenRow?.usedAt).toBeNull();
const oidcTokenRow = await context.db.query.passwordResetTokens.findFirst({
where: eq(schema.passwordResetTokens.tokenHash, sha256(oidcUserToken)),
});
expect(oidcTokenRow?.usedAt).toBeNull();
});
});
describe('OIDC logout', () => {
it('returns provider logout URL and revokes local OIDC session', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
await setOidcEnabledConfig(context.db);
const [createdOidcSession] = await context.db
.insert(schema.oidcSessions)
.values({
userId: user.userId,
oidcSubject: `sub-${randomUUID()}`,
oidcIssuer: 'https://issuer.example',
oidcSessionId: `sid-${randomUUID()}`,
idTokenHint: `id-token-${randomUUID()}`,
})
.returning({ id: schema.oidcSessions.id, idTokenHint: schema.oidcSessions.idTokenHint });
const logoutResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: {
cookie: cookieHeader(session.jar),
origin: 'http://localhost:5173',
},
});
expect(logoutResponse.statusCode).toBe(200);
const body = logoutResponse.json() as { logoutUrl?: string };
expect(body.logoutUrl).toEqual(expect.any(String));
const logoutUrl = new URL(body.logoutUrl!);
expect(`${logoutUrl.origin}${logoutUrl.pathname}`).toBe('https://issuer.example/logout');
expect(logoutUrl.searchParams.get('id_token_hint')).toBe(createdOidcSession.idTokenHint);
expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5173/login');
const logoutCookies = getSetCookieLines(logoutResponse.headers);
expect(cookieValue(logoutCookies, 'refresh_token')).toBe('');
expect(cookieValue(logoutCookies, 'access_token')).toBe('');
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
where: eq(schema.oidcSessions.id, createdOidcSession.id),
});
expect(oidcSessionAfter?.revoked).toBe(true);
const refreshAfterLogout = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/refresh',
headers: {
cookie: `refresh_token=${session.refreshToken}`,
},
});
expect(refreshAfterLogout.statusCode).toBe(401);
const meAfterLogout = await context.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: {
authorization: `Bearer ${session.accessToken}`,
},
});
expect(meAfterLogout.statusCode).toBe(401);
});
it('revokes local OIDC session even when id token hint is missing', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
await setOidcEnabledConfig(context.db);
const [createdOidcSession] = await context.db
.insert(schema.oidcSessions)
.values({
userId: user.userId,
oidcSubject: `sub-${randomUUID()}`,
oidcIssuer: 'https://issuer.example',
oidcSessionId: `sid-${randomUUID()}`,
idTokenHint: null,
})
.returning({ id: schema.oidcSessions.id });
const logoutResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: {
cookie: cookieHeader(session.jar),
},
});
expect(logoutResponse.statusCode).toBe(200);
expect(logoutResponse.json()).toEqual({});
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
where: eq(schema.oidcSessions.id, createdOidcSession.id),
});
expect(oidcSessionAfter?.revoked).toBe(true);
});
it('still revokes local OIDC session when end-session endpoint is unavailable', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
await setOidcEnabledConfig(context.db);
const [createdOidcSession] = await context.db
.insert(schema.oidcSessions)
.values({
userId: user.userId,
oidcSubject: `sub-${randomUUID()}`,
oidcIssuer: 'https://issuer.example',
oidcSessionId: `sid-${randomUUID()}`,
idTokenHint: `id-token-${randomUUID()}`,
})
.returning({ id: schema.oidcSessions.id });
context.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue({
...defaultDiscoveryDoc(),
endSessionEndpoint: undefined,
});
const logoutResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: {
cookie: cookieHeader(session.jar),
},
});
expect(logoutResponse.statusCode).toBe(200);
expect(logoutResponse.json()).toEqual({});
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
where: eq(schema.oidcSessions.id, createdOidcSession.id),
});
expect(oidcSessionAfter?.revoked).toBe(true);
});
it('still revokes local OIDC session when discovery lookup fails', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
await setOidcEnabledConfig(context.db);
const [createdOidcSession] = await context.db
.insert(schema.oidcSessions)
.values({
userId: user.userId,
oidcSubject: `sub-${randomUUID()}`,
oidcIssuer: 'https://issuer.example',
oidcSessionId: `sid-${randomUUID()}`,
idTokenHint: `id-token-${randomUUID()}`,
})
.returning({ id: schema.oidcSessions.id });
context.oidcDiscoveryMock.getDiscoveryDoc.mockRejectedValueOnce(new Error('discovery unavailable'));
const logoutResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: {
cookie: cookieHeader(session.jar),
},
});
expect(logoutResponse.statusCode).toBe(200);
expect(logoutResponse.json()).toEqual({});
const oidcSessionAfter = await context.db.query.oidcSessions.findFirst({
where: eq(schema.oidcSessions.id, createdOidcSession.id),
});
expect(oidcSessionAfter?.revoked).toBe(true);
});
});
});
@@ -0,0 +1,729 @@
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 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',
};
const SETUP_RESET_HINT =
'Initial setup already completed. Run "pnpm run test:e2e -- auth-session-security" from the repository root before this suite.';
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('; ');
}
function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
return {
extractAndSave: () => Promise.resolve(undefined),
refreshCoverForBook: () => Promise.resolve(false),
extractAudioFileDuration: () => Promise.resolve(undefined),
aggregateAudioDuration: () => Promise.resolve(undefined),
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
};
}
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) {
throw new Error(SETUP_RESET_HINT);
}
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(accessCookieLine!, 'path')).toBe('/api');
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 loginResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: {
username: ADMIN_SETUP_DTO.username,
password: ADMIN_SETUP_DTO.password,
},
});
expect(loginResponse.statusCode).toBe(200);
expect(loginResponse.json()).toMatchObject({
user: {
username: ADMIN_SETUP_DTO.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: ADMIN_SETUP_DTO.username,
password: 'WrongPass123',
},
});
expect(invalidLoginResponse.statusCode).toBe(401);
expect(getSetCookieLines(invalidLoginResponse.headers)).toHaveLength(0);
});
});
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({ 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 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('revokes all sessions and invalidates access tokens when a revoked token is reused', 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);
const rotatedAccessToken = (rotateResponse.json() as { accessToken: string }).accessToken;
const reuseResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/refresh',
headers: {
cookie: `refresh_token=${revokedRefreshToken}`,
},
});
expect(reuseResponse.statusCode).toBe(401);
const reuseCookies = getSetCookieLines(reuseResponse.headers);
expect(cookieValue(reuseCookies, 'refresh_token')).toBe('');
expect(cookieValue(reuseCookies, 'access_token')).toBe('');
const remainingSessions = await context.db
.select({ id: schema.refreshTokens.id })
.from(schema.refreshTokens)
.where(eq(schema.refreshTokens.userId, credentials.userId));
expect(remainingSessions).toHaveLength(0);
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).toBeGreaterThan(1);
const meResponse = await context.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: {
authorization: `Bearer ${rotatedAccessToken}`,
},
});
expect(meResponse.statusCode).toBe(401);
});
});
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);
});
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);
});
});
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(401);
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);
});
});
});