feat(auth): track sign-in sessions so a session can end before it expires

A session token was a signed JWT and nothing else. Logout only cleared
the cookie, a 30-day "remember me" token stayed valid wherever a copy of
it had ended up, and nobody could sign out one device short of changing
the password or rotating the JWT secret for everyone.

Every session token now carries an id (jti) naming a row in the new
user_sessions table (id, user_id, created_at, last_seen_at, expires_at,
revoked_at, user_agent cut to 256 characters; no address is kept). The
rows live behind UserSessionsRepository and the new sessions domain,
whose SessionsService is the one issuer: AuthService.generateToken and
the OIDC sign-in both go through it, and sliding renewal re-signs a
tracked token under the same id and moves its expiry. verifyJwtAndLoadUser
takes the repository and refuses a token whose session is revoked,
expired or gone, refreshing last_seen_at at most every five minutes.
A token without a jti was issued before this change; it passes until its
own expiry (or a password change), so the upgrade signs nobody out, and
renewal turns it into a tracked session.

Sessions end on logout (the one the request names, verified even when
expired), on a password change (all, the device gets a new one), a
password reset, an admin setting a password, reset-admin.js, an MFA
disable (all but the current one), an admin clearing a user's MFA, and
with the account. GET /api/auth/sessions lists the active sessions,
DELETE /api/auth/sessions/:id ends one and POST
/api/auth/sessions/revoke-others ends all but the current, with their
contracts in @trek/shared and two new audit actions. There is no UI for
them yet. MCP exposes no account or session tools, so there is no
parallel tool to change; an MCP call made with a session JWT as bearer
goes through the same check.

The token in the login, register, demo-login, MFA and passkey bodies
stays for API clients but is marked deprecated in the API docs and in
the client type, which never read it.

