Integrate Oudelaa backend features, security, tests, and deployment updates
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
import { JwtRefreshStrategy } from './jwt-refresh.strategy';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { SuperAdminJwtStrategy } from './super-admin-jwt.strategy';
|
||||
|
||||
const config = (values: Record<string, string> = {}) => ({
|
||||
get: jest.fn((key: string) => values[key] ?? 'test-secret'),
|
||||
});
|
||||
|
||||
describe('authentication strategies', () => {
|
||||
describe('JwtStrategy', () => {
|
||||
it('accepts access tokens only for active users', async () => {
|
||||
const repository = { findById: jest.fn().mockResolvedValue({ isDisabled: false }) };
|
||||
const strategy = new JwtStrategy(
|
||||
config({ 'jwt.accessSecret': 'access-secret' }) as any,
|
||||
repository as any,
|
||||
);
|
||||
const payload = { sub: 'user-1', tokenType: 'access' } as any;
|
||||
|
||||
await expect(strategy.validate(payload)).resolves.toBe(payload);
|
||||
expect(repository.findById).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('rejects wrong token purpose before database access', async () => {
|
||||
const repository = { findById: jest.fn() };
|
||||
const strategy = new JwtStrategy(config() as any, repository as any);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'user-1', tokenType: 'refresh' } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(repository.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, { isDisabled: true }])('rejects unavailable account %#', async (user) => {
|
||||
const strategy = new JwtStrategy(
|
||||
config() as any,
|
||||
{ findById: jest.fn().mockResolvedValue(user) } as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 'user-1', tokenType: 'access' } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purpose-bound stateless strategies', () => {
|
||||
it('accepts only user refresh tokens in JwtRefreshStrategy', () => {
|
||||
const strategy = new JwtRefreshStrategy(config() as any);
|
||||
const payload = { sub: 'user-1', tokenType: 'refresh' } as any;
|
||||
|
||||
expect(strategy.validate(payload)).toBe(payload);
|
||||
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts only superadmin access tokens', () => {
|
||||
const strategy = new SuperAdminJwtStrategy(config() as any);
|
||||
const payload = { sub: 'superadmin', tokenType: 'superadmin_access' } as any;
|
||||
|
||||
expect(strategy.validate(payload)).toBe(payload);
|
||||
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleStrategy', () => {
|
||||
it('normalizes profile data before authentication', () => {
|
||||
const strategy = new GoogleStrategy(config() as any);
|
||||
const done = jest.fn();
|
||||
|
||||
strategy.validate(
|
||||
'access',
|
||||
'refresh',
|
||||
{
|
||||
id: 'google-1',
|
||||
displayName: 'Google Artist',
|
||||
emails: [{ value: 'ARTIST@EXAMPLE.COM', verified: true }],
|
||||
photos: [{ value: 'https://example.com/avatar.jpg' }],
|
||||
} as any,
|
||||
done,
|
||||
);
|
||||
|
||||
expect(done).toHaveBeenCalledWith(null, {
|
||||
googleId: 'google-1',
|
||||
email: 'artist@example.com',
|
||||
name: 'Google Artist',
|
||||
avatar: 'https://example.com/avatar.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects profiles without an email', () => {
|
||||
const strategy = new GoogleStrategy(config() as any);
|
||||
const done = jest.fn();
|
||||
|
||||
strategy.validate('access', 'refresh', { id: 'google-1' } as any, done);
|
||||
|
||||
expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException));
|
||||
});
|
||||
|
||||
it('provides a safe fallback display name', () => {
|
||||
const strategy = new GoogleStrategy(config() as any);
|
||||
const done = jest.fn();
|
||||
|
||||
strategy.validate(
|
||||
'access',
|
||||
'refresh',
|
||||
{ id: 'google-1', emails: [{ value: 'artist@example.com' }] } as any,
|
||||
done,
|
||||
);
|
||||
|
||||
expect(done).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.objectContaining({ name: 'Google User', avatar: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||
|
||||
@@ -11,12 +10,11 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh'
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
|
||||
passReqToCallback: true,
|
||||
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }) ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
validate(_: Request, payload: JwtPayload): JwtPayload {
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
if (payload.tokenType !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
@@ -3,22 +3,27 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
|
||||
import { UsersRepository } from '../../users/users.repository';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
constructor(configService: ConfigService, private readonly usersRepository: UsersRepository) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }) ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
if (payload.tokenType !== 'access') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findById(payload.sub);
|
||||
if (!user || user.isDisabled) {
|
||||
throw new UnauthorizedException('Account is unavailable');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmi
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }),
|
||||
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }) ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user