Auth
Contract
@modularityjs/auth defines AuthIdentity plus the AuthResolver contract drivers implement, and ships AuthService — a concrete dispatcher that iterates registered resolvers:
interface AuthIdentity {
readonly id: string;
readonly attributes: Record<string, unknown>;
}
interface AuthResolver {
resolve(token: string): Promise<AuthIdentity | undefined>;
}
class AuthService {
resolve(token: string): Promise<AuthIdentity | undefined>;
}The contract handles identity resolution only — given a credential string (JWT, API key, etc.), return the identity it represents. Login flows and token issuance are driver-specific.
AuthService iterates the AuthResolversPool — each driver contributes an AuthResolver. The first resolver returning a non-undefined identity wins.
Drivers
JWT (@modularityjs/auth-jwt)
JWT authentication using Node.js native crypto. HMAC-SHA256 signing and verification, no external dependencies.
import { AuthModule } from '@modularityjs/auth';
import { AuthJwtModule } from '@modularityjs/auth-jwt';
const modules = [
AuthModule,
AuthJwtModule.forRoot({ secret: process.env.JWT_SECRET! }),
];Configuration:
AuthJwtModule.forRoot({
secret: 'change-me-please-use-a-32-byte-or-longer-secret', // Required — must be ≥32 bytes
algorithm: 'HS256', // HMAC algorithm (default)
issuer: 'my-app', // Optional JWT issuer claim
audience: 'my-api', // Optional JWT audience claim
ttl: 3600, // Default token TTL in seconds (1 hour)
});Issuing tokens — inject JwtAuthService directly (driver-specific, not part of the contract):
@Injectable()
class LoginService {
constructor(@Inject(JwtAuthService) private readonly jwt: JwtAuthService) {}
async login(name: string): Promise<string> {
return this.jwt.sign({ id: crypto.randomUUID(), attributes: { name } });
}
}API Key (@modularityjs/auth-api-key)
API key authentication via a sub-contract pattern. The driver delegates key lookup to an ApiKeyStore that the application must implement.
import { AuthModule } from '@modularityjs/auth';
import { AuthApiKeyModule } from '@modularityjs/auth-api-key';
const modules = [AuthModule, AuthApiKeyModule];Implement the ApiKeyStore contract:
import { ApiKeyStore } from '@modularityjs/auth-api-key';
@Injectable()
class DatabaseApiKeyStore extends ApiKeyStore {
constructor(
@Inject(DatabaseConnection) private readonly db: DatabaseConnection,
) {}
async find(apiKey: string): Promise<AuthIdentity | undefined> {
const row = await this.db.query('SELECT * FROM api_keys WHERE key = ?', [
apiKey,
]);
if (!row) return undefined;
return { id: row.userId, attributes: { scope: row.scope } };
}
}
@Module({
name: 'my-api-keys',
imports: [AuthApiKeyModule],
providers: [DatabaseApiKeyStore],
preferences: [{ provide: ApiKeyStore, useClass: DatabaseApiKeyStore }],
})
class MyApiKeyModule {}Boot fails if no ApiKeyStore implementation is provided — the sub-contract is enforced at startup.
Local / Password (@modularityjs/auth-local)
Username/password authentication with pluggable password hashing. Uses the sub-contract pattern — apps provide a LocalUserStore implementation for user lookup.
import { AuthModule } from '@modularityjs/auth';
import { AuthLocalModule } from '@modularityjs/auth-local';
import { AuthLocalScryptModule } from '@modularityjs/auth-local-scrypt';
const modules = [
AuthModule,
AuthLocalModule,
AuthLocalScryptModule, // or AuthLocalBcryptModule
MyUserStoreModule, // provides LocalUserStore implementation
];Contracts (apps must implement):
LocalUserStore—findByUsername(username): Promise<LocalUserRecord | undefined>. Returns{ id, passwordHash, attributes }.PasswordHasher— provided by a driver (auth-local-scryptorauth-local-bcrypt)
Service:
@Injectable()
class LoginController {
constructor(
@Inject(LocalAuthService) private readonly auth: LocalAuthService,
) {}
@Post('/login')
async login(
@Body() body: { username: string; password: string },
@Session() session: SessionData,
) {
const identity = await this.auth.authenticate(body.username, body.password);
if (!identity) return { error: 'invalid credentials' };
session.data['identity'] = identity;
return { success: true };
}
@Post('/register')
async register(@Body() body: { username: string; password: string }) {
const hash = await this.auth.hashPassword(body.password);
// Store user in database with hash
}
}Password auth is a login mechanism, not an AuthResolver. After login, identity is stored in session (HttpAuthSessionModule) or a JWT is issued (JwtAuthService.sign()). Subsequent requests authenticate via session cookie or Bearer token.
Hasher drivers:
| Driver | Package | Dependency |
|---|---|---|
| scrypt (Node.js built-in) | @modularityjs/auth-local-scrypt | None |
| bcrypt | @modularityjs/auth-local-bcrypt | bcryptjs |
HTTP Basic auth (@modularityjs/http-auth-local): Optional adapter that reads Authorization: Basic base64(user:pass) headers and authenticates via LocalAuthService.
OIDC (@modularityjs/auth-oidc)
OpenID Connect token verification using the provider's JWKS (asymmetric keys). Uses jose for JWT/JWK operations.
import { AuthModule } from '@modularityjs/auth';
import { AuthOidcModule } from '@modularityjs/auth-oidc';
const modules = [
AuthModule,
AuthOidcModule.forRoot({
issuer: 'https://accounts.google.com',
audience: 'your-client-id', // optional
}),
];OidcAuthService lazily discovers the JWKS URI from the provider's .well-known/openid-configuration endpoint and caches the remote JWKS. Key rotation is handled automatically by jose.
The resolve() method validates the token signature, issuer, audience, and expiry. It maps the sub claim to AuthIdentity.id and remaining claims (email, name, etc.) to attributes.
Multiple Providers
To accept tokens from multiple OIDC providers (e.g. Google + Azure AD), contribute additional providers to OidcProvidersPool from app modules:
import { AuthOidcModule, OidcProvidersPool } from '@modularityjs/auth-oidc';
const modules = [
AuthModule,
AuthOidcModule.forRoot({ issuer: 'https://accounts.google.com' }), // primary
AzureOidcModule, // additional provider
];
@Module({
name: 'azure-oidc',
imports: [AuthOidcModule],
pools: [
{
pool: OidcProvidersPool,
key: 'azure',
useValue: {
issuer: 'https://login.microsoftonline.com/tenant/v2.0',
audience: 'my-azure-app',
},
},
],
})
class AzureOidcModule {}When a Bearer token arrives, OidcAuthService decodes the iss claim (without verifying), finds the matching provider, and verifies against that provider's JWKS. Each provider's discovery document and JWKS are cached independently.
The primary provider from forRoot() is automatically contributed to the pool — no extra configuration needed for single-provider setups.
HTTP Bridge (@modularityjs/http-auth)
Connects auth to the HTTP request lifecycle via HttpServer.onRequest(). Authentication is pool-based — each HttpAuthenticator handles credential extraction and identity resolution in one step:
interface HttpAuthenticator {
authenticate(request: HttpRequest): Promise<AuthIdentity | undefined>;
}The hook iterates all authenticators in pool order. The first one returning an identity wins, and it's attached to request.auth.
HttpAuthModule ships no default authenticators. Without at least one entry contributed to HttpAuthenticatorsPool, request.auth is always undefined, every @RequireAuth() route returns 401, and @Auth() resolves to undefined. Load one of the adapter packages below or register a custom HttpAuthenticator in the pool yourself:
Bearer Token — JWT (@modularityjs/http-auth-jwt)
Reads Authorization: Bearer <token>, resolves via JwtAuthService:
const modules = [
AuthModule,
AuthJwtModule.forRoot({ secret: '...' }),
HttpAuthModule,
HttpAuthJwtModule,
];Bearer Token — OIDC (@modularityjs/http-auth-oidc)
Reads Authorization: Bearer <token>, resolves via OidcAuthService:
const modules = [
AuthModule,
AuthOidcModule.forRoot({ issuer: 'https://accounts.google.com' }),
HttpAuthModule,
HttpAuthOidcModule,
];JWT and OIDC bearer authenticators use different pool keys ('bearer' and 'oidc-bearer') and can coexist — the first to verify the token wins.
API Key Header (@modularityjs/http-auth-api-key)
Reads the X-API-Key header, resolves via ApiKeyStore:
const modules = [
AuthModule,
AuthApiKeyModule,
MyApiKeyModule, // provides ApiKeyStore implementation
HttpAuthModule,
HttpAuthApiKeyModule,
];Session Identity (@modularityjs/http-auth-session)
Reads identity from the session (no header needed). If no other authenticator resolved an identity, it checks session.data[identityKey]:
import { HttpAuthSessionModule } from '@modularityjs/http-auth-session';
const modules = [
SessionModule,
SessionMemoryModule,
HttpSessionModule,
HttpAuthModule,
HttpAuthSessionModule,
// or with config:
HttpAuthSessionModule.forRoot({ identityKey: 'user' }), // default: 'identity'
];This enables session-based login flows: store the identity in session.data['identity'] after successful authentication, and subsequent requests automatically have request.auth populated.
@Auth() Decorator
Inject the authenticated identity into controllers. @Auth() resolves to undefined when no identity is on the request — it does not auto-reject. For routes that should be inaccessible without authentication, combine with @RequireAuth():
import { Auth, RequireAuth } from '@modularityjs/http-auth';
@Controller('/api')
class ApiController {
@Get('/me')
@RequireAuth() // adapter returns 401 before the handler runs if request.auth is missing
me(@Auth() identity: AuthIdentity) {
return identity;
}
@Get('/user-id')
@RequireAuth()
userId(@Auth('id') id: string) {
return { id };
}
}@RequireAuth() can also be applied at the class level to protect every method:
@RequireAuth()
@Controller('/dashboard')
class DashboardController { ... }For routes that need both authentication AND a specific permission, use @RequirePermission() from @modularityjs/http-authz — it implies @RequireAuth() automatically (no identity → 401, identity without permission → 403).
OIDC Login Flow (@modularityjs/oidc-client)
For applications that need the full OAuth2 Authorization Code flow (redirect to provider, handle callback, exchange code for tokens):
import { OidcClientModule, OidcFlowService } from '@modularityjs/oidc-client';
const modules = [
AuthOidcModule.forRoot({ issuer: 'https://accounts.google.com' }),
OidcClientModule.forRoot({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
redirectUri: 'http://localhost:3000/auth/callback',
scope: 'openid profile email', // default
}),
];PKCE (RFC 7636) is always enabled — OidcFlowService automatically generates a code_verifier and S256 code_challenge for every login. The verifier must be stored in the session and passed to handleCallback().
UserInfo enrichment: Set fetchUserInfo: true in OidcClientModule.forRoot() to fetch supplementary claims from the provider's UserInfo endpoint during login. UserInfo claims are merged into AuthIdentity.attributes (overriding ID token claims). Useful when the provider puts email, groups, or other claims only in the UserInfo response, not in the ID token.
Custom auth URL parameters: Some providers need extra query parameters on the authorization URL that aren't part of the standard OIDC flow. Two levels of configuration:
Config-level (applies to every login):
OidcClientModule.forRoot({
clientId: '...',
clientSecret: '...',
redirectUri: '...',
extraAuthParams: { access_type: 'offline', prompt: 'consent' },
});Per-request (for a specific authorization URL):
const url = await client.getAuthorizationUrl({
extraParams: { login_hint: 'alice@example.com' },
});Per-request params override config-level when both set the same key.
Common provider-specific parameters:
| Provider | Parameter | Purpose |
|---|---|---|
access_type=offline | Get a refresh token | |
prompt=consent | Force re-consent (required for refresh on re-login) | |
| Azure AD | domain_hint=contoso.com | Skip organization picker |
| Any | login_hint=alice@example.com | Pre-fill the email field |
| Any | prompt=login | Force re-authentication |
The package provides two services — apps create their own controller routes:
OidcFlowService — orchestrates the login flow:
@Controller('/auth')
class AuthController {
constructor(
@Inject(OidcFlowService) private readonly oidcFlow: OidcFlowService,
) {}
@Get('/login')
async login(
@Session() session: SessionData,
@Response() response: HttpResponse,
) {
const { url, state, nonce, codeVerifier } =
await this.oidcFlow.getLoginUrl();
session.data['oidc_state'] = state;
session.data['oidc_nonce'] = nonce;
session.data['oidc_code_verifier'] = codeVerifier;
response.redirect(url);
}
@Get('/callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Session() session: SessionData,
@Response() response: HttpResponse,
) {
const { identity, tokens } = await this.oidcFlow.handleCallback(
code,
session.data['oidc_state'] as string,
state,
session.data['oidc_nonce'] as string,
session.data['oidc_code_verifier'] as string,
);
session.data['identity'] = identity; // HttpAuthSessionModule picks this up
session.data['oidc_tokens'] = tokens; // optional — keep for refresh/userinfo
response.redirect('/dashboard');
}
}OidcClientService — lower-level OIDC client operations for custom flows:
| Method | Description |
|---|---|
discover() | Fetch and cache the OIDC discovery document |
getAuthorizationUrl(params) | Build the authorization endpoint URL |
exchangeCode(code, codeVerifier?) | Exchange an authorization code for tokens |
getUserInfo(accessToken) | Fetch the userinfo endpoint |
refreshAccessToken(refreshToken) | Exchange a refresh token for new tokens |
Token Introspection (@modularityjs/auth-oidc-introspection)
OidcAuthService verifies OIDC tokens locally by checking the JWT signature against the provider's JWKS. This requires the token to contain a sub claim — which ID tokens always have, but some providers omit from access tokens (e.g. Keycloak 26+ lightweight tokens).
Token introspection (RFC 7662) solves this by asking the provider directly: "is this token valid, and who does it belong to?" The provider always returns sub because it looks up the token in its own database.
Setup
const modules = [
AuthOidcModule.forRoot({ issuer: 'https://auth.example.com' }),
AuthOidcIntrospectionModule.forRoot({
clientId: 'my-client',
clientSecret: 'my-client-secret',
}),
];How it works
Both resolvers are registered in AuthResolversPool. AuthService iterates them in pool order:
Bearer token arrives
│
▼
OidcAuthService.resolve(token) ← JWT verification (fast, local)
│
├── sub exists → return identity ✓ done
│
└── sub missing → return undefined
│
▼
OidcIntrospectionService.resolve(token) ← introspection (network call)
│
│ POST <issuer>/introspection_endpoint
│ Content-Type: application/x-www-form-urlencoded
│ token=<access_token>&client_id=<id>&client_secret=<secret>
│
│ Response (JSON): { active: true, sub: "744f...", username: "alice", ... }
│
├── active + sub → return identity ✓ done
│
└── inactive or no sub → return undefinedThe JWT resolver runs first (fast, no network call). Introspection only fires when the JWT resolver can't extract an identity — typically when the access token lacks sub.
When to use it
- Your OIDC provider uses lightweight access tokens without
sub - You can't control the provider's token configuration
- You need to support opaque (non-JWT) access tokens
- You want server-side token revocation checks (introspection checks revocation, JWT verification doesn't)
Trade-offs
| JWT verification | Introspection | |
|---|---|---|
| Speed | ~0ms (local) | ~5-20ms (network call) |
Requires sub | Yes | No (provider always returns it) |
| Detects revocation | No | Yes |
| Works offline | Yes | No (needs provider) |
Architecture
The unified output: AuthIdentity
Every auth mechanism — regardless of how credentials are provided — produces the same type:
interface AuthIdentity {
readonly id: string;
readonly attributes: Record<string, unknown>;
}Once you have an AuthIdentity, everything downstream (authorization, session storage, @Auth() decorator) works identically. The identity doesn't carry information about how it was obtained.
Three layers
The auth system has three distinct layers, each with its own contract:
Layer 1: Auth resolvers (token → identity)
AuthResolver.resolve(token: string): Promise<AuthIdentity | undefined>
Pool: AuthResolversPool
Drivers: JWT, OIDC, OIDC introspection, API key
Layer 2: Local auth (credentials → identity)
LocalAuthService.authenticate(username, password): Promise<AuthIdentity | undefined>
Not an AuthResolver — different input shape, different semantics
Layer 3: HTTP authenticators (request → identity)
HttpAuthenticator.authenticate(request: HttpRequest): Promise<AuthIdentity | undefined>
Pool: HttpAuthenticatorsPool
Bridges: Bearer JWT, Bearer OIDC, API key header, HTTP Basic, session cookieWhy local auth is separate from AuthResolver
AuthResolver.resolve(token: string) is designed for token verification — a single opaque string goes in, an identity comes out. All token-based auth mechanisms share these properties:
- Single input — one string (JWT, access token, API key)
- Stateless — verifying the same token twice produces the same result
- Idempotent — no side effects from verification
- Pool-compatible — multiple resolvers can try the same token; first match wins
Password authentication breaks all four:
- Two inputs — username and password are separate fields
- Stateful — may trigger account lockout counters, login audit logs
- Not idempotent — failed attempts may have side effects (rate limiting)
- Not pool-compatible — you wouldn't want a JWT token attempted as a
username:passwordpair
Forcing password auth into AuthResolver would require encoding credentials as a single string (e.g. Base64 user:pass). This creates problems:
- The caller must know the encoding convention (leaky abstraction)
- Every non-password resolver in the pool would receive encoded credentials and need to fail gracefully
- Type safety is lost —
resolve()can't distinguish token types at compile time - Login semantics (rate limiting, lockout) are hidden behind a verification interface
Instead, LocalAuthService.authenticate(username, password) is explicit and type-safe. The trade-off: two APIs instead of one. But these are two fundamentally different operations — login (interactive, stateful) vs verification (passive, stateless).
Where unification happens
The HTTP layer is where all mechanisms unify. HttpAuthenticator.authenticate(request) is the universal contract — it takes an HTTP request and returns an identity:
| Bridge | Extracts from | Delegates to |
|---|---|---|
http-auth-jwt | Authorization: Bearer header | JwtAuthService.resolve() |
http-auth-oidc | Authorization: Bearer header | OidcAuthService.resolve() |
http-auth-api-key | X-API-Key header | ApiKeyStore.find() |
http-auth-local | Authorization: Basic header | LocalAuthService.authenticate() |
http-auth-session | Session cookie | session.data[identityKey] |
All five produce AuthIdentity | undefined. The HttpAuthModule iterates the HttpAuthenticatorsPool — first match wins, identity attached to request.auth. Controllers use @Auth() without knowing which mechanism authenticated the request.
Login vs verification
Each mechanism has its own login/issuance API because login is mechanism-specific:
| Mechanism | Login API | Verification |
|---|---|---|
| JWT | JwtAuthService.sign(identity) → token | AuthResolver pool |
| OIDC | OidcFlowService.handleCallback(code, ...) → { identity, tokens } | AuthResolver pool |
| API Key | Pre-created (no login flow) | AuthResolver pool |
| Local | LocalAuthService.authenticate(user, pass) → identity | N/A — uses session or JWT after login |
After login, the identity is stored in session (HttpAuthSessionModule) or a JWT is issued (JwtAuthService.sign()). Subsequent requests authenticate via session cookie or Bearer token — at that point, the auth layer handles verification through the resolver pool.
Comparison with other frameworks
| Framework | Approach | Unification level | Trade-off |
|---|---|---|---|
| ModularityJS | Separate contracts per input shape | HTTP layer (HttpAuthenticator) | Type-safe, two APIs |
| Passport.js | All strategies receive (req, done) | Full — one authenticate() call | HTTP-coupled, can't use outside HTTP |
| Spring Security | Polymorphic Authentication object | Full — one authenticate() call | Requires downcasting, loses type safety |
| ASP.NET Core | Per-scheme handlers | None — each scheme is independent | Type-safe, no universal dispatch |
ModularityJS unifies at the right abstraction level: the HTTP layer (where dispatch matters) is unified, the auth layer preserves type safety and semantic clarity per mechanism.