JWT / Keycloak authentication
AuthGuard extends PassportAuthGuard('jwt'), so you register a passport-jwt strategy via JwtModule (wired automatically by SdCoreModule when the jwt key is set).
Keycloak / OIDC (asymmetric, JWKS)
Set jwt.jwks and SdCoreModule wires KeycloakJwtStrategy. The signing key is fetched per-token from the issuer's JWKS endpoint, so multiple realms / tenants (different iss) work with no shared secret. Requires jwks-rsa@^4 + jsonwebtoken@^9.
SdCoreModule.forRoot({
jwt: {
jwks: {
// Static, known realms — exact match:
allowedIssuers: [process.env.KEYCLOAK_ISSUER!],
// jwksUriFromIssuer defaults to `${iss}/protocol/openid-connect/certs` (Keycloak)
},
},
});An issuer policy is REQUIRED
You must declare which issuers to trust — set at least one of allowedIssuers, allowedIssuerHosts, or issuerValidator. The strategy throws at construction otherwise. Without a policy the signing key would be fetched from any token-supplied iss URL (issuer spoofing + SSRF).
Dynamic multi-realm (realms created at runtime)
A single iss is not enough — every realm is a distinct issuer (…/realms/<realm>). Don't try to list them all. Instead pin the Keycloak origin with allowedIssuerHosts: any realm under a trusted host is accepted, and the JWKS is only ever fetched from that host (so SSRF stays closed) even as new tenants/realms are provisioned.
jwks: {
// Accepts https://kc.example.com/realms/<any-tenant> — no per-realm config:
allowedIssuerHosts: ['https://kc.example.com'],
}For anything more bespoke (regex, a DB lookup of provisioned realms), use the predicate:
jwks: { issuerValidator: (iss) => /^https:\/\/kc\.example\.com\/realms\/[a-z0-9-]+$/.test(iss) }The three options compose — an iss is accepted if it matches allowedIssuers, OR its origin is in allowedIssuerHosts, OR issuerValidator(iss) returns true.
JWKS client cache
One JwksClient per issuer is created and reused across requests (signing keys are stable). The strategy holds a maximum of 100 clients (LRU-eviction by insertion order) so long-running services with allowedIssuerHosts don't accumulate unbounded memory as new realms are provisioned over time.
Normalizing allowedIssuerHosts
Values in allowedIssuerHosts are normalized to their bare origin at strategy construction — https://kc.example.com/ and https://kc.example.com are treated identically.
To turn the verified token into your app's user, subclass and override validate():
import { Inject, Injectable } from '@nestjs/common';
import { KeycloakJwtStrategy, JWT_CONFIG, type JwtConfig, type JwtPayload } from '@sdcorejs/nestjs/auth';
@Injectable()
export class AppJwtStrategy extends KeycloakJwtStrategy {
constructor(@Inject(JWT_CONFIG) cfg: JwtConfig, private readonly users: UserService) {
super(cfg);
}
async validate(payload: JwtPayload) {
return {
id: payload.sub,
email: payload.email,
roles: (payload.realm_access as { roles?: string[] })?.roles ?? [],
};
}
}
// pass it through JwtModule options when you need constructor deps:
// JwtModule.forRoot(config, { strategy: AppJwtStrategy, imports: [UserModule] })The object returned from validate() becomes req.user and is mirrored into ContextService.user by AuthGuard.
Symmetric secret (HS*)
Omit jwks and pass a secret — SdCoreModule wires the symmetric JwtStrategy:
SdCoreModule.forRoot({ jwt: { secret: process.env.JWT_SECRET! } });