npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@profeskills/nestjs-auth

v0.2.1

Published

Provider-agnostic authentication and authorization module for NestJS with Keycloak support.

Readme

@profeskills/nestjs-auth

Librairie NestJS d'authentification, conçue pour être provider-agnostic.
Fonctionne avec Keycloak par défaut — remplaçable par Auth0, Okta, ou tout autre provider JWT sans toucher à l'application.


Installation

pnpm add @profeskills/nestjs-auth @nestjs/common @nestjs/core reflect-metadata rxjs

Node.js 20 ou supérieur est requis. Le paquet est distribué en ESM natif.


Architecture interne

constants ← types/ (DTOs) ← interface/ (contrats) ← decorators ← services (implémentations) ← guards
interface/   ← contrats avec méthodes (IAuthInstance, IToken, IUmaCache, KeycloakOptionsFactory…)
types/       ← objets de configuration (KeycloakConfig, KeycloakModuleAsyncOptions…)
services/    ← implémentations (KeycloakInstance, KeycloakToken)
guards/      ← AuthGuard, RoleGuard, ResourceGuard
decorators/  ← @Public, @Roles, @Resource, @Scopes, @AuthUser, @AccessToken…

Les guards ne dépendent que des interfaces IAuthInstance et IToken.
Changer de provider = fournir de nouvelles implémentations de ces interfaces.
Les guards, décorateurs, et toute l'application restent inchangés.


Tokens DI exportés

| Token | Rôle | |---|---| | AUTH_GUARD | Guard d'authentification (remplaçable via DIP) | | ROLE_GUARD | Guard de vérification des rôles | | RESOURCE_GUARD | Guard UMA Authorization Services | | AUTH_INSTANCE | Instance IAuthInstance (Keycloak, Auth0…) | | AUTH_CONNECT_OPTIONS | Configuration passée au module | | UMA_CACHE | Store de cache des décisions UMA — fourni par CacheModule.register({ extras }) |


Utilisation avec Keycloak

// app.module.ts
import { KeycloakModule, TokenValidation, PolicyEnforcementMode } from '@profeskills/nestjs-auth';

@Module({
    imports: [
        KeycloakModule.register({
            authServerUrl:     process.env.KEYCLOAK_URL,
            realm:             process.env.KEYCLOAK_REALM,
            clientId:          process.env.KEYCLOAK_CLIENT_ID,
            secret:            process.env.KEYCLOAK_CLIENT_SECRET,
            policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
            tokenValidation:   TokenValidation.OFFLINE,
            cookieKey:         'KEYCLOAK_JWT',  // BFF HTTP-only cookie
        }),
    ],
})
export class AppModule {}
// N'importe quel controller — inchangé quel que soit le provider
@Controller('users')
@Roles('admin')
export class UserController {
    @Get()
    @Public()
    findAll() { ... }

    @Get('me')
    @Roles('user')
    getMe(@AuthUser() user: any) { ... }
}

Autorisations fines — @Resource + @Scopes

Contrôle d'accès par ressource et par action via le protocole UMA 2.0 de Keycloak Authorization Services.

Modèle recommandé — 4 scopes globaux

| Scope | Usage | |---|---| | READ | Lecture, liste | | CREATE | Création | | UPDATE | Modification | | DELETE | Suppression |

Chaque entité métier devient une Resource Keycloak :

Device   → READ, CREATE, UPDATE, DELETE
Product  → READ, CREATE, UPDATE, DELETE
Order    → READ, CREATE, UPDATE, DELETE

Dans les controllers

@Controller('devices')
@Resource('Device')
export class DeviceController {
    @Get()    @Scopes('READ')   findAll()                      { ... }
    @Post()   @Scopes('CREATE') create(@Body() dto)            { ... }
    @Patch()  @Scopes('UPDATE') update(@Param('id') id, @Body() dto) { ... }
    @Delete() @Scopes('DELETE') remove(@Param('id') id)        { ... }
}

Configuration Keycloak requise

