Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save culttm/48ccef7195c3532dbac4844c1c75b22d to your computer and use it in GitHub Desktop.
Save culttm/48ccef7195c3532dbac4844c1c75b22d 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)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment