Skip to content

Instantly share code, notes, and snippets.

@andreacioni
Last active February 4, 2025 14:27
Show Gist options
  • Save andreacioni/eb5bad0bcca18cf4fb732c6d7e29e3e8 to your computer and use it in GitHub Desktop.
Save andreacioni/eb5bad0bcca18cf4fb732c6d7e29e3e8 to your computer and use it in GitHub Desktop.
NestJS Authentication: Single Sign On with SAML 2.0
@Get('api/auth/sso/saml/login')
@UseGuards(SamlAuthGuard)
async samlLogin() {
//this route is handled by passport-saml
return;
}
@Post('api/auth/sso/saml/ac')
@UseGuards(SamlAuthGuard)
async samlAssertionConsumer(
@Request() req: express.Request,
@Response() res: express.Response,
) {
//this routes gets executed on successful assertion from IdP
if (req.user) {
const user = req.user as User;
const jwt = this.authService.getTokenForUser(user);
this.userService.storeUser(user);
this, res.redirect('/?jwt=' + jwt);
}
}
@UseGuards(JwtAuthGuard)
@Get('api/profile')
getProfile(@Request() req: any) {
return req.user;
}
@Controller()
export class AppController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly samlStrategy: SamlStrategy, //new dependency needed here
) {}
// [...]
@Get('api/auth/sso/saml/metadata')
async getSpMetadata(@Response() res: express.Response) {
const ret = this.samlStrategy.generateServiceProviderMetadata(null, null);
res.type('application/xml');
res.send(ret);
}
}
@Get()
async homepage(@Response() res: express.Response) {
res.sendFile(resolve('web/index.html'));
}
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '../model/user';
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService) {}
getTokenForUser(user: User) {
const payload = {
sub: user.username,
iss: user.issuer,
};
return this.jwtService.sign(payload);
}
}
<html>
<head>
<title>Home</title>
<script>
const goToLoginPage = () => {
document.getElementById("user-profile-div").style = 'display: none;'
document.getElementById("login-div").style = ''
}
const goToProfilePage = () => {
document.getElementById("user-profile-div").style = ''
document.getElementById("login-div").style = 'display: none;'
}
const loadProfile =async () => {
const params = (new URL(document.location)).searchParams;
let jwt;
if(params.has("jwt")) {
jwt = params.get("jwt");
localStorage.setItem('jwt', jwt);
window.location.href = '/'
} else {
jwt = localStorage.getItem('jwt')
}
const options = jwt ? {
headers: {'Authorization': 'Bearer ' + jwt}
} : undefined;
const res = await fetch("/api/profile", options);
if(!res.ok) {
localStorage.removeItem('jwt');
goToLoginPage()
return
} else {
const json = await res.json()
goToProfilePage()
document.getElementById("userid").innerHTML = json.username;
document.getElementById("phone").innerHTML = json.phone;
document.getElementById("email").innerHTML = json.email;
}
}
const logout = () => {
localStorage.removeItem('jwt');
goToLoginPage()
}
</script>
</head>
<body onload="loadProfile()">
<div id="login-div">
<p>User not logged in</p>
<p><a id="login" href="/api/auth/sso/saml/login">Log In</a></p>
</div>
<div style="display: none;" id="user-profile-div">
<p>Hello <span id="userid"></span>!</p>
<ul>
<li>Email: <span id="email"></span></li>
<li>Phone: <span id="phone"></span></li>
</ul>
<p><a id="logout" href="#" onclick="logout()">Log Out</a></p>
</div>
</body>
</html>
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';
import { UserService } from 'src/user/user.service';
import { User } from '../model/user';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
const user: User | undefined = this.userService.retrieveUser(payload.sub);
if (user) {
return user;
}
throw new ForbiddenException('user was not found');
}
}
type Profile = {
issuer?: string;
sessionIndex?: string;
nameID?: string;
nameIDFormat?: string;
nameQualifier?: string;
spNameQualifier?: string;
mail?: string; // InCommon Attribute urn:oid:0.9.2342.19200300.100.1.3
email?: string; // `mail` if not present in the assertion
getAssertionXml(): string; // get the raw assertion XML
getAssertion(): object; // get the assertion XML parsed as a JavaScript object
getSamlResponseXml(): string; // get the raw SAML response XML
ID?: string;
} & {
[attributeName: string]: unknown; // arbitrary `AttributeValue`s
};
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class SamlAuthGuard extends AuthGuard('saml') {}
import { PassportStrategy } from '@nestjs/passport';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Strategy, Profile } from 'passport-saml';
import { User } from '../model/user';
@Injectable()
export class SamlStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
issuer: 'saml2-nest-poc',
callbackUrl: 'http://localhost:3000/api/auth/sso/saml/ac',
cert: 'MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA==',
entryPoint: 'https://samltest.id/idp/profile/SAML2/Redirect/SSO',
wantAssertionsSigned: true,
});
}
async validate(profile: Profile) {
try {
const user: User = {
username: profile['urn:oid:0.9.2342.19200300.100.1.1'] as string,
email: profile.mail as string,
issuer: profile.issuer as string,
phone: profile['urn:oid:2.5.4.20'] as string,
};
return user;
} catch (e) {
throw new ForbiddenException('invalid user attributes');
}
}
}
import { Injectable } from '@nestjs/common';
import { User } from '../model/user';
@Injectable()
export class UserService {
private _store: Map<string, User>;
constructor() {
this._store = new Map<string, User>();
}
storeUser(user: User): void {
this._store.set(user.username, user);
}
retrieveUser(id: string): User | undefined {
return this._store.get(id);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment