Skip to content

Instantly share code, notes, and snippets.

@jengel3
Last active April 23, 2024 19:26
Show Gist options
  • Star 27 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save jengel3/6a49a25b2fc2eb56fcf8b38f5004ea2c to your computer and use it in GitHub Desktop.
Save jengel3/6a49a25b2fc2eb56fcf8b38f5004ea2c to your computer and use it in GitHub Desktop.
NestJS - Implementing Access & Refresh Token Authentication
// app/modules/authentication/authentication.controller.ts
import { Body, Controller, Post } from '@nestjs/common'
import { RegisterRequest } from './requests'
import { User } from '../../modules/user'
import { UsersService } from '../users/users.service'
export interface AuthenticationPayload {
user: User
payload: {
type: string
token: string
refresh_token?: string
}
}
@Controller('/api/auth')
export class AuthenticationController {
private readonly users: UsersService
private readonly tokens: TokensService
public constructor (users: UsersService, tokens: TokensService) {
this.users = users
this.tokens = tokens
}
@Post('/register')
public async register (@Body() body: RegisterRequest) {
const user = await this.users.createUserFromRequest(body)
const token = await this.tokens.generateAccessToken(user)
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30)
const payload = this.buildResponsePayload(user, token, refresh)
return {
status: 'success',
data: payload,
}
}
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload {
return {
user: user,
payload: {
type: 'bearer',
token: accessToken,
...(refreshToken ? { refresh_token: refreshToken } : {}),
}
}
}
}
// app/modules/authentication/authentication.controller.ts
import { Body, Controller, Post, UnauthorizedException } from '@nestjs/common'
import { RegisterRequest, LoginRequest } from '../requests'
import { User } from '../../models/user.model'
import { UsersService } from '../users/users.service'
export interface AuthenticationPayload {
user: User
payload: {
type: string
token: string
refresh_token?: string
}
}
@Controller('/api/auth')
export class AuthenticationController {
private readonly users: UsersService
private readonly tokens: TokensService
public constructor (users: UsersService, tokens: TokensService) {
this.users = users
this.tokens = tokens
}
@Post('/register')
public async register (@Body() body: RegisterRequest) {
const user = await this.users.createUserFromRequest(body)
const token = await this.tokens.generateAccessToken(user)
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30)
const payload = this.buildResponsePayload(user, token, refresh)
return {
status: 'success',
data: payload,
}
}
@Post('/login')
public async login (@Body() body: LoginRequest) {
const { username, password } = body
const user = await this.users.findForUsername(username)
const valid = user ? await this.users.validateCredentials(user, password) : false
if (!valid) {
throw new UnauthorizedException('The login is invalid')
}
const token = await this.tokens.generateAccessToken(user)
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30)
const payload = this.buildResponsePayload(user, token, refresh)
return {
status: 'success',
data: payload,
}
}
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload {
return {
user: user,
payload: {
type: 'bearer',
token: accessToken,
...(refreshToken ? { refresh_token: refreshToken } : {}),
}
}
}
}
// app/modules/authentication/authentication.controller.ts
import { Body, Controller, Post, UnauthorizedException } from '@nestjs/common'
import { RegisterRequest, LoginRequest, RefreshRequest } from '../requests'
import { User } from '../../models/user.model'
import { UsersService } from '../users/users.service'
export interface AuthenticationPayload {
user: User
payload: {
type: string
token: string
refresh_token?: string
}
}
@Controller('/api/auth')
export class AuthenticationController {
private readonly users: UsersService
private readonly tokens: TokensService
public constructor (users: UsersService, tokens: TokensService) {
this.users = users
this.tokens = tokens
}
@Post('/register')
public async register (@Body() body: RegisterRequest) {
const user = await this.users.createUserFromRequest(body)
const token = await this.tokens.generateAccessToken(user)
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30)
const payload = this.buildResponsePayload(user, token, refresh)
return {
status: 'success',
data: payload,
}
}
@Post('/login')
public async login (@Body() body: LoginRequest) {
const { username, password } = body
const user = await this.users.findForUsername(username)
const valid = user ? await this.users.validateCredentials(user, password) : false
if (!valid) {
throw new UnauthorizedException('The login is invalid')
}
const token = await this.tokens.generateAccessToken(user)
const refresh = await this.tokens.generateRefreshToken(user, 60 * 60 * 24 * 30)
const payload = this.buildResponsePayload(user, token, refresh)
return {
status: 'success',
data: payload,
}
}
@Post('/refresh')
public async refresh (@Body() body: RefreshRequest) {
const { user, token } = await this.tokens.createAccessTokenFromRefreshToken(body.refresh_token)
const payload = this.buildResponsePayload(user, token)
return {
status: 'success',
data: payload,
}
}
private buildResponsePayload (user: User, accessToken: string, refreshToken?: string): AuthenticationPayload {
return {
user: user,
payload: {
type: 'bearer',
token: accessToken,
...(refreshToken ? { refresh_token: refreshToken } : {}),
}
}
}
}
// app/modules/authentication/authentication.controller.ts
// ...
import { Controller, UseGuards, Get, Req } from '@nestjs/common'
import { JWTGuard } from './jwt.guard.ts'
// ...
@Controller('/api/auth')
export class AuthenticationController {
// ...
@Get('/me')
@UseGuards(JWTGuard)
public async getUser (@Req() request) {
const userId = request.user.id
const user = await this.users.findForId(userId)
return {
status: 'success',
data: user,
}
}
// ...
}
// app/modules/authentication/authentication.module.ts
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { SequelizeModule } from '@nestjs/sequelize'
import { UsersModule } from '../users/users.module'
import { RefreshToken } from '../../models/RefreshToken'
import { TokensService } from './tokens.service'
import { RefreshTokensRepository } from './refresh-tokens.repository'
import { AuthenticationController } from './authentication.controller'
@Module({
imports: [
SequelizeModule.forFeature([
RefreshToken,
]),
JwtModule.register({
secret: '<SECRET KEY>',
signOptions: {
expiresIn: '5m',
}
}),
UsersModule,
],
controllers: [
AuthenticationController,
],
providers: [
TokensService,
RefreshTokensRepository,
],
})
export class AuthenticationModule {}
// app/modules/authentication/jwt.guard.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JWTGuard extends AuthGuard('jwt') {
handleRequest (err, user, info: Error) {
if (err || info || !user) {
throw err || info || new UnauthorizedException()
}
return user
}
}
// app/modules/authentication/jwt.strategy.ts
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { UsersService } from '../../users/users.service'
import { User } from '../../models'
export interface AccessTokenPayload {
sub: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
private users: UsersService
public constructor (users: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: '<SECRET KEY>',
signOptions: {
expiresIn: '5m',
},
})
this.users = users
}
async validate (payload: AccessTokenPayload): Promise<User> {
const { sub: id } = payload
const user = await this.users.findForId(id)
if (!user) {
return null
}
return user
}
}
// app/models/refresh-token.model.ts
import { Table, Column, Model } from 'sequelize-typescript'
@Table({ tableName: 'refresh_tokens', underscored: true })
export class RefreshToken extends Model<RefreshToken> {
@Column
user_id: number
@Column
is_revoked: boolean
@Column
expires: Date
}
// app/modules/authentication/refresh-tokens.repository.ts
import { Injectable } from '@nestjs/common'
import { User } from '../../../models/user.model'
import { RefreshToken } from '../../../models/refresh-token.model'
@Injectable()
export class RefreshTokensRepository {
public async createRefreshToken (user: User, ttl: number): Promise<RefreshToken> {
const token = new RefreshToken()
token.user_id = user.id
token.is_revoked = false
const expiration = new Date()
expiration.setTime(expiration.getTime() + ttl)
token.expires = expiration
return token.save()
}
public async findTokenById (id: number): Promise<RefreshToken | null> {
return RefreshToken.findOne({
where: {
id,
}
})
}
}
// app/requests.ts
import { IsNotEmpty, MinLength } from 'class-validator'
export class LoginRequest {
@IsNotEmpty({ message: 'A username is required' })
readonly username: string
@IsNotEmpty({ message: 'A password is required to login' })
readonly password: string
}
export class RegisterRequest {
@IsNotEmpty({ message: 'An username is required' })
readonly username: string
@IsNotEmpty({ message: 'A password is required' })
@MinLength(6, { message: 'Your password must be at least 6 characters' })
readonly password: string
}
export class RefreshRequest {
@IsNotEmpty({ message: 'The refresh token is required' })
readonly refresh_token: string
}
// app/modules/authentication/tokens.service.ts
import { Injectable } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { SignOptions } from 'jsonwebtoken'
import { User } from '../../models'
import { RefreshTokensRepository } from './refresh-tokens.repository'
const BASE_OPTIONS: SignOptions = {
issuer: 'https://my-app.com',
audience:'https://my-app.com',
}
@Injectable()
export class TokensService {
private readonly tokens: RefreshTokensRepository
private readonly jwt: JwtService
public constructor (tokens: RefreshTokensRepository, jwt: JwtService) {
this.tokens = tokens
this.jwt = jwt
}
public async generateAccessToken (user: User): Promise<string> {
const opts: SignOptions = {
...BASE_OPTIONS,
subject: String(user.id),
}
return this.jwt.signAsync({}, opts)
}
public async generateRefreshToken (user: User, expiresIn: number): Promise<string> {
const token = await this.tokens.createRefreshToken(user, expiresIn)
const opts: SignOptions = {
...BASE_OPTIONS,
expiresIn,
subject: String(user.id),
jwtid: String(token.id),
}
return this.jwt.signAsync({}, opts)
}
}
// app/modules/authentication/tokens.service.ts
import { UnprocessableEntityException, Injectable } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { SignOptions, TokenExpiredError } from 'jsonwebtoken'
import { User } from '../../models'
import { RefreshToken } from '../../models/refresh-token.model'
import { UsersRepository } from '../users/users.repository'
import { RefreshTokensRepository } from './refresh-tokens.repository'
const BASE_OPTIONS: SignOptions = {
issuer: 'https://my-app.com',
audience:'https://my-app.com',
}
export interface RefreshTokenPayload {
jti: number;
sub: number
}
@Injectable()
export class TokensService {
private readonly tokens: RefreshTokensRepository
private readonly users: UsersRepository
private readonly jwt: JwtService
public constructor (tokens: RefreshTokensRepository, users: UsersRepository, jwt: JwtService) {
this.tokens = tokens
this.users = users
this.jwt = jwt
}
public async generateAccessToken (user: User): Promise<string> {
const opts: SignOptions = {
...BASE_OPTIONS,
subject: String(user.id),
}
return this.jwt.signAsync({}, opts)
}
public async generateRefreshToken (user: User, expiresIn: number): Promise<string> {
const token = await this.tokens.createRefreshToken(user, expiresIn)
const opts: SignOptions = {
...BASE_OPTIONS,
expiresIn,
subject: String(user.id),
jwtid: String(token.id),
}
return this.jwt.signAsync({}, opts)
}
public async resolveRefreshToken (encoded: string): Promise<{ user: User, token: RefreshToken }> {
const payload = await this.decodeRefreshToken(encoded)
const token = await this.getStoredTokenFromRefreshTokenPayload(payload)
if (!token) {
throw new UnprocessableEntityException('Refresh token not found')
}
if (token.is_revoked) {
throw new UnprocessableEntityException('Refresh token revoked')
}
const user = await this.getUserFromRefreshTokenPayload(payload)
if (!user) {
throw new UnprocessableEntityException('Refresh token malformed')
}
return { user, token }
}
public async createAccessTokenFromRefreshToken (refresh: string): Promise<{ token: string, user: User }> {
const { user } = await this.resolveRefreshToken(refresh)
const token = await this.generateAccessToken(user)
return { user, token }
}
private async decodeRefreshToken (token: string): Promise<RefreshTokenPayload> {
try {
return this.jwt.verifyAsync(token)
} catch (e) {
if (e instanceof TokenExpiredError) {
throw new UnprocessableEntityException('Refresh token expired')
} else {
throw new UnprocessableEntityException('Refresh token malformed')
}
}
}
private async getUserFromRefreshTokenPayload (payload: RefreshTokenPayload): Promise<User> {
const subId = payload.sub
if (!subId) {
throw new UnprocessableEntityException('Refresh token malformed')
}
return this.users.findForId(subId)
}
private async getStoredTokenFromRefreshTokenPayload (payload: RefreshTokenPayload): Promise<RefreshToken | null> {
const tokenId = payload.jti
if (!tokenId) {
throw new UnprocessableEntityException('Refresh token malformed')
}
return this.tokens.findTokenById(tokenId)
}
}
// app/models/user.model.ts
import { Column, Model, Table } from 'sequelize-typescript'
@Table({ tableName: 'users', underscored: true })
export class User extends Model<User> {
@Column
username: string
@Column
password: string
}
// app/modules/users/users.module.ts
import { Module } from '@nestjs/common'
import { SequelizeModule } from '@nestjs/sequelize'
import { User } from '../../models/user'
import { UsersService } from './users.service'
import { UsersRepository } from './users.repository'
@Module({
imports: [
SequelizeModule.forFeature([
User,
]),
],
providers: [
UsersService,
UsersRepository,
],
exports: [
UsersService,
UsersRepository,
],
})
export class UsersModule {}
// app/modules/users/users.repository.ts
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/sequelize'
import { hash } from 'bcrypt'
import { col, fn, where } from 'sequelize'
import { User } from '../../models'
@Injectable()
export class UsersRepository {
private readonly users: typeof User
public constructor (@InjectModel(User) users: typeof User) {
this.users = users
}
public async findForId (id: number): Promise<User | null> {
return this.users.findOne({
where: {
id,
},
})
}
public async findForUsername (username: string): Promise<User | null> {
return this.users.findOne({
where: {
username: where(fn('lower', col('username')), username),
},
})
}
public async create (username: string, password: string): Promise<User> {
const user = new User()
user.username = username
user.password = await hash(password, 10)
return user.save()
}
}
// app/modules/user/users.service.ts
import { UnprocessableEntityException, Injectable } from '@nestjs/common'
import { compare } from 'bcrypt'
import { RegisterRequest } from '../../requests'
import { User } from '../../models'
import { UsersRepository } from '../users/users.repository'
@Injectable()
export class UsersService {
private readonly users: UsersRepository
public constructor (users: UsersRepository) {
this.users = users
}
public async validateCredentials (user: User, password: string): Promise<boolean> {
return compare(password, user.password)
}
public async createUserFromRequest (request: RegisterRequest): Promise<User> {
const { username, password } = request
const existingFromUsername = await this.findForUsername(request.username)
if (existingFromUsername) {
throw new UnprocessableEntityException('Username already in use')
}
return this.users.create(username, password)
}
public async findForId (id: number): Promise<User | null> {
return this.users.findForId(id)
}
public async findForUsername (username: string): Promise<User | null> {
return this.users.findForUsername(username)
}
}
@mortezasabihi
Copy link

When is_revoked changes to true?? I implemented this code but never did this happen.

@goniszewski
Copy link

When is_revoked changes to true?? I implemented this code but never did this happen.

You have to implement some automatic process (e.g., max number of uses of refresh token) or just allow user to logout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment