Guide to Implementing Authentication with NestJS and SSO Saml2

Introduction

SSO (Single Sign-On) is a centralized authentication mechanism that allows users to access multiple different systems with a single set of login credentials.

Key advantages include:

  • Improving user experience by reducing the number of passwords to remember.
  • Enhancing security through centralized management and minimizing the risk of brute-force attacks at various points.
  • Purpose: To enable users to log in only once to one location (Identity Provider - IdP) but be able to access multiple different applications without re-entering their password.
    • Example: You log into your Google account, then open Gmail, YouTube, Drive without logging in again.


SAML & SAML2 (Security Assertion Markup Language)

  • SAML 1.0/1.1 were the first versions that laid the foundation for exchanging identity data using XML, but are now obsolete.
  • SAML 2.0 (Saml2) is a strong combination and improvement, supporting modern web scenarios and becoming the most popular standard for SSO in Corporate/Enterprise environments due to its flexible integration and high security (requiring Certificate, Metadata XML).
  • Nature: Uses XML data structures to exchange authentication and authorization information between an Identity Provider (such as Microsoft Entra) and a Service Provider (such as the web servers we develop).

Prerequisites

This article will continue developing from a previous project using JWT. After performing login via SSO, token information will be sent to the client for use. You can review previous articles to grasp the necessary information before continuing.


Detail

First, access the page to create an application to get SSO Saml2 information as follows: Enterprise app > New application

After successfully creating the application, you can see information such as:

  • Reply URL (Assertion Consumer Service URL): is the callback url that will be redirected to after successful login. This value can accept http, so you can input an arbitrary value to test on localhost.
  • Logout Url (Optional): is the callback url that will be redirected to after successful logout, usually set to your frontend url. This value only accepts HTTPS, so you can deploy your web application to some host to input the appropriate value.


Please download the Federation Metadata XML file, this file will contain the necessary information for SSO authentication and will be used in the NestJS project in the next steps.


Update the environment.service.ts file with information from the .env file as follows:

import {Injectable} from '@nestjs/common'
import {ConfigService} from '@nestjs/config'

@Injectable()
export class EnvironmentService {
issuer: string
callbackUrl: string
hostFe: string

constructor(private readonly configService: ConfigService) {
this.issuer = this.configService.get<string>('ISSUER') || ''
this.callbackUrl = this.configService.get<string>('CALLBACK_URL') || ''
this.hostFe = this.configService.get<string>('HOST_FE') || ''
}
}


Create the saml2.strategy.ts file:

import {Injectable} from '@nestjs/common'
import {PassportStrategy} from '@nestjs/passport'
import * as fs from 'fs'
import {MultiSamlStrategy, Profile} from 'passport-saml'
import {MetadataReader} from 'passport-saml-metadata'
import * as path from 'path'
import {EnvironmentService} from 'src/service/environment.service'

@Injectable()
export class Saml2Strategy extends PassportStrategy(MultiSamlStrategy, 'saml') {
constructor(private readonly envService: EnvironmentService) {
super({
passReqToCallback: true,
getSamlOptions: (req, callback) => {
callback(null, this.loadConfiguration())
},
})
}

loadConfiguration() {
const xmlPath = path.join(__dirname, '.path-to-file/metadata.xml')
const xml = fs.readFileSync(xmlPath, 'utf8')
const reader = new MetadataReader(xml)
return {
...reader,
entryPoint: reader.identityProviderUrl,
issuer: this.envService.issuer,
cert: reader.signingCert,
callbackUrl: this.envService.callbackUrl,
}
}

validate(req: any, profile: Profile) {
return profile
}
}

This code segment initializes the SAML2 authentication strategy by reading the Federation Metadata XML file (which you downloaded earlier) to automatically configure parameters such as Identity Provider URL and certificate (Cert), and then returns the user profile after successful login.


Create the saml2.guard.ts file to use Saml2Strategy:

import {Injectable} from '@nestjs/common'
import {AuthGuard} from '@nestjs/passport'

@Injectable()
export class Saml2AuthGuard extends AuthGuard('saml') {}


The auth.service.ts file with the following content:

import {Injectable} from '@nestjs/common'
import {JwtService} from '@nestjs/jwt'
import {EnvironmentService} from './environment.service'

@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly envService: EnvironmentService,
) {}

async getTokens(payload: Record<string, any>) {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.envService.jwtSecret,
expiresIn: this.envService.jwtExpires,
}),
this.jwtService.signAsync(payload, {
secret: this.envService.jwtRefreshSecret,
expiresIn: this.envService.jwtExpiresRefresh,
}),
])
return {
accessToken,
refreshToken,
expiresIn: Date.now() + this.envService.jwtExpires,
}
}
}


Create the sso.controller.ts file:

