Passport, OAuth2 et OpenID Connect avec @nestjs/passport
TL;DR —
@nestjs/passportest un wrapper Nest autour depassport, permettant d'utiliser ses ~500 stratégies (local, JWT, OAuth2, OIDC, SAML, magic link). Pour atteindre un niveau ninja, il faut maîtriser le flowAuthorization Code + PKCE, la rotation des refresh tokens, la validation JWKS, la gestion dustate/nonce(anti-CSRF/anti-replay), et savoir choisir entre sessions et JWT stateless. La sécurité d'auth repose sur des détails (cookie flags, scopes, audience) plus que sur la stratégie elle-même.
🧠 Mental model — diagramme ASCII + analogie
Passport est une « chaîne de stratégies » : chaque stratégie sait extraire des credentials de la requête, les valider auprès d'une source (DB, IDP, JWT), et retourner un user qui sera attaché à req.user. NestJS encapsule chaque stratégie dans un AuthGuard('strategyName') réutilisable.
┌──────────────────────────────────────────────────┐
HTTP request ───► │ AuthGuard('jwt') │
│ ┌──────────────────────────────────────────┐ │
│ │ JwtStrategy.validate(payload) ─► user │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
│
▼
req.user = { id, roles, ... }
Flow OAuth2 Authorization Code + PKCE :
┌────────┐ 1. GET /authorize?...&code_challenge=... ┌────────┐
│ Client │ ────────────────────────────────────────────► │ IDP │
└────────┘ └────────┘
▲ 2. Login + Consent │
│ │
│ 3. 302 redirect with ?code=xxx&state=yyy │
◄───────────────────────────────────────────────────────│
│
│ 4. POST /token code, code_verifier
│ ────────────────────────────────────────────────────► │
│ │
│ 5. { access_token, id_token, refresh_token } │
◄───────────────────────────────────────────────────────│
│
│ 6. Validate id_token signature via JWKS endpointAnalogie : un vigile à l'entrée d'un immeuble. Chaque badge (stratégie) ouvre un type de porte. Le badge local lit un mot de passe sur place, le badge jwt vérifie un sceau cryptographique, le badge oauth2 appelle un service externe pour valider. Le vigile (le Guard) refuse l'entrée si aucun badge ne marche.
🛠️ Code minimal (ts)
// src/auth/strategies/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as jwksRsa from 'jwks-rsa';
import { UserService } from '../../user/user.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private readonly users: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
audience: process.env.JWT_AUDIENCE,
issuer: process.env.JWT_ISSUER,
algorithms: ['RS256'],
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
jwksUri: `${process.env.JWT_ISSUER}/.well-known/jwks.json`,
}),
});
}
async validate(payload: { sub: string; scope?: string }) {
const user = await this.users.findById(payload.sub);
if (!user) throw new UnauthorizedException();
return { id: user.id, scopes: payload.scope?.split(' ') ?? [] };
}
}// src/auth/strategies/google.strategy.ts
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: process.env.GOOGLE_CALLBACK_URL!,
scope: ['email', 'profile', 'openid'],
state: true, // CSRF protection
pkce: true,
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
const user = {
provider: 'google',
providerId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
accessToken,
refreshToken,
};
done(null, user);
}
}// src/auth/auth.controller.ts
import { Controller, Get, Req, UseGuards, Res } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Response, Request } from 'express';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Get('google')
@UseGuards(AuthGuard('google'))
redirectToGoogle() {
/* redirect happens via guard */
}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req: Request, @Res() res: Response) {
const { access, refresh } = await this.auth.issueSession(req.user);
res
.cookie('refresh_token', refresh, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
})
.json({ access_token: access });
}
}// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomBytes, createHash } from 'node:crypto';
import { RefreshTokenRepository } from './refresh-token.repository';
@Injectable()
export class AuthService {
constructor(
private readonly jwt: JwtService,
private readonly refreshes: RefreshTokenRepository,
) {}
async issueSession(user: { id: string; scopes?: string[] }) {
const access = await this.jwt.signAsync(
{ sub: user.id, scope: (user.scopes ?? []).join(' ') },
{ expiresIn: '10m', audience: process.env.JWT_AUDIENCE },
);
const raw = randomBytes(64).toString('base64url');
const hash = createHash('sha256').update(raw).digest('hex');
await this.refreshes.create({
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
});
return { access, refresh: raw };
}
async rotateRefresh(rawToken: string) {
const hash = createHash('sha256').update(rawToken).digest('hex');
const found = await this.refreshes.findByHash(hash);
if (!found || found.usedAt) {
// possible token reuse — revoke all sessions for this user
if (found?.userId) await this.refreshes.revokeAllForUser(found.userId);
throw new Error('refresh_reuse_detected');
}
await this.refreshes.markUsed(found.id);
return this.issueSession({ id: found.userId });
}
}🎯 Patterns courants — 3-6 patterns
1. Authorization Code + PKCE pour SPA et mobiles
PKCE (Proof Key for Code Exchange, RFC 7636) protège l'échange code → token quand le client ne peut pas garder un client_secret (SPA, mobile). Le client génère un code_verifier (43-128 caractères aléatoires) et envoie code_challenge = BASE64URL(SHA256(verifier)). Au moment de l'échange, le serveur recalcule et compare.
passport-oauth2 v2 supporte pkce: true nativement. Pour Google/GitHub côté Nest, il suffit d'ajouter l'option. Pour l'IDP custom (Keycloak, Auth0, Okta), configurer clientType: 'public' + pkce: 'S256'.
2. Refresh token rotation avec détection de réutilisation
Un refresh_token ne devrait jamais être réutilisé. À chaque rotation, on émet un nouveau token et on marque l'ancien used. Si on voit un token déjà used, c'est un signal d'attaque : on révoque toute la chaîne de tokens (family) de cet utilisateur.
// schéma DB
type RefreshToken = {
id: string;
userId: string;
tokenHash: string; // never store raw token
familyId: string; // identifies a chain
usedAt: Date | null;
expiresAt: Date;
};Cette approche est l'état de l'art ; elle est documentée par Auth0 et OAuth2.1 (draft).
3. JWKS et rotation des clés de signature
En prod, le JWT doit être signé avec une clé asymétrique (RS256, ES256) et la clé publique exposée via JWKS. La rotation se fait en publiant deux clés simultanément (kid: 2024-01, kid: 2024-07), puis en arrêtant de signer avec l'ancienne tout en la gardant publiée le temps que les tokens en circulation expirent.
secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000,
rateLimit: true,
jwksRequestsPerMinute: 10,
})Ne pas oublier d'inclure le kid dans le header JWT pour que la lib trouve la bonne clé.
4. Magic link (passwordless)
@Injectable()
export class MagicLinkStrategy extends PassportStrategy(Strategy, 'magic') {
constructor(private readonly mail: MailService) {
super({ usernameField: 'email' });
}
async send(email: string) {
const token = await this.jwt.signAsync({ email }, { expiresIn: '15m' });
await this.mail.send(email, `https://app.example/auth/magic?t=${token}`);
}
async validate(token: string) {
const { email } = await this.jwt.verifyAsync(token);
return this.users.findOrCreateByEmail(email);
}
}Toujours expiresIn court (5-15 minutes) et single-use (stocker le jti dans Redis avec NX). Inclure audience: 'magic-link' pour ne pas confondre avec un JWT API.
5. Sessions vs stateless
| Critère | Sessions (cookie + store) | JWT stateless |
|---|---|---|
| Révocation immédiate | Oui (delete from store) | Difficile (blacklist Redis) |
| Scalabilité | Sticky session ou store partagé | Excellente |
| Mobile-friendly | Moyen (cookies + CORS) | Excellent |
| Taille requête | Faible (id de session) | Plus grosse (payload signé) |
| Audit/observabilité | Centralisée | Décentralisée |
Recommandation ninja : hybride. Access token JWT stateless court (5-15 min), refresh token en cookie httpOnly/Secure/SameSite=Lax stocké en DB avec rotation. On a le meilleur des deux mondes.
6. Multi-tenancy auth
Trois patterns courants :
- Tenant dans le claim JWT :
{ sub, tid }; un guard valide quetidmatche le sous-domaine ou le path. - IDP par tenant : chaque client a son propre IDP (Keycloak realm, Auth0 organization). On choisit la stratégie OAuth2 dynamiquement en lisant le hostname.
- Federated SSO via SAML : pour les clients enterprise qui exigent leur IDP (Okta, Azure AD).
passport-samlgère la SP-initiated et l'IDP-initiated flow.
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const sub = req.hostname.split('.')[0];
if (req.user.tid !== sub) throw new ForbiddenException();
return true;
}
}🔄 Versions — Nest 7 → 11 + libs tierces
- Nest 7 :
@nestjs/passportv7, support dePassportSerializerpour les sessions. Pas degetRequest()enrichi. - Nest 8 :
@nestjs/passportv8 introduitAuthGuardtypé,passReqToCallbackcorrectement propagé. - Nest 9 :
@nestjs/passportv9, support de Fastify amélioré.[email protected]corrige un bug de session fixation (CVE-2022-25896). - Nest 10 :
@nestjs/passportv10.[email protected]introduitreq.logIn()async ; les helpersreq.login/req.logoutexigent maintenant un callback. Breaking si vous utilisiez du code legacy. - Nest 11 :
@nestjs/passportv11 (compatible Express 5).passport-jwtv4 utilisejsonwebtokenv9 (correction CVE sur l'algorithmnone). Vérifieralgorithmsexplicitement, ne jamais accepternone. jwks-rsav3 : nouvelle APIpassportJwtSecret, cache configurable, supportkidstrict.passport-samlv4 → v5 : breaking sur les noms d'options (issuer→entryPoint,cert→idpCert). Lire le changelog avant migration.openid-client: alternative moderne àpassport-openidconnectpour OIDC strict (DPoP, PAR, etc.). Préférable pour les nouveaux projets enterprise.
⚠️ Pitfalls — 6-10 pièges
- Accepter
algorithm: none. Toujours préciseralgorithms: ['RS256'](ou ES256) dansJwtStrategy. Sans cette option, certaines versions dejsonwebtokenacceptaient des tokens non signés. - Stocker le
refresh_tokenen clair en DB. Toujours hasher (SHA256 suffit, le token est aléatoire de 64+ bytes). Sinon une fuite DB compromet toutes les sessions. - Pas de
stateninoncesur OAuth2. Sansstate, on est vulnérable au CSRF. Sansnonce(OIDC), on est vulnérable au replay duid_token.passport-oauth2v2 activestate: truepar défaut, mais les vieux usages le désactivent. - Cookie
refresh_tokensanshttpOnly. Si le cookie n'est pashttpOnly, un XSS exfiltre tout. ToujourshttpOnly: true,secure: true,sameSite: 'lax'oustrict. - Pas de
audiencedans le JWT. Sansaud, un token émis pour le service A peut être utilisé sur le service B. Toujours vérifieraudiencecôté service. - JWT trop long avec trop d'infos. Le JWT est dans tous les headers ; si on y met 50 claims, on alourdit chaque requête. Limiter aux claims indispensables (
sub,aud,scope,tid). ignoreExpiration: true. Souvent activé pour « débugger » et oublié en prod. Tokens éternels en circulation. Toujoursfalse.- Sessions sans rotation d'ID. Quand un user se reconnecte, régénérer l'ID de session (
req.session.regenerate()) pour éviter le session fixation. - Stratégie Google sans
scope: ['openid']. Si on veut unid_tokenvalide pour OIDC, il faut explicitement demander le scopeopenid. Sans cela, on n'a que les scopes Google legacy. - JWKS sans
rateLimit. Un attaquant peut forger des tokens avec deskiddifférents et forcer votre serveur à hammerer l'endpoint JWKS. ToujoursrateLimit: trueavecjwksRequestsPerMinute.
🧪 Testing — exemples concrets
Test d'une route protégée par AuthGuard('jwt')
// test/protected.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { sign } from 'jsonwebtoken';
import { AppModule } from '../src/app.module';
describe('Protected (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const m = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = m.createNestApplication();
await app.init();
});
it('returns 401 without token', async () => {
await request(app.getHttpServer()).get('/me').expect(401);
});
it('returns user for valid token', async () => {
const token = sign(
{ sub: 'user-1', scope: 'read:me' },
process.env.JWT_SECRET!,
{ algorithm: 'HS256', audience: process.env.JWT_AUDIENCE, issuer: process.env.JWT_ISSUER },
);
const res = await request(app.getHttpServer())
.get('/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.id).toBe('user-1');
});
afterAll(async () => app.close());
});Test d'un callback OAuth2 (mock du provider)
import { AuthService } from '../src/auth/auth.service';
describe('AuthService.issueSession', () => {
let service: AuthService;
let refreshes: jest.Mocked<RefreshTokenRepository>;
let jwt: { signAsync: jest.Mock };
beforeEach(async () => {
refreshes = { create: jest.fn(), findByHash: jest.fn(), markUsed: jest.fn(), revokeAllForUser: jest.fn() } as any;
jwt = { signAsync: jest.fn().mockResolvedValue('access-token') };
service = new AuthService(jwt as any, refreshes);
});
it('hashes refresh before storing', async () => {
const { refresh } = await service.issueSession({ id: 'u1' });
expect(refreshes.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'u1',
tokenHash: expect.not.stringContaining(refresh),
}),
);
});
});Test de rotation et détection de réutilisation
it('detects refresh reuse and revokes family', async () => {
refreshes.findByHash.mockResolvedValue({ id: 't1', userId: 'u1', usedAt: new Date() });
await expect(service.rotateRefresh('raw')).rejects.toThrow('refresh_reuse_detected');
expect(refreshes.revokeAllForUser).toHaveBeenCalledWith('u1');
});Test d'une stratégie locale (login/password)
import { LocalStrategy } from '../src/auth/strategies/local.strategy';
it('rejects wrong password', async () => {
const users = { findByEmail: jest.fn().mockResolvedValue({ id: 'u1', passwordHash: 'bcrypted' }) };
const compare = jest.fn().mockResolvedValue(false);
const strategy = new LocalStrategy(users as any, { compare } as any);
await expect(strategy.validate('[email protected]', 'wrong')).rejects.toThrow(UnauthorizedException);
});Test d'une stratégie magic link
it('rejects expired magic link', async () => {
const expired = sign({ email: '[email protected]' }, secret, { expiresIn: -1 });
await expect(strategy.validate(expired)).rejects.toThrow();
});Test JWKS avec un IDP simulé
Utiliser nock ou un petit serveur Express qui sert un jwks.json local et signer les JWTs avec la clé privée correspondante pour des tests d'intégration réalistes.
import { generateKeyPairSync } from 'node:crypto';
import * as jose from 'jose';
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = await jose.exportJWK(publicKey);
jwk.kid = 'test-key';
jwk.alg = 'RS256';
// servir { keys: [jwk] } sur /.well-known/jwks.json via nockTest du flow OAuth2 complet avec un IDP mocké
Pour un test e2e du callback Google, on remplace passport-google-oauth20 par une stratégie de test qui simule le profil Google et bypass l'appel HTTP réel.
class TestGoogleStrategy extends PassportStrategy(Strategy, 'google') {
authenticate(req: any) {
const profile = { id: 'g-1', emails: [{ value: '[email protected]' }], displayName: 'Tester' };
this.success({ provider: 'google', providerId: profile.id, email: profile.emails[0].value, name: profile.displayName });
}
}
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(GoogleStrategy)
.useClass(TestGoogleStrategy)
.compile();Test de cookie flags
it('sets secure httpOnly cookie on login', async () => {
const res = await request(app.getHttpServer()).get('/auth/google/callback').expect(200);
const cookie = res.headers['set-cookie']?.[0] ?? '';
expect(cookie).toContain('refresh_token=');
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('Secure');
expect(cookie).toContain('SameSite=Lax');
});🎬 Cas d'usage concrets
Cabinet juridique — SSO entreprise via Azure AD
Qui : éditeur SaaS legaltech servant 300 cabinets d'avocats. Les grands cabinets exigent une connexion via leur Azure AD d'entreprise, audit log centralisé, désactivation immédiate à la sortie d'un collaborateur.
Problème : chaque cabinet utilise son propre tenant Azure AD. Il faut une stratégie OIDC dynamique qui résout l'IDP par sous-domaine et synchronise les attributs (rôle, barreau d'inscription, statut associé/collaborateur).
@Injectable()
export class TenantAwareOidcStrategy extends PassportStrategy(Strategy, 'tenant-oidc') {
constructor(private readonly tenants: TenantConfigService, private readonly users: UsersService) {
super({ /* dummy, real config injected via param */ } as any);
}
async authenticate(req: Request, opts?: AuthenticateOptions) {
const tenantSlug = (req.headers.host ?? '').split('.')[0];
const tenant = await this.tenants.findBySlug(tenantSlug);
const dynamicConfig = {
issuer: tenant.azureIssuer,
clientID: tenant.azureClientId,
clientSecret: tenant.azureClientSecret,
callbackURL: `https://${tenantSlug}.legal-saas.com/auth/callback`,
scope: 'openid profile email',
};
return super.authenticate.call(Object.assign(this, { _options: dynamicConfig }), req, opts);
}
async validate(_iss: string, _sub: string, profile: Profile) {
return this.users.upsertFromOidc({
email: profile.email,
barNumber: profile._json['barNumber'],
role: profile._json['extension_role'],
tenantId: profile._json['tid'],
});
}
}Gains : 30 cabinets onboardés en SSO en 4 mois sans toucher au code (seule la config tenant en base change). Désactivation immédiate des accès quand Azure AD désactive le compte. Conformité audit améliorée car la source de vérité est l'AD du cabinet.
FinTech — OAuth Stripe Connect pour onboarding marchands
Qui : place de marché B2B mettant en relation acheteurs et fournisseurs de matériel. Chaque fournisseur doit créer un compte Stripe Connect pour encaisser les commissions.
Problème : il faut un flow OAuth Stripe propre, idempotent, avec gestion du refresh token, et reconnexion automatique si le marchand révoque l'accès.
@Injectable()
export class StripeConnectStrategy extends PassportStrategy(OAuth2Strategy, 'stripe-connect') {
constructor(private readonly merchants: MerchantsService) {
super({
authorizationURL: 'https://connect.stripe.com/oauth/authorize',
tokenURL: 'https://connect.stripe.com/oauth/token',
clientID: process.env.STRIPE_CONNECT_CLIENT_ID!,
clientSecret: process.env.STRIPE_SECRET_KEY!,
callbackURL: `${process.env.PUBLIC_URL}/auth/stripe/callback`,
passReqToCallback: true,
});
}
async validate(req: Request, accessToken: string, refreshToken: string, params: any) {
const stripeUserId = params.stripe_user_id;
const merchantId = req.session.merchantId;
await this.merchants.linkStripeAccount(merchantId, {
stripeUserId, accessToken, refreshToken, scope: params.scope,
});
return { merchantId, stripeUserId };
}
}
@Controller('auth/stripe')
export class StripeConnectController {
@Get('connect')
@UseGuards(AuthGuard('stripe-connect'))
connect() {}
@Get('callback')
@UseGuards(AuthGuard('stripe-connect'))
callback(@Req() req: any) {
return { ok: true, merchantId: req.user.merchantId };
}
}Gains : 1 200 marchands connectés sans intervention support, taux de réussite onboarding 94%. Le pattern OAuth2Strategy de Passport gère naturellement la gestion d'erreur et le retour callback.
E-commerce — magic link passwordless
Qui : DNVB cosmétique, audience majoritairement mobile. Les utilisateurs oublient leur mot de passe à 40% des tentatives, le support croule sous les demandes de reset.
Problème : il faut éliminer le mot de passe pour les comptes acheteur. Magic link envoyé par email, validité 15 minutes, single-use, anti-brute-force par IP.
@Injectable()
export class MagicLinkStrategy extends PassportStrategy(Strategy, 'magic-link') {
constructor(private readonly tokens: MagicLinkTokenService, private readonly users: UsersService) {
super();
}
async validate(req: Request) {
const token = req.query.token as string;
const payload = await this.tokens.consume(token);
if (!payload) throw new UnauthorizedException('Invalid or expired link');
return this.users.findOneOrCreate(payload.email);
}
}
@Injectable()
export class MagicLinkTokenService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async issue(email: string): Promise<string> {
const token = randomUUID();
await this.cache.set(`magic:${token}`, { email }, 15 * 60 * 1000);
return token;
}
async consume(token: string): Promise<{ email: string } | null> {
const key = `magic:${token}`;
const payload = await this.cache.get<{ email: string }>(key);
if (payload) await this.cache.del(key); // single-use
return payload ?? null;
}
}Gains : -38% de tickets support liés au login en 2 mois. Conversion mobile en hausse de 22% sur l'étape login. Sécurité renforcée car aucun mot de passe stocké côté DB.
🛠️ Exemple end-to-end
Contexte : plateforme SaaS B2B avec trois modes d'authentification cohabitant : JWT classique pour les utilisateurs particuliers, OIDC Google pour les comptes pro standards, et SAML/OIDC entreprise par tenant pour les grands comptes. Refresh tokens en cookie httpOnly, rotation à chaque usage, blacklist côté Redis pour les déconnexions immédiates.
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategies/jwt.strategy';
import { GoogleStrategy } from './strategies/google.strategy';
import { TenantOidcStrategy } from './strategies/tenant-oidc.strategy';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
secret: cfg.getOrThrow('JWT_SECRET'),
signOptions: { expiresIn: '15m', issuer: 'saas-platform' },
}),
}),
],
controllers: [AuthController],
providers: [JwtStrategy, GoogleStrategy, TenantOidcStrategy, AuthService, RefreshTokenService],
exports: [AuthService],
})
export class AuthModule {}
// src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly jwt: JwtService,
private readonly refresh: RefreshTokenService,
private readonly users: UsersService,
) {}
async issueTokens(user: User): Promise<TokenPair> {
const accessToken = await this.jwt.signAsync({
sub: user.id, email: user.email, tenantId: user.tenantId, roles: user.roles,
});
const refreshToken = await this.refresh.issue(user.id);
return { accessToken, refreshToken };
}
async refreshSession(refreshToken: string): Promise<TokenPair> {
const payload = await this.refresh.verifyAndRotate(refreshToken);
if (!payload) throw new UnauthorizedException('Invalid refresh');
const user = await this.users.findOneOrFail(payload.sub);
return this.issueTokens(user);
}
async logout(refreshToken: string, accessTokenJti?: string) {
await this.refresh.revoke(refreshToken);
if (accessTokenJti) await this.refresh.blacklistAccess(accessTokenJti, 900);
}
}
// src/auth/refresh-token.service.ts
@Injectable()
export class RefreshTokenService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async issue(userId: string): Promise<string> {
const token = randomBytes(48).toString('base64url');
await this.cache.set(`rt:${token}`, { sub: userId, iat: Date.now() }, 30 * 24 * 60 * 60 * 1000);
return token;
}
async verifyAndRotate(token: string): Promise<{ sub: string } | null> {
const payload = await this.cache.get<{ sub: string }>(`rt:${token}`);
if (!payload) return null;
await this.cache.del(`rt:${token}`); // rotation
return payload;
}
async revoke(token: string) {
await this.cache.del(`rt:${token}`);
}
async blacklistAccess(jti: string, ttlSec: number) {
await this.cache.set(`blk:${jti}`, '1', ttlSec * 1000);
}
async isAccessBlacklisted(jti: string): Promise<boolean> {
return Boolean(await this.cache.get(`blk:${jti}`));
}
}
// src/auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(cfg: ConfigService, private readonly refresh: RefreshTokenService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: cfg.getOrThrow('JWT_SECRET'),
issuer: 'saas-platform',
});
}
async validate(payload: any) {
if (payload.jti && await this.refresh.isAccessBlacklisted(payload.jti)) {
throw new UnauthorizedException('Token revoked');
}
return { id: payload.sub, email: payload.email, tenantId: payload.tenantId, roles: payload.roles };
}
}
// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const user = await this.auth.validateCredentials(dto.email, dto.password);
const { accessToken, refreshToken } = await this.auth.issueTokens(user);
this.setRefreshCookie(res, refreshToken);
return { accessToken };
}
@Get('google')
@UseGuards(AuthGuard('google'))
google() {}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req: any, @Res({ passthrough: true }) res: Response) {
const { accessToken, refreshToken } = await this.auth.issueTokens(req.user);
this.setRefreshCookie(res, refreshToken);
return { accessToken };
}
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const cookie = req.cookies['rt'];
if (!cookie) throw new UnauthorizedException();
const { accessToken, refreshToken } = await this.auth.refreshSession(cookie);
this.setRefreshCookie(res, refreshToken);
return { accessToken };
}
@Post('logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const cookie = req.cookies['rt'];
if (cookie) await this.auth.logout(cookie);
res.clearCookie('rt');
return { ok: true };
}
private setRefreshCookie(res: Response, token: string) {
res.cookie('rt', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/auth',
});
}
}Trois strategies cohabitent, refresh token en cookie httpOnly avec rotation systématique à chaque usage, access token 15 min, blacklist Redis pour les déconnexions immédiates. Le système soutient 800 k utilisateurs actifs mensuels avec une latence p99 d'auth sous 30 ms (validation JWT locale + un Redis lookup pour la blacklist).
🔁 Quand utiliser / éviter
Utiliser quand : besoin d'authentification HTTP traditionnelle (web/mobile), intégration avec un IDP existant (Google, GitHub, Okta, Auth0), bring-your-own JWT avec validation stricte, support de magic links pour réduire la friction passwordless, multi-tenancy avec IDP par tenant, mise en place rapide de stratégies éprouvées.
Éviter quand : architecture purement service-to-service (préférer mTLS ou SPIFFE), authentification fortement basée sur le navigateur moderne (WebAuthn/passkeys via simplewebauthn), besoin de DPoP, PAR, JAR (préférer openid-client qui supporte ces extensions modernes), API GraphQL avec besoin d'autorisation fine par champ (combiner avec CASL/Cerbos, voir fichier 08). Pour les nouveaux projets enterprise avec OIDC strict, considérer sérieusement openid-client (lib certifiée OpenID Foundation) à la place de passport-openidconnect.
🧰 Aller plus loin — détails enterprise
Custom strategy : un exemple complet (API key signée HMAC)
Toutes les stratégies Passport héritent de la même interface passport.Strategy. En écrire une est trivial et permet de couvrir des cas spécifiques (signature de webhook, HMAC, JWS, partenaire B2B avec format custom).
import { Strategy } from 'passport-custom';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
@Injectable()
export class HmacApiKeyStrategy extends PassportStrategy(Strategy, 'hmac') {
constructor(private readonly partners: PartnerService) {
super();
}
async validate(req: any) {
const keyId = req.headers['x-api-key-id'];
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-timestamp'];
if (!keyId || !signature || !timestamp) throw new UnauthorizedException();
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) {
throw new UnauthorizedException('clock skew too large');
}
const partner = await this.partners.findByKeyId(keyId);
if (!partner) throw new UnauthorizedException();
const payload = `${req.method}\n${req.url}\n${timestamp}\n${JSON.stringify(req.body)}`;
const expected = createHmac('sha256', partner.secret).update(payload).digest();
const provided = Buffer.from(signature, 'hex');
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
throw new UnauthorizedException();
}
return { partnerId: partner.id };
}
}Points clés : timingSafeEqual empêche les timing attacks, le timestamp empêche le replay, et le payload inclut method+url+body pour signer l'intention complète.
SAML pour les clients enterprise
@node-saml/passport-saml reste la référence pour SAML 2.0. La complexité ne vient pas du protocole mais de la configuration : entryPoint (URL IDP), idpCert (cert public IDP), issuer (votre EntityID), callbackUrl, et la gestion des attributs (profile.email vs profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']).
@Injectable()
export class SamlStrategy extends PassportStrategy(SamlBase, 'saml') {
constructor() {
super({
entryPoint: process.env.SAML_ENTRY_POINT!,
issuer: 'https://app.example.com',
callbackUrl: 'https://app.example.com/auth/saml/callback',
idpCert: process.env.SAML_IDP_CERT!,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
});
}
async validate(profile: any) {
return {
email: profile.email ?? profile.nameID,
tenantId: profile['http://schemas.example.com/tenantId'],
};
}
}Toujours valider que la réponse est signée (wantAssertionsSigned), sinon un attaquant peut forger une assertion.
Migration JWT HS256 → RS256 sans downtime
Strategy en deux phases :
- Émettre les nouveaux tokens en RS256 ; côté serveur, accepter HS256 et RS256 (
algorithms: ['HS256', 'RS256']). - Une fois le TTL maximum des anciens tokens écoulé (ex : 7 jours après le déploiement), supprimer HS256 de la liste.
Pendant la phase 1, surveiller un compteur tokens_validated_total{alg} ; quand alg=HS256 tombe à zéro, lancer la phase 2.
📊 Observabilité & production — comment un staff raisonne
L'auth est un système dont les pannes sont silencieuses puis catastrophiques : une clé JWKS mal rotée ne casse rien… jusqu'à ce que 100 % des tokens deviennent invalides en même temps. Un staff instrumente l'auth comme un système distribué, pas comme un middleware.
Les 4 signaux qui comptent (golden signals appliqués à l'auth)
| Signal | Métrique concrète | Seuil d'alerte | Ce qu'il révèle |
|---|---|---|---|
| Taux d'échec | auth_failures_total{reason} (expired, bad_sig, aud_mismatch, jwks_miss) | spike > 3× baseline sur 5 min | rotation de clé ratée, IDP down, attaque par bruteforce |
| Latence JWKS | jwks_fetch_duration_seconds (p99) + jwks_cache_hit_ratio | hit ratio < 0.95 | cache mal configuré → DDoS auto-infligé sur l'IDP |
| Refresh reuse | refresh_reuse_detected_total | > 0 = page immédiate | vol de token en cours, ou bug de double-submit côté SPA |
| Issuance volume | tokens_issued_total{grant_type} | drop soudain | login cassé ; spike soudain = credential stuffing |
// Intercepteur d'observabilité, branché globalement
@Injectable()
export class AuthMetricsInterceptor implements NestInterceptor {
constructor(@InjectMetric('auth_failures_total') private readonly fails: Counter) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
catchError((err) => {
if (err instanceof UnauthorizedException) {
// NE JAMAIS logger le token brut. Hash tronqué pour corréler sans fuiter.
this.fails.inc({ reason: err.message.slice(0, 32) });
}
return throwError(() => err);
}),
);
}
}Règles d'or de logging d'auth
- Jamais de token brut, de
client_secret, decode_verifier, ni de cookie dans les logs. Redaction au niveau du logger (pinoredact: ['req.headers.authorization', 'req.headers.cookie']). - Toujours un
trace_idpropagé du callback OAuth jusqu'à l'émission de session — un login échoué se débogue sur 4 hops (browser → callback → IDP token endpoint → JWKS). - Logger l'intention, pas le secret :
{ event: 'refresh_rotated', user_id, family_id, jti }. - Émettre un événement d'audit immuable (append-only, ex. table
auth_auditou stream Kafka) pour : login, logout, refresh reuse, échec MFA, révocation. C'est une exigence SOC2/ISO 27001, pas un nice-to-have.
Failure modes en cascade
JWKS endpoint down ──► cache expiré ──► 100% des tokens RS256 rejetés ──► outage total
Mitigation : cacheMaxAge long (10 min) + stale-while-revalidate +
fallback sur dernière clé connue + circuit breakerUn staff ajoute un bulkhead : la validation JWT ne doit jamais bloquer sur un fetch JWKS synchrone côté chemin chaud. jwks-rsa met en cache, mais sur cache-miss l'appel est bloquant — d'où un cacheMaxAge généreux et un préchauffage au boot (onApplicationBootstrap qui fetch la JWKS une fois).
🏛️ Décision d'architecture — l'arbre de décision auth
Avant d'écrire une ligne, un staff répond à 4 questions. Ce sont elles qui dictent la stratégie, pas l'inverse.
1. Qui appelle ? ──► humain via browser ──► OIDC + cookie httpOnly (BFF pattern)
──► humain via mobile ──► Auth Code + PKCE, token en keychain
──► service → service ──► mTLS / client_credentials / SPIFFE
──► partenaire B2B ──► HMAC signé ou API key + scopes
2. Qui possède l'identité ? ──► vous ──► local + JWT maison
──► un IDP tiers ──► OAuth2/OIDC, vous ne stockez pas de mdp
──► l'entreprise cliente ──► SAML/OIDC fédéré par tenant
3. Besoin de révocation immédiate ? ──► oui ──► sessions OU JWT court + blacklist Redis
──► non ──► JWT stateless pur
4. Surface multi-services ? ──► oui ──► aud par service, JWT centralisé, gateway valide
──► non ──► validation locale suffitLe pattern BFF (Backend-For-Frontend) — l'état de l'art 2026 pour SPA
Stocker un access token dans localStorage est l'anti-pattern le plus répandu : un seul XSS et tout fuit. L'OAuth Security BCP recommande désormais le BFF : le SPA ne voit jamais de token. Le backend détient la session, le SPA n'a qu'un cookie httpOnly opaque.
| Approche | Token exposé au JS ? | Vulnérable XSS ? | Complexité |
|---|---|---|---|
Token en localStorage | Oui | Critique | Faible |
| Token en mémoire JS | Oui (runtime) | Élevée | Moyenne |
| BFF (cookie httpOnly) | Non | Faible | Moyenne |
NestJS est idéal comme BFF : il fait l'échange OAuth côté serveur, garde le token en session/Redis, et le SPA appelle l'API avec son cookie. Le token tiers ne traverse jamais le navigateur.
🤖 Servir des agents IA authentifiés depuis NestJS
Le sujet rejoint l'auth dès qu'on expose un endpoint d'agent (MCP, tool-use, streaming LLM) : qui a le droit d'appeler l'agent, avec quel budget, et comment couper proprement ? Le client LLM doit être injecté via DI (forRootAsync), jamais instancié dans un champ — pour la testabilité, le secret-management et le mock.
// llm.module.ts — client Anthropic DI'd, jamais `new Anthropic()` dans un service
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
return {
module: LlmModule,
providers: [
{
provide: 'ANTHROPIC',
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK gère le backoff sur 429/529
}),
},
],
exports: ['ANTHROPIC'],
global: true,
};
}
}Auth + cost-guard à l'edge de l'endpoint agent
Un endpoint LLM est coûteux : chaque requête peut brûler plusieurs centimes. La porte d'auth doit aussi être une porte de coût. On compose trois guards : AuthGuard('jwt') (qui), ScopeGuard('agent:invoke') (droit), puis un CostGuard (budget restant par tenant).
@Injectable()
export class CostGuard implements CanActivate {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const { user } = ctx.switchToHttp().getRequest();
const spentCents = (await this.cache.get<number>(`cost:${user.tenantId}`)) ?? 0;
if (spentCents > user.monthlyBudgetCents) {
throw new HttpException('LLM budget exceeded', 402); // Payment Required
}
return true;
}
}Streaming de tokens en SSE avec annulation propre
Le point subtil : quand le client ferme l'onglet, il faut annuler le stream LLM côté serveur (sinon on paie des tokens pour une réponse que personne ne lit). On câble un AbortController sur la déconnexion HTTP.
@Controller('agent')
@UseGuards(AuthGuard('jwt'), ScopeGuard, CostGuard)
export class AgentController {
constructor(@Inject('ANTHROPIC') private readonly anthropic: Anthropic) {}
@Sse('stream')
stream(@Query('q') prompt: string, @Req() req: Request): Observable<MessageEvent> {
const ac = new AbortController();
req.on('close', () => ac.abort()); // client parti → on coupe l'appel LLM
return new Observable((subscriber) => {
(async () => {
try {
const s = await this.anthropic.messages.stream(
{ model: 'claude-sonnet-4-6', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] },
{ signal: ac.signal },
);
for await (const ev of s) {
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
subscriber.next({ data: ev.delta.text });
}
}
subscriber.complete();
} catch (e) {
if (!ac.signal.aborted) subscriber.error(e);
}
})();
return () => ac.abort();
});
}
}Pour les jobs longs (agentic loop multi-tours), on délègue à BullMQ : jobId = generationId pour l'idempotence (un retry ne relance pas une génération déjà faite), retry cost-aware (ne pas re-payer un appel qui a réussi mais dont le commit a échoué), et persistance des sorties partielles pour reprendre. Le model recommandé : claude-opus-4-8 pour le raisonnement lourd, claude-haiku-4-5 pour le routing/classification bon marché à l'edge.
🏋️ Exercices
Difficulté croissante. Chaque exercice se fait dans un projet Nest 11 réel, pas sur papier. Le but : tu dois pouvoir le casser, l'observer casser, puis le réparer.
Exercice 1 — Refresh rotation avec détection de famille (implémenter)
Objectif : implémenter une rotation de refresh token avec familyId, qui révoque toute la famille à la première réutilisation détectée.
Indice/Solution : table refresh_tokens(id, userId, familyId, tokenHash, usedAt, expiresAt). À l'émission initiale, familyId = randomUUID(). À chaque rotation, on hérite du familyId du parent. rotate(raw) : hash, lookup ; si usedAt != null → revokeAllForFamily(familyId) + throw. Écris le test qui prouve que présenter deux fois le même token révoque bien la chaîne entière (pas juste le token).
Exercice 2 — BFF OAuth Google sans token côté navigateur (production-grade)
Objectif : transformer le flow Google localStorage en BFF : le SPA ne reçoit jamais d'access token, seulement un cookie de session httpOnly opaque.
Indice/Solution : le callback échange le code, stocke { googleAccessToken, userId } dans une session Redis, et renvoie un cookie sid opaque (httpOnly, Secure, SameSite=Lax). Un middleware résout sid → session → req.user. Vérifie au DevTools qu'aucun token Google n'apparaît jamais côté client. Ajoute le state et vérifie qu'un callback avec mauvais state est rejeté en 403.
Exercice 3 — Survivre à une rotation de clé JWKS (production-grade)
Objectif : rendre la validation JWT résiliente à une rotation de clé IDP sans aucune fenêtre d'outage.
Indice/Solution : configure jwks-rsa avec cache: true, cacheMaxAge: 10min, rateLimit: true. Préchauffe la JWKS dans onApplicationBootstrap. Simule la rotation : IDP publie kid=v2 et signe désormais avec, mais garde kid=v1 publié 24 h. Mesure jwks_cache_hit_ratio et prouve qu'aucun token v1 encore valide n'est rejeté pendant la transition.
Exercice 4 — Casser puis réparer : le algorithm: none (break-then-fix)
Objectif : exploiter une JwtStrategy mal configurée, observer l'exploit réussir, puis le fermer.
Indice/Solution : retire algorithms de la config (ou mets ['none']). Forge un token avec alg: none et signature vide, sans connaître le secret, prouve qu'il passe. Puis corrige avec algorithms: ['RS256'] explicite, vérifie aud et iss, et prouve que le même token forgé est désormais rejeté. Documente pourquoi RFC 8725 interdit l'algorithm confusion (HS256 signé avec la clé publique RSA).
Exercice 5 — Endpoint agent IA authentifié, budgété, annulable (intégration stack)
Objectif : exposer un @Sse('agent/stream') protégé par AuthGuard('jwt') + ScopeGuard + CostGuard, qui stream des tokens Claude et annule l'appel côté serveur quand le client se déconnecte.
Indice/Solution : câble req.on('close', () => ac.abort()). Pour tester l'annulation : curl -N puis Ctrl-C, et vérifie dans les logs que ac.signal.aborted === true et qu'aucun token n'est facturé après. Ajoute un compteur llm_tokens_streamed_total{tenant} et prouve que dépasser le budget renvoie un 402 avant tout appel au modèle.
Exercice 6 — Multi-tenant OIDC dynamique + révocation immédiate (architecte)
Objectif : résoudre l'IDP par sous-domaine au runtime, et garantir qu'une désactivation côté IDP coupe l'accès en moins de 15 minutes.
Indice/Solution : stratégie OIDC dont la config est résolue par req.hostname. Access token court (10 min) + introspection/blacklist Redis pour la révocation sous-TTL. Simule un offboarding : désactive le compte côté IDP, prouve que le refresh échoue immédiatement et que l'access token restant expire en ≤ 10 min. Réfléchis au tradeoff : introspection à chaque requête (coût latence) vs TTL court (fenêtre de révocation).
🎤 En entretien
Q : Où stockes-tu un access token dans une SPA, et pourquoi ? R : Idéalement nulle part côté JS — pattern BFF, le backend détient la session et le SPA n'a qu'un cookie httpOnly opaque. localStorage est exclu (XSS = exfiltration totale) ; à défaut de BFF, en mémoire JS (perdu au refresh, mais pas persisté). Le refresh token, lui, toujours en cookie httpOnly/Secure/SameSite.
Q : C'est quoi PKCE, et quel problème exact résout-il que le state ne résout pas ? R : state protège du CSRF (lier la réponse à la requête initiale). PKCE protège l'interception du code d'autorisation : un client public ne peut pas garder de client_secret, donc un attaquant qui intercepte le code (deep link mobile, redirect) ne peut pas l'échanger sans le code_verifier que seul le client légitime connaît. state = anti-CSRF, PKCE = anti-vol-de-code.
Q : Comment révoques-tu un JWT stateless avant son expiration ? R : Par définition un JWT stateless n'est pas révocable — donc on ne le rend pas vraiment stateless : access token très court (5-15 min) + blacklist Redis sur le jti (TTL = durée de vie restante du token), et révocation réelle au niveau du refresh token (qui, lui, est stateful en DB). La vraie réponse senior : on accepte une fenêtre de révocation = TTL de l'access token, et on la dimensionne selon le risque.
Q : Un attaquant rejoue un refresh token déjà utilisé. Que se passe-t-il dans un système bien conçu ? R : Détection de réutilisation : le token est marqué used, donc le re-présenter déclenche refresh_reuse_detected → on révoque toute la famille de tokens de cet utilisateur (pas juste le token), on émet une alerte de sécurité, et on force une ré-authentification. C'est la défense recommandée par l'OAuth 2.1 et Auth0 : un token volé devient inutile dès que le légitime ou l'attaquant l'a utilisé une fois.
🔗 Liens
- Doc officielle : https://docs.nestjs.com/security/authentication
- Passport.js : https://www.passportjs.org
- RFC 6749 (OAuth 2.0) : https://datatracker.ietf.org/doc/html/rfc6749
- RFC 7636 (PKCE) : https://datatracker.ietf.org/doc/html/rfc7636
- OpenID Connect Core : https://openid.net/specs/openid-connect-core-1_0.html
- OAuth 2.0 Security Best Current Practice : https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
openid-client(panva) : https://github.com/panva/openid-client- Refresh token rotation (Auth0) : https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
- WebAuthn / passkeys : https://simplewebauthn.dev
@node-saml/passport-saml: https://github.com/node-saml/passport-saml- DPoP (RFC 9449) : https://datatracker.ietf.org/doc/html/rfc9449
- JWT Best Current Practice (RFC 8725) : https://datatracker.ietf.org/doc/html/rfc8725