feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration - Login now issues short-lived access token (30m default) + 7d refresh token, both embedding tokenVersion and a typ discriminator - Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped to /auth); refresh token never leaves the cookie - New endpoints: POST /auth/refresh (rotation), GET /auth/me, POST /auth/logout (bumps tokenVersion, revoking all tokens) - JWT strategy accepts bearer or cookie, rejects refresh tokens, and verifies tokenVersion + user existence on every request - Global RolesGuard: authenticated routes require ADMIN unless widened via @Roles(...) - Admin SPA: session fully cookie-based, no token in localStorage; router guard restores session via /auth/me; axios auto-refreshes once on 401; stale localStorage keys cleaned up
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
|
||||
import type { AuthenticatedUser } from './strategies/jwt.strategy';
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||
const REFRESH_TOKEN_COOKIE = 'ir_rt';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -25,10 +43,90 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
||||
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
const result = await this.authService.login(dto);
|
||||
// HttpOnly cookies are the primary session channel for the admin SPA
|
||||
// (XSS cannot read them). The access token is also returned in the
|
||||
// body for non-browser API clients.
|
||||
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
});
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
// Only ever sent to /auth/refresh and /auth/logout
|
||||
path: '/auth',
|
||||
});
|
||||
// The refresh token deliberately stays HttpOnly-only.
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
} as unknown as LoginResponseDto;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
||||
if (!token) {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
const result = await this.authService.refresh(token);
|
||||
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
});
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/auth',
|
||||
});
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
} as unknown as LoginResponseDto;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Current authenticated user' })
|
||||
@ApiResponse({ status: 200, type: UserPublicDto })
|
||||
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
|
||||
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
|
||||
async logout(
|
||||
@Req() req: Request & { user: AuthenticatedUser },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true }> {
|
||||
await this.authService.logout(req.user.id);
|
||||
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -7,11 +7,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let jwt: JwtService;
|
||||
let prisma: {
|
||||
user: {
|
||||
count: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,12 +22,19 @@ describe('AuthService', () => {
|
||||
id: 1n,
|
||||
username: 'alice',
|
||||
passwordHash: HASH,
|
||||
role: 'ADMIN' as const,
|
||||
tokenVersion: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = {
|
||||
user: { count: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
|
||||
user: {
|
||||
count: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -37,6 +46,7 @@ describe('AuthService', () => {
|
||||
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
|
||||
}).compile();
|
||||
service = moduleRef.get(AuthService);
|
||||
jwt = moduleRef.get(JwtService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -49,10 +59,7 @@ describe('AuthService', () => {
|
||||
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||
prisma.user.create.mockResolvedValueOnce(dbUser);
|
||||
|
||||
const user = await service.register({
|
||||
username: 'alice',
|
||||
password: 'plain-pwd',
|
||||
});
|
||||
const user = await service.register({ username: 'alice', password: 'plain-pwd' });
|
||||
|
||||
expect(user.username).toBe('alice');
|
||||
const created = prisma.user.create.mock.calls[0][0].data;
|
||||
@@ -78,14 +85,17 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns an access token for valid credentials', async () => {
|
||||
it('returns access + refresh tokens with tokenVersion and type', async () => {
|
||||
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||
const result = await service.login({
|
||||
username: 'alice',
|
||||
password: 'plain-pwd',
|
||||
});
|
||||
expect(result.accessToken.split('.').length).toBe(3);
|
||||
const result = await service.login({ username: 'alice', password: 'plain-pwd' });
|
||||
|
||||
expect(result.user.username).toBe('alice');
|
||||
const access = jwt.decode(result.accessToken) as Record<string, unknown>;
|
||||
expect(access.typ).toBe('access');
|
||||
expect(access.tv).toBe(0);
|
||||
const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
|
||||
expect(refresh.typ).toBe('refresh');
|
||||
expect(refresh.tv).toBe(0);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException for wrong password', async () => {
|
||||
@@ -102,4 +112,56 @@ describe('AuthService', () => {
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('rotates a valid refresh token', async () => {
|
||||
const refreshToken = await jwt.signAsync({
|
||||
sub: '1',
|
||||
username: 'alice',
|
||||
role: 'ADMIN',
|
||||
tv: 0,
|
||||
typ: 'refresh',
|
||||
});
|
||||
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||
|
||||
const result = await service.refresh(refreshToken);
|
||||
expect(result.user.username).toBe('alice');
|
||||
expect(result.accessToken).not.toBe(refreshToken);
|
||||
});
|
||||
|
||||
it('rejects access tokens used as refresh tokens', async () => {
|
||||
const accessToken = await jwt.signAsync({
|
||||
sub: '1',
|
||||
username: 'alice',
|
||||
role: 'ADMIN',
|
||||
tv: 0,
|
||||
typ: 'access',
|
||||
});
|
||||
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
|
||||
const refreshToken = await jwt.signAsync({
|
||||
sub: '1',
|
||||
username: 'alice',
|
||||
role: 'ADMIN',
|
||||
tv: 0,
|
||||
typ: 'refresh',
|
||||
});
|
||||
// User logged out elsewhere: tokenVersion bumped to 1
|
||||
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('bumps tokenVersion to revoke all tokens', async () => {
|
||||
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||
await service.logout(1n);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 1n },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,16 +14,19 @@ import type { JwtPayload } from './strategies/jwt.strategy';
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '7d';
|
||||
const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
|
||||
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
|
||||
|
||||
/**
|
||||
* Compared against when the username does not exist so that login takes
|
||||
@@ -62,7 +65,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies credentials and returns a signed JWT.
|
||||
* Verifies credentials and returns signed access + refresh tokens.
|
||||
* Both tokens embed the user's tokenVersion so bumping it on the user
|
||||
* row (logout / revocation) invalidates them immediately.
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
@@ -74,20 +79,104 @@ export class AuthService {
|
||||
if (!user || !ok) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
return {
|
||||
accessToken: await this.signAccessToken(user),
|
||||
refreshToken: await this.signRefreshToken(user),
|
||||
user: this.toPublic(user),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a refresh token: the old refresh token becomes invalid as
|
||||
* soon as tokenVersion is bumped (logout, revocation).
|
||||
*/
|
||||
async refresh(refreshToken: string): Promise<LoginResult> {
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync(refreshToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
if (payload.typ !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const user = await this.prisma.user
|
||||
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||
.catch(() => null);
|
||||
if (!user || user.tokenVersion !== payload.tv) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
return {
|
||||
accessToken: await this.signAccessToken(user),
|
||||
refreshToken: await this.signRefreshToken(user),
|
||||
user: this.toPublic(user),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all tokens of a user by bumping tokenVersion.
|
||||
*/
|
||||
async logout(userId: bigint): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async me(userId: bigint): Promise<PublicUser> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toPublic(user);
|
||||
}
|
||||
|
||||
private async signAccessToken(user: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
role: string;
|
||||
tokenVersion: number;
|
||||
}): Promise<string> {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tv: user.tokenVersion,
|
||||
typ: 'access',
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload, {
|
||||
expiresIn: TOKEN_EXPIRES_IN,
|
||||
return this.jwt.signAsync(payload, {
|
||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||
});
|
||||
return { accessToken, user: this.toPublic(user) };
|
||||
}
|
||||
|
||||
private toPublic(user: { id: bigint; username: string; createdAt: Date }): PublicUser {
|
||||
private async signRefreshToken(user: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
role: string;
|
||||
tokenVersion: number;
|
||||
}): Promise<string> {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tv: user.tokenVersion,
|
||||
typ: 'refresh',
|
||||
};
|
||||
return this.jwt.signAsync(payload, {
|
||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
private toPublic(user: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
}): PublicUser {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/**
|
||||
* Restricts a route to the given roles. When omitted, any
|
||||
* authenticated user with an ADMIN role passes the RolesGuard.
|
||||
*/
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
let guard: RolesGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new RolesGuard(new Reflector());
|
||||
});
|
||||
|
||||
const context = (user: unknown, roles?: string[]) =>
|
||||
({
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
getHandler: () => (roles ? { __roles: roles } : {}),
|
||||
getClass: () => ({}),
|
||||
}) as never;
|
||||
|
||||
it('passes public routes (no authenticated user)', () => {
|
||||
expect(guard.canActivate(context(undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes ADMIN users by default', () => {
|
||||
expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks users without the ADMIN role', () => {
|
||||
expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import type { AuthenticatedUser } from '../strategies/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Role-based access control. Applied globally: any authenticated user
|
||||
* reaching a protected route must hold the ADMIN role unless the route
|
||||
* declares a wider set with @Roles(...). Routes without a JwtAuthGuard
|
||||
* (public endpoints) have no `request.user` and are skipped here — their
|
||||
* openness is decided by the controller's own guards.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthenticatedUser;
|
||||
}>();
|
||||
if (!request.user) {
|
||||
return true; // public route — no JwtAuthGuard in front
|
||||
}
|
||||
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? ['ADMIN'];
|
||||
if (!required.includes(request.user.role)) {
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,27 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: bigint;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the JWT we issue.
|
||||
*
|
||||
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
||||
* `typ` distinguishes access tokens from refresh tokens; `tv` is the user's
|
||||
* tokenVersion and `role` drives the RolesGuard.
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
tv?: number;
|
||||
typ?: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -28,7 +41,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new Error('JWT_SECRET must be at least 32 characters');
|
||||
}
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
// Access tokens are accepted from the HttpOnly cookie (browser) or
|
||||
// the Authorization header (non-browser API clients).
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
(req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null,
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
@@ -36,19 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
|
||||
/**
|
||||
* Runs on every authenticated request. The returned object becomes
|
||||
* `request.user` for downstream controllers. The user is re-checked in
|
||||
* the database so tokens of deleted users stop working immediately.
|
||||
* `request.user` for downstream controllers. The user and its
|
||||
* tokenVersion are re-checked in the database so tokens of deleted
|
||||
* users, logged-out users, or refresh tokens stop working immediately.
|
||||
*/
|
||||
async validate(payload: JwtPayload): Promise<{ id: bigint; username: string }> {
|
||||
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
|
||||
if (!payload?.sub || !payload.username) {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
// Refresh tokens must never be accepted as API credentials.
|
||||
if (payload.typ === 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
const user = await this.prisma.user
|
||||
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||
.catch(() => null);
|
||||
if (!user || user.username !== payload.username) {
|
||||
if (
|
||||
!user ||
|
||||
user.username !== payload.username ||
|
||||
(payload.tv !== undefined && user.tokenVersion !== payload.tv)
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
return { id: user.id, username: user.username };
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user