1. Activer Authorization Services

Admin Console → Clients → <clientId> → Settings → Authorization: ON

2. Créer les Scopes (une seule fois)

Clients → <clientId> → Authorization → Scopes → Créer : READ, CREATE, UPDATE, DELETE

3. Créer les Resources

Authorization → Resources → Device  (4 scopes)
                          → Product (4 scopes)
                          → Order   (4 scopes)

4. Créer les Policies

Authorization → Policies → Role Policy
  → "Admin Policy"     : rôle admin
  → "Viewer Policy"    : rôle viewer
  → "Moderator Policy" : rôle moderator

5. Créer les Permissions (Scope-based)

Device READ   → Admin Policy + Viewer Policy + Moderator Policy
Device CREATE → Admin Policy
Device UPDATE → Admin Policy + Moderator Policy
Device DELETE → Admin Policy
(répéter pour chaque Resource)

Cache des décisions UMA

Le problème sans cache

Chaque requête @Resource @Scopes déclenche un appel réseau vers Keycloak.
Si Keycloak est temporairement indisponible, toutes les routes protégées par @Scopes sont refusées, même pour des utilisateurs parfaitement authentifiés.

Les routes @Roles ne sont pas affectées — elles lisent directement les claims JWT, sans appel réseau.

La solution — cache des décisions

KeycloakInstance met en cache les décisions UMA par (utilisateur, permissions).
La clé est sub:permissions — les décisions de Alice n'affectent jamais celles de Bob.

Requête 1  → Keycloak UP   → appel UMA → accordé → cache (TTL)
Requête 2  → Keycloak DOWN → cache hit → accordé servi offline ✓
TTL expiré → Keycloak DOWN → cache miss → appel échoue → refusé (fail-closed)
alice-uuid : Device#READ   → granted, expire dans 15 min
alice-uuid : Device#CREATE → denied,  expire dans 15 min
bob-uuid   : Device#READ   → granted, expire dans 15 min

Configuration du TTL

KeycloakModule.register({
    ...
    umaCacheTtl: 15 * 60 * 1000,  // 15 minutes
})

| Valeur umaCacheTtl | Comportement | |---|---| | 0 | Cache désactivé — Keycloak appelé à chaque requête | | 60_000 (défaut) | 1 minute | | 900_000 | 15 minutes — recommandé en production |

Store par défaut — mémoire locale

Sans configuration supplémentaire, KeycloakModule utilise un InMemoryUmaCache interne (Map + TTL).
Convient pour un déploiement mono-instance.

Déploiement multi-instances — cache Redis partagé

Derrière un load balancer, chaque instance a son propre cache mémoire indépendant.
Pour partager les décisions : brancher un store Redis via le token UMA_CACHE et CacheModule.

nestjs-auth et nestjs-cache sont complètement indépendants — l'app fait le lien :

// app.module.ts — composition root
import { UMA_CACHE, KeycloakModule }  from '@profeskills/nestjs-auth';
import { CacheModule }               from '@profeskills/nestjs-cache';

@Module({
    imports: [
        CacheModule.register({
            store:        { type: 'redis', url: process.env.REDIS_URL, namespace: 'app' },
            sessionStore: { type: 'redis', url: process.env.REDIS_URL },
            extras: [
                // UMA_CACHE branché sur Redis — KeycloakModule l'injecte automatiquement
                { provide: UMA_CACHE, config: { type: 'redis', url: process.env.REDIS_URL, namespace: 'uma' } },
            ],
        }),
        KeycloakModule.register({
            ...
            umaCacheTtl: 15 * 60 * 1000,
            // ← pas de umaCacheStore — KeycloakModule injecte UMA_CACHE via @Optional()
        }),
    ],
})
export class AppModule {}

KeycloakModule injecte UMA_CACHE via @Optional() :

  • Token fourni (via extras) → store Redis, décisions partagées entre instances
  • Token absent → fallback InMemoryUmaCache, décisions locales au process