A nightly session-purge job on CronRegistrarService deletes the expired
and revoked rows. The migration is 042500. The sessions domain gets its
own coverage entry at the 80 floor until a full run measures it.
This commit is contained in:
Maurice
2026-10-08 17:09:37 +02:00
parent 094e0b28f8
commit 9aab9e7513
65 changed files with 2188 additions and 154 deletions
+5 -1
View File
@@ -24,7 +24,11 @@ const LOGOUT_FLUSH_MS = 5000
interface AuthResponse {
user: User
token: string
/**
* @deprecated Still in the body for API clients that read it; the session is
* the httpOnly cookie the same response sets. Nothing here reads it.
*/
token?: string
}
export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string }
+2 -2
View File
@@ -64,7 +64,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
- **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record<string, unknown>` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip.
- **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation: no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. ESLint requires an init with a `signal` on `fetch`, `safeFetch`, `safeFetchFollow` and `safeFetchAdminConfigured` alike, or a literal `timeoutMs` in the options object of `safeFetch`/`safeFetchFollow`; an init spelled `undefined`, `null` or `void 0` counts as none (`safeFetchLlm` takes `LLM_TIMEOUT_MS` inside). Behind that, the guarded wrappers (`utils/ssrfGuard.ts`) cap every body at `DEFAULT_MAX_RESPONSE_BYTES` (64 MB) and, for a call without a signal, wait `DEFAULT_RESPONSE_TIMEOUT_MS` (30 s) for the headers. Pass a tighter `maxBytes` when you buffer a small answer, `timeoutMs` for a deadline over the whole exchange, and `maxBytes: null` only where the body is streamed on or counted by the caller (`pipeAsset`, the doc-sync transfers, the Ollama pull).
- **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write.
- **Don't fork canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate) — reuse it.
- **Don't fork canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate and the session gate); reuse it. A session token is minted only by `SessionsService.issue` (`nest/sessions/`, reached through `AuthService.generateToken`), which writes the `user_sessions` row its `jti` names; a token signed anywhere else carries no session and cannot be listed or revoked. An action that should sign a user out everywhere calls `SessionsService.revokeAll` inside the same transaction as the write that warrants it.
- **Money**: integer cents; largest-remainder splits (`BudgetService.splitEqualShares` is the reference); FX frozen at entry; explicit flags, never sentinel values; custom splits must reconcile to zero before persisting.
## SQL dialect
@@ -96,7 +96,7 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s
## Cross-cutting pieces
- **`src/nest/common/`**: the global exception filter (error envelope), the Zod validation pipe, the idempotency interceptor (`X-Idempotency-Key` replay), and `validate-route-guards.ts` (boot-time ratchet refusing any public route not on its allow-list).
- **Auth is default-deny**: the global auth guard and the MFA policy guard run as `APP_GUARD`s, followed by the managed-install guard (`managed.guard.ts`), which refuses the routes a centrally administered install withholds from its own admin — it runs last so strangers still get a 401, never a route-revealing 403. Routes opt out with `@Public()`/`@OptionalAuth()`. The global guard stands down for routes declaring their own guard chain, so `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission` stacks work. Session is a cookie (`trek_session`); `@CurrentUser()` reads the user. **Never gate a multipart upload with a guard** — guards run before the parser and the client sees ECONNRESET instead of your 403; check in the handler.
- **Auth is default-deny**: the global auth guard and the MFA policy guard run as `APP_GUARD`s, followed by the managed-install guard (`managed.guard.ts`), which refuses the routes a centrally administered install withholds from its own admin — it runs last so strangers still get a 401, never a route-revealing 403. Routes opt out with `@Public()`/`@OptionalAuth()`. The global guard stands down for routes declaring their own guard chain, so `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission` stacks work. Session is a cookie (`trek_session`); `@CurrentUser()` reads the user. Every session token carries a `jti` naming its `user_sessions` row, and `verifyJwtAndLoadUser` refuses one whose row is revoked, expired or gone (logout, `/api/auth/sessions`, a password change or reset, an MFA disable all end rows; `session-purge.job.ts` deletes the dead ones nightly). A token without a `jti` predates the table and passes until it expires, so an upgrade signs nobody out. The `token` field in the login-type JSON bodies is deprecated: kept for API clients, read by nothing of ours. **Never gate a multipart upload with a guard** — guards run before the parser and the client sees ECONNRESET instead of your 403; check in the handler.
- **Logging**: `src/nest/audit/audit-log.logger.ts` (`logInfo`/`logWarn`/`logError`/`logDebug`) prints each line to the console and queues it for `data/logs/trek.log`, which `log-file.ts` appends in batches off the event loop and rotates at 10 MB over five files; `index.ts` flushes what is queued on exit. Never write the log file synchronously from a request path.
- **Request correlation** (`src/nest/common/request-correlation.ts`): every HTTP request runs under an id (a well-formed incoming `X-Request-Id`, else a fresh UUID) that the response carries in `X-Request-Id`, and every log line written inside it is tagged `[http <id>]`. The exception filter leaves a 5xx on `res.locals` so the access log writes the request line and the stack as one entry. The other entry points (MCP tool/resource/prompt calls via the registry's `around` seam, WebSocket messages in `TrekWsAdapter`, plugin RPC dispatches in the supervisor, cron ticks in `CronRegistrarService`) each run under their own id through `traceEntry()` (`nest/audit/entry-trace.logger.ts`), which logs one line per call: debug on success, debug as `skipped` when the call declines its work on purpose (a cron tick whose lease another process holds), warn or error with the reason on failure. A new entry point goes through `traceEntry()` too.
- **Process safety nets** (`src/index.ts`, `src/shutdown.ts`): `unhandledRejection` and `uncaughtException` are logged to trek.log and end in the same orderly shutdown as SIGTERM, with exit code 1; a second fatal error during it exits at once. A shutdown first marks `ReadinessService` (`nest/health/`) as draining, so `/api/health/ready` answers 503 for the whole drain. The HTTP server keeps idle keep-alive connections for `HTTP_KEEP_ALIVE_TIMEOUT_MS` (95 s, above the 60 s of nginx and the 90 s of Traefik) with the headers timeout one second above it (`applyProxyTimeouts` in `bootstrap.ts`).
+9
View File
@@ -66,6 +66,15 @@ if (existing) {
// what ends the sessions still signed in to an account that needed resetting.
db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1, password_version = COALESCE(password_version, 0) + 1 WHERE id = ?')
.run(hash, 'admin', existing.id);
// Its sessions end too, so the account's session list shows none of them
// as live. The table is missing on a database that no server tracking
// sessions has booted yet; the password_version bump above already refuses
// every token there.
const sessionsTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_sessions'").get();
if (sessionsTable) {
db.prepare('UPDATE user_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL')
.run(existing.id);
}
console.log(`\n✓ Admin password reset: ${email}`);
} else {
// 'admin' is usually taken by the first-run seed — pick the first free username
@@ -0,0 +1,31 @@
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { UserSessionsRepository } from '../repositories/UserSessions.repository';
import { DbTimestampType } from '../types';
import { Users } from './Users.entity';
export class UserSessions {
[EntityRepositoryType]?: UserSessionsRepository;
id!: string;
user!: Ref<Users>;
user_id!: number;
created_at!: string & Opt;
last_seen_at!: string & Opt;
expires_at!: string;
revoked_at?: string | null;
user_agent?: string | null;
}
export const UserSessionsSchema = defineEntity({
class: UserSessions,
repository: () => UserSessionsRepository,
properties: {
id: p.text().primary(),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_user_sessions_user'),
user_id: p.integer().persist(false).index('idx_user_sessions_user'),
created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
last_seen_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
expires_at: p.type(DbTimestampType).index('idx_user_sessions_expires'),
revoked_at: p.type(DbTimestampType).nullable(),
user_agent: p.text().nullable(),
},
});
+3
View File
@@ -60,6 +60,7 @@ import { TripMembers } from './TripMembers.entity';
import { TripPhotos } from './TripPhotos.entity';
import { Trips } from './Trips.entity';
import { UserNoticeDismissals } from './UserNoticeDismissals.entity';
import { UserSessions } from './UserSessions.entity';
import { VacayEntries } from './VacayEntries.entity';
import { VacayPlanMembers } from './VacayPlanMembers.entity';
import { VacayPlans } from './VacayPlans.entity';
@@ -182,6 +183,7 @@ export class Users {
trip_photos_collection = new Collection<TripPhotos>(this);
trips_collection = new Collection<Trips>(this);
user_notice_dismissals_collection = new Collection<UserNoticeDismissals>(this);
user_sessions_collection = new Collection<UserSessions>(this);
vacay_entries_collection = new Collection<VacayEntries>(this);
vacay_plan_members_collection = new Collection<VacayPlanMembers>(this);
vacay_plans_collection = new Collection<VacayPlans>(this);
@@ -321,6 +323,7 @@ export const UsersSchema = defineEntity({
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
trips_collection: () => p.oneToMany(Trips).mappedBy('user').hidden(),
user_notice_dismissals_collection: () => p.oneToMany(UserNoticeDismissals).mappedBy('user').hidden(),
user_sessions_collection: () => p.oneToMany(UserSessions).mappedBy('user').hidden(),
vacay_entries_collection: () => p.oneToMany(VacayEntries).mappedBy('user').hidden(),
vacay_plan_members_collection: () => p.oneToMany(VacayPlanMembers).mappedBy('user').hidden(),
vacay_plans_collection: () => p.oneToMany(VacayPlans).mappedBy('owner').hidden(),
+2
View File
@@ -115,6 +115,7 @@ import { TripMembersSchema } from './TripMembers.entity';
import { TripPhotosSchema } from './TripPhotos.entity';
import { TripsSchema } from './Trips.entity';
import { UserNoticeDismissalsSchema } from './UserNoticeDismissals.entity';
import { UserSessionsSchema } from './UserSessions.entity';
import { UsersSchema } from './Users.entity';
import { VacayCompanyHolidaysSchema } from './VacayCompanyHolidays.entity';
import { VacayEntriesSchema } from './VacayEntries.entity';
@@ -248,6 +249,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TripPhotosSchema,
TripsSchema,
UserNoticeDismissalsSchema,
UserSessionsSchema,
UsersSchema,
VacayCompanyHolidaysSchema,
VacayEntriesSchema,
@@ -0,0 +1,38 @@
import { Migration } from '@mikro-orm/migrations';
/**
* One row per sign-in, so a session can end before its token expires.
*
* A session token used to be a signed JWT and nothing else: logging out only
* cleared the cookie, a 30-day "remember me" token stayed valid wherever a
* copy of it had ended up, and nobody could sign out one device without
* changing the password. Every token issued from now on carries an id (the JWT
* `jti`) naming a row here, and the auth check refuses a token whose row is
* gone, revoked or expired.
*
* `id` is that `jti`, a random uuid. `expires_at` follows the token's own
* expiry (sliding renewal moves both). `user_agent` is what the browser sent at
* sign-in, cut to 256 characters like a Web Push device's, so a list of
* sessions can tell them apart; no address is kept. Tokens issued before this
* migration have no `jti` and keep working until they expire. Rows go with
* their user, and a nightly job removes the expired and revoked ones.
*/
export class Migration20200101042600_a_session_row_per_sign_in extends Migration {
override name = 'Migration20200101042600_a_session_row_per_sign_in';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME,
user_agent TEXT
)
`);
await this.execute('CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)');
await this.execute('CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)');
}
}
@@ -0,0 +1,135 @@
import type { UserSessions } from '../entities/UserSessions.entity';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/** A `user_sessions` row exactly as `SELECT *` reads it. */
export interface UserSessionRow {
id: string;
user_id: number;
created_at: string;
last_seen_at: string;
expires_at: string;
revoked_at: string | null;
user_agent: string | null;
}
const _userSessionRowKeys: AssertRowKeys<UserSessionRow, UserSessions> = true;
/** What a session list shows: the row without its owner, who is the caller. */
export type UserSessionListRow = Omit<UserSessionRow, 'user_id' | 'revoked_at'>;
/** The columns a new sign-in writes; `last_seen_at` starts at `created_at`. */
export interface NewUserSessionRow {
id: string;
user_id: number;
created_at: string;
expires_at: string;
user_agent: string | null;
}
/**
* `user_sessions`: one row per issued session token, keyed by the token's
* `jti`. Every timestamp is the `YYYY-MM-DD HH:MM:SS` text the column type
* stores, and the caller passes "now" in that same form, so "active" (not
* revoked, `expires_at` still ahead) is one comparison the database makes and
* a test can pin a clock for.
*/
export class UserSessionsRepository extends TrekRepository<UserSessions> {
/** `INSERT INTO user_sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)`. */
async insertSession(row: NewUserSessionRow): Promise<void> {
await this.insert({
id: row.id,
user: row.user_id,
created_at: row.created_at,
last_seen_at: row.created_at,
expires_at: row.expires_at,
user_agent: row.user_agent,
});
}
/**
* The check behind every session token that carries an id:
* `SELECT id, last_seen_at FROM user_sessions WHERE id = ? AND user_id = ?
* AND revoked_at IS NULL AND expires_at > ?`. The owner is part of the
* match, so a token can only ever name a session of the user it was signed for.
*/
async findActive(id: string, userId: number, now: string): Promise<{ id: string; last_seen_at: string } | null> {
const row = await this.findOne(
{ id, user: userId, revoked_at: null, expires_at: { $gt: now } },
{ fields: ['id', 'last_seen_at'] },
);
return row ? { id: row.id, last_seen_at: row.last_seen_at } : null;
}
/** `UPDATE user_sessions SET last_seen_at = ? WHERE id = ?`. */
async touchLastSeen(id: string, now: string): Promise<void> {
await this.nativeUpdate({ id }, { last_seen_at: now });
}
/**
* Sliding renewal: `UPDATE user_sessions SET expires_at = ?, last_seen_at = ?
* WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?`.
* Answers whether the session was still active, so a session revoked a
* moment ago is never brought back.
*/
async extendActive(id: string, userId: number, now: string, expiresAt: string): Promise<boolean> {
const changed = await this.nativeUpdate(
{ id, user: userId, revoked_at: null, expires_at: { $gt: now } },
{ expires_at: expiresAt, last_seen_at: now },
);
return changed > 0;
}
/**
* `SELECT id, created_at, last_seen_at, expires_at, user_agent FROM user_sessions
* WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
* ORDER BY last_seen_at DESC, id ASC`.
*/
async listActiveForUser(userId: number, now: string): Promise<UserSessionListRow[]> {
const rows = await this.find(
{ user: userId, revoked_at: null, expires_at: { $gt: now } },
{
fields: ['id', 'created_at', 'last_seen_at', 'expires_at', 'user_agent'],
orderBy: { last_seen_at: 'desc', id: 'asc' },
},
);
return rows.map((row) => ({
id: row.id,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
expires_at: row.expires_at,
user_agent: row.user_agent ?? null,
}));
}
/**
* `UPDATE user_sessions SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`;
* answers whether a session of that user was revoked. Another user's id
* matches nothing, the same answer as an id that never existed.
*/
async revokeForUser(id: string, userId: number, now: string): Promise<boolean> {
return (await this.nativeUpdate({ id, user: userId, revoked_at: null }, { revoked_at: now })) > 0;
}
/**
* `UPDATE user_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`,
* with `AND id <> ?` when one session is kept. Answers how many were revoked.
*/
async revokeAllForUser(userId: number, now: string, exceptId?: string): Promise<number> {
return await this.nativeUpdate(
exceptId === undefined
? { user: userId, revoked_at: null }
: { user: userId, revoked_at: null, id: { $ne: exceptId } },
{ revoked_at: now },
);
}
/**
* The nightly purge: `DELETE FROM user_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL`.
* A row that is gone refuses its token just as a revoked one does, so
* nothing depends on keeping them.
*/
async deleteInactive(now: string): Promise<number> {
return await this.nativeDelete({ $or: [{ expires_at: { $lte: now } }, { revoked_at: { $ne: null } }] });
}
}
+2 -1
View File
@@ -45,6 +45,7 @@ import { Trips } from '../../db/entities/Trips.entity';
import { Places } from '../../db/entities/Places.entity';
import { TripFiles } from '../../db/entities/TripFiles.entity';
import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
import { SessionsModule } from '../sessions/sessions.module';
/**
* MikroOrmModule.forFeature registers the repositories `AdminService`'s
@@ -62,7 +63,7 @@ import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
@Module({
imports: [
MikroOrmModule.forFeature([Users, AuditLog, AppSettings, Addons, PhotoProviders, PhotoProviderFields, DocumentProviders, McpTokens, OauthTokens, Trips, Places, TripFiles, PushSubscriptions]),
AppConfigModule, DatabaseLifecycleModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule,
AppConfigModule, DatabaseLifecycleModule, PluginsRuntimeModule, SessionsModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule,
],
controllers: [AdminController],
providers: [AdminService, VersionCheckJob, DemoResetJob],
+9 -2
View File
@@ -67,6 +67,7 @@ import {
type VersionInfo,
} from './admin.helpers';
import { MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import { SessionsService } from '../sessions/sessions.service';
/** Outbound GitHub calls: hard timeout and response-size cap (server/CLAUDE.md). */
const GITHUB_TIMEOUT_MS = 10_000;
@@ -131,6 +132,7 @@ export class AdminService {
private readonly uow: UnitOfWork,
@Inject(DATABASE_BACKUP) private readonly database: DatabaseBackupStrategy,
private readonly dataPaths: DataPathsService,
private readonly sessions: SessionsService,
) {}
// ── User CRUD ──────────────────────────────────────────────────────────────
@@ -284,6 +286,7 @@ export class AdminService {
// Push devices outlive every session, so the intruder's browser would
// keep receiving this account's notifications. They go with the rest.
await this.pushSubscriptions.deleteAllForUser(userId);
await this.sessions.revokeAll(userId);
}
});
} catch (err) {
@@ -351,8 +354,12 @@ export class AdminService {
if (!target) return { error: 'User not found', status: 404 };
// Same three columns disableMfa clears, so an admin reset and a self-service
// disable leave the account in exactly one state rather than two.
await this.users.disableMfa(targetId);
// disable leave the account in exactly one state rather than two; the
// account's sessions end with the second factor, as on that path.
await this.uow.transactional(async () => {
await this.users.disableMfa(targetId);
await this.sessions.revokeAll(targetId);
});
return { success: true, email: target.email };
}
+2
View File
@@ -71,6 +71,7 @@ import { RoadtripModule } from './roadtrip/roadtrip.module';
import { RouteUsageModule } from './route-usage/route-usage.module';
import { SchedulingModule } from './scheduling/scheduling.module';
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
import { SessionsModule } from './sessions/sessions.module';
import { SettingsModule } from './settings/settings.module';
import { ShareModule } from './share/share.module';
import { StorageModule } from './storage/storage.module';
@@ -150,6 +151,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
StorageModule,
BackupModule,
AuthModule,
SessionsModule,
OidcModule,
OauthModule,
AdminModule,
+2
View File
@@ -15,6 +15,8 @@ const ACTION_LABELS: Record<string, string> = {
'user.account_delete': 'deleted account',
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'user.session_revoke': 'signed out a session',
'user.sessions_revoke_others': 'signed out other sessions',
'settings.app_update': 'updated settings',
'settings.api_keys_update': 'updated API keys',
'trip.create': 'created trip',
+22 -6
View File
@@ -10,6 +10,8 @@ import { willDropSecureCookie } from '../common/cookie';
import type { User } from '../../types';
import { Public } from './public.decorator';
import { MfaExempt } from './mfa-policy.guard';
import { extractToken, verifiedSessionClaims } from './jwt-verify';
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
const WINDOW = 15 * 60 * 1000;
const LOGIN_MIN_LATENCY_MS = 350;
@@ -23,11 +25,21 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
* Express route (server/src/routes/auth.ts): the same per-IP rate-limit buckets
* + limits, the constant-time login/forgot latency padding, the enumeration-safe
* forgot response, the audit writes and the JWT httpOnly cookie set/clear via
* the shared cookie service (no new token shape).
* the shared cookie service.
*
* The `token` in the login, register, demo-login and MFA bodies is deprecated:
* the httpOnly cookie is the session, and the web app has never read the field.
* It stays for API clients that took it as a bearer token, and the API docs
* mark it; nothing here or in the client relies on it.
*/
@Controller('api/auth')
export class AuthPublicController {
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
constructor(
private readonly auth: AuthService,
private readonly rl: RateLimitService,
private readonly audit: AuditService,
private readonly sessions: SessionsService,
) {}
private async limit(bucket: string, req: Request, max: number): Promise<void> {
if (!(await this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now()))) {
@@ -70,7 +82,7 @@ export class AuthPublicController {
@HttpCode(201)
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('login', req, 10);
const result = await this.auth.registerUser(body);
const result = await this.auth.registerUser(body, sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -85,7 +97,7 @@ export class AuthPublicController {
async login(@Body() body: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('login', req, 10);
const started = Date.now();
const result = await this.auth.loginUser(body);
const result = await this.auth.loginUser(body, sessionClientFrom(req));
if (result.auditAction) {
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
@@ -160,7 +172,7 @@ export class AuthPublicController {
@HttpCode(200)
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('mfa', req, 5);
const result = await this.auth.verifyMfaLogin(body);
const result = await this.auth.verifyMfaLogin(body, sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -172,7 +184,11 @@ export class AuthPublicController {
@Post('logout')
@Public('clearing a cookie must work even with an expired token, or the client cannot sign out')
@HttpCode(200)
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
// Ends the session itself, not only this browser's copy of it: a copy of
// the token anywhere else stops working too. Only a token whose signature
// checks out names the session to end; anything else just loses its cookie.
await this.sessions.endSession(verifiedSessionClaims(extractToken(req)));
this.auth.clearAuthCookie(res, req);
return { success: true };
}
+4 -3
View File
@@ -39,7 +39,8 @@ import {
import { RateLimitService } from '../common/rate-limit.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import { decodeSessionClaims } from './jwt-verify';
import { currentSessionId, decodeSessionClaims } from './jwt-verify';
import { sessionClientFrom } from '../sessions/sessions.service';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import type { User } from '../../types';
@@ -113,7 +114,7 @@ export class AuthController {
// "remember me" login survives a password change (#1927). Bearer callers
// have no cookie → undefined → the historical default duration.
const remember = decodeSessionClaims((req.cookies as Record<string, string> | undefined)?.trek_session)?.remember;
const result = await this.auth.changePassword(user.id, user.email, body, remember);
const result = await this.auth.changePassword(user.id, user.email, body, remember, sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -288,7 +289,7 @@ export class AuthController {
@HttpCode(200)
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
await this.limit('login', req, 5);
const result = await this.auth.disableMfa(user.id, user.email, body);
const result = await this.auth.disableMfa(user.id, user.email, body, currentSessionId(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+8 -3
View File
@@ -21,10 +21,13 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
import { Plugins } from '../../db/entities/Plugins.entity';
import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity';
import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { TokensModule } from '../tokens/tokens.module';
import { AuthPublicController } from './auth-public.controller';
import { AuthController } from './auth.controller';
import { PasskeyController } from './passkey.controller';
import { SessionsController } from './sessions.controller';
import { SessionsModule } from '../sessions/sessions.module';
import { AuthService } from './auth.service';
import { UserProfileService } from './user-profile.service';
import { RegistrationInvitesService } from './registration-invites.service';
@@ -84,7 +87,7 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
}),
StorageModule,
AllowedFileTypesModule,
EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule, BudgetModule,
EphemeralTokenModule, SessionsModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule, BudgetModule,
// MaintenanceRepository: UserCleanupService's UC1 plugin-table erasure.
MaintenanceModule,
// AppSettings/Users: AuthService/UserProfileService each pass their own
@@ -118,8 +121,10 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
// .enqueueUserErasure` rather than re-implementing the same filter twice.
// PushSubscriptions: AuthService drops every Web Push device of the user
// on password change/reset (Web Push, #894), owned by `nest/notifications`.
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, BudgetSettlements, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors, ShareTokens, Trips, TripMembers, Plugins, PluginUserErasureQueue, PushSubscriptions])],
controllers: [AuthPublicController, AuthController, PasskeyController],
// UserSessions: AuthService.verifyJwtToken hands it to verifyJwtAndLoadUser,
// owned by `nest/sessions`.
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, BudgetSettlements, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors, ShareTokens, Trips, TripMembers, Plugins, PluginUserErasureQueue, PushSubscriptions, UserSessions])],
controllers: [AuthPublicController, AuthController, PasskeyController, SessionsController],
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
})
+31 -32
View File
@@ -9,7 +9,7 @@ import { randomBytes, createHash } from 'crypto';
import type { Request, Response } from 'express';
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { JWT_SECRET } from '../../config';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
@@ -33,6 +33,9 @@ import { PasswordResetTokens } from '../../db/entities/PasswordResetTokens.entit
import type { PasswordResetTokensRepository } from '../../db/repositories/PasswordResetTokens.repository';
import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
import type { PushSubscriptionsRepository } from '../../db/repositories/PushSubscriptions.repository';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import { SessionsService, type SessionClient } from '../sessions/sessions.service';
// Type-and-guard only: the app-config read reports the provider choice, it does
// not construct one, so this does not pull the maps domain into auth.
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
@@ -185,6 +188,8 @@ export class AuthService {
@InjectRepository(WebauthnCredentials) private readonly webauthnCredentials: WebauthnCredentialsRepository,
@InjectRepository(PasswordResetTokens) private readonly passwordResetTokens: PasswordResetTokensRepository,
@InjectRepository(PushSubscriptions) private readonly pushSubscriptions: PushSubscriptionsRepository,
@InjectRepository(UserSessions) private readonly userSessions: UserSessionsRepository,
private readonly sessions: SessionsService,
) {}
// Cookie
@@ -255,22 +260,11 @@ export class AuthService {
return !(await this.resolveAuthToggles()).password_login;
}
async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) {
async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean, client?: SessionClient) {
const pv = typeof user.password_version === 'number'
? user.password_version
: ((await this.usersRepo.getPasswordVersion(Number(user.id))) ?? 0);
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
// the cookie service decides session-vs-persistent off the same flag.
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
// The flag is embedded as a claim so sliding renewal can re-issue with the
// same duration AND cookie semantics (false → browser-session cookie is not
// recoverable from exp − iat). Omitted when the caller didn't choose, so
// register/demo/passkey tokens keep their historical payload.
return jwt.sign(
{ id: user.id, pv, ...(typeof remember === 'boolean' ? { remember } : {}) },
JWT_SECRET,
{ expiresIn, algorithm: 'HS256' }
);
return this.sessions.issue({ id: Number(user.id), pv }, remember, client);
}
getPendingMfaSecret(userId: number): string | null {
@@ -442,7 +436,7 @@ export class AuthService {
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
}
async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
const username = typeof body.username === 'string' ? body.username.trim() : '';
const email = typeof body.email === 'string' ? body.email.trim() : '';
@@ -496,7 +490,7 @@ export class AuthService {
});
const user = { id: inserted.id, username, email, role, avatar: null, mfa_enabled: false };
const token = await this.generateToken(user);
const token = await this.generateToken(user, undefined, client);
if (validInvite) {
const updated = await this.inviteTokens.incrementUsedCount(validInvite.token);
@@ -527,7 +521,7 @@ export class AuthService {
}
}
async loginUser(rawBody: unknown): Promise<{
async loginUser(rawBody: unknown, client?: SessionClient): Promise<{
error?: string;
status?: number;
token?: string;
@@ -589,7 +583,7 @@ export class AuthService {
}
await this.usersRepo.touchLastLogin(user.id);
const token = await this.generateToken(user, remember);
const token = await this.generateToken(user, remember, client);
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
@@ -632,6 +626,7 @@ export class AuthService {
userEmail: string,
rawBody: unknown,
remember?: boolean,
client?: SessionClient,
): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> {
const body = rawBody as { current_password?: string; new_password?: string };
if (await this.isOidcOnlyMode()) {
@@ -658,10 +653,9 @@ export class AuthService {
await this.uow.transactional(async () => {
await this.usersRepo.setPassword(userId, hash, newPv);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
// token and OAuth bearer-token stores are pruned to match (same set the
// password-reset path already revokes).
// A password change ends every session (the pv bump covers the tokens from
// before sessions were tracked) and prunes the MCP static token and OAuth
// bearer-token stores to match, the same set the password reset revokes.
await this.mcpTokens.deleteAllForUser(userId);
try {
await this.oauthTokens.revokeAllForUser(userId);
@@ -671,15 +665,14 @@ export class AuthService {
// the client reloads the user after the change, and that re-syncs its
// subscription. Other devices do so after their next sign-in.
await this.pushSubscriptions.deleteAllForUser(userId);
await this.sessions.revokeAll(userId);
});
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
// Re-issue a session bound to the new password_version so the current device
// stays logged in while other existing sessions are rotated out by the pv
// gate — preserving the login's remember choice instead of downgrading a
// remembered session to the default duration (#1927).
const token = await this.generateToken({ id: userId, password_version: newPv }, remember);
// Re-issue a session for the current device only, keeping the login's remember
// choice instead of downgrading it to the default duration (#1927).
const token = await this.generateToken({ id: userId, password_version: newPv }, remember, client);
return { success: true, token };
}
@@ -868,7 +861,8 @@ export class AuthService {
async disableMfa(
userId: number,
userEmail: string,
rawBody: unknown
rawBody: unknown,
currentSessionId?: string,
): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> {
const body = rawBody as { password?: string; code?: string };
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
@@ -895,12 +889,16 @@ export class AuthService {
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
}
await this.usersRepo.disableMfa(userId);
// Every other session ends with the second factor; this one just proved both.
await this.uow.transactional(async () => {
await this.usersRepo.disableMfa(userId);
await this.sessions.revokeAll(userId, currentSessionId);
});
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: false };
}
async verifyMfaLogin(rawBody: unknown): Promise<{
async verifyMfaLogin(rawBody: unknown, client?: SessionClient): Promise<{
error?: string;
status?: number;
token?: string;
@@ -944,7 +942,7 @@ export class AuthService {
} else {
await this.usersRepo.touchLastLogin(user.id);
}
const sessionToken = await this.generateToken(user, remember);
const sessionToken = await this.generateToken(user, remember, client);
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
token: sessionToken,
@@ -1103,6 +1101,7 @@ export class AuthService {
// Push devices are a delivery channel that outlives every session, so an
// intruder's browser would keep reading this account's notifications.
await this.pushSubscriptions.deleteAllForUser(user.id);
await this.sessions.revokeAll(user.id);
});
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
@@ -1135,6 +1134,6 @@ export class AuthService {
* route) should go through.
*/
async verifyJwtToken(token: string): Promise<User | null> {
return verifyJwtAndLoadUser(token, this.usersRepo);
return verifyJwtAndLoadUser(token, this.usersRepo, this.userSessions);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestj
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -25,7 +26,7 @@ export class CookieAuthGuard implements CanActivate {
if (!cookieToken) {
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users));
const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users), this.em.getRepository(UserSessions));
if (!user) {
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { Reflector } from '@nestjs/core';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
@@ -49,7 +50,7 @@ export class GlobalAuthGuard implements CanActivate {
if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) {
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
return true;
}
@@ -66,7 +67,7 @@ export class GlobalAuthGuard implements CanActivate {
];
if (declared.length > 0) {
const declaredToken = extractToken(req);
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users)) : null) || null;
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
return true;
}
@@ -74,7 +75,7 @@ export class GlobalAuthGuard implements CanActivate {
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions));
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestj
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -53,7 +54,7 @@ export class JwtAuthGuard implements CanActivate {
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions));
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+76 -3
View File
@@ -3,6 +3,8 @@ import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../../config';
import type { User } from '../../types';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import { dbNow } from '../../db/types';
/**
* The canonical JWT session check. Every auth surface goes through here — the
@@ -24,7 +26,8 @@ import type { UsersRepository } from '../../db/repositories/Users.repository';
* pre-init caller, `platform.routes.ts::servePhoto` — `orm.em.getRepository(Users)`
* resolved INSIDE `applyPlatformUploads`'s `withRequestContext` wrapper,
* `orm` threaded in from `bootstrap.ts`) and passes it in. The legacy `db`
* proxy import is gone.
* proxy import is gone. The `UserSessionsRepository` beside it is resolved
* the same way, from the same entity manager, and carries the session check.
*/
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -49,8 +52,17 @@ export interface SessionClaims {
purpose?: string;
iat?: number;
exp?: number;
/** The session id (`user_sessions.id`); absent on tokens issued before sessions were tracked. */
jti?: string;
}
/**
* How old a session's `last_seen_at` may get before a request refreshes it.
* Coarse on purpose: the list only has to tell "today" from "last month", and
* a write on every request would be a write on every GET.
*/
export const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
/**
* Decode (NOT verify) a session token's claims. Only for callers that have
* already authenticated the request through a guard and need the token's
@@ -64,9 +76,69 @@ export function decodeSessionClaims(token: string | undefined): SessionClaims |
return decoded as SessionClaims;
}
export async function verifyJwtAndLoadUser(token: string, users: UsersRepository): Promise<User | null> {
/**
* The session id of the token this request authenticated with, or undefined
* for a token from before sessions were tracked. Decoded, not verified: only
* for handlers behind the auth guard, which verified this same token.
*/
export function currentSessionId(req: Request): string | undefined {
const jti = decodeSessionClaims(extractToken(req) ?? undefined)?.jti;
return typeof jti === 'string' ? jti : undefined;
}
/**
* The claims of a session token whose signature checks out, expired or not;
* null for anything else (a forged or purpose-scoped token). Logout reads the
* session to end through this, since it must work with an expired token too.
*/
export function verifiedSessionClaims(token: string | null | undefined): (SessionClaims & { id: number }) | null {
if (!token) return null;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], ignoreExpiration: true }) as SessionClaims;
if (decoded.purpose || typeof decoded.id !== 'number') return null;
return decoded as SessionClaims & { id: number };
} catch {
return null;
}
}
/**
* The session half of the check. A token with a `jti` must name an active
* session of its own user: one revoked (logout, a password change, "sign out
* other sessions"), expired or purged refuses the token. A token without one
* was issued before sessions were tracked and passes until its own expiry, so
* the upgrade signs nobody out; a password change still ends it through `pv`.
*/
async function sessionIsActive(decoded: { id: number; jti?: unknown }, sessions: UserSessionsRepository): Promise<boolean> {
if (decoded.jti === undefined) return true;
if (typeof decoded.jti !== 'string') return false;
const now = new Date();
const session = await sessions.findActive(decoded.jti, decoded.id, dbNow(now));
if (!session) return false;
const lastSeen = Date.parse(`${session.last_seen_at.replace(' ', 'T')}Z`);
if (!(now.getTime() - lastSeen < SESSION_TOUCH_INTERVAL_MS)) {
try {
await sessions.touchLastSeen(session.id, dbNow(now));
} catch {
// Bookkeeping for the session list only: a busy database must not turn
// a valid session into a 401.
}
}
return true;
}
export async function verifyJwtAndLoadUser(
token: string,
users: UsersRepository,
sessions: UserSessionsRepository,
): Promise<User | null> {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as {
id: number;
pv?: number;
purpose?: string;
jti?: unknown;
};
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
// secret but are not full session tokens — only their dedicated endpoint
// may accept them, so reject any token carrying a purpose claim here.
@@ -80,6 +152,7 @@ export async function verifyJwtAndLoadUser(token: string, users: UsersRepository
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) return null;
if (!(await sessionIsActive(decoded, sessions))) return null;
// Don't leak password_version beyond the verify.
const { password_version: _pv, ...user } = row;
return user as User;
+2 -1
View File
@@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -20,7 +21,7 @@ export class OptionalJwtGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null;
return true;
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { PasskeyService } from './passkey.service';
import { PasskeyRegisterOptionsDto, PasskeyRegisterVerifyDto, PasskeyLoginVerifyDto, PasskeyRenameDto, PasskeyDeleteDto } from './auth.dto';
import type { User } from '../../types';
import { MfaExempt } from './mfa-policy.guard';
import { sessionClientFrom } from '../sessions/sessions.service';
const WINDOW = 15 * 60 * 1000;
const LOGIN_MIN_LATENCY_MS = 350;
@@ -86,7 +87,7 @@ export class PasskeyController {
async loginVerify(@Body() body: PasskeyLoginVerifyDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('login', req, 10);
const started = Date.now();
const result = await this.passkeys.passkeyLoginVerify(body);
const result = await this.passkeys.passkeyLoginVerify(body, sessionClientFrom(req));
if (result.auditAction) {
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
}
+3 -2
View File
@@ -21,6 +21,7 @@ import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository, UserRow } from '../../db/repositories/Users.repository';
import type { User } from '../../types';
import { toRowId } from '../common/row-id';
import type { SessionClient } from '../sessions/sessions.service';
// ---------------------------------------------------------------------------
// Constants
@@ -364,7 +365,7 @@ export class PasskeyService {
return { options };
}
async passkeyLoginVerify(body: { assertionResponse?: unknown }): Promise<{
async passkeyLoginVerify(body: { assertionResponse?: unknown }, client?: SessionClient): Promise<{
error?: string;
status?: number;
token?: string;
@@ -437,7 +438,7 @@ export class PasskeyService {
// A user-verified passkey is phishing-resistant and inherently two-factor
// (device possession + biometric/PIN), so it mints the real session directly
// — the SAME path as password and OIDC login (no new token shape).
const token = await this.auth.generateToken(user);
const token = await this.auth.generateToken(user, undefined, client);
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
}
@@ -1,7 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { Observable } from 'rxjs';
import { AuthService } from './auth.service';
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
import { decodeSessionClaims } from './jwt-verify';
import { setAuthCookie } from '../common/cookie';
@@ -13,10 +13,14 @@ import { setAuthCookie } from '../common/cookie';
* (true → long persistent cookie, false → browser-session cookie, absent → the
* historical default duration).
*
* Cheap by construction — no DB access:
* Cheap by construction, one write per half-life at most:
* - `req.user` is only set after a guard ran verifyJwtAndLoadUser on this exact
* request, which already proved the token's `pv` matches the DB, so both the
* user id and pv can be copied from the decoded (not re-verified) token.
* request, which already proved the token's `pv` matches the DB and its
* session is active, so the user id, pv and session id can be copied from
* the decoded (not re-verified) token.
* - A tracked token is re-signed under the same session id and the session's
* expiry moves with it; a token from before sessions were tracked comes back
* as a tracked session. If the session ended in between, nothing is renewed.
* - The cookie is only renewed when it is the verified credential: extractToken
* prefers the cookie over the Authorization header, so cookie-present +
* req.user set means the guard verified the cookie. Bearer-only callers
@@ -31,7 +35,7 @@ import { setAuthCookie } from '../common/cookie';
*/
@Injectable()
export class SessionRenewalInterceptor implements NestInterceptor {
constructor(private readonly auth: AuthService) {}
constructor(private readonly sessions: SessionsService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
if (context.getType() === 'http') {
@@ -53,10 +57,10 @@ export class SessionRenewalInterceptor implements NestInterceptor {
const halfLife = claims.iat + (claims.exp - claims.iat) / 2;
if (Date.now() / 1000 < halfLife) return;
const token = await this.auth.generateToken(
{ id: req.user.id, password_version: claims.pv ?? 0 },
claims.remember,
const token = await this.sessions.renew(
{ id: req.user.id, pv: claims.pv, remember: claims.remember, jti: claims.jti },
sessionClientFrom(req),
);
setAuthCookie(res, token, req, claims.remember);
if (token) setAuthCookie(res, token, req, claims.remember);
}
}
@@ -0,0 +1,71 @@
import { Controller, Delete, Get, HttpCode, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import {
userSessionRevokeParamsSchema,
type UserSessionListResponse,
type UserSessionRevokeOthersResponse,
type UserSessionRevokeParams,
type UserSessionRevokeResponse,
} from '@trek/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { clearAuthCookie } from '../common/cookie';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import { SessionsService } from '../sessions/sessions.service';
import type { User } from '../../types';
import { CurrentUser } from './current-user.decorator';
import { currentSessionId } from './jwt-verify';
import { JwtAuthGuard } from './jwt-auth.guard';
/**
* The signed-in user's own sessions: where they are signed in, and signing
* one or all the others out. Behind JwtAuthGuard like the other account
* routes, and scoped to the caller: another user's session id answers 404,
* exactly like one that does not exist.
*
* A request made with a token from before sessions were tracked sees no
* current session (`current_tracked: false`). Signing out the others then
* ends every tracked session, while that token itself runs on to its expiry.
*
* No MCP tool mirrors these routes: MCP exposes no account or session tools,
* and a session is a browser credential an assistant has no business ending.
*/
@Controller('api/auth/sessions')
@UseGuards(JwtAuthGuard)
export class SessionsController {
constructor(
private readonly sessions: SessionsService,
private readonly audit: AuditService,
) {}
@Get()
async list(@CurrentUser() user: User, @Req() req: Request): Promise<UserSessionListResponse> {
const current = currentSessionId(req);
return { sessions: await this.sessions.list(user.id, current), current_tracked: current !== undefined };
}
// Static sub-route before the `:id` one.
@Post('revoke-others')
@HttpCode(200)
async revokeOthers(@CurrentUser() user: User, @Req() req: Request): Promise<UserSessionRevokeOthersResponse> {
const revoked = await this.sessions.revokeAll(user.id, currentSessionId(req));
await this.audit.writeAudit({ userId: user.id, action: 'user.sessions_revoke_others', ip: getClientIp(req), details: { revoked } });
return { success: true, revoked };
}
@Delete(':id')
async revoke(
@CurrentUser() user: User,
@Param(new ZodValidationPipe(userSessionRevokeParamsSchema)) params: UserSessionRevokeParams,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<UserSessionRevokeResponse> {
if (!(await this.sessions.revoke(user.id, params.id))) {
throw new HttpException({ error: 'Session not found' }, 404);
}
// Ending the session this request came with is a logout: the cookie goes too.
if (params.id === currentSessionId(req)) clearAuthCookie(res, req);
await this.audit.writeAudit({ userId: user.id, action: 'user.session_revoke', ip: getClientIp(req), resource: params.id });
return { success: true };
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { EphemeralTokenService } from '../auth/ephemeral-token.service';
import { verifyJwtAndLoadUser } from '../auth/jwt-verify';
import { EntityManager } from '@mikro-orm/core';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import type { User } from '../../types';
import type { TripAccess } from '../../db/repositories/Trips.repository';
import { Trips } from '../../db/entities/Trips.entity';
@@ -168,7 +169,7 @@ export class FilesService {
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = await verifyJwtAndLoadUser(jwtToken, this.em.getRepository(Users));
const user = await verifyJwtAndLoadUser(jwtToken, this.em.getRepository(Users), this.em.getRepository(UserSessions));
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { getClientIp } from '../audit/client-ip';
import { AdminOidcUpdateDto } from '../admin/admin.dto';
import { Public } from '../auth/public.decorator';
import { ManagedForbidden } from '../common/managed';
import { sessionClientFrom } from '../sessions/sessions.service';
const OIDC_STATE_COOKIE = 'trek_oidc_state';
const OIDC_EXCHANGE_COOKIE = 'trek_oidc_exchange';
@@ -202,7 +203,7 @@ export class OidcController {
// Pass the flag through untouched: `undefined` must reach the token as
// "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927).
const jwtToken = await this.oidc.generateToken(result.user, pending.remember);
const jwtToken = await this.oidc.generateToken(result.user, pending.remember, sessionClientFrom(req));
const { code: authCode, binding } = await this.oidc.createAuthCode(jwtToken, pending.remember);
// Bind the code to THIS browser, the way the state cookie binds the callback.
// The code rides home in a URL, so it is readable from history, from a
+9 -15
View File
@@ -7,12 +7,12 @@ import bcrypt from 'bcryptjs';
import type { Request, Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { readEnv, getAppUrl } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { User } from '../../types';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { setAuthCookie, RememberOption } from '../common/cookie';
import { AuthService } from '../auth/auth.service';
import type { SessionClient } from '../sessions/sessions.service';
import { UnitOfWork } from '../database/unit-of-work';
import { logError } from '../audit/audit-log.logger';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
@@ -460,20 +460,14 @@ export class OidcService implements OnModuleDestroy {
return base + path;
}
async generateToken(user: { id: number }, remember?: boolean): Promise<string> {
// Embed the current password_version so an OIDC-issued session is invalidated
// by a password change/reset exactly like a password-login session (the auth
// middleware compares this `pv` against users.password_version).
const pv = (await this.usersRepo.getPasswordVersion(user.id)) ?? 0;
// "Remember me" mirrors the password flow: the JWT lifetime matches the
// persistent cookie maxAge picked by the cookie service off the same flag,
// and the claim lets sliding renewal preserve those semantics.
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
return jwt.sign(
{ id: user.id, pv, ...(typeof remember === 'boolean' ? { remember } : {}) },
JWT_SECRET,
{ expiresIn, algorithm: 'HS256' },
);
/**
* The same session token a password login gets, through the one issuer: it
* embeds the current password_version (so a password change or reset ends an
* SSO session too), takes the "remember me" lifetime and claim, and records
* the session so it can be listed and revoked.
*/
async generateToken(user: { id: number }, remember?: boolean, client?: SessionClient): Promise<string> {
return this.auth.generateToken({ id: user.id }, remember, client);
}
// -------------------------------------------------------------------------
+7 -1
View File
@@ -22,7 +22,13 @@ export function setupApiDocs(app: INestApplication): void {
.setTitle('TREK API')
.setDescription(
'The REST API the TREK web app itself runs on. Authenticate with a session JWT — '
+ 'either the `trek_session` cookie (same browser) or an `Authorization: Bearer <jwt>` header.',
+ 'either the `trek_session` cookie (same browser) or an `Authorization: Bearer <jwt>` header.\n\n'
+ 'Every session can be ended before it expires: `POST /api/auth/logout` ends the one it is called with, '
+ '`GET /api/auth/sessions` lists the signed-in sessions, `DELETE /api/auth/sessions/{id}` ends one and '
+ '`POST /api/auth/sessions/revoke-others` ends all but the current one.\n\n'
+ '**Deprecated:** the `token` field in the bodies of `POST /api/auth/login`, `/register`, `/demo-login`, '
+ '`/mfa/verify-login` and `/passkey/login/verify`. It is still sent, for clients that read it, but will be '
+ 'removed in a future major version. Use the `trek_session` cookie the same response sets.',
)
.setVersion(version)
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'session')
+23 -3
View File
@@ -8,7 +8,9 @@ import { withRequestContext } from '../database/request-context';
import { StorageService } from '../storage/storage.service';
import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } from '../storage/storage.types';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import type { ShareTokensRepository } from '../../db/repositories/ShareTokens.repository';
import { Photos } from '../../db/entities/Photos.entity';
@@ -114,7 +116,15 @@ export function storageStaticHandler(storage: StorageService, category: StorageC
};
}
async function servePhoto(storage: StorageService, req: Request, res: Response, users: UsersRepository, shareTokens: ShareTokensRepository, photos: PhotosRepository): Promise<void> {
async function servePhoto(
storage: StorageService,
req: Request,
res: Response,
users: UsersRepository,
shareTokens: ShareTokensRepository,
photos: PhotosRepository,
sessions: UserSessionsRepository,
): Promise<void> {
const safeName = path.basename(req.params.filename);
// Parity: after basename(), the old resolve()+startsWith guard could only
// fire when the remaining segment was '..' — keep that exact 403.
@@ -146,7 +156,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response,
}
// JWT session path (with pv check).
const user = await verifyJwtAndLoadUser(rawToken, users);
const user = await verifyJwtAndLoadUser(rawToken, users, sessions);
if (user) return sendPhoto();
// Share-token path: require the token to cover the exact trip the
@@ -240,7 +250,17 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
next(new Error('applyPlatformUploads: no MikroORM available to build a request context for /uploads/photos/*'));
return;
}
return withRequestContext(orm, () => servePhoto(storage, req, res, orm.em.getRepository(Users), orm.em.getRepository(ShareTokens), orm.em.getRepository(Photos))).catch(next);
return withRequestContext(orm, () =>
servePhoto(
storage,
req,
res,
orm.em.getRepository(Users),
orm.em.getRepository(ShareTokens),
orm.em.getRepository(Photos),
orm.em.getRepository(UserSessions),
),
).catch(next);
});
// Block direct access to /uploads/files
@@ -6,6 +6,7 @@ import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
import { Public } from '../auth/public.decorator';
import { Users } from '../../db/entities/Users.entity';
import { UserSessions } from '../../db/entities/UserSessions.entity';
/**
* Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2).
@@ -102,7 +103,7 @@ export class PluginsProxyController {
let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null;
if (route.auth) {
const token = extractToken(req);
const loaded = token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null;
const loaded = token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null;
if (!loaded) {
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
return;
@@ -0,0 +1,33 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { logError, logInfo } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { SessionsService } from './sessions.service';
/**
* Nightly removal of the `user_sessions` rows that no longer let anyone in:
* expired ones, and revoked ones. Every sign-in writes a row, so without this
* the table would grow by one row per login for as long as the install runs.
* Deleting a revoked row changes nothing for its token, which a missing row
* refuses just the same.
*/
@Injectable()
export class SessionPurgeJob implements OnApplicationBootstrap {
constructor(
private readonly sessions: SessionsService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('session-purge', '45 3 * * *', () => this.tick());
}
async tick(now: Date = new Date()): Promise<void> {
try {
const removed = await this.sessions.purgeInactive(now);
if (removed > 0) logInfo(`Session purge: removed ${removed} expired or revoked session(s)`);
} catch (err: unknown) {
logError(`Session purge: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { SessionPurgeJob } from './session-purge.job';
import { SessionsService } from './sessions.service';
/**
* Session tokens and the rows that let them be revoked. A leaf: it imports no
* other domain module, so auth (sign-in, logout, the session list), oidc
* (sign-in) and admin (resets) can all import it without a cycle. The routes
* over it live in auth, beside the other /api/auth account routes.
*/
@Module({
imports: [SchedulingModule, MikroOrmModule.forFeature([UserSessions])],
providers: [SessionsService, SessionPurgeJob],
exports: [SessionsService],
})
export class SessionsModule {}
@@ -0,0 +1,149 @@
import { randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { Request } from 'express';
import jwt from 'jsonwebtoken';
import type { UserSession } from '@trek/shared';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository';
import { dbNow } from '../../db/types';
/** The longest User-Agent kept, the same cap a Web Push device's gets. */
export const USER_AGENT_MAX_LENGTH = 256;
/** What the issuing request tells about the device a session belongs to. */
export interface SessionClient {
userAgent?: string | null;
}
/** The claims a session token is renewed from (decoded by the caller after a guard verified it). */
export interface RenewableSessionClaims {
id: number;
pv?: number;
remember?: boolean;
jti?: string;
}
/** The device description of a request: its User-Agent header, when it sent one. */
export function sessionClientFrom(req: Pick<Request, 'headers'>): SessionClient {
const agent = req.headers['user-agent'];
return { userAgent: typeof agent === 'string' && agent.length > 0 ? agent : null };
}
/**
* Session tokens and the `user_sessions` rows behind them.
*
* Every token issued here carries a random `jti` naming its row, and
* `verifyJwtAndLoadUser` (auth/jwt-verify.ts) refuses a token whose row is
* revoked, expired or gone. That is what lets a logout end the token instead
* of only clearing the cookie, and lets a user sign out a device. A token
* without a `jti` was issued before sessions were tracked: it is never looked
* up here and stays valid until it expires or the password changes, so the
* upgrade signs nobody out.
*
* Its own domain, with no import of auth, because auth, oidc and admin all end
* or start sessions; a home inside auth would have made oidc's and admin's
* reach for it a reach into auth's internals.
*/
@Injectable()
export class SessionsService {
constructor(@InjectRepository(UserSessions) private readonly sessions: UserSessionsRepository) {}
/**
* Mint a session token for the user and record its session.
*
* `remember` picks the lifetime (true: the long "remember me" one) and is
* kept as a claim so sliding renewal keeps the same cookie semantics; it is
* left out when the caller made no choice, as before.
*
* @txStandalone the row only makes the token it returns usable: if the
* caller fails after it, nobody holds that token and the row expires with it.
*/
async issue(user: { id: number; pv: number }, remember?: boolean, client: SessionClient = {}): Promise<string> {
const jti = randomUUID();
const token = this.sign(user, remember, jti);
const { iat, exp } = this.lifetimeOf(token);
await this.sessions.insertSession({
id: jti,
user_id: user.id,
created_at: dbNow(new Date(iat * 1000)),
expires_at: dbNow(new Date(exp * 1000)),
user_agent: clipUserAgent(client.userAgent),
});
return token;
}
/**
* Sliding renewal of a verified token past half its life. A tracked token
* is re-signed under the same id and its row's expiry moves with it; a
* token from before sessions were tracked becomes a tracked session here.
* Null when the session ended in the meantime, so nothing is renewed.
*
* @txStandalone one statement either way, and a renewal stands on its own:
* the old token keeps working until its own expiry whatever happens next.
*/
async renew(claims: RenewableSessionClaims, client: SessionClient = {}): Promise<string | null> {
const user = { id: claims.id, pv: claims.pv ?? 0 };
if (claims.jti === undefined) return this.issue(user, claims.remember, client);
const token = this.sign(user, claims.remember, claims.jti);
const { exp } = this.lifetimeOf(token);
const extended = await this.sessions.extendActive(claims.jti, claims.id, dbNow(), dbNow(new Date(exp * 1000)));
return extended ? token : null;
}
/** The user's active sessions, most recently used first, the one in `currentId` flagged. */
async list(userId: number, currentId?: string): Promise<UserSession[]> {
const rows = await this.sessions.listActiveForUser(userId, dbNow());
return rows.map((row) => ({ ...row, current: row.id === currentId }));
}
/** End one of the user's sessions. False when the user has no active session by that id. */
async revoke(userId: number, sessionId: string): Promise<boolean> {
return this.sessions.revokeForUser(sessionId, userId, dbNow());
}
/**
* Logout: end the session a verified token names. False for no token, or
* for one from before sessions were tracked, which has no session to end.
*/
async endSession(claims: { id: number; jti?: string } | null): Promise<boolean> {
if (!claims || typeof claims.jti !== 'string') return false;
return this.revoke(claims.id, claims.jti);
}
/**
* End every session of the user, or every one but `exceptId`. Answers how
* many ended. Runs inside the caller's transaction where there is one (a
* password change revokes the sessions with the password write).
*/
async revokeAll(userId: number, exceptId?: string): Promise<number> {
return this.sessions.revokeAllForUser(userId, dbNow(), exceptId);
}
/** Remove the rows that refuse their token anyway: expired or revoked. */
async purgeInactive(now: Date): Promise<number> {
return this.sessions.deleteInactive(dbNow(now));
}
private sign(user: { id: number; pv: number }, remember: boolean | undefined, jti: string): string {
// "Remember me" extends the JWT lifetime to match the persistent cookie
// maxAge; the cookie service decides session-vs-persistent off the same flag.
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
return jwt.sign(
{ id: user.id, pv: user.pv, ...(typeof remember === 'boolean' ? { remember } : {}) },
JWT_SECRET,
{ expiresIn, algorithm: 'HS256', jwtid: jti },
);
}
/** `iat` and `exp` of a token this service just signed. */
private lifetimeOf(token: string): { iat: number; exp: number } {
const { iat, exp } = jwt.decode(token) as { iat: number; exp: number };
return { iat, exp };
}
}
function clipUserAgent(agent: string | null | undefined): string | null {
return agent ? agent.slice(0, USER_AGENT_MAX_LENGTH) : null;
}
+2 -2
View File
@@ -45,7 +45,7 @@ import { createUser } from '../helpers/factories';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { AuthService } from '../../src/nest/auth/auth.service';
import { SessionsService } from '../../src/nest/sessions/sessions.service';
import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
@@ -74,7 +74,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
nest.useGlobalPipes(new ZodValidationPipe());
// Mirror the production APP_INTERCEPTOR: sliding session renewal (#1927).
// Inert for the harness's exp-less tokens and freshly issued logins.
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(AuthService)));
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(SessionsService)));
await nest.init();
return nest;
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Sessions e2e: session tokens that can be ended before they expire.
*
* Boots AuthModule (which brings SessionsModule) against the snapshot schema,
* with the real JwtAuthGuard, the real cookie service and the real
* renewal interceptor, then drives the flows a browser would: sign in on two
* devices, list the sessions, end one, end the others, log out, change the
* password. A token from before sessions were tracked (the harness's
* `sessionCookie`, which carries no `jti`) must keep working throughout.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import jwt from 'jsonwebtoken';
import { sessionCookie } from './harness';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
};
});
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
import { db } from '../../src/db/database';
import { createUser } from '../helpers/factories';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { SessionsService } from '../../src/nest/sessions/sessions.service';
import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
describe('Sessions e2e (sign-in sessions that can be ended)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AuthModule],
})
.overrideProvider(MailerService)
.useValue({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: 'email' }) })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(SessionsService)));
await nest.init();
return nest;
}
beforeAll(async () => {
app = await build();
server = app.getHttpServer();
});
beforeEach(() => resetRateLimits(app));
afterAll(async () => {
await app.close();
});
/** Sign in as the user from a device; returns the `trek_session=…` cookie pair. */
async function signIn(email: string, password: string, device: string): Promise<string> {
const res = await request(server).post('/api/auth/login').set('User-Agent', device).send({ email, password });
expect(res.status).toBe(200);
const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
return /^(trek_session=[^;]+)/.exec(cookie)![1];
}
const sessionIdOf = (cookie: string) => (jwt.decode(cookie.slice('trek_session='.length)) as { jti: string }).jti;
const me = (cookie: string) => request(server).get('/api/auth/me').set('Cookie', cookie);
function freshUser(name: string) {
return createUser(db as never, { username: name, email: `${name}@example.test` });
}
it('a login is listed as the current session, with its device', async () => {
const { user, password } = freshUser('sess-list');
const laptop = await signIn(user.email, password, 'Laptop Browser');
const phone = await signIn(user.email, password, 'Phone Browser');
const res = await request(server).get('/api/auth/sessions').set('Cookie', laptop);
expect(res.status).toBe(200);
expect(res.body.current_tracked).toBe(true);
const byId = Object.fromEntries((res.body.sessions as { id: string; user_agent: string; current: boolean }[]).map((s) => [s.id, s]));
expect(Object.keys(byId).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort());
expect(byId[sessionIdOf(laptop)]).toEqual(expect.objectContaining({ user_agent: 'Laptop Browser', current: true }));
expect(byId[sessionIdOf(phone)]).toEqual(expect.objectContaining({ user_agent: 'Phone Browser', current: false }));
}, 15000);
it('the session routes need a session', async () => {
expect((await request(server).get('/api/auth/sessions')).status).toBe(401);
expect((await request(server).post('/api/auth/sessions/revoke-others')).status).toBe(401);
});
it('logout ends the session itself, so a copy of the cookie stops working', async () => {
const { user, password } = freshUser('sess-logout');
const cookie = await signIn(user.email, password, 'Browser');
expect((await me(cookie)).status).toBe(200);
const out = await request(server).post('/api/auth/logout').set('Cookie', cookie);
expect(out.status).toBe(200);
expect(out.body).toEqual({ success: true });
expect((await me(cookie)).status).toBe(401);
}, 10000);
it('ending another session signs that device out and leaves this one signed in', async () => {
const { user, password } = freshUser('sess-revoke-one');
const laptop = await signIn(user.email, password, 'Laptop');
const phone = await signIn(user.email, password, 'Phone');
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(phone)}`).set('Cookie', laptop);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(((res.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false);
expect((await me(phone)).status).toBe(401);
expect((await me(laptop)).status).toBe(200);
// Ended already: the same id now answers like one that never existed.
expect((await request(server).delete(`/api/auth/sessions/${sessionIdOf(phone)}`).set('Cookie', laptop)).status).toBe(404);
}, 15000);
it('ending the current session is a logout: the cookie is cleared and stops working', async () => {
const { user, password } = freshUser('sess-revoke-self');
const cookie = await signIn(user.email, password, 'Browser');
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(cookie)}`).set('Cookie', cookie);
expect(res.status).toBe(200);
expect(((res.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session=;'))).toBe(true);
expect((await me(cookie)).status).toBe(401);
}, 10000);
it('another user\'s session id answers 404 and stays signed in; a malformed id answers 400', async () => {
const owner = freshUser('sess-owner');
const intruder = freshUser('sess-intruder');
const ownerCookie = await signIn(owner.user.email, owner.password, 'Owner');
const intruderCookie = await signIn(intruder.user.email, intruder.password, 'Intruder');
const res = await request(server).delete(`/api/auth/sessions/${sessionIdOf(ownerCookie)}`).set('Cookie', intruderCookie);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Session not found' });
expect((await me(ownerCookie)).status).toBe(200);
const bad = await request(server).delete('/api/auth/sessions/not-a-session').set('Cookie', intruderCookie);
expect(bad.status).toBe(400);
}, 15000);
it('signing out the other sessions keeps only this one', async () => {
const { user, password } = freshUser('sess-others');
const here = await signIn(user.email, password, 'Here');
const there = await signIn(user.email, password, 'There');
const elsewhere = await signIn(user.email, password, 'Elsewhere');
const res = await request(server).post('/api/auth/sessions/revoke-others').set('Cookie', here);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, revoked: 2 });
expect((await me(here)).status).toBe(200);
expect((await me(there)).status).toBe(401);
expect((await me(elsewhere)).status).toBe(401);
const list = await request(server).get('/api/auth/sessions').set('Cookie', here);
expect((list.body.sessions as { id: string }[]).map((s) => s.id)).toEqual([sessionIdOf(here)]);
}, 20000);
it('a password change ends every other session; the re-issued cookie is a new session', async () => {
const { user, password } = freshUser('sess-password');
const here = await signIn(user.email, password, 'Here');
const there = await signIn(user.email, password, 'There');
const change = await request(server)
.put('/api/auth/me/password')
.set('Cookie', here)
.set('User-Agent', 'Here')
.send({ current_password: password, new_password: 'New1234!x' });
expect(change.status).toBe(200);
const reissued = ((change.headers['set-cookie'] ?? []) as unknown as string[]).filter((c) => c.startsWith('trek_session=')).pop()!;
const next = /^(trek_session=[^;]+)/.exec(reissued)![1];
expect((await me(there)).status).toBe(401);
expect((await me(here)).status).toBe(401);
expect((await me(next)).status).toBe(200);
const list = await request(server).get('/api/auth/sessions').set('Cookie', next);
expect(list.body.sessions).toEqual([expect.objectContaining({ id: sessionIdOf(next), user_agent: 'Here', current: true })]);
}, 15000);
it('a token from before sessions were tracked keeps working, is listed as untracked, and survives "sign out others"', async () => {
const { user, password } = freshUser('sess-legacy');
const legacy = sessionCookie(user.id);
const tracked = await signIn(user.email, password, 'Tracked');
const list = await request(server).get('/api/auth/sessions').set('Cookie', legacy);
expect(list.status).toBe(200);
expect(list.body.current_tracked).toBe(false);
expect((list.body.sessions as { current: boolean }[]).every((s) => !s.current)).toBe(true);
const res = await request(server).post('/api/auth/sessions/revoke-others').set('Cookie', legacy);
expect(res.body).toEqual({ success: true, revoked: 1 });
expect((await me(tracked)).status).toBe(401);
expect((await me(legacy)).status).toBe(200);
}, 10000);
it('sliding renewal keeps the session id, and a renewed token from before tracking becomes a session', async () => {
const { user } = freshUser('sess-renew');
const old = sessionCookie(user.id, 0, { lifetime: 86400, consumed: 60000 });
const first = await request(server).get('/api/auth/me').set('Cookie', old).set('User-Agent', 'Renewed');
expect(first.status).toBe(200);
const renewedCookie = ((first.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
const renewed = /^(trek_session=[^;]+)/.exec(renewedCookie)![1];
const list = await request(server).get('/api/auth/sessions').set('Cookie', renewed);
expect(list.body.current_tracked).toBe(true);
expect(list.body.sessions).toEqual([expect.objectContaining({ id: sessionIdOf(renewed), user_agent: 'Renewed', current: true })]);
}, 10000);
});
@@ -190,6 +190,7 @@ import { createTestPushSubscriptionsRepo } from './notifications-repos';
import { createTestToursRepo } from './tours-repos';
import { createTestBudgetSettlementsRepo } from './budget-repos';
import { MaintenanceRepository } from '../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService, createTestUserSessionsRepo } from './sessions';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
@@ -240,6 +241,8 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AllowedFileTypesService(appSettings), await createTestUnitOfWork(db),
appSettings, usersRepo, inviteTokensRepo, mcpTokensRepoForAuth, oauthTokensRepo, webauthnCredentialsRepoForAuth, passwordResetTokensRepo,
await createTestPushSubscriptionsRepo(db),
await createTestUserSessionsRepo(db),
await createTestSessionsService(db),
);
const queryHelpersService = new QueryHelpersService(await createTestTagsRepo(db), await createTestPlaceRatingsRepo(db), await createTestAssignmentParticipantsRepo(db));
const daysService = new DaysService(
+30
View File
@@ -0,0 +1,30 @@
import type Database from 'better-sqlite3';
import { sharedTestOrm } from './test-uow';
import { UserSessions } from '../../src/db/entities/UserSessions.entity';
import type { UserSessionsRepository } from '../../src/db/repositories/UserSessions.repository';
import { SessionsService } from '../../src/nest/sessions/sessions.service';
/**
* The `user_sessions` table behind `SessionsService`, bound to a suite's own
* better-sqlite3 handle through the memoised `sharedTestOrm`, so a session row
* written inside a hand-built service's `UnitOfWork` transaction lands on the
* same connection the suite reads back.
*/
export function createTestUserSessionsRepo(db: Database.Database): Promise<UserSessionsRepository> {
return sharedTestOrm(db).then((t) => t.repo(UserSessions));
}
/** A real `SessionsService` over that repository, for the hand-constructed `AuthService`/`AdminService`. */
export async function createTestSessionsService(db: Database.Database): Promise<SessionsService> {
return new SessionsService(await createTestUserSessionsRepo(db));
}
/** The session rows of a user, oldest first, straight from the table. */
export function sessionRows(
db: Database.Database,
userId: number,
): { id: string; revoked_at: string | null; expires_at: string; user_agent: string | null }[] {
return db
.prepare('SELECT id, revoked_at, expires_at, user_agent FROM user_sessions WHERE user_id = ? ORDER BY created_at, id')
.all(userId) as { id: string; revoked_at: string | null; expires_at: string; user_agent: string | null }[];
}
+5 -4
View File
@@ -26,18 +26,19 @@ describe('ALL_ENTITIES', () => {
});
/**
* Plan 2 Task 3: every one of the 131 entities the migrated schema
* Plan 2 Task 3: every one of the 132 entities the migrated schema
* describes (120 rewritten + the 5 document-sync tables added this task,
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
* + tour_types, tours and tour_waypoints from the Tours migration,
* + scheduler_leases from the cron lease migration)
* + scheduler_leases from the cron lease migration
* + user_sessions, the session rows behind revocable session tokens)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 131 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(131);
it('ENT-003: exactly 132 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(132);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -14,7 +14,7 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
* the mechanism). This file pins the OUTPUT of that rule against the real,
* committed entities (every one of the 131, not a sample) and reproduces
* committed entities (every one of the 132, not a sample) and reproduces
* the reviewer's own insert probe so a future regenerate/hand-edit that
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
* caller's first `em.create()`.
@@ -23,10 +23,11 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
// 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key.
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
// + scheduler_leases: name, owner, expires_at (the scheduler always writes all three).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 443;
// + user_sessions: id, user_id, expires_at.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 446;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 131 entities', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 132 entities', () => {
const failures: string[] = [];
for (const schema of ALL_ENTITIES) {
const Ctor = schema.class as new () => Record<string, unknown>;
@@ -37,6 +37,7 @@ const UNNUMBERED = [
'Migration20200101042300_every_tour_type_the_contract_names',
'Migration20200101042400_one_account_per_email_whatever_its_case',
'Migration20200101042500_a_cron_tick_runs_in_one_process',
'Migration20200101042600_a_session_row_per_sign_in',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,149 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { UserSessions } from '../../../../src/db/entities/UserSessions.entity';
import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let sessions: UserSessionsRepository;
const NOW = '2026-10-08 12:00:00';
const LATER = '2026-11-07 12:00:00';
const EARLIER = '2026-10-01 12:00:00';
beforeAll(async () => {
t = await createTestOrm(testDb);
sessions = t.repo(UserSessions);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function row(id: string) {
return testDb.prepare('SELECT * FROM user_sessions WHERE id = ?').get(id) as Record<string, unknown> | undefined;
}
async function add(id: string, userId: number, overrides: { created_at?: string; expires_at?: string; user_agent?: string | null } = {}) {
await sessions.insertSession({
id,
user_id: userId,
created_at: overrides.created_at ?? EARLIER,
expires_at: overrides.expires_at ?? LATER,
user_agent: overrides.user_agent ?? null,
});
}
describe('UserSessionsRepository', () => {
it('SESSREPO-001: insert writes the row, last_seen_at starting at created_at', async () => {
const { user } = createUser(testDb);
await add('s1', user.id, { user_agent: 'Firefox' });
expect(row('s1')).toStrictEqual({
id: 's1',
user_id: user.id,
created_at: EARLIER,
last_seen_at: EARLIER,
expires_at: LATER,
revoked_at: null,
user_agent: 'Firefox',
});
});
it('SESSREPO-002: findActive matches only an unrevoked, unexpired session of that user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await add('live', user.id);
await add('expired', user.id, { expires_at: NOW });
await add('revoked', user.id);
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER);
expect(await sessions.findActive('live', user.id, NOW)).toEqual({ id: 'live', last_seen_at: EARLIER });
expect(await sessions.findActive('live', other.id, NOW)).toBeNull();
expect(await sessions.findActive('expired', user.id, NOW)).toBeNull();
expect(await sessions.findActive('revoked', user.id, NOW)).toBeNull();
expect(await sessions.findActive('missing', user.id, NOW)).toBeNull();
});
it('SESSREPO-003: touchLastSeen moves last_seen_at only', async () => {
const { user } = createUser(testDb);
await add('s1', user.id);
await sessions.touchLastSeen('s1', NOW);
expect(row('s1')).toEqual(expect.objectContaining({ last_seen_at: NOW, created_at: EARLIER, expires_at: LATER }));
});
it('SESSREPO-004: extendActive moves the expiry of an active session and refuses an ended one', async () => {
const { user } = createUser(testDb);
await add('live', user.id);
await add('revoked', user.id);
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER);
expect(await sessions.extendActive('live', user.id, NOW, '2026-12-01 00:00:00')).toBe(true);
expect(row('live')).toEqual(expect.objectContaining({ expires_at: '2026-12-01 00:00:00', last_seen_at: NOW }));
expect(await sessions.extendActive('revoked', user.id, NOW, '2026-12-01 00:00:00')).toBe(false);
expect(row('revoked')).toEqual(expect.objectContaining({ expires_at: LATER }));
});
it('SESSREPO-005: listActiveForUser lists the active sessions, most recently seen first', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await add('older', user.id, { user_agent: 'A' });
await add('newer', user.id, { user_agent: 'B' });
await add('ended', user.id);
await add('theirs', other.id);
await sessions.touchLastSeen('newer', NOW);
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'ended'").run(EARLIER);
expect(await sessions.listActiveForUser(user.id, NOW)).toEqual([
{ id: 'newer', created_at: EARLIER, last_seen_at: NOW, expires_at: LATER, user_agent: 'B' },
{ id: 'older', created_at: EARLIER, last_seen_at: EARLIER, expires_at: LATER, user_agent: 'A' },
]);
});
it('SESSREPO-006: revokeForUser ends one session of that user, once', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await add('s1', user.id);
expect(await sessions.revokeForUser('s1', other.id, NOW)).toBe(false);
expect(row('s1')).toEqual(expect.objectContaining({ revoked_at: null }));
expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(true);
expect(row('s1')).toEqual(expect.objectContaining({ revoked_at: NOW }));
expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(false);
});
it('SESSREPO-007: revokeAllForUser ends every session of the user, or all but one', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await add('a', user.id);
await add('b', user.id);
await add('c', user.id);
await add('theirs', other.id);
expect(await sessions.revokeAllForUser(user.id, NOW, 'b')).toBe(2);
expect(row('b')).toEqual(expect.objectContaining({ revoked_at: null }));
expect(await sessions.revokeAllForUser(user.id, NOW)).toBe(1);
expect(row('a')).toEqual(expect.objectContaining({ revoked_at: NOW }));
expect(row('theirs')).toEqual(expect.objectContaining({ revoked_at: null }));
});
it('SESSREPO-008: deleteInactive removes the expired and the revoked rows and keeps the live ones', async () => {
const { user } = createUser(testDb);
await add('live', user.id);
await add('expired', user.id, { expires_at: NOW });
await add('revoked', user.id);
testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER);
expect(await sessions.deleteInactive(NOW)).toBe(2);
expect(row('live')).toBeDefined();
expect(row('expired')).toBeUndefined();
expect(row('revoked')).toBeUndefined();
});
it('SESSREPO-009: the rows go with their user', async () => {
const { user } = createUser(testDb);
await add('s1', user.id);
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
expect(row('s1')).toBeUndefined();
});
});
@@ -0,0 +1,96 @@
/**
* `Migration20200101042600_a_session_row_per_sign_in`: the `user_sessions`
* table behind revocable session tokens. Migrate to the step before it, apply
* just this one, assert the shape, and that an install with users and no
* sessions comes through untouched (tokens from before carry no session id
* and are not looked up, so nothing has to be backfilled).
*/
import { describe, expect, it } from 'vitest';
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
const TARGET = 'Migration20200101042600_a_session_row_per_sign_in';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
describe('user_sessions migration', () => {
it('USERSESSMIG-001: creates the table with a non-null text id, the owner foreign key and both indexes', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
await migrateTo(orm, TARGET);
const columns = await rawQuery<{ name: string; type: string; notnull: number; pk: number }>(
orm,
"SELECT name, type, \"notnull\", pk FROM pragma_table_info('user_sessions') ORDER BY cid",
);
expect(columns).toEqual([
{ name: 'id', type: 'TEXT', notnull: 1, pk: 1 },
{ name: 'user_id', type: 'INTEGER', notnull: 1, pk: 0 },
{ name: 'created_at', type: 'DATETIME', notnull: 1, pk: 0 },
{ name: 'last_seen_at', type: 'DATETIME', notnull: 1, pk: 0 },
{ name: 'expires_at', type: 'DATETIME', notnull: 1, pk: 0 },
{ name: 'revoked_at', type: 'DATETIME', notnull: 0, pk: 0 },
{ name: 'user_agent', type: 'TEXT', notnull: 0, pk: 0 },
]);
const fks = await rawQuery<{ table: string; from: string; on_delete: string }>(
orm,
"SELECT \"table\", \"from\", on_delete FROM pragma_foreign_key_list('user_sessions')",
);
expect(fks).toEqual([{ table: 'users', from: 'user_id', on_delete: 'CASCADE' }]);
const indexes = await rawQuery<{ name: string }>(orm, "SELECT name FROM pragma_index_list('user_sessions') ORDER BY name");
expect(indexes.map((i) => i.name)).toEqual(expect.arrayContaining(['idx_user_sessions_expires', 'idx_user_sessions_user']));
// The existing account is untouched and has no session rows to begin with.
expect(await rawQuery(orm, 'SELECT id, username FROM users')).toEqual([{ id: 1, username: 'owner' }]);
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM user_sessions')).toEqual([{ n: 0 }]);
} finally {
await orm.close(true);
}
});
it('USERSESSMIG-002: a session row defaults its timestamps and goes with its user', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
await rawExec(orm, 'PRAGMA foreign_keys = ON');
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (2, 'sam', 'sam@test', 'x')");
await rawExec(orm, "INSERT INTO user_sessions (id, user_id, expires_at) VALUES ('s1', 2, '2030-01-01 00:00:00')");
const [row] = await rawQuery<{ created_at: string; last_seen_at: string; revoked_at: null }>(
orm,
"SELECT created_at, last_seen_at, revoked_at FROM user_sessions WHERE id = 's1'",
);
expect(row.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(row.last_seen_at).toBe(row.created_at);
expect(row.revoked_at).toBeNull();
await rawExec(orm, 'DELETE FROM users WHERE id = 2');
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM user_sessions')).toEqual([{ n: 0 }]);
} finally {
await orm.close(true);
}
});
it('USERSESSMIG-003: refuses a session row without an id', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (3, 'kim', 'kim@test', 'x')");
await expect(
rawExec(orm, "INSERT INTO user_sessions (id, user_id, expires_at) VALUES (NULL, 3, '2030-01-01 00:00:00')"),
).rejects.toThrow(/NOT NULL/);
} finally {
await orm.close(true);
}
});
});
@@ -34,6 +34,9 @@ vi.mock('../../../src/db/database', async () => {
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
// The session cases sign real session tokens.
SESSION_DURATION_SECONDS: 86400,
SESSION_DURATION_REMEMBER_SECONDS: 2592000,
updateJwtSecret: () => {},
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
@@ -114,6 +117,7 @@ import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-rep
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
import { createTestSessionsService, createTestUserSessionsRepo } from '../../helpers/sessions';
const realtime = new RealtimeService();
@@ -148,6 +152,8 @@ beforeAll(async () => {
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb),
await createTestOauthTokensRepo(testDb), await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestUserSessionsRepo(testDb),
await createTestSessionsService(testDb),
);
const t = await sharedTestOrm(testDb);
mcpTokensRepo = await createTestMcpTokensRepo(testDb);
@@ -176,6 +182,7 @@ beforeAll(async () => {
await createTestUnitOfWork(testDb),
databaseBackupStub,
new DataPathsService(),
await createTestSessionsService(testDb),
);
});
@@ -731,6 +738,9 @@ const addPushDevice = (userId: number, endpoint: string): void => {
.run(userId, endpoint);
};
const liveSessions = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL').get(id) as { n: number }).n;
const pushDeviceCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n;
@@ -792,6 +802,20 @@ describe('admin password reset revokes what an intruder already holds', () => {
expect(pushDeviceCount(user.id)).toBe(1);
});
it('ADMIN-SVC-081d: and ends every session of that user, and only of that user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const intruder = await auth.generateToken({ id: user.id });
const bystander = await auth.generateToken({ id: other.id });
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(liveSessions(user.id)).toBe(0);
expect(liveSessions(other.id)).toBe(1);
expect(await auth.verifyJwtToken(intruder)).toBeNull();
expect(await auth.verifyJwtToken(bystander)).not.toBeNull();
});
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => {
const { user } = createUser(testDb);
const before = pv(user.id);
@@ -807,6 +831,17 @@ describe('admin password reset revokes what an intruder already holds', () => {
});
describe('resetUserMfa', () => {
it('ADMIN-SVC-083b: ends the sessions of the account whose second factor it removed', async () => {
const admin = createAdmin(testDb);
const { user } = createUserWithMfa(testDb);
await auth.generateToken({ id: user.id });
await auth.generateToken({ id: admin.user.id });
expect((await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean }).toMatchObject({ success: true });
expect(liveSessions(user.id)).toBe(0);
expect(liveSessions(admin.user.id)).toBe(1);
});
it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', async () => {
// The passkey half has existed since passkeys landed; TOTP never had an
// answer, which left "somebody on the trip lost their phone" with no way out
@@ -90,6 +90,7 @@ import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-rep
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
import { createTestSessionsService, createTestUserSessionsRepo } from '../../helpers/sessions';
const realtime = new RealtimeService();
@@ -115,6 +116,8 @@ beforeAll(async () => {
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb),
await createTestOauthTokensRepo(testDb), await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestUserSessionsRepo(testDb),
await createTestSessionsService(testDb),
);
const t = await sharedTestOrm(testDb);
svc = new AdminService(
@@ -141,6 +144,7 @@ beforeAll(async () => {
await createTestUnitOfWork(testDb),
databaseBackupStub,
new DataPathsService(),
await createTestSessionsService(testDb),
);
});
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
+51 -4
View File
@@ -18,6 +18,9 @@ import { isDemoEmail } from '../../../src/nest/common/demo';
import type { User } from '../../../src/types';
import { anyBody } from '../../helpers/dto';
import type { ForgotPasswordDto } from '../../../src/nest/auth/auth.dto';
import type { SessionsService } from '../../../src/nest/sessions/sessions.service';
import { JWT_SECRET } from '../../../src/config';
import jwt from 'jsonwebtoken';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
const req = { ip: '9.9.9.9', headers: {} } as Request;
@@ -32,7 +35,10 @@ function rl(): RateLimitService { return new RateLimitService(); }
// wrappers keep the historical construction sites positional.
const writeAudit = vi.fn();
const audit = { writeAudit } as unknown as AuditService;
const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit);
// Logout ends the session through SessionsService, the controller's fourth argument.
const endSession = vi.fn().mockResolvedValue(true);
const sessionsStub = { endSession } as unknown as SessionsService;
const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit, sessionsStub);
// Tokens moved to TokenService; the controller takes it second. Stubbed via a
// third, optional argument so every non-token call site stays as it was.
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as import('../../../src/nest/storage/storage.service').StorageService;
@@ -204,9 +210,43 @@ describe('AuthPublicController', () => {
expect(await c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalled();
const clearAuthCookie = vi.fn();
expect(apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
expect(clearAuthCookie).toHaveBeenCalledWith(res, req);
// No token: nothing to end, the cookie still goes.
expect(endSession).toHaveBeenCalledWith(null);
});
it('logout ends the session a well-signed token names, expired or not', async () => {
const expired = jwt.sign({ id: 1, pv: 0, exp: Math.floor(Date.now() / 1000) - 60 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-1' });
const withCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: expired } } as unknown as Request;
const clearAuthCookie = vi.fn();
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(withCookie, res)).toEqual({ success: true });
expect(endSession).toHaveBeenCalledWith(expect.objectContaining({ id: 1, jti: 'sid-1' }));
expect(clearAuthCookie).toHaveBeenCalledWith(res, withCookie);
});
it('logout with a forged token only clears the cookie', async () => {
const forged = jwt.sign({ id: 1, pv: 0 }, 'not-the-secret', { algorithm: 'HS256', jwtid: 'sid-1' });
const withHeader = { ip: '9.9.9.9', headers: { authorization: `Bearer ${forged}` } } as unknown as Request;
const clearAuthCookie = vi.fn();
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(withHeader, res)).toEqual({ success: true });
expect(endSession).toHaveBeenCalledWith(null);
expect(clearAuthCookie).toHaveBeenCalled();
});
it('login, register and MFA login hand the device to the service', async () => {
const withAgent = { ip: '9.9.9.9', headers: { 'user-agent': 'Firefox/130' } } as unknown as Request;
const loginUser = vi.fn().mockReturnValue({ token: 'tk', user });
const registerUser = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 });
const verifyMfaLogin = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 });
const c = apc(asvc({ loginUser, registerUser, verifyMfaLogin } as Partial<AuthService>), rl());
await c.login(anyBody(), withAgent, res);
await c.register(anyBody(), withAgent, res);
await c.verifyMfaLogin(anyBody(), withAgent, res);
expect(loginUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
expect(registerUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
expect(verifyMfaLogin).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
}, 10000);
});
describe('AuthController (authenticated)', () => {
@@ -309,7 +349,7 @@ describe('AuthController (authenticated)', () => {
const changePassword = vi.fn().mockReturnValue({ token: 'tk3' });
const c = ac(asvc({ changePassword, setAuthCookie } as Partial<AuthService>), rl());
expect(await c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true });
expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true);
expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true, { userAgent: null });
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true);
});
@@ -403,8 +443,15 @@ describe('AuthController (authenticated)', () => {
it('mfa/enable + mfa/disable map errors', async () => {
expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
const disableMfa = vi.fn().mockReturnValue({ mfa_enabled: false });
const ok = ac(asvc({ disableMfa } as Partial<AuthService>), rl());
expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
// No session id on this request's token, so no session is kept back.
expect(disableMfa).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), undefined);
const tracked = jwt.sign({ id: 1, pv: 0 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-here' });
const reqTracked = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: tracked } } as unknown as Request;
await ok.mfaDisable(user, anyBody(), reqTracked);
expect(disableMfa).toHaveBeenLastCalledWith(1, 'u@example.test', anyBody(), 'sid-here');
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
});
+102 -2
View File
@@ -101,6 +101,8 @@ import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourney
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService, createTestUserSessionsRepo, sessionRows } from '../../helpers/sessions';
import { decode as decodeJwt } from 'jsonwebtoken';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
@@ -129,6 +131,8 @@ beforeAll(async () => {
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestUserSessionsRepo(testDb),
await createTestSessionsService(testDb),
);
});
@@ -518,11 +522,11 @@ describe('changePassword — session invalidation', () => {
const { user, password } = createUser(testDb);
const stolen = await svc.generateToken({ id: user.id }); // pv=0 at mint time
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb))).not.toBeNull();
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).not.toBeNull();
await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb))).toBeNull(); // invalidated by the pv bump
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).toBeNull(); // invalidated by the pv bump
});
it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', async () => {
@@ -1268,3 +1272,99 @@ describe('registerUser loses the race for an email', () => {
});
});
});
describe('session tracking', () => {
const active = (userId: number) => sessionRows(testDb, userId).filter((row) => row.revoked_at === null);
it('AUTH-SESS-001: a minted token names a new session row whose expiry is the token\'s own', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id }, true, { userAgent: 'x'.repeat(300) });
const claims = decodeJwt(token) as { jti: string; exp: number };
const rows = sessionRows(testDb, user.id);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(claims.jti);
expect(rows[0].expires_at).toBe(new Date(claims.exp * 1000).toISOString().slice(0, 19).replace('T', ' '));
expect(rows[0].user_agent).toHaveLength(256); // cut like a Web Push device's
expect(rows[0].revoked_at).toBeNull();
});
it('AUTH-SESS-002: login records the device it came from', async () => {
const { user, password } = createUser(testDb);
const result = await svc.loginUser({ email: user.email, password }, { userAgent: 'Firefox/130' });
expect(typeof result.token).toBe('string');
expect(sessionRows(testDb, user.id).map((row) => row.user_agent)).toEqual(['Firefox/130']);
});
it('AUTH-SESS-003: a revoked session token no longer verifies', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id });
expect(await svc.verifyJwtToken(token)).not.toBeNull();
testDb.prepare("UPDATE user_sessions SET revoked_at = '2026-01-01 00:00:00' WHERE user_id = ?").run(user.id);
expect(await svc.verifyJwtToken(token)).toBeNull();
});
it('AUTH-SESS-004: a password change ends every session and hands back a new one for this device', async () => {
const { user, password } = createUser(testDb);
const laptop = await svc.generateToken({ id: user.id });
const phone = await svc.generateToken({ id: user.id });
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, { userAgent: 'Safari' });
expect(await svc.verifyJwtToken(laptop)).toBeNull();
expect(await svc.verifyJwtToken(phone)).toBeNull();
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
expect(active(user.id).map((row) => row.user_agent)).toEqual(['Safari']);
});
it('AUTH-SESS-005: a password reset ends every session', async () => {
const { user } = createUser(testDb);
const token = await svc.generateToken({ id: user.id });
const issued = await svc.requestPasswordReset(user.email, null);
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toEqual({ success: true, userId: user.id });
expect(active(user.id)).toEqual([]);
expect(await svc.verifyJwtToken(token)).toBeNull();
});
it('AUTH-SESS-006: disabling MFA ends every other session and keeps the one it was done from', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
const here = await svc.generateToken({ id: user.id });
const elsewhere = await svc.generateToken({ id: user.id });
const hereId = (decodeJwt(here) as { jti: string }).jti;
expect(await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }, hereId)).toEqual({ success: true, mfa_enabled: false });
expect(await svc.verifyJwtToken(here)).not.toBeNull();
expect(await svc.verifyJwtToken(elsewhere)).toBeNull();
});
it('AUTH-SESS-007: a refused MFA disable leaves the sessions alone', async () => {
const { user } = createUser(testDb);
const secret = authenticator.generateSecret();
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
const token = await svc.generateToken({ id: user.id });
expect((await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })).status).toBe(401);
expect(await svc.verifyJwtToken(token)).not.toBeNull();
});
it('AUTH-SESS-008: an MFA login records the device too', async () => {
const { user, password } = createUser(testDb);
const secret = authenticator.generateSecret();
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
const interstitial = await svc.loginUser({ email: user.email, password });
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }, { userAgent: 'Edge' });
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
expect(sessionRows(testDb, user.id).map((row) => row.user_agent)).toEqual(['Edge']);
});
it('AUTH-SESS-009: register records the first session with its device', async () => {
const result = await svc.registerUser({ username: 'sess-reg', email: 'sess-reg@example.test', password: 'Secure123!' }, { userAgent: 'Chrome' });
const userId = (decodeJwt(result.token!) as { id: number }).id;
expect(sessionRows(testDb, userId).map((row) => row.user_agent)).toEqual(['Chrome']);
});
});
+149 -12
View File
@@ -23,8 +23,16 @@ import jwt from 'jsonwebtoken';
vi.mock('../../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { extractToken, verifyJwtAndLoadUser } from '../../../../src/nest/auth/jwt-verify';
import {
SESSION_TOUCH_INTERVAL_MS,
currentSessionId,
extractToken,
verifiedSessionClaims,
verifyJwtAndLoadUser,
} from '../../../../src/nest/auth/jwt-verify';
import type { UsersRepository, UserWithPasswordVersion } from '../../../../src/db/repositories/Users.repository';
import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository';
import { dbNow } from '../../../../src/db/types';
import type { Request } from 'express';
function makeReq(overrides: {
@@ -55,7 +63,24 @@ function usersRepo(row: (Omit<UserWithPasswordVersion, 'password_version'> & { p
return { findByIdWithPasswordVersion: vi.fn(async () => row) } as unknown as UsersRepository;
}
afterEach(() => vi.clearAllMocks());
/**
* A fake `UserSessionsRepository`: `findActive` answers `active` (null = no
* active session by that id), `touchLastSeen` records or fails as asked.
*/
function sessionsRepo(
active: { id: string; last_seen_at: string } | null = null,
touch: () => Promise<void> = async () => {},
): UserSessionsRepository & { findActive: ReturnType<typeof vi.fn>; touchLastSeen: ReturnType<typeof vi.fn> } {
return { findActive: vi.fn(async () => active), touchLastSeen: vi.fn(touch) } as unknown as UserSessionsRepository & {
findActive: ReturnType<typeof vi.fn>;
touchLastSeen: ReturnType<typeof vi.fn>;
};
}
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('extractToken', () => {
it('returns cookie value when trek_session cookie is set', () => {
@@ -92,26 +117,26 @@ describe('verifyJwtAndLoadUser', () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 });
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(token, users)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' });
expect(await verifyJwtAndLoadUser(token, users, sessionsRepo())).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' });
expect(users.findByIdWithPasswordVersion).toHaveBeenCalledWith(1);
});
it('AUTH-JWT-002: returns null for a malformed token', async () => {
expect(await verifyJwtAndLoadUser('invalid.jwt.token', usersRepo(null))).toBeNull();
expect(await verifyJwtAndLoadUser('invalid.jwt.token', usersRepo(null), sessionsRepo())).toBeNull();
});
it('AUTH-JWT-003: returns null when the user no longer exists', async () => {
const users = usersRepo(null);
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }), users)).toBeNull();
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }), users, sessionsRepo())).toBeNull();
});
it('AUTH-JWT-004: returns null for an expired token', async () => {
const expired = jwt.sign({ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(expired, usersRepo(null))).toBeNull();
expect(await verifyJwtAndLoadUser(expired, usersRepo(null), sessionsRepo())).toBeNull();
});
it('AUTH-JWT-005: returns null for a token signed with the wrong secret', async () => {
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }), usersRepo(null))).toBeNull();
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }), usersRepo(null), sessionsRepo())).toBeNull();
});
it('AUTH-JWT-006: rejects a purpose-scoped mfa_login token even when the user is valid', async () => {
@@ -120,7 +145,7 @@ describe('verifyJwtAndLoadUser', () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 });
const mfaToken = jwt.sign({ id: 1, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(mfaToken, users)).toBeNull();
expect(await verifyJwtAndLoadUser(mfaToken, users, sessionsRepo())).toBeNull();
expect(users.findByIdWithPasswordVersion).not.toHaveBeenCalled();
});
@@ -128,14 +153,14 @@ describe('verifyJwtAndLoadUser', () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 });
const stale = jwt.sign({ id: 1, pv: 1 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(stale, users)).toBeNull();
expect(await verifyJwtAndLoadUser(stale, users, sessionsRepo())).toBeNull();
});
it('AUTH-JWT-008: accepts a token whose password_version matches', async () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 });
const current = jwt.sign({ id: 1, pv: 2 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(current, users)).not.toBeNull();
expect(await verifyJwtAndLoadUser(current, users, sessionsRepo())).not.toBeNull();
});
it('AUTH-JWT-009: a pre-pv token still works against a row whose password_version reads as unset (both fall back to 0)', async () => {
@@ -146,13 +171,125 @@ describe('verifyJwtAndLoadUser', () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: null });
const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(legacy, users)).not.toBeNull();
expect(await verifyJwtAndLoadUser(legacy, users, sessionsRepo())).not.toBeNull();
});
it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', async () => {
const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 1 });
const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(legacy, users)).toBeNull();
expect(await verifyJwtAndLoadUser(legacy, users, sessionsRepo())).toBeNull();
});
});
describe('verifyJwtAndLoadUser: the session check', () => {
const alice = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 };
const SID = '0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10';
it('AUTH-JWT-011: a token without a jti is never looked up, so tokens from before the upgrade keep working', async () => {
const sessions = sessionsRepo();
const legacy = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(legacy, usersRepo(alice), sessions)).not.toBeNull();
expect(sessions.findActive).not.toHaveBeenCalled();
});
it('AUTH-JWT-012: a token naming an active session of its own user passes', async () => {
const sessions = sessionsRepo({ id: SID, last_seen_at: dbNow() });
const token = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessions)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' });
expect(sessions.findActive).toHaveBeenCalledWith(SID, 1, expect.stringMatching(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/));
});
it('AUTH-JWT-013: a token whose session is revoked, expired or unknown is refused', async () => {
const token = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessionsRepo(null))).toBeNull();
});
it('AUTH-JWT-014: a jti that is not a string is refused without a lookup', async () => {
const sessions = sessionsRepo({ id: SID, last_seen_at: dbNow() });
const token = jwt.sign({ id: 1, pv: 0, jti: 42 }, 'test-secret', { algorithm: 'HS256' });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessions)).toBeNull();
expect(sessions.findActive).not.toHaveBeenCalled();
});
it('AUTH-JWT-015: the password_version gate runs before the session lookup', async () => {
const sessions = sessionsRepo({ id: SID, last_seen_at: dbNow() });
const stale = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID });
expect(await verifyJwtAndLoadUser(stale, usersRepo({ ...alice, password_version: 1 }), sessions)).toBeNull();
expect(sessions.findActive).not.toHaveBeenCalled();
});
it('AUTH-JWT-016: last_seen_at is refreshed once it is older than the touch interval, not before', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-08T12:00:00Z'));
const token = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID });
const fresh = sessionsRepo({ id: SID, last_seen_at: '2026-10-08 11:58:00' });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), fresh)).not.toBeNull();
expect(fresh.touchLastSeen).not.toHaveBeenCalled();
const staleAt = dbNow(new Date(Date.parse('2026-10-08T12:00:00Z') - SESSION_TOUCH_INTERVAL_MS));
const stale = sessionsRepo({ id: SID, last_seen_at: staleAt });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), stale)).not.toBeNull();
expect(stale.touchLastSeen).toHaveBeenCalledWith(SID, '2026-10-08 12:00:00');
});
it('AUTH-JWT-017: a failed last_seen_at write does not turn a valid session into a refusal', async () => {
const sessions = sessionsRepo({ id: SID, last_seen_at: '2020-01-01 00:00:00' }, async () => {
throw new Error('database is locked');
});
const token = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID });
expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessions)).not.toBeNull();
expect(sessions.touchLastSeen).toHaveBeenCalled();
});
});
describe('currentSessionId', () => {
it('reads the jti of the cookie token', () => {
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256', jwtid: 'sid-1' });
expect(currentSessionId(makeReq({ cookies: { trek_session: token } }))).toBe('sid-1');
});
it('falls back to the bearer token', () => {
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256', jwtid: 'sid-2' });
expect(currentSessionId(makeReq({ headers: { authorization: `Bearer ${token}` } }))).toBe('sid-2');
});
it('is undefined for a token without a jti, and without any token', () => {
expect(currentSessionId(makeReq({ cookies: { trek_session: jwt.sign({ id: 1 }, 'test-secret') } }))).toBeUndefined();
expect(currentSessionId(makeReq())).toBeUndefined();
});
it('is undefined for a jti that is not a string', () => {
const token = jwt.sign({ id: 1, jti: 7 }, 'test-secret', { algorithm: 'HS256' });
expect(currentSessionId(makeReq({ cookies: { trek_session: token } }))).toBeUndefined();
});
});
describe('verifiedSessionClaims', () => {
it('returns the claims of a well-signed token, even an expired one', () => {
const expired = jwt.sign({ id: 3, jti: 'sid', exp: Math.floor(Date.now() / 1000) - 60 }, 'test-secret', { algorithm: 'HS256' });
expect(verifiedSessionClaims(expired)).toEqual(expect.objectContaining({ id: 3, jti: 'sid' }));
});
it('refuses a token signed with another secret', () => {
expect(verifiedSessionClaims(jwt.sign({ id: 3, jti: 'sid' }, 'wrong-secret', { algorithm: 'HS256' }))).toBeNull();
});
it('refuses a purpose-scoped token and one without a numeric user id', () => {
expect(verifiedSessionClaims(jwt.sign({ id: 3, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' }))).toBeNull();
expect(verifiedSessionClaims(jwt.sign({ id: 'x' }, 'test-secret', { algorithm: 'HS256' }))).toBeNull();
});
it('is null without a token', () => {
expect(verifiedSessionClaims(null)).toBeNull();
expect(verifiedSessionClaims(undefined)).toBeNull();
expect(verifiedSessionClaims('')).toBeNull();
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { Request, Response } from 'express';
vi.mock('../../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
import { SessionsController } from '../../../../src/nest/auth/sessions.controller';
import type { SessionsService } from '../../../../src/nest/sessions/sessions.service';
import type { AuditService } from '../../../../src/nest/audit/audit.service';
import type { User } from '../../../../src/types';
const SID = '0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10';
const OTHER = '9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f';
const user = { id: 7, username: 'u', email: 'u@example.test', role: 'user' } as User;
const writeAudit = vi.fn();
const audit = { writeAudit } as unknown as AuditService;
/** A request carrying a session token; the controller only decodes it, the guard verified it. */
function reqWith(jti?: string): Request {
const token = jwt.sign({ id: 7, pv: 0 }, 'any-secret', { algorithm: 'HS256', ...(jti ? { jwtid: jti } : {}) });
return { ip: '9.9.9.9', headers: {}, cookies: { trek_session: token } } as unknown as Request;
}
function resStub() {
return { clearCookie: vi.fn() } as unknown as Response & { clearCookie: ReturnType<typeof vi.fn> };
}
function controller(o: Partial<Record<'list' | 'revoke' | 'revokeAll', ReturnType<typeof vi.fn>>> = {}) {
const sessions = { list: vi.fn(), revoke: vi.fn(), revokeAll: vi.fn(), ...o };
return { c: new SessionsController(sessions as unknown as SessionsService, audit), sessions };
}
beforeEach(() => vi.clearAllMocks());
describe('SessionsController', () => {
it('GET lists the sessions with the current one flagged', async () => {
const listed = [{ id: SID, created_at: 'a', last_seen_at: 'b', expires_at: 'c', user_agent: null, current: true }];
const { c, sessions } = controller({ list: vi.fn().mockResolvedValue(listed) });
expect(await c.list(user, reqWith(SID))).toEqual({ sessions: listed, current_tracked: true });
expect(sessions.list).toHaveBeenCalledWith(7, SID);
});
it('GET says so when the request token is not tracked', async () => {
const { c, sessions } = controller({ list: vi.fn().mockResolvedValue([]) });
expect(await c.list(user, reqWith())).toEqual({ sessions: [], current_tracked: false });
expect(sessions.list).toHaveBeenCalledWith(7, undefined);
});
it('POST revoke-others keeps the current session and audits the count', async () => {
const { c, sessions } = controller({ revokeAll: vi.fn().mockResolvedValue(3) });
expect(await c.revokeOthers(user, reqWith(SID))).toEqual({ success: true, revoked: 3 });
expect(sessions.revokeAll).toHaveBeenCalledWith(7, SID);
expect(writeAudit).toHaveBeenCalledWith({ userId: 7, action: 'user.sessions_revoke_others', ip: '1.2.3.4', details: { revoked: 3 } });
});
it('DELETE ends another session and leaves this cookie alone', async () => {
const { c, sessions } = controller({ revoke: vi.fn().mockResolvedValue(true) });
const res = resStub();
expect(await c.revoke(user, { id: OTHER }, reqWith(SID), res)).toEqual({ success: true });
expect(sessions.revoke).toHaveBeenCalledWith(7, OTHER);
expect(res.clearCookie).not.toHaveBeenCalled();
expect(writeAudit).toHaveBeenCalledWith({ userId: 7, action: 'user.session_revoke', ip: '1.2.3.4', resource: OTHER });
});
it('DELETE of the current session clears the cookie too', async () => {
const { c } = controller({ revoke: vi.fn().mockResolvedValue(true) });
const res = resStub();
expect(await c.revoke(user, { id: SID }, reqWith(SID), res)).toEqual({ success: true });
expect(res.clearCookie).toHaveBeenCalledWith('trek_session', expect.any(Object));
});
it('DELETE answers 404 for a session the caller does not have', async () => {
const { c } = controller({ revoke: vi.fn().mockResolvedValue(false) });
const err = await c.revoke(user, { id: OTHER }, reqWith(SID), resStub()).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(404);
expect((err as HttpException).getResponse()).toEqual({ error: 'Session not found' });
expect(writeAudit).not.toHaveBeenCalled();
});
});
@@ -464,7 +464,8 @@ describe('OidcController /callback', () => {
generateToken,
createAuthCode,
})).callback('c', 's', undefined, reqCb('s'), res);
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, remember);
// The device rides along so the session list can name it (none on this stub request).
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, remember, { userAgent: null });
expect(createAuthCode).toHaveBeenCalledWith('jwt', remember);
}
});
@@ -123,6 +123,7 @@ import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-re
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService, createTestUserSessionsRepo } from '../../helpers/sessions';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
@@ -150,6 +151,8 @@ beforeAll(async () => {
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestUserSessionsRepo(testDb),
await createTestSessionsService(testDb),
);
svc = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb));
});
@@ -307,6 +310,15 @@ describe('generateToken', () => {
expect(decoded.exp - decoded.iat).toBe(86400);
}
});
it('OIDC-SVC-059: an SSO session is a tracked session like a password login, with its device', async () => {
const { user } = createUser(testDb, { email: 'sso-session@example.com' });
const token = await svc.generateToken({ id: user.id }, undefined, { userAgent: 'SSO Browser' });
const { jti } = jwtLib.decode(token) as { jti: string };
const row = testDb.prepare('SELECT user_id, user_agent, revoked_at FROM user_sessions WHERE id = ?').get(jti);
expect(row).toEqual({ user_id: user.id, user_agent: 'SSO Browser', revoked_at: null });
expect((await auth.verifyJwtToken(token))?.id).toBe(user.id);
});
});
// ── resolveOidcRole ───────────────────────────────────────────────────────────
@@ -107,6 +107,7 @@ import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-re
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService, createTestUserSessionsRepo } from '../../helpers/sessions';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
@@ -143,6 +144,8 @@ beforeAll(async () => {
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestUserSessionsRepo(testDb),
await createTestSessionsService(testDb),
);
svc = new PasskeyService(
auth,
@@ -23,7 +23,7 @@ vi.mock('../../../src/config', () => ({
}));
import { SessionRenewalInterceptor } from '../../../src/nest/auth/session-renewal.interceptor';
import type { AuthService } from '../../../src/nest/auth/auth.service';
import type { SessionsService } from '../../../src/nest/sessions/sessions.service';
const SECRET = 'test-jwt-secret-for-trek-testing-only';
@@ -43,7 +43,8 @@ type ReqShape = { cookies?: Record<string, string>; user?: { id: number }; heade
function ctx(req: ReqShape, res: ReturnType<typeof makeRes>, type = 'http'): ExecutionContext {
return {
getType: () => type,
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
// Express always hands over a headers object; the shapes below leave it out.
switchToHttp: () => ({ getRequest: () => ({ headers: {}, ...req }), getResponse: () => res }),
} as unknown as ExecutionContext;
}
@@ -51,10 +52,10 @@ function handler(): CallHandler & { handle: ReturnType<typeof vi.fn> } {
return { handle: vi.fn(() => of('ok')) };
}
function makeInterceptor() {
const generateToken = vi.fn().mockResolvedValue('renewed.jwt');
const interceptor = new SessionRenewalInterceptor({ generateToken } as unknown as AuthService);
return { interceptor, generateToken };
function makeInterceptor(renewed: string | null = 'renewed.jwt') {
const renew = vi.fn().mockResolvedValue(renewed);
const interceptor = new SessionRenewalInterceptor({ renew } as unknown as SessionsService);
return { interceptor, renew };
}
async function run(interceptor: SessionRenewalInterceptor, req: ReqShape, res: ReturnType<typeof makeRes>, type = 'http') {
@@ -66,69 +67,69 @@ async function run(interceptor: SessionRenewalInterceptor, req: ReqShape, res: R
describe('SessionRenewalInterceptor', () => {
it('SES-RENEW-001: does not renew a token under half of its lifetime', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0 }, 86400, 1000); // ~1% consumed
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
expect(res.cookie).not.toHaveBeenCalled();
});
it('SES-RENEW-002: renews a token past half of its lifetime, copying pv from the old token', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 3 }, 86400, 60000); // ~70% consumed
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 3 }, undefined);
expect(renew).toHaveBeenCalledWith({ id: 1, pv: 3, remember: undefined, jti: undefined }, { userAgent: null });
expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 86400000, httpOnly: true }));
});
it('SES-RENEW-003: a remember:true claim renews with the long persistent cookie', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0, remember: true }, 2592000, 1600000); // ~62% consumed
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, true);
expect(renew).toHaveBeenCalledWith({ id: 1, pv: 0, remember: true, jti: undefined }, { userAgent: null });
expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 2592000000 }));
});
it('SES-RENEW-004: a remember:false claim renews as a browser-session cookie (no maxAge)', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0, remember: false }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, false);
expect(renew).toHaveBeenCalledWith({ id: 1, pv: 0, remember: false, jti: undefined }, { userAgent: null });
const options = res.cookie.mock.calls[0][2];
expect(options).not.toHaveProperty('maxAge');
});
it('SES-RENEW-005: a claimless legacy token renews with the historical default cookie', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, undefined);
expect(renew).toHaveBeenCalledWith({ id: 1, pv: 0, remember: undefined, jti: undefined }, { userAgent: null });
expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 86400000 }));
});
it('SES-RENEW-006: never renews a Bearer-only request (no session cookie)', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
await run(interceptor, { headers: { authorization: 'Bearer x' }, user: { id: 1 } }, res);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
expect(res.cookie).not.toHaveBeenCalled();
});
it('SES-RENEW-007: never renews an unauthenticated (public-route) request', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t } }, res);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
});
it('SES-RENEW-008: never renews a purpose-scoped token or a cookie whose id differs from req.user', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const purposeRes = makeRes();
const purposeToken = token({ id: 1, pv: 0, purpose: 'mfa_login' }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: purposeToken }, user: { id: 1 } }, purposeRes);
@@ -137,13 +138,13 @@ describe('SessionRenewalInterceptor', () => {
const otherUsers = token({ id: 2, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: otherUsers }, user: { id: 1 } }, mismatchRes);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
expect(purposeRes.cookie).not.toHaveBeenCalled();
expect(mismatchRes.cookie).not.toHaveBeenCalled();
});
it('SES-RENEW-009: skips when headers were already sent or the cookie is not a JWT', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const sentRes = makeRes(true);
const t = token({ id: 1, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, sentRes);
@@ -151,25 +152,43 @@ describe('SessionRenewalInterceptor', () => {
const garbageRes = makeRes();
await run(interceptor, { cookies: { trek_session: 'not-a-jwt' }, user: { id: 1 } }, garbageRes);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
expect(sentRes.cookie).not.toHaveBeenCalled();
expect(garbageRes.cookie).not.toHaveBeenCalled();
});
it('SES-RENEW-010: skips tokens with missing or inverted iat/exp claims', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
// Hand-built token without exp (noTimestamp keeps iat but expiresIn omitted).
const noExp = jwt.sign({ id: 1, pv: 0 }, SECRET, { algorithm: 'HS256' });
await run(interceptor, { cookies: { trek_session: noExp }, user: { id: 1 } }, res);
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
});
it('SES-RENEW-011: ignores non-http execution contexts', async () => {
const { interceptor, generateToken } = makeInterceptor();
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = token({ id: 1, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res, 'ws');
expect(generateToken).not.toHaveBeenCalled();
expect(renew).not.toHaveBeenCalled();
});
it('SES-RENEW-012: a tracked token renews under its own session id, with the device', async () => {
const { interceptor, renew } = makeInterceptor();
const res = makeRes();
const t = jwt.sign({ id: 1, pv: 0, iat: Math.floor(Date.now() / 1000) - 60000 }, SECRET, { expiresIn: 86400, algorithm: 'HS256', jwtid: 'sid-1' });
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 }, headers: { 'user-agent': 'Firefox/130' } }, res);
expect(renew).toHaveBeenCalledWith({ id: 1, pv: 0, remember: undefined, jti: 'sid-1' }, { userAgent: 'Firefox/130' });
expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 86400000 }));
});
it('SES-RENEW-013: sets no cookie when the session ended before it could be renewed', async () => {
const { interceptor, renew } = makeInterceptor(null);
const res = makeRes();
const t = token({ id: 1, pv: 0 }, 86400, 60000);
await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res);
expect(renew).toHaveBeenCalled();
expect(res.cookie).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
import { SessionPurgeJob } from '../../../../src/nest/sessions/session-purge.job';
import type { SessionsService } from '../../../../src/nest/sessions/sessions.service';
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
function make(purgeInactive: (now: Date) => Promise<number>, enabled = true) {
const registrar = { isEnabled: vi.fn(() => enabled), register: vi.fn() };
const job = new SessionPurgeJob({ purgeInactive } as unknown as SessionsService, registrar as unknown as CronRegistrarService);
return { job, registrar };
}
describe('SessionPurgeJob', () => {
it('SESSPURGE-001: registers a nightly job through the registrar, and nothing under the test gate', async () => {
const purge = vi.fn().mockResolvedValue(0);
const { job, registrar } = make(purge);
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('session-purge', '45 3 * * *', expect.any(Function));
// The registered callback is the tick.
await (registrar.register.mock.calls[0][2] as () => Promise<void>)();
expect(purge).toHaveBeenCalledTimes(1);
const gated = make(vi.fn(), false);
gated.job.onApplicationBootstrap();
expect(gated.registrar.register).not.toHaveBeenCalled();
});
it('SESSPURGE-002: purges as of the tick time and logs what went', async () => {
const purge = vi.fn().mockResolvedValue(3);
const now = new Date('2026-10-08T03:45:00.000Z');
await make(purge).job.tick(now);
expect(purge).toHaveBeenCalledWith(now);
expect(logMock.logInfo).toHaveBeenCalledWith('Session purge: removed 3 expired or revoked session(s)');
});
it('SESSPURGE-003: stays quiet when there was nothing to purge', async () => {
logMock.logInfo.mockClear();
await make(vi.fn().mockResolvedValue(0)).job.tick();
expect(logMock.logInfo).not.toHaveBeenCalled();
});
it('SESSPURGE-004: a failing purge is logged, not thrown', async () => {
await make(vi.fn().mockRejectedValue(new Error('database is locked'))).job.tick();
expect(logMock.logError).toHaveBeenCalledWith('Session purge: database is locked');
await make(vi.fn().mockRejectedValue('plain')).job.tick();
expect(logMock.logError).toHaveBeenCalledWith('Session purge: plain');
});
});
@@ -0,0 +1,204 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import jwt from 'jsonwebtoken';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { UserSessions } from '../../../../src/db/entities/UserSessions.entity';
import { SessionsService, USER_AGENT_MAX_LENGTH, sessionClientFrom } from '../../../../src/nest/sessions/sessions.service';
import type { Request } from 'express';
import { JWT_SECRET, SESSION_DURATION_REMEMBER_SECONDS, SESSION_DURATION_SECONDS } from '../../../../src/config';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let svc: SessionsService;
beforeAll(async () => {
t = await createTestOrm(testDb);
svc = new SessionsService(t.repo(UserSessions));
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterEach(() => { vi.useRealTimers(); });
afterAll(async () => { await t.close(); testDb.close(); });
interface Claims { id: number; pv: number; jti: string; iat: number; exp: number; remember?: boolean }
function claimsOf(token: string): Claims {
return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as Claims;
}
function rowOf(id: string) {
return testDb.prepare('SELECT * FROM user_sessions WHERE id = ?').get(id) as
| { user_id: number; created_at: string; last_seen_at: string; expires_at: string; revoked_at: string | null; user_agent: string | null }
| undefined;
}
function textOf(seconds: number): string {
return new Date(seconds * 1000).toISOString().slice(0, 19).replace('T', ' ');
}
describe('sessionClientFrom', () => {
it('reads the User-Agent header', () => {
expect(sessionClientFrom({ headers: { 'user-agent': 'Firefox/130' } } as unknown as Request)).toEqual({ userAgent: 'Firefox/130' });
});
it('is null without one, or with an empty one', () => {
expect(sessionClientFrom({ headers: {} } as unknown as Request)).toEqual({ userAgent: null });
expect(sessionClientFrom({ headers: { 'user-agent': '' } } as unknown as Request)).toEqual({ userAgent: null });
});
});
describe('SessionsService.issue', () => {
it('SESS-001: signs a token with a fresh jti and records its session with the token\'s own times', async () => {
const { user } = createUser(testDb);
const token = await svc.issue({ id: user.id, pv: 2 }, undefined, { userAgent: 'Firefox/130' });
const claims = claimsOf(token);
expect(claims).toEqual(expect.objectContaining({ id: user.id, pv: 2 }));
expect('remember' in claims).toBe(false);
expect(claims.jti).toMatch(/^[0-9a-f-]{36}$/);
expect(claims.exp - claims.iat).toBe(SESSION_DURATION_SECONDS);
expect(rowOf(claims.jti)).toEqual({
id: claims.jti,
user_id: user.id,
created_at: textOf(claims.iat),
last_seen_at: textOf(claims.iat),
expires_at: textOf(claims.exp),
revoked_at: null,
user_agent: 'Firefox/130',
});
});
it('SESS-002: "remember me" picks the long lifetime and keeps the claim either way', async () => {
const { user } = createUser(testDb);
const long = claimsOf(await svc.issue({ id: user.id, pv: 0 }, true));
const short = claimsOf(await svc.issue({ id: user.id, pv: 0 }, false));
expect(long.remember).toBe(true);
expect(long.exp - long.iat).toBe(SESSION_DURATION_REMEMBER_SECONDS);
expect(short.remember).toBe(false);
expect(short.exp - short.iat).toBe(SESSION_DURATION_SECONDS);
});
it('SESS-003: every sign-in is its own session; the user agent is cut and may be absent', async () => {
const { user } = createUser(testDb);
const a = claimsOf(await svc.issue({ id: user.id, pv: 0 }, undefined, { userAgent: 'x'.repeat(400) }));
const b = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
expect(a.jti).not.toBe(b.jti);
expect(rowOf(a.jti)?.user_agent).toHaveLength(USER_AGENT_MAX_LENGTH);
expect(rowOf(b.jti)?.user_agent).toBeNull();
});
});
describe('SessionsService.renew', () => {
it('SESS-004: re-signs a tracked token under the same id and moves the session expiry with it', async () => {
// Only the clock: the ORM's own timers keep running.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-08T00:00:00Z'));
const { user } = createUser(testDb);
const first = claimsOf(await svc.issue({ id: user.id, pv: 1 }, false));
// Past half of the default lifetime, still inside it.
vi.setSystemTime(new Date(Date.parse('2026-10-08T00:00:00Z') + SESSION_DURATION_SECONDS * 750));
const renewed = await svc.renew({ id: user.id, pv: 1, remember: false, jti: first.jti }, { userAgent: 'ignored' });
const claims = claimsOf(renewed!);
expect(claims.jti).toBe(first.jti);
expect(claims.remember).toBe(false);
expect(claims.exp).toBeGreaterThan(first.exp);
expect(rowOf(first.jti)).toEqual(expect.objectContaining({
expires_at: textOf(claims.exp),
last_seen_at: textOf(Date.parse('2026-10-08T00:00:00Z') / 1000 + SESSION_DURATION_SECONDS * 0.75),
created_at: '2026-10-08 00:00:00',
user_agent: null,
}));
expect(testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions').get()).toEqual({ n: 1 });
});
it('SESS-005: a session that ended in the meantime is not renewed', async () => {
const { user } = createUser(testDb);
const first = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
await svc.revoke(user.id, first.jti);
expect(await svc.renew({ id: user.id, pv: 0, jti: first.jti })).toBeNull();
expect(rowOf(first.jti)?.expires_at).toBe(textOf(first.exp));
});
it('SESS-006: a token from before sessions were tracked comes back as a tracked session', async () => {
const { user } = createUser(testDb);
const renewed = await svc.renew({ id: user.id, remember: true }, { userAgent: 'Safari' });
const claims = claimsOf(renewed!);
expect(claims.pv).toBe(0);
expect(claims.remember).toBe(true);
expect(rowOf(claims.jti)).toEqual(expect.objectContaining({ user_id: user.id, user_agent: 'Safari', revoked_at: null }));
});
});
describe('SessionsService list and revoke', () => {
it('SESS-007: lists the active sessions and flags the current one', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const here = claimsOf(await svc.issue({ id: user.id, pv: 0 }, undefined, { userAgent: 'here' }));
const there = claimsOf(await svc.issue({ id: user.id, pv: 0 }, undefined, { userAgent: 'there' }));
const ended = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
await svc.issue({ id: other.id, pv: 0 });
await svc.revoke(user.id, ended.jti);
const listed = await svc.list(user.id, here.jti);
expect(listed.map((s) => [s.id, s.user_agent, s.current]).sort()).toEqual(
[[here.jti, 'here', true], [there.jti, 'there', false]].sort(),
);
expect((await svc.list(user.id)).every((s) => !s.current)).toBe(true);
});
it('SESS-008: revoke ends one session of the caller only', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const mine = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
expect(await svc.revoke(other.id, mine.jti)).toBe(false);
expect(await svc.revoke(user.id, mine.jti)).toBe(true);
expect(rowOf(mine.jti)?.revoked_at).not.toBeNull();
});
it('SESS-009: revokeAll ends every session, or every one but the current', async () => {
const { user } = createUser(testDb);
const a = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
const b = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
const c = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
expect(await svc.revokeAll(user.id, b.jti)).toBe(2);
expect(rowOf(b.jti)?.revoked_at).toBeNull();
expect(rowOf(a.jti)?.revoked_at).not.toBeNull();
expect(rowOf(c.jti)?.revoked_at).not.toBeNull();
expect(await svc.revokeAll(user.id)).toBe(1);
});
it('SESS-010: endSession ends the session a token names, and nothing for an untracked or missing token', async () => {
const { user } = createUser(testDb);
const mine = claimsOf(await svc.issue({ id: user.id, pv: 0 }));
expect(await svc.endSession(null)).toBe(false);
expect(await svc.endSession({ id: user.id })).toBe(false);
expect(await svc.endSession({ id: user.id, jti: mine.jti })).toBe(true);
expect(rowOf(mine.jti)?.revoked_at).not.toBeNull();
});
it('SESS-011: purgeInactive removes the expired and revoked rows', async () => {
const { user } = createUser(testDb);
const live = claimsOf(await svc.issue({ id: user.id, pv: 0 }, true));
const ended = claimsOf(await svc.issue({ id: user.id, pv: 0 }, true));
const short = claimsOf(await svc.issue({ id: user.id, pv: 0 }, false));
await svc.revoke(user.id, ended.jti);
// Just past the default lifetime: the short session has expired, the remembered one has not.
expect(SESSION_DURATION_REMEMBER_SECONDS).toBeGreaterThan(SESSION_DURATION_SECONDS + 60);
expect(await svc.purgeInactive(new Date(Date.now() + (SESSION_DURATION_SECONDS + 60) * 1000))).toBe(2);
expect(rowOf(live.jti)).toBeDefined();
expect(rowOf(ended.jti)).toBeUndefined();
expect(rowOf(short.jti)).toBeUndefined();
});
});
+5
View File
@@ -166,6 +166,11 @@ export default defineConfig({
'src/nest/route-usage/**/*.ts': { statements: 84, branches: 66, functions: 77, lines: 83 },
'src/nest/scheduling/**/*.ts': { statements: 99, branches: 92, functions: 99, lines: 99 },
'src/nest/school-holidays/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 97 },
// New domain in this change, unmeasured: pinned at the 80 floor so it
// has its own entry rather than hiding in the catch-all. Its suites
// cover every branch they can reach; regenerate with
// scripts/coverage-thresholds.mjs after the first full run.
'src/nest/sessions/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/nest/settings/**/*.ts': { statements: 87, branches: 71, functions: 99, lines: 88 },
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
'src/nest/storage/**/*.ts': { statements: 94, branches: 84, functions: 97, lines: 94 },
+1
View File
@@ -69,6 +69,7 @@ export * from './appearance/appearance.schema';
export * from './backup/backup.schema';
export * from './auth/auth.schema';
export * from './auth/password-policy';
export * from './sessions/sessions.schema';
export * from './oidc/oidc.schema';
export * from './oauth/oauth.schema';
export * from './admin/admin.schema';
@@ -0,0 +1,81 @@
import {
userSessionIdSchema,
userSessionListResponseSchema,
userSessionRevokeOthersResponseSchema,
userSessionRevokeParamsSchema,
userSessionRevokeResponseSchema,
userSessionSchema,
} from './sessions.schema';
import { describe, expect, it } from 'vitest';
const ID = '0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10';
const session = {
id: ID,
created_at: '2026-10-01 08:00:00',
last_seen_at: '2026-10-02 09:30:00',
expires_at: '2026-10-31 08:00:00',
user_agent: 'Mozilla/5.0',
current: true,
};
describe('userSessionIdSchema', () => {
it('accepts a uuid', () => {
expect(userSessionIdSchema.safeParse(ID).success).toBe(true);
});
it('rejects anything that is not a uuid', () => {
expect(userSessionIdSchema.safeParse('42').success).toBe(false);
expect(userSessionIdSchema.safeParse('').success).toBe(false);
expect(userSessionIdSchema.safeParse(`${ID}x`).success).toBe(false);
});
});
describe('userSessionRevokeParamsSchema', () => {
it('takes the id from the path', () => {
expect(userSessionRevokeParamsSchema.parse({ id: ID })).toEqual({ id: ID });
});
it('refuses a malformed id', () => {
expect(userSessionRevokeParamsSchema.safeParse({ id: 'not-a-session' }).success).toBe(false);
});
});
describe('userSessionSchema', () => {
it('accepts a listed session', () => {
expect(userSessionSchema.parse(session)).toEqual(session);
});
it('allows a session without a user agent', () => {
expect(userSessionSchema.safeParse({ ...session, user_agent: null }).success).toBe(true);
});
it('requires the current flag to be a boolean', () => {
expect(userSessionSchema.safeParse({ ...session, current: 1 }).success).toBe(false);
});
});
describe('userSessionListResponseSchema', () => {
it('wraps the sessions with the tracked flag', () => {
const body = { sessions: [session], current_tracked: true };
expect(userSessionListResponseSchema.parse(body)).toEqual(body);
});
it('accepts an empty list for a request made with an untracked token', () => {
expect(userSessionListResponseSchema.safeParse({ sessions: [], current_tracked: false }).success).toBe(true);
});
});
describe('revoke responses', () => {
it('pins the single revoke body', () => {
expect(userSessionRevokeResponseSchema.safeParse({ success: true }).success).toBe(true);
expect(userSessionRevokeResponseSchema.safeParse({ success: false }).success).toBe(false);
});
it('counts the sessions signed out', () => {
expect(userSessionRevokeOthersResponseSchema.safeParse({ success: true, revoked: 2 }).success).toBe(true);
expect(userSessionRevokeOthersResponseSchema.safeParse({ success: true, revoked: -1 }).success).toBe(false);
expect(userSessionRevokeOthersResponseSchema.safeParse({ success: true, revoked: 1.5 }).success).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod';
/**
* Sign-in sessions contract for /api/auth/sessions.
*
* Every session token the server issues carries an id (the JWT `jti`) and a
* row in `user_sessions`, so a user can see where they are signed in and end
* one of those sessions, or all but the current one. Tokens issued before
* sessions were tracked carry no id; they are not listed and stay valid until
* they expire or the password changes.
*
* Timestamps are the database text the rest of the API emits
* (`YYYY-MM-DD HH:MM:SS`, UTC).
*/
export const userSessionIdSchema = z.uuid();
export type UserSessionId = z.infer<typeof userSessionIdSchema>;
export const userSessionSchema = z.object({
id: userSessionIdSchema,
created_at: z.string(),
last_seen_at: z.string(),
expires_at: z.string(),
/** The browser's User-Agent at sign-in, cut to 256 characters; null when it sent none. */
user_agent: z.string().nullable(),
/** True for the session the request itself was made with. */
current: z.boolean(),
});
export type UserSession = z.infer<typeof userSessionSchema>;
export const userSessionListResponseSchema = z.object({
sessions: z.array(userSessionSchema),
/**
* False when the request was made with a token issued before sessions were
* tracked: no row is `current` then, and signing out the others leaves that
* token alone too.
*/
current_tracked: z.boolean(),
});
export type UserSessionListResponse = z.infer<typeof userSessionListResponseSchema>;
export const userSessionRevokeParamsSchema = z.object({
id: userSessionIdSchema,
});
export type UserSessionRevokeParams = z.infer<typeof userSessionRevokeParamsSchema>;
export const userSessionRevokeResponseSchema = z.object({
success: z.literal(true),
});
export type UserSessionRevokeResponse = z.infer<typeof userSessionRevokeResponseSchema>;
export const userSessionRevokeOthersResponseSchema = z.object({
success: z.literal(true),
/** How many sessions were signed out. */
revoked: z.number().int().nonnegative(),
});
export type UserSessionRevokeOthersResponse = z.infer<typeof userSessionRevokeOthersResponseSchema>;
+9
View File
@@ -34,6 +34,15 @@ A request for an account that can actually be reset writes **two** rows: one wit
| `user.mfa_enable` | MFA enabled on an account |
| `user.mfa_disable` | MFA disabled on an account |
### Sessions
| Action key | Description |
|---|---|
| `user.session_revoke` | A user ended one of their own sessions (resource = the session id) |
| `user.sessions_revoke_others` | A user ended every session but the current one (`revoked` = how many in details) |
Logging out writes no row, and neither do the sessions a password change, a reset or an MFA change ends along the way: those actions have rows of their own.
### Passkeys
| Action key | Description |
+6
View File
@@ -29,6 +29,12 @@ Some accounts are flagged as needing a new password: the admin account TREK seed
There is no control in the Admin Panel that flags an existing account. An admin-set password (see [Admin-Users-and-Invites](Admin-Users-and-Invites)) neither raises the flag nor clears it, so a user who already carries it is still asked to choose their own password at the next sign-in.
### Signing out
Logging out ends the session on the server, not only in this browser: the token behind the cookie stops working wherever a copy of it may be. Each sign-in is its own session, so logging out on the laptop leaves the phone signed in.
To end sessions on other devices, the API offers `GET /api/auth/sessions` (the signed-in sessions with their browser and last use), `DELETE /api/auth/sessions/{id}` (end one) and `POST /api/auth/sessions/revoke-others` (end all but this one). The settings screen has no list of sessions yet. Changing the password also signs out every other device. Which other actions end sessions is listed under [Security-Hardening](Security-Hardening#ending-sessions).
## Registering
The Register form appears under one of these conditions:
+2 -2
View File
@@ -26,7 +26,7 @@ TREK supports email-based self-service password reset. On the login page, click
| Enumeration safety | `/forgot-password` always returns `{ok:true}` with a minimum response latency pad |
| Rate limiting | 3 requests / 15-min per IP on `/forgot-password`; 5 requests / 15-min per IP on `/reset-password` |
| MFA gate | If the account has 2FA enabled, a valid TOTP code or backup code is required to complete the reset — a compromised mailbox alone cannot take over a 2FA-protected account |
| Session invalidation | Resetting the password bumps the `password_version` on the account and the `pv` claim in all JWTs, which immediately rejects every live session |
| Session invalidation | Resetting the password ends every session of the account on the server and bumps the `password_version` on the account and the `pv` claim in all JWTs, so every live session is rejected immediately, including ones from before sessions were tracked |
| Audit log | `user.password_reset_request`, `user.password_reset_success`, and `user.password_reset_fail` events are recorded |
### SMTP requirement
@@ -43,7 +43,7 @@ If the admin has globally disabled password login, no reset link is ever issued.
## Admin-initiated reset
An admin can set a new password for any user directly from the admin panel (**Admin → Users**, edit the user). Leaving the **New Password** field empty keeps the current password; entering one saves it immediately — no email is required. Setting a password this way also bumps the account's `password_version`, which signs the user out of every live session; separately, it deletes all of their MCP tokens and revokes their OAuth tokens.
An admin can set a new password for any user directly from the admin panel (**Admin → Users**, edit the user). Leaving the **New Password** field empty keeps the current password; entering one saves it immediately — no email is required. Setting a password this way also ends every session of the account and bumps its `password_version`, which signs the user out everywhere; separately, it deletes all of their MCP tokens and revokes their OAuth tokens.
There is no **"Force password change on next login"** option in the Admin Panel. That prompt is raised only for the admin account TREK seeds on first boot and for accounts restored with the `reset-admin.js` recovery script (see [Troubleshooting](Troubleshooting)). An admin-set password neither raises the flag nor clears it, so a user who already carries it is still asked to choose their own password at the next sign-in.
+27 -1
View File
@@ -21,7 +21,8 @@ A production TREK deployment checklist. All items reference actual TREK configur
- [ ] Enable two-factor authentication for your admin account. See [Two-Factor-Authentication](Two-Factor-Authentication).
- [ ] Require MFA for all users if your use case demands it: Admin Panel → Settings → **Require two-factor authentication (2FA)**. Note: you must secure your own admin account first, with either TOTP or a registered passkey — the server refuses the toggle otherwise. A passkey satisfies the policy for everyone else too, so nobody is forced onto TOTP specifically.
- [ ] Disable open registration if you control who can access the instance. See [Admin-Users-and-Invites](Admin-Users-and-Invites).
- [ ] Rotate the JWT signing secret if a session may have been leaked: Admin Panel → Settings → Danger Zone → **Rotate** (`POST /api/admin/rotate-jwt-secret`). This invalidates all active sessions immediately, including your own.
- [ ] If one account's session may have been leaked, end that account's sessions rather than everyone's: the user signs out the other sessions (`POST /api/auth/sessions/revoke-others`) or changes the password, or an admin sets a new password for them. See [Ending sessions](#ending-sessions) below.
- [ ] Rotate the JWT signing secret if the secret itself may have been leaked: Admin Panel → Settings → Danger Zone → **Rotate** (`POST /api/admin/rotate-jwt-secret`). This invalidates all active sessions immediately, including your own.
## Session Security
@@ -30,6 +31,31 @@ TREK stores sessions as JWTs in an httpOnly `trek_session` cookie (SameSite=Lax)
- [ ] Ensure `FORCE_HTTPS=true` (or `NODE_ENV=production`) so the `trek_session` cookie carries the `secure` flag and is never sent over plain HTTP.
- [ ] Set `COOKIE_SECURE=false` only as a temporary escape hatch for LAN testing without TLS — do not use in production.
### Ending sessions
Every sign-in (password, MFA, passkey, SSO, registration, the demo button) is also recorded on the server as a session: the JWT carries a session id, and the server refuses a token whose session has ended, even though its signature and expiry are still good. A copy of the cookie that ended up somewhere else (a proxy log, a debugging tool, a stolen laptop) therefore stops working the moment its session ends, rather than living out its 24 hours or 30 days.
A session ends when:
| Action | Sessions ended |
|---|---|
| **Log out** (`POST /api/auth/logout`) | The one it is called with. Clearing the cookie alone used to leave the token valid. |
| `DELETE /api/auth/sessions/{id}` | That one session of your own account. |
| `POST /api/auth/sessions/revoke-others` | Every session of your account but the current one. |
| Password change | Every session; the device the change was made on gets a new one. |
| Password reset by email | Every session. |
| Admin sets a new password for a user | Every session of that user. |
| Recovery script `reset-admin.js` | Every session of the account it resets. |
| Disabling two-factor authentication | Every other session; the one that proved the password and code stays. |
| Admin clears a user's two-factor authentication | Every session of that user. |
| Account deletion | Every session, with the account. |
`GET /api/auth/sessions` lists the active sessions of your account, most recently used first, with when each started, when it was last used (refreshed at most every few minutes), when it expires, the browser's User-Agent at sign-in (cut to 256 characters) and which one is the current one. No IP address is stored. The settings screen does not show this list yet; the routes are there for it and for API clients.
Sessions issued before this was introduced carry no session id. They keep working until they expire (or until the password changes, which ends them through the password version), are not listed, and are not ended by **revoke-others**. Once such a session passes half its lifetime, the sliding renewal replaces it with a tracked one. Expired and ended session rows are removed by a nightly job.
> **Deprecated:** the login, registration, demo-login, MFA and passkey sign-in responses still carry the session JWT as `token` in the JSON body, for API clients that read it. The web app does not use it; the session is the httpOnly cookie the same response sets. The field will be removed in a future major version.
## Password Policy
TREK enforces a minimum password policy on all registrations and password changes:
+1 -1
View File
@@ -43,7 +43,7 @@ Log in with what it shows; you will be asked to set a new password.
docker exec -it trek node server/reset-admin.js
```
This resets (or creates) `admin@trek.local` and prints a generated password. Override with `-e RESET_ADMIN_EMAIL=you@example.com -e RESET_ADMIN_PASSWORD=yourpass`. You will be asked to change it on first login.
This resets (or creates) `admin@trek.local` and prints a generated password. Override with `-e RESET_ADMIN_EMAIL=you@example.com -e RESET_ADMIN_PASSWORD=yourpass`. You will be asked to change it on first login. Resetting an existing account also signs it out of every session it still had.
**Start over with chosen credentials** (fresh install, no data to keep):
+3 -1
View File
@@ -30,6 +30,8 @@ Go to **Settings → Account** and use the **Disable 2FA** form. You must provid
- Your current account **password**
- A valid **TOTP code** from your authenticator app
Disabling 2FA signs out every other session of your account: the second factor is what kept a stolen password out of them. The session you disabled it from stays signed in, since it just proved both the password and the code.
> **Note:** You cannot disable 2FA while the admin has required it for all users (see below). Owning a passkey does not lift that restriction.
## Admin-enforced 2FA
@@ -42,7 +44,7 @@ A user-verified **passkey** satisfies this policy the way a TOTP authenticator d
Whether enrolling a passkey is a way *out* of the lockout depends on the instance. Passkey login is off by default (see [Passkeys](Passkeys)); where an admin has turned it on, the enrolment endpoints stay reachable while the rest of the API is blocked, so you can add a passkey from **Settings → Account** instead of setting up an authenticator app. Where it is off, TOTP setup is the only way to unblock yourself — or ask an admin to reset your 2FA.
> **Admin:** You can clear a locked-out user's 2FA with `DELETE /api/admin/users/<id>/mfa` (admin session required; it refuses your own account). The admin panel has no button for it: the user modal offers only **Reset passkeys**. See [Troubleshooting](Troubleshooting#locked-out-of-mfa--lost-authenticator).
> **Admin:** You can clear a locked-out user's 2FA with `DELETE /api/admin/users/<id>/mfa` (admin session required; it refuses your own account). It also signs that user out of every session. The admin panel has no button for it: the user modal offers only **Reset passkeys**. See [Troubleshooting](Troubleshooting#locked-out-of-mfa--lost-authenticator).
## Rate limits