import {
Controller,
Get,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common'
import express from 'express'
import {Profile} from 'passport-saml'
import {JwtAuthGuard} from 'src/guard/jwt.guard'
import {Saml2AuthGuard} from 'src/guard/saml2.guard'
import {AuthService} from 'src/service/auth.service'
import {EnvironmentService} from 'src/service/environment.service'
import {Saml2Strategy} from 'src/strategy/saml2.strategy'

@Controller('sso')
export class SsoController {
constructor(
private readonly authService: AuthService,
private readonly envService: EnvironmentService,
private readonly saml2Service: Saml2Strategy
) {}

@Get('login')
@UseGuards(Saml2AuthGuard)
login() {
// auto redirect to to login page
}

@Post('login-callback')
@UseGuards(Saml2AuthGuard)
async loginCallback(@Req() req, @Res() res: express.Response) {
let redirectUrl = this.envService.hostFe
try {
const profile = req?.user as Profile
const payload = {
nameID: profile.nameID,
nameIDFormat: profile.nameIDFormat,
sessionIndex: profile.sessionIndex,
}
const data = await this.authService.getTokens(payload)
const stringParams = new URLSearchParams(
Object.entries(data).map(([key, value]) => [key, String(value)])
).toString()
redirectUrl += '?' + stringParams
} catch (e) {
console.error('SSO authentication failure', e)
} finally {
res.redirect(HttpStatus.FOUND, redirectUrl)
}
}

@Get('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req) {
const redirect = await new Promise<string>((rs, rj) => {
this.saml2Service.logout(req, (err, logoutUrl) => {
if (logoutUrl) return rs(logoutUrl)
if (err) return rj(err)
return rs(this.envService.hostFe)
})
})
return {redirect}
}
}

  • This Controller directs the SSO login flow: the /login endpoint redirects to the IdP (Identity Provider), /login-callback receives information after login to create a JWT token and send it back to the frontend, and /logout handles synchronization logout with the SSO system.
  • Note here that there must be a /logout api to delete the logged-in session from the IdP, not just clear the token on the frontend side. Otherwise, after the user logs out and then logs in again, the session from the old account will still be kept, and it's impossible to switch to using another account.


Update the app.module.ts file:

import {Module} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {EnvironmentService} from './service/environment.service'
import {AuthService} from './service/auth.service'
import {AuthController} from './controller/auth.controller'
import {JwtModule} from '@nestjs/jwt'
import {PassportModule} from '@nestjs/passport'
import {JwtStrategy} from './strategy/jwt.strategy'
import {JwtRefreshStrategy} from './strategy/jwt-refresh.strategy'
import {SsoController} from './controller/sso.controller'
import {Saml2Strategy} from './strategy/saml2.strategy'

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
PassportModule,
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET,
signOptions: {expiresIn: parseInt(process.env.JWT_EXPIRES || '0')},
}),
],
controllers: [SsoController],
providers: [
EnvironmentService,
AuthService,
JwtStrategy,
JwtRefreshStrategy,
Saml2Strategy,
],
})
export class AppModule {}


This is the .env file, please change other information according to the application you created on Microsoft Entra:

JWT_SECRET = JWT_SECRET
JWT_REFRESH_SECRET = JWT_REFRESH_SECRET
JWT_EXPIRES = 300000
JWT_EXPIRES_REFRESH = 864000000

ISSUER = nestjs-app # Identifier (Entity ID)
CALLBACK_URL = http://localhost:3000/sso/login-callback
HOST_FE = https://your-domain.com


Next, in the NextJS project to perform SSO login, create a page with the following content:

'use client'

import {LoginOutlined, LogoutOutlined, UserOutlined} from '@ant-design/icons'
import {Button, Card, message, Space, Typography} from 'antd'
import {useEffect, useState} from 'react'

const {Text, Title} = Typography

export default function SSOPage() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [accessToken, setAccessToken] = useState<string | null>(null)

useEffect(() => {
const query = new URLSearchParams(window.location.search)

const redirectUrl = query.get('redirect')
if (redirectUrl) {
sessionStorage.setItem('postLoginRedirect', redirectUrl)
}

const tokenFromUrl = query.get('accessToken')
const refreshTokenFromUrl = query.get('refreshToken')
const expiresInFromUrl = query.get('expiresIn')

if (tokenFromUrl) {
localStorage.setItem('accessToken', tokenFromUrl)
if (refreshTokenFromUrl)
localStorage.setItem('refreshToken', refreshTokenFromUrl)
if (expiresInFromUrl) localStorage.setItem('expiresIn', expiresInFromUrl)

setAccessToken(tokenFromUrl)
setIsLoggedIn(true)

window.history.replaceState({}, document.title, '/')
message.success('Login successful!')
} else {
const savedToken = localStorage.getItem('accessToken')
if (savedToken) {
setAccessToken(savedToken)
setIsLoggedIn(true)
}
}

const savedRedirect = sessionStorage.getItem('postLoginRedirect')
if (savedRedirect) {
sessionStorage.removeItem('postLoginRedirect')
message.success('Login successful! Redirecting...')
window.location.href = savedRedirect
}
}, [])

