Set up PostgreSQL database configuration and enhance application structure
- Added .env.example with database connection details and JWT configuration. - Introduced docker-compose.yml for PostgreSQL service setup with health checks. - Created drizzle.config.ts for database schema and migration management. - Updated nest-cli.json to include Swagger plugin configuration for API documentation. - Enhanced package.json with new database-related scripts and dependencies. - Implemented initial database migrations for user and token management. - Configured application bootstrap process to load environment variables and set up Swagger. - Added shared application configuration in configure-app.ts for consistent setup. - Included unit tests for application configuration and Swagger setup.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let authService: jest.Mocked<
|
||||
Pick<AuthService, 'register' | 'login' | 'refresh' | 'revoke'>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = {
|
||||
register: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'b'.repeat(64),
|
||||
}),
|
||||
login: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'b'.repeat(64),
|
||||
}),
|
||||
refresh: jest.fn().mockResolvedValue({
|
||||
accessToken: 'c',
|
||||
refreshToken: 'd'.repeat(64),
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [{ provide: AuthService, useValue: authService }],
|
||||
}).compile();
|
||||
|
||||
controller = moduleRef.get(AuthController);
|
||||
});
|
||||
|
||||
it('register delegates to AuthService', async () => {
|
||||
await controller.register({ username: 'alice', password: 'password123' });
|
||||
expect(authService.register).toHaveBeenCalledWith('alice', 'password123');
|
||||
});
|
||||
|
||||
it('login delegates to AuthService', async () => {
|
||||
await controller.login({ username: 'alice', password: 'password123' });
|
||||
expect(authService.login).toHaveBeenCalledWith('alice', 'password123');
|
||||
});
|
||||
|
||||
it('refresh delegates to AuthService', async () => {
|
||||
const token = 'e'.repeat(64);
|
||||
await controller.refresh({ refreshToken: token });
|
||||
expect(authService.refresh).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it('revoke delegates to AuthService', async () => {
|
||||
const token = 'f'.repeat(64);
|
||||
await controller.revoke({ refreshToken: token });
|
||||
expect(authService.revoke).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it('me returns id and username', () => {
|
||||
expect(
|
||||
controller.me({ id: 'user-1', username: 'alice', jti: 'jti-1' }),
|
||||
).toEqual({ id: 'user-1', username: 'alice' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiTooManyRequestsResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { AuthUser } from '../../common/auth/auth-user';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
LoginDto,
|
||||
MeResponseDto,
|
||||
RefreshTokenDto,
|
||||
RegisterDto,
|
||||
TokenPairDto,
|
||||
} from './dto/auth.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiCreatedResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiConflictResponse({ description: 'Username already registered' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
|
||||
return this.authService.register(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Log in with username and password' })
|
||||
@ApiOkResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
login(@Body() dto: LoginDto): Promise<TokenPairDto> {
|
||||
return this.authService.login(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Rotate refresh token and issue a new access token',
|
||||
})
|
||||
@ApiOkResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid refresh token' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
refresh(@Body() dto: RefreshTokenDto): Promise<TokenPairDto> {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('revoke')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Revoke a refresh token session' })
|
||||
@ApiNoContentResponse({ description: 'Session revoked (or already invalid)' })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
async revoke(@Body() dto: RefreshTokenDto): Promise<void> {
|
||||
await this.authService.revoke(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@ApiOperation({ summary: 'Get the current authenticated user' })
|
||||
@ApiOkResponse({ type: MeResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid access token' })
|
||||
me(@CurrentUser() user: AuthUser): MeResponseDto {
|
||||
return { id: user.id, username: user.username };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.getOrThrow<string>(
|
||||
'JWT_ACCESS_EXPIRES_IN',
|
||||
) as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
RefreshTokensRepository,
|
||||
RevokedAccessTokensRepository,
|
||||
JwtStrategy,
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../users/user';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
RefreshTokensRepository,
|
||||
type RefreshSession,
|
||||
} from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let usersService: jest.Mocked<
|
||||
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
|
||||
>;
|
||||
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
|
||||
let config: { getOrThrow: jest.Mock };
|
||||
let refreshTokensRepository: jest.Mocked<
|
||||
Pick<
|
||||
RefreshTokensRepository,
|
||||
| 'create'
|
||||
| 'findByTokenHash'
|
||||
| 'claimForRotation'
|
||||
| 'setReplacedBy'
|
||||
| 'markRevoked'
|
||||
| 'revokeAllForUser'
|
||||
>
|
||||
>;
|
||||
let revokedAccessTokensRepository: jest.Mocked<
|
||||
Pick<RevokedAccessTokensRepository, 'add' | 'exists'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
let user: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
usersService = {
|
||||
create: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
jwtService = {
|
||||
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
|
||||
};
|
||||
config = {
|
||||
getOrThrow: jest.fn((key: string) => {
|
||||
const values: Record<string, string | number> = {
|
||||
BCRYPT_SALT_ROUNDS: 4,
|
||||
JWT_ACCESS_EXPIRES_IN: '15m',
|
||||
JWT_ACCESS_EXPIRES_IN_MS: 15 * 60 * 1000,
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return values[key];
|
||||
}),
|
||||
};
|
||||
refreshTokensRepository = {
|
||||
create: jest.fn().mockImplementation(async (input) => ({
|
||||
id: 'session-new',
|
||||
userId: input.userId,
|
||||
tokenHash: input.tokenHash,
|
||||
accessJti: input.accessJti,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
})),
|
||||
findByTokenHash: jest.fn(),
|
||||
claimForRotation: jest.fn(),
|
||||
setReplacedBy: jest.fn(),
|
||||
markRevoked: jest.fn(),
|
||||
revokeAllForUser: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
revokedAccessTokensRepository = {
|
||||
add: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: JwtService, useValue: jwtService },
|
||||
{ provide: ConfigService, useValue: config },
|
||||
{ provide: RefreshTokensRepository, useValue: refreshTokensRepository },
|
||||
{
|
||||
provide: RevokedAccessTokensRepository,
|
||||
useValue: revokedAccessTokensRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(AuthService);
|
||||
});
|
||||
|
||||
it('register creates user and returns token pair', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.create.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.register('Alice', 'password123');
|
||||
|
||||
expect(usersService.create).toHaveBeenCalled();
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
expect(pair.refreshToken).toHaveLength(64);
|
||||
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
|
||||
expect(refreshTokensRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('register throws ConflictException when username exists', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
service.register('alice', 'password123'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('login returns tokens for valid credentials', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.login('alice', 'password123');
|
||||
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
expect(pair.refreshToken).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('login throws UnauthorizedException for unknown user', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login('alice', 'password123')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('login throws UnauthorizedException for wrong password', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
await expect(service.login('alice', 'wrong-pass')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refresh rotates tokens and denylists old access jti', async () => {
|
||||
const rawRefresh = 'a'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now()),
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
|
||||
usersService.findById.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.refresh(rawRefresh);
|
||||
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-old',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
expect(refreshTokensRepository.setReplacedBy).toHaveBeenCalledWith(
|
||||
'session-old',
|
||||
'session-new',
|
||||
);
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
});
|
||||
|
||||
it('refresh reuse revokes all user sessions and access jtis', async () => {
|
||||
const rawRefresh = 'b'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now() - 1000),
|
||||
replacedBy: 'session-new',
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(null);
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
|
||||
refreshTokensRepository.revokeAllForUser.mockResolvedValue([
|
||||
{
|
||||
...session,
|
||||
id: 'other-session',
|
||||
accessJti: 'jti-other',
|
||||
revokedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.refresh(rawRefresh)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(refreshTokensRepository.revokeAllForUser).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
);
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-other',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
});
|
||||
|
||||
it('refresh rejects expired tokens', async () => {
|
||||
const rawRefresh = 'c'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() - 1000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now()),
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
|
||||
|
||||
await expect(service.refresh(rawRefresh)).rejects.toThrow(
|
||||
'Invalid refresh token',
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke marks session revoked and denylists access jti', async () => {
|
||||
const rawRefresh = 'd'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-1',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
|
||||
refreshTokensRepository.markRevoked.mockResolvedValue(true);
|
||||
|
||||
await service.revoke(rawRefresh);
|
||||
|
||||
expect(refreshTokensRepository.markRevoked).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
);
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-1',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke is a no-op for unknown refresh tokens', async () => {
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.revoke('unknown')).resolves.toBeUndefined();
|
||||
expect(refreshTokensRepository.markRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { JwtAccessPayload } from '../../common/auth/auth-user';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../users/user';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
export type TokenPair = {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
};
|
||||
|
||||
/** Precomputed bcrypt hash used only to equalize login timing on unknown users. */
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly refreshTokensRepository: RefreshTokensRepository,
|
||||
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
||||
) {}
|
||||
|
||||
async register(username: string, password: string): Promise<TokenPair> {
|
||||
const existing = await this.usersService.findByUsername(username);
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const user = await this.usersService.create(username, passwordHash);
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<TokenPair> {
|
||||
const user = await this.usersService.findByUsername(username);
|
||||
const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
||||
const match = await bcrypt.compare(password, passwordHash);
|
||||
if (!user || !match) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<TokenPair> {
|
||||
const tokenHash = this.hashRefreshToken(refreshToken);
|
||||
const claimed =
|
||||
await this.refreshTokensRepository.claimForRotation(tokenHash);
|
||||
|
||||
if (!claimed) {
|
||||
const existing =
|
||||
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
||||
if (existing) {
|
||||
await this.revokeAllSessionsForUser(existing.userId);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
if (claimed.expiresAt.value <= Date.now()) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(claimed.userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
await this.denylistAccessJti(claimed.accessJti);
|
||||
const issued = await this.issueTokenPair(user);
|
||||
await this.refreshTokensRepository.setReplacedBy(
|
||||
claimed.id,
|
||||
issued.sessionId,
|
||||
);
|
||||
|
||||
return issued.tokens;
|
||||
}
|
||||
|
||||
async revoke(refreshToken: string): Promise<void> {
|
||||
const tokenHash = this.hashRefreshToken(refreshToken);
|
||||
const session =
|
||||
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
||||
|
||||
if (!session || session.revokedAt !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claimed = await this.refreshTokensRepository.markRevoked(session.id);
|
||||
if (claimed) {
|
||||
await this.denylistAccessJti(session.accessJti);
|
||||
}
|
||||
}
|
||||
|
||||
async isAccessJtiRevoked(jti: string): Promise<boolean> {
|
||||
return this.revokedAccessTokensRepository.exists(jti);
|
||||
}
|
||||
|
||||
private async revokeAllSessionsForUser(userId: string): Promise<void> {
|
||||
const revoked = await this.refreshTokensRepository.revokeAllForUser(userId);
|
||||
await Promise.all(
|
||||
revoked.map((session) => this.denylistAccessJti(session.accessJti)),
|
||||
);
|
||||
}
|
||||
|
||||
private async issueTokenPair(
|
||||
user: User,
|
||||
): Promise<{ tokens: TokenPair; sessionId: string }> {
|
||||
const jti = randomUUID();
|
||||
const payload: JwtAccessPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
jti,
|
||||
typ: 'access',
|
||||
};
|
||||
|
||||
const expiresIn = this.config.getOrThrow<string>('JWT_ACCESS_EXPIRES_IN');
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ ...payload },
|
||||
{ expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}` },
|
||||
);
|
||||
|
||||
const refreshToken = randomBytes(32).toString('hex');
|
||||
const refreshMs = this.config.getOrThrow<number>(
|
||||
'REFRESH_TOKEN_EXPIRES_IN_MS',
|
||||
);
|
||||
const expiresAt = DateTime.fromUnixMs(Date.now() + refreshMs);
|
||||
|
||||
const session = await this.refreshTokensRepository.create({
|
||||
userId: user.id,
|
||||
tokenHash: this.hashRefreshToken(refreshToken),
|
||||
accessJti: jti,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
tokens: { accessToken, refreshToken },
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
private hashRefreshToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private async denylistAccessJti(jti: string): Promise<void> {
|
||||
const ttlMs = this.config.getOrThrow<number>('JWT_ACCESS_EXPIRES_IN_MS');
|
||||
const expiresAt = DateTime.fromUnixMs(Date.now() + ttlMs);
|
||||
await this.revokedAccessTokensRepository.add(jti, expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
import type { TokenPair } from '../auth.service';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
example: 'alice',
|
||||
description: 'Letters, numbers, and underscores only',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(32)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||
message: 'username must contain only letters, numbers, and underscores',
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'alice' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(32)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({
|
||||
example: 'a'.repeat(64),
|
||||
description: 'Opaque refresh token from login or register',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class TokenPairDto implements TokenPair {
|
||||
@ApiProperty({
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
|
||||
description: 'JWT access token',
|
||||
})
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'a'.repeat(64),
|
||||
description: 'Opaque refresh token',
|
||||
})
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'alice' })
|
||||
username!: string;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { refreshTokens, type RefreshTokenRow } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
|
||||
export type RefreshSession = {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly tokenHash: string;
|
||||
readonly accessJti: string;
|
||||
readonly expiresAt: DateTime;
|
||||
readonly revokedAt: DateTime | null;
|
||||
readonly replacedBy: string | null;
|
||||
readonly createdAt: DateTime;
|
||||
};
|
||||
|
||||
export type CreateRefreshSessionInput = {
|
||||
readonly userId: string;
|
||||
readonly tokenHash: string;
|
||||
readonly accessJti: string;
|
||||
readonly expiresAt: DateTime;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokensRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async create(input: CreateRefreshSessionInput): Promise<RefreshSession> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const [row] = await this.db
|
||||
.insert(refreshTokens)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
tokenHash: input.tokenHash,
|
||||
accessJti: input.accessJti,
|
||||
expiresAt: input.expiresAt.value,
|
||||
createdAt: now.value,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(row);
|
||||
}
|
||||
|
||||
async findByTokenHash(tokenHash: string): Promise<RefreshSession | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(refreshTokens)
|
||||
.where(eq(refreshTokens.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claims a session for rotation. Returns null if already revoked
|
||||
* or missing (loser of a concurrent refresh race).
|
||||
*/
|
||||
async claimForRotation(tokenHash: string): Promise<RefreshSession | null> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const [row] = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(
|
||||
and(
|
||||
eq(refreshTokens.tokenHash, tokenHash),
|
||||
isNull(refreshTokens.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async setReplacedBy(id: string, replacedBy: string): Promise<void> {
|
||||
await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ replacedBy })
|
||||
.where(eq(refreshTokens.id, id));
|
||||
}
|
||||
|
||||
async markRevoked(id: string): Promise<boolean> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(and(eq(refreshTokens.id, id), isNull(refreshTokens.revokedAt)))
|
||||
.returning({ id: refreshTokens.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<RefreshSession[]> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(
|
||||
and(eq(refreshTokens.userId, userId), isNull(refreshTokens.revokedAt)),
|
||||
)
|
||||
.returning();
|
||||
return rows.map((row) => this.toDomain(row));
|
||||
}
|
||||
|
||||
private toDomain(row: RefreshTokenRow): RefreshSession {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
tokenHash: row.tokenHash,
|
||||
accessJti: row.accessJti,
|
||||
expiresAt: DateTime.fromUnixMs(row.expiresAt),
|
||||
revokedAt:
|
||||
row.revokedAt === null || row.revokedAt === undefined
|
||||
? null
|
||||
: DateTime.fromUnixMs(row.revokedAt),
|
||||
replacedBy: row.replacedBy ?? null,
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { revokedAccessTokens } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
|
||||
@Injectable()
|
||||
export class RevokedAccessTokensRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async add(jti: string, expiresAt: DateTime): Promise<void> {
|
||||
await this.db
|
||||
.insert(revokedAccessTokens)
|
||||
.values({ jti, expiresAt: expiresAt.value })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async exists(jti: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({ jti: revokedAccessTokens.jti })
|
||||
.from(revokedAccessTokens)
|
||||
.where(eq(revokedAccessTokens.jti, jti))
|
||||
.limit(1);
|
||||
return row !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../../users/user';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
let strategy: JwtStrategy;
|
||||
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
|
||||
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const user: User = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
usersService = { findById: jest.fn() };
|
||||
revoked = { exists: jest.fn() };
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getOrThrow: () => 'test-secret-at-least-32-characters-long!!',
|
||||
},
|
||||
},
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: RevokedAccessTokensRepository, useValue: revoked },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
strategy = moduleRef.get(JwtStrategy);
|
||||
});
|
||||
|
||||
it('returns AuthUser for a valid access payload', async () => {
|
||||
revoked.exists.mockResolvedValue(false);
|
||||
usersService.findById.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'access',
|
||||
}),
|
||||
).resolves.toEqual({ id: 'user-1', username: 'alice', jti: 'jti-1' });
|
||||
});
|
||||
|
||||
it('rejects revoked access tokens', async () => {
|
||||
revoked.exists.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'access',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects non-access token types', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'refresh' as 'access',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type {
|
||||
AuthUser,
|
||||
JwtAccessPayload,
|
||||
} from '../../../common/auth/auth-user';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtAccessPayload): Promise<AuthUser> {
|
||||
if (payload.typ !== 'access' || !payload.sub || !payload.jti) {
|
||||
throw new UnauthorizedException('Invalid access token');
|
||||
}
|
||||
|
||||
const revoked = await this.revokedAccessTokensRepository.exists(
|
||||
payload.jti,
|
||||
);
|
||||
if (revoked) {
|
||||
throw new UnauthorizedException('Access token revoked');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
jti: payload.jti,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DRIZZLE } from '../../database/database.module';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
describe('RefreshTokensRepository', () => {
|
||||
let repository: RefreshTokensRepository;
|
||||
const returning = jest.fn();
|
||||
const where = jest.fn(() => ({ returning, limit: jest.fn() }));
|
||||
const set = jest.fn(() => ({ where }));
|
||||
const values = jest.fn(() => ({ returning }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
const update = jest.fn(() => ({ set }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const db = { insert, update, select };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [RefreshTokensRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(RefreshTokensRepository);
|
||||
});
|
||||
|
||||
it('create returns a domain session', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await repository.create({
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: DateTime.fromUnixMs(1_700_000_100_000),
|
||||
});
|
||||
|
||||
expect(session.id).toBe('session-1');
|
||||
expect(session.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('claimForRotation returns null when no row claimed', async () => {
|
||||
returning.mockResolvedValue([]);
|
||||
await expect(repository.claimForRotation('hash')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findByTokenHash returns a session', async () => {
|
||||
const limitFn = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
where.mockReturnValueOnce({ limit: limitFn, returning });
|
||||
const session = await repository.findByTokenHash('hash');
|
||||
expect(session?.id).toBe('session-1');
|
||||
});
|
||||
|
||||
it('setReplacedBy updates the row', async () => {
|
||||
where.mockReturnValueOnce({ returning, limit: jest.fn() });
|
||||
await repository.setReplacedBy('session-1', 'session-2');
|
||||
expect(set).toHaveBeenCalledWith({ replacedBy: 'session-2' });
|
||||
});
|
||||
|
||||
it('markRevoked returns true when a row is updated', async () => {
|
||||
returning.mockResolvedValue([{ id: 'session-1' }]);
|
||||
await expect(repository.markRevoked('session-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('revokeAllForUser returns revoked sessions', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: 1_700_000_050_000,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const sessions = await repository.revokeAllForUser('user-1');
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].accessJti).toBe('jti-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RevokedAccessTokensRepository', () => {
|
||||
let repository: RevokedAccessTokensRepository;
|
||||
const limit = jest.fn();
|
||||
const where = jest.fn(() => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const onConflictDoNothing = jest.fn();
|
||||
const values = jest.fn(() => ({ onConflictDoNothing }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
const db = { insert, select };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RevokedAccessTokensRepository,
|
||||
{ provide: DRIZZLE, useValue: db },
|
||||
],
|
||||
}).compile();
|
||||
repository = moduleRef.get(RevokedAccessTokensRepository);
|
||||
});
|
||||
|
||||
it('add inserts a denylist row', async () => {
|
||||
onConflictDoNothing.mockResolvedValue(undefined);
|
||||
await repository.add('jti-1', DateTime.fromUnixMs(1_700_000_100_000));
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
jti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('exists returns true when jti is present', async () => {
|
||||
limit.mockResolvedValue([{ jti: 'jti-1' }]);
|
||||
await expect(repository.exists('jti-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('exists returns false when jti is absent', async () => {
|
||||
limit.mockResolvedValue([]);
|
||||
await expect(repository.exists('missing')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
|
||||
export type User = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly passwordHash: string;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
};
|
||||
|
||||
export type CreateUserInput = {
|
||||
readonly username: string;
|
||||
readonly passwordHash: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersRepository, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DRIZZLE } from '../../database/database.module';
|
||||
import { UsersRepository } from './users.repository';
|
||||
|
||||
describe('UsersRepository', () => {
|
||||
let repository: UsersRepository;
|
||||
const limit = jest.fn();
|
||||
const where = jest.fn(() => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn(() => ({ returning }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
|
||||
const db = { select, insert };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [UsersRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(UsersRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row to domain User', async () => {
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const user = await repository.findById('user-1');
|
||||
expect(user).toMatchObject({ id: 'user-1', username: 'alice' });
|
||||
expect(user?.createdAt.value).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValue([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('create inserts lowercase username', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const user = await repository.create({
|
||||
username: 'Alice',
|
||||
passwordHash: 'hash',
|
||||
});
|
||||
expect(user.username).toBe('alice');
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('findByUsername maps a row', async () => {
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
const user = await repository.findByUsername('Alice');
|
||||
expect(user?.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('create maps unique violations to ConflictException', async () => {
|
||||
returning.mockRejectedValue({ code: '23505' });
|
||||
await expect(
|
||||
repository.create({ username: 'alice', passwordHash: 'hash' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
returning.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
repository.create({ username: 'alice', passwordHash: 'hash' }),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, type UserRow } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
import type { CreateUserInput, User } from './user';
|
||||
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
const normalized = username.toLowerCase();
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, normalized))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<User> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const [row] = await this.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: input.username.toLowerCase(),
|
||||
passwordHash: input.passwordHash,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private toDomain(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.passwordHash,
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from './user';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<UsersRepository, 'findById' | 'findByUsername' | 'create'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sampleUser: User = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hashed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
findById: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: repository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(UsersService);
|
||||
});
|
||||
|
||||
it('findById delegates to repository', async () => {
|
||||
repository.findById.mockResolvedValue(sampleUser);
|
||||
await expect(service.findById('user-1')).resolves.toEqual(sampleUser);
|
||||
});
|
||||
|
||||
it('create stores lowercase username', async () => {
|
||||
repository.findByUsername.mockResolvedValue(null);
|
||||
repository.create.mockResolvedValue(sampleUser);
|
||||
|
||||
await service.create('Alice', 'hashed');
|
||||
|
||||
expect(repository.findByUsername).toHaveBeenCalledWith('alice');
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
username: 'alice',
|
||||
passwordHash: 'hashed',
|
||||
});
|
||||
});
|
||||
|
||||
it('create throws ConflictException when username exists', async () => {
|
||||
repository.findByUsername.mockResolvedValue(sampleUser);
|
||||
|
||||
await expect(service.create('alice', 'hashed')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('findByUsername delegates to repository', async () => {
|
||||
repository.findByUsername.mockResolvedValue(sampleUser);
|
||||
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import type { User } from './user';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findById(id);
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.usersRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
async create(username: string, passwordHash: string): Promise<User> {
|
||||
const normalized = username.toLowerCase();
|
||||
const existing = await this.usersRepository.findByUsername(normalized);
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
return this.usersRepository.create({
|
||||
username: normalized,
|
||||
passwordHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user