Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ DATABASE_NAME=stationDb
# ─── Redis ──────────────────────────────────────────────────────────────────────
REDIS_HOST=localhost
REDIS_PORT=6379
# IMPORTANT: when true, Redis is REQUIRED for auth (refresh tokens, blacklist,
# sessions). The app will refuse to start if Redis is unavailable — this is
# intentional: auth state must be shared across all instances and must not
# silently fall back to per-process memory.
# Set to false only for single-instance local development without Redis, or
# for tests (.env.test already sets this to false).
USE_REDIS_CACHE=true

# ─── Token Cleanup ──────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
"test:e2e:redis": "USE_REDIS_CACHE=true jest --config ./test/jest-e2e.json --runInBand --testPathPattern=auth-session-concurrency",
"clean": "rm -rf dist"
},
"dependencies": {
Expand Down
6 changes: 4 additions & 2 deletions backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { User } from './modules/users/user.entity';
import { Organization } from './modules/organizations/organization.entity';
import { Role } from './modules/roles/role.entity';
import { UserOrganizationRole } from './modules/user-organization-roles/user-organization-role.entity';
import { RefreshToken } from './modules/auth/refresh-token.entity';
import { PasswordReset } from './modules/auth/password-reset.entity';
import { AuditLog } from './modules/audit-logs/audit-log.entity';
import { Game } from './modules/games/game.entity';
Expand Down Expand Up @@ -49,6 +48,7 @@ import { SeedInventoryManagerRole1764961461064 } from './migrations/176496146106
import { CreateOrgInventoryItemsTable1764964935270 } from './migrations/1764964935270-CreateOrgInventoryItemsTable';
import { AddUserInventoryUniqueIndex1765035000000 } from './migrations/1765035000000-AddUserInventoryUniqueIndex';
import { AddTokenCleanupIndexes1765038000000 } from './migrations/1765038000000-AddTokenCleanupIndexes';
import { DropRefreshTokensTable1777409770542 } from './migrations/1777409770542-DropRefreshTokensTable';

export const AppDataSource = new DataSource({
type: 'postgres',
Expand All @@ -62,7 +62,6 @@ export const AppDataSource = new DataSource({
Organization,
Role,
UserOrganizationRole,
RefreshToken,
PasswordReset,
AuditLog,
Game,
Expand Down Expand Up @@ -118,6 +117,9 @@ export const AppDataSource = new DataSource({

// Token cleanup indexes (supports efficient revoked/expired deletes)
AddTokenCleanupIndexes1765038000000,

// Refresh tokens moved to Redis — drop the DB table
DropRefreshTokensTable1777409770542,
],
synchronize: false,
});
Expand Down
32 changes: 32 additions & 0 deletions backend/src/migrations/1777409770542-DropRefreshTokensTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class DropRefreshTokensTable1777409770542 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Refresh tokens now live in Redis with per-entry TTL.
// The DB table is no longer written to after ISSUE-109.
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_refresh_tokens_userId"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_refresh_tokens_token"`);
await queryRunner.dropTable('refresh_tokens', true);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "refresh_tokens" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"token" varchar NOT NULL UNIQUE,
"userId" integer NOT NULL,
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL DEFAULT now(),
"revoked" boolean NOT NULL DEFAULT false,
CONSTRAINT "FK_refresh_tokens_user"
FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE INDEX "IDX_refresh_tokens_token" ON "refresh_tokens" ("token")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_refresh_tokens_userId" ON "refresh_tokens" ("userId")`,
);
}
}
7 changes: 7 additions & 0 deletions backend/src/modules/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuthService } from './auth.service';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthenticatedRequest } from './interfaces/authenticated-request.interface';
import { RefreshTokenAuthGuard } from './refresh-token-auth.guard';

describe('AuthController - Password Reset', () => {
let controller: AuthController;
Expand All @@ -17,6 +18,11 @@ describe('AuthController - Password Reset', () => {
register: jest.fn(),
refreshAccessToken: jest.fn(),
revokeRefreshToken: jest.fn(),
logout: jest.fn(),
blacklistAccessToken: jest.fn(),
isAccessTokenBlacklisted: jest.fn(),
isSessionAlive: jest.fn(),
parseRefreshTokenJti: jest.fn(),
};

beforeEach(async () => {
Expand All @@ -33,6 +39,7 @@ describe('AuthController - Password Reset', () => {
get: jest.fn().mockReturnValue('test'),
},
},
RefreshTokenAuthGuard,
],
}).compile();

Expand Down
9 changes: 8 additions & 1 deletion backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export class AuthController {
) {
const tokens = await this.authService.refreshAccessToken(
req.user.refreshToken,
req.user.jti,
);
res.cookie(
'access_token',
Expand All @@ -171,7 +172,13 @@ export class AuthController {
@Request() req: RefreshTokenRequest,
@Res({ passthrough: true }) res: Response,
) {
await this.authService.revokeRefreshToken(req.user.refreshToken);
const rawAccessToken = req.cookies?.access_token as string | undefined;
await this.authService.logout(
req.user.refreshToken,
req.user.jti,
rawAccessToken,
);

const { maxAge: _maxAge, ...clearOpts } = this.cookieOptions(0);
res.clearCookie('access_token', clearOpts);
res.clearCookie('refresh_token', clearOpts);
Expand Down
41 changes: 36 additions & 5 deletions backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,62 @@ import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthService, REDIS_CLIENT } from './auth.service';
import { TokenCleanupService } from './token-cleanup.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { RefreshToken } from './refresh-token.entity';
import { PasswordReset } from './password-reset.entity';
import { RefreshTokenAuthGuard } from './refresh-token-auth.guard';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createClient } from 'redis';

@Module({
imports: [
UsersModule,
PassportModule,
TypeOrmModule.forFeature([RefreshToken, PasswordReset]),
TypeOrmModule.forFeature([PasswordReset]),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' }, // Shorter access token expiry
signOptions: { expiresIn: '15m' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, TokenCleanupService, LocalStrategy, JwtStrategy],
providers: [
AuthService,
TokenCleanupService,
LocalStrategy,
JwtStrategy,
RefreshTokenAuthGuard,
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const useRedis =
configService.get<string>('USE_REDIS_CACHE', 'true') === 'true';
if (!useRedis) return null;

const client = createClient({
socket: {
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
},
password: configService.get<string>('REDIS_PASSWORD') || undefined,
});

try {
await client.connect();
return client;
} catch {
return null;
}
},
},
],
exports: [AuthService],
})
export class AuthModule {}
Loading
Loading