Aucune configuration Keycloak supplémentaire n'est nécessaire — le cache est 100% côté NestJS.


Pattern DIP — override des guards

Remplacer n'importe quel guard sans toucher à la lib :

// En développement — bypass auth
import { AUTH_GUARD } from '@profeskills/nestjs-auth';
import { DevAuthGuard } from './guards/dev-auth.guard';

@Module({
    providers: [
        { provide: AUTH_GUARD, useClass: DevAuthGuard }
    ],
})
export class AppModule {}

Décorateurs

| Décorateur | Description | |---|---| | @Public() | Bypass tous les guards sur la route | | @Roles(...roles) | Rôles requis (realm ou client) | | @RoleMatchingMode(mode) | RoleMatch.ANY (défaut) ou RoleMatch.ALL | | @Resource(name) | Déclare la resource UMA (contrôleur ou méthode) | | @Scopes(...scopes) | Scopes UMA requis | | @ConditionalScopes(fn) | Scopes calculés dynamiquement selon le token | | @UseEnforcerOptions(opts) | Claims contextuels pour l'endpoint UMA | | @AuthUser() | Injecte le payload JWT décodé | | @AccessToken() | Injecte le JWT brut (propagation inter-services) | | @ResolvedScopes() | Injecte les scopes résolus après évaluation |

// Exemples de formats @Roles
@Roles('admin')                    // rôle realm
@Roles('realm:admin')              // rôle realm (explicite)
@Roles('hz-api:horizon_admin')     // rôle client
@Roles('admin', 'moderator')       // plusieurs rôles

// Scopes dynamiques
@ConditionalScopes((req, token) => {
    if (token.hasRealmRole('admin')) return ['View.All'];
    return ['View'];
})

Module BFF (Back For Front)

AuthModule expose automatiquement les routes OIDC :

| Route | Description | |---|---| | GET /auth/login | Génère le state PKCE, redirige vers Keycloak | | GET /auth/callback | Échange le code PKCE contre les tokens, pose le cookie | | GET /auth/refresh | Rafraîchit l'access token via le refresh token | | GET /auth/logout | Supprime les cookies, redirige vers Keycloak logout | | GET /auth/me | Retourne les infos de l'utilisateur connecté |

TokenRefreshInterceptor — Rafraîchit automatiquement l'access token s'il expire dans moins de 60 secondes.

// app.module.ts — activer le BFF
import { AuthModule } from './auth/auth.module';  // module local qui wrape AuthModule de nestjs-auth

imports: [
    KeycloakModule.register({ cookieKey: 'KEYCLOAK_JWT', ... }),
    AuthModule,
]

Requiert nestjs-cache avec sessionStore configuré (Redis en production, Memory en dev).


Services internes

KeycloakInstance — implémente IAuthInstance

| Méthode | Description | |---|---| | validateToken() | Validation OFFLINE via jose JWKS — pas d'appel réseau | | validateAccessToken() | Validation ONLINE via /userinfo — révocation immédiate | | enforcer() | Évaluation UMA — appel Keycloak + cache des décisions |

KeycloakToken — implémente IToken

token.hasRole('admin')                      // realm_access.roles
token.hasRole('hz-api:admin')               // resource_access.hz-api.roles
token.hasRealmRole('admin')                 // realm_access.roles
token.hasApplicationRole('hz-api', 'admin') // resource_access.hz-api.roles
token.isExpired()                           // vérifie exp vs Date.now()

KeycloakConfig — référence complète

KeycloakModule.register({
    // ── Requis ──────────────────────────────────────────────────────────────
    authServerUrl:     'http://localhost:8080',  // URL Keycloak (sans /auth depuis KC17+)
    realm:             'my-realm',
    clientId:          'my-api',
    secret:            'my-secret',

    // ── Validation JWT ───────────────────────────────────────────────────────
    tokenValidation:   TokenValidation.OFFLINE,  // OFFLINE | ONLINE | NONE
    cookieKey:         'KEYCLOAK_JWT',           // BFF uniquement

    // ── Authorization ────────────────────────────────────────────────────────
    policyEnforcement: PolicyEnforcementMode.PERMISSIVE, // PERMISSIVE | ENFORCING

    // ── Cache UMA ────────────────────────────────────────────────────────────
    umaCacheTtl:       15 * 60 * 1000,  // TTL en ms (0 = désactivé, défaut: 60 000)
    // Store via DI : fournir UMA_CACHE dans CacheModule.register({ extras: [...] })
})

