diff --git a/Dockerfile.db b/Dockerfile.db new file mode 100644 index 0000000..806a5e2 --- /dev/null +++ b/Dockerfile.db @@ -0,0 +1,14 @@ +FROM postgres:17 +# Update the package list and install the pg_cron extension +RUN apt-get update && \ + apt-get install -y postgresql-17-cron && \ + rm -rf /var/lib/apt/lists/* +# Initialize pg_cron and schedule the job in DB +RUN cat < /docker-entrypoint-initdb.d/001-pg-cron.sql +CREATE EXTENSION IF NOT EXISTS pg_cron; +SELECT cron.schedule( + 'refresh-token-cleanup', + '0 0 * * *', + \$\$DELETE FROM refresh_tokens WHERE expires_at < NOW();\$\$ +); +EOF \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1ae8131..d76c697 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,3 +1,4 @@ +name: auth_system services: dev-api: build: @@ -24,9 +25,9 @@ services: - ./pnpm-lock.yaml:/app/pnpm-lock.yaml - dev_node_modules:/app/node_modules # Prevents local node_modules from overwriting container's networks: - - infra_net + - dev_infra_net dev-db: - image: postgres:16-alpine + image: postgres:17 container_name: dev_db ports: - '5431:5432' @@ -40,7 +41,7 @@ services: timeout: 5s retries: 5 networks: - - infra_net + - dev_infra_net dev-redis: image: redis:7-alpine container_name: dev_redis @@ -50,11 +51,11 @@ services: ports: - '6371:6379' networks: - - infra_net + - dev_infra_net volumes: dev_db_data: dev_redis_data: dev_node_modules: networks: - infra_net: + dev_infra_net: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index e3af5bb..2c58680 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +name: auth_system services: prod-api: build: @@ -17,10 +18,15 @@ services: condition: service_healthy restart: unless-stopped networks: - - infra_net + - prod_infra_net prod-db: - image: postgres:16-alpine + build: + context: . + dockerfile: Dockerfile.db container_name: prod_db + command: > + postgres -c shared_preload_libraries=pg_cron + -c cron.database_name=auth_system ports: - '5432:5432' env_file: @@ -34,7 +40,7 @@ services: retries: 5 restart: unless-stopped networks: - - infra_net + - prod_infra_net prod-redis: image: redis:7-alpine container_name: prod_redis @@ -43,10 +49,10 @@ services: - prod_redis_data:/data restart: unless-stopped networks: - - infra_net + - prod_infra_net volumes: prod_db_data: prod_redis_data: networks: - infra_net: + prod_infra_net: driver: bridge diff --git a/prisma/migrations/20260428122248_add_rt_expires_at/migration.sql b/prisma/migrations/20260428122248_add_rt_expires_at/migration.sql new file mode 100644 index 0000000..4782c43 --- /dev/null +++ b/prisma/migrations/20260428122248_add_rt_expires_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "refresh_tokens" ADD COLUMN "expires_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce34ba8..5876e0a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model RefreshToken { userId Int @map("user_id") token String @map("token") createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() + expiresAt DateTime @default(now()) @map("expires_at") @db.Timestamp() user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("refresh_tokens") diff --git a/src/token/token.service.spec.ts b/src/token/token.service.spec.ts index d7016db..9161139 100644 --- a/src/token/token.service.spec.ts +++ b/src/token/token.service.spec.ts @@ -19,6 +19,7 @@ jest.mock('uuid', () => ({ const mockJwtService = { signAsync: jest.fn(), + decode: jest.fn(), } const mockAuthConfig = { @@ -83,18 +84,21 @@ describe('TokenService', () => { mockJwtService.signAsync .mockResolvedValueOnce('mockAccessToken') // first call (access) .mockResolvedValueOnce('mockRefreshToken') // second call (refresh) + mockJwtService.decode.mockReturnValue({ exp: 1_700_000_000 }) ;(argon.hash as jest.Mock).mockResolvedValue('hashedRefreshToken') mockPrismaService.refreshToken.create.mockResolvedValue({}) const result = await service.generateToken(mockUser) expect(mockJwtService.signAsync).toHaveBeenCalledTimes(2) + expect(mockJwtService.decode).toHaveBeenCalledWith('mockRefreshToken') expect(argon.hash).toHaveBeenCalledWith('mockRefreshToken') expect(mockPrismaService.refreshToken.create).toHaveBeenCalledWith({ data: { id: 'mock-uuid-v7', token: 'hashedRefreshToken', userId: mockUser.id, + expiresAt: new Date(1_700_000_000 * 1000), }, }) expect(result).toEqual({ diff --git a/src/token/token.service.ts b/src/token/token.service.ts index a960270..0d25508 100644 --- a/src/token/token.service.ts +++ b/src/token/token.service.ts @@ -54,6 +54,9 @@ export class TokenService { rtid: refreshTokenId, }, ) + // decode refresh token expiration time from header + const { exp: rtExp }: { exp: number } = + this.jwtService.decode(refreshToken) // hash refresh token const hashedRefreshToken = await argon.hash(refreshToken) // store refresh token in db @@ -62,6 +65,7 @@ export class TokenService { id: refreshTokenId, token: hashedRefreshToken, userId: user.id, + expiresAt: new Date(rtExp * 1000), }, }) // return access and refresh tokens diff --git a/test/global/global-setup.ts b/test/global/global-setup.ts index b7c3190..7e45ea9 100644 --- a/test/global/global-setup.ts +++ b/test/global/global-setup.ts @@ -17,7 +17,7 @@ export default async () => { console.log('\n[Global Setup] Starting Testcontainers...') const [pgContainer, redisContainer] = await Promise.all([ - new PostgreSqlContainer('postgres:16-alpine').withReuse().start(), + new PostgreSqlContainer('postgres:17').withReuse().start(), new RedisContainer('redis:7-alpine').withReuse().start(), ])