Skip to content

Auth

Contract

@modularityjs/auth defines AuthIdentity plus the AuthResolver contract drivers implement, and ships AuthService — a concrete dispatcher that iterates registered resolvers:

typescript
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.

typescript
import { AuthModule } from '@modularityjs/auth';
import { AuthJwtModule } from '@modularityjs/auth-jwt';

const modules = [
  AuthModule,
  AuthJwtModule.forRoot({ secret: process.env.JWT_SECRET! }),
];

Configuration:

typescript
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):

typescript
@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.

typescript
import { AuthModule } from '@modularityjs/auth';
import { AuthApiKeyModule } from '@modularityjs/auth-api-key';

const modules = [AuthModule, AuthApiKeyModule];

Implement the ApiKeyStore contract:

typescript
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.

typescript
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):

  • LocalUserStorefindByUsername(username): Promise<LocalUserRecord | undefined>. Returns { id, passwordHash, attributes }.
  • PasswordHasher — provided by a driver (auth-local-scrypt or auth-local-bcrypt)

Service:

typescript
@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:

DriverPackageDependency
scrypt (Node.js built-in)@modularityjs/auth-local-scryptNone
bcrypt@modularityjs/auth-local-bcryptbcryptjs

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.

typescript
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:

typescript
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:

typescript
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:

typescript
const modules = [
  AuthModule,
  AuthJwtModule.forRoot({ secret: '...' }),
  HttpAuthModule,
  HttpAuthJwtModule,
];

Bearer Token — OIDC (@modularityjs/http-auth-oidc)

Reads Authorization: Bearer <token>, resolves via OidcAuthService:

typescript
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:

typescript
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]:

typescript
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():

typescript
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:

typescript
@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):

typescript
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):

typescript
OidcClientModule.forRoot({
  clientId: '...',
  clientSecret: '...',
  redirectUri: '...',
  extraAuthParams: { access_type: 'offline', prompt: 'consent' },
});

Per-request (for a specific authorization URL):

typescript
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:

ProviderParameterPurpose
Googleaccess_type=offlineGet a refresh token
Googleprompt=consentForce re-consent (required for refresh on re-login)
Azure ADdomain_hint=contoso.comSkip organization picker
Anylogin_hint=alice@example.comPre-fill the email field
Anyprompt=loginForce re-authentication

The package provides two services — apps create their own controller routes:

OidcFlowService — orchestrates the login flow:

typescript
@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:

MethodDescription
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

typescript
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 undefined

The 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 verificationIntrospection
Speed~0ms (local)~5-20ms (network call)
Requires subYesNo (provider always returns it)
Detects revocationNoYes
Works offlineYesNo (needs provider)

Architecture

The unified output: AuthIdentity

Every auth mechanism — regardless of how credentials are provided — produces the same type:

typescript
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 cookie

Why 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:password pair

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:

BridgeExtracts fromDelegates to
http-auth-jwtAuthorization: Bearer headerJwtAuthService.resolve()
http-auth-oidcAuthorization: Bearer headerOidcAuthService.resolve()
http-auth-api-keyX-API-Key headerApiKeyStore.find()
http-auth-localAuthorization: Basic headerLocalAuthService.authenticate()
http-auth-sessionSession cookiesession.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:

MechanismLogin APIVerification
JWTJwtAuthService.sign(identity) → tokenAuthResolver pool
OIDCOidcFlowService.handleCallback(code, ...){ identity, tokens }AuthResolver pool
API KeyPre-created (no login flow)AuthResolver pool
LocalLocalAuthService.authenticate(user, pass) → identityN/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

FrameworkApproachUnification levelTrade-off
ModularityJSSeparate contracts per input shapeHTTP layer (HttpAuthenticator)Type-safe, two APIs
Passport.jsAll strategies receive (req, done)Full — one authenticate() callHTTP-coupled, can't use outside HTTP
Spring SecurityPolymorphic Authentication objectFull — one authenticate() callRequires downcasting, loses type safety
ASP.NET CorePer-scheme handlersNone — each scheme is independentType-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.