Changer de provider — scénario Auth0

L'application ne change pas. Seul le module d'infrastructure est remplacé.

Étape 1 — Auth0Token implements IToken

export class Auth0Token implements IToken {
    constructor(readonly token: string) {
        const [, payload] = token.split('.');
        this.content = JSON.parse(Buffer.from(payload, 'base64url').toString());
    }

    hasRole(role: string): boolean {
        const roles: string[] = this.content['https://myapp.com/roles'] ?? [];
        return roles.includes(role);
    }
    hasRealmRole(role: string) { return this.hasRole(role); }
    hasApplicationRole(app: string, role: string) { return this.hasRole(`${app}:${role}`); }
    isExpired(): boolean { return this.content.exp < Math.floor(Date.now() / 1000); }
}

Étape 2 — Auth0Instance implements IAuthInstance

export class Auth0Instance implements IAuthInstance {
    private readonly JWKS = createRemoteJWKSet(
        new URL(`https://${domain}/.well-known/jwks.json`)
    );

    async validateToken(token: IToken): Promise<IToken | false> {
        try {
            await jwtVerify(token.token, this.JWKS, { issuer: `https://${domain}/`, audience });
            return token;
        } catch { return false; }
    }

    enforcer() { return async (_req, _res, next) => next(); } // Auth0 FGA si nécessaire
}

Étape 3 — Auth0Module câble sous les tokens DI génériques

@Module({})
export class Auth0Module {
    static register(config: Auth0Config): DynamicModule {
        return {
            module: Auth0Module,
            providers: [
                { provide: AUTH_INSTANCE, useValue: new Auth0Instance(config.domain, config.audience) },
                { provide: AUTH_GUARD,     useClass: AuthGuard },
                { provide: ROLE_GUARD,     useClass: RoleGuard },
                { provide: RESOURCE_GUARD, useClass: ResourceGuard },
                { provide: APP_GUARD, useExisting: AUTH_GUARD },
                { provide: APP_GUARD, useExisting: ROLE_GUARD },
                { provide: APP_GUARD, useExisting: RESOURCE_GUARD },
            ],
        };
    }
}

Étape 4 — app.module.ts — 1 ligne change

// AVANT
KeycloakModule.register({ authServerUrl, realm, clientId, secret })

// APRÈS
Auth0Module.register({ domain: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_AUDIENCE })

Aucun controller, service, ou décorateur ne change.

| Étape | Effort estimé | |---|---| | Auth0Token | ~30 min | | Auth0Instance | ~1h30 | | Auth0Module | ~30 min | | app.module.ts | ~2 min | | Total | ~2h30 |


Variables d'environnement

KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=my-realm
KEYCLOAK_CLIENT_ID=my-api
KEYCLOAK_CLIENT_SECRET=your-secret
REDIS_URL=redis://:changeme@localhost:6379
SESSION_SECRET=change-me-in-production
FRONTEND_URL=http://localhost:4200
PORT=3000
NODE_ENV=development

Ce qui ne change jamais lors d'un changement de provider

| Élément | Raison | |---|---| | @Roles, @Public, @Resource, @Scopes, @AuthUser | Décorateurs purs, provider-agnostic | | AuthGuard, RoleGuard, ResourceGuard | Ne dépendent que de IAuthInstance / IToken | | Pattern DIP (AUTH_GUARD, ROLE_GUARD, RESOURCE_GUARD) | Override inchangé | | Tous les controllers et services métier | Zéro dépendance au provider |