const handleLogin = () => {
window.location.href = 'http://localhost:3000/sso/login'
}

const handleLogout = async () => {
try {
const token = localStorage.getItem('accessToken')
const response = await fetch('http://localhost:3000/sso/logout', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.ok || response.status === 401) {
localStorage.clear()
sessionStorage.removeItem('postLoginRedirect')
setIsLoggedIn(false)
setAccessToken(null)
message.info('Logged out')

const data = await response.json()
if (data?.redirect) {
window.location.href = data.redirect
}
} else {
message.error('Logout failed')
}
} catch (error) {
console.error('Logout error:', error)
message.error('Logout error')
}
}

return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<Card className="w-full max-w-md shadow-lg text-center border-none">
<Space orientation="vertical" size="large" className="w-full">
<Title level={3}>SSO Gateway</Title>
{!isLoggedIn ? (
<div className="py-8">
<Button
type="primary"
size="large"
icon={<LoginOutlined />}
onClick={handleLogin}
className="w-full h-12 bg-blue-600 hover:bg-blue-700 shadow-md"
>
Sign In with SSO
</Button>
</div>
) : (
<div className="py-8 space-y-4">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-2">
<UserOutlined className="text-2xl text-green-600" />
</div>
<Text strong className="text-lg block text-green-700">
Welcome back!
</Text>
<Text
type="secondary"
className="break-all text-[10px] mt-2 font-mono bg-gray-50 p-2 rounded border w-full"
>
ID: {accessToken?.substring(0, 32)}...
</Text>
</div>
<Button
danger
icon={<LogoutOutlined />}
onClick={handleLogout}
className="w-full h-10 mt-4"
>
Sign Out
</Button>
</div>
)}
</Space>
</Card>
</main>
)
}

This React page manages client-side login state: it checks and stores the JWT token from the URL after SSO redirect back, handles user navigation based on the redirect parameter, and provides an interface to trigger the login or logout process.


Here the operating flow will be as follows:

  • Use on the web:
    • When accessing the page https://your-domain.com
      • Check if a token already exists, then allow usage.
      • If there is no token, automatically redirect to the /sso/login page to log in with SSO.
    • After successful login, token information will be returned for the frontend to save and use.
  • In case it needs to be used on other devices like mobile with a change in the redirect url:
    • Access the page https://your-domain.com?redirect=https://your-domain.com/deep-link-mobile
    • The Redirect url (https://your-domain.com/deep-link-mobile) will be saved to perform redirect after successful login.
    • Also check the token to allow usage or redirect to the SSO login page.
    • After successful login, it will redirect back to the previously saved page (https://your-domain.com/deep-link-mobile)
  • When performing logout, calling the /sso/logout api will return a redirect url. When the frontend redirects to that page, it will clear the session of that account and then redirect to the home page.


Token information will be attached to the url after successful SSO login in a form like this:

https://your-domain.com?accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lSUQiOiJoY2FjaDkxX2xpdmUuY29tI0VYVCNAaGNhY2g5MWxpdmUub25taWNyb3NvZnQuY29tIiwibmFtZUlERm9ybWF0IjoidXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6ZW1haWxBZGRyZXNzIiwic2Vzc2lvbkluZGV4IjoiX2I1MDkzNzYzLTZiOWEtNGM4Mi05ZjI1LTlmOTIwMTM1NWQwMCIsImlhdCI6MTc3Mzk3ODA5NywiZXhwIjoxNzczOTc4Mzk3fQ.swfvqDjQcfwViBZHOwmOUfUt-j6TVLSF84s5cH2XbkM&refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lSUQiOiJoY2FjaDkxX2xpdmUuY29tI0VYVCNAaGNhY2g5MWxpdmUub25taWNyb3NvZnQuY29tIiwibmFtZUlERm9ybWF0IjoidXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6ZW1haWxBZGRyZXNzIiwic2Vzc2lvbkluZGV4IjoiX2I1MDkzNzYzLTZiOWEtNGM4Mi05ZjI1LTlmOTIwMTM1NWQwMCIsImlhdCI6MTc3Mzk3ODA5NywiZXhwIjoxNzc0ODQyMDk3fQ.Zuf7zdJj2t_LwafiCJiVfRYVtrw2loyW1JDe05-a-Fw&expiresIn=1773978097652300000


Check the login and logout results as follows:






Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Deploying a NodeJS Server on Google Kubernetes Engine

Kubernetes Deployment for Zero Downtime

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

NodeJS Practice Series

Sitemap