Skip to content

Repository files navigation

DefAuth - OIDC Authentication Library

A TypeScript library for handling OIDC authentication with support for both JWT and opaque tokens, intelligent caching, and customizable storage adapters.

Features

  • 🔐 Dual Token Support: Handles both JWT and opaque tokens seamlessly
  • 🚀 Intelligent Caching: Configurable refresh conditions to minimize API calls
  • 🧩 Hybrid Approach: Combines signature validation, introspection, and UserInfo
  • 🔧 Pluggable Storage: Comes with in-memory adapter, easily extend with your own
  • JWT Verification: Built-in signature verification using openid-client
  • 👤 UserInfo Integration: Enriches tokens with data from UserInfo endpoint
  • 🔒 Validation: Robust input validation using Zod
  • 📝 TypeScript: Full TypeScript support with comprehensive type definitions

Migration Guides

Upgrading from an earlier major version? See MIGRATION.md for the v2 → v3 and v1 → v2 breaking changes and how to update your code.

Installation

npm install defauth

Quick Start

import { Defauth } from 'defauth';

// Create and initialize an authenticator (recommended approach)
const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret' // Optional for public clients
});

// Get user from any token type
const user = await auth.getUser(token);

// Force introspection (for high-security scenarios)
const validatedUser = await auth.getUser(token, { forceIntrospection: true });

// Custom validation (e.g., validate claim against request header)
const user = await auth.getUser(token, {
  customValidator: async (claims) => {
    if (claims.organizationId !== requestOrgId) {
      throw new Error('Organization mismatch');
    }
  }
});

console.log(user.sub, user.email, user.name);

Breaking Change Notice: v2.0 introduces major breaking changes including class rename (AuthenticatorDefauth), error class rename (DefAuthErrorDefauthError), and private constructor (must use Defauth.create()). See the Migration Guide above for complete upgrade instructions.

Configuration

Basic Configuration

Confidential Client (with client secret)

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret'
});

Public Client (without client secret)

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-public-client-id'
  // No client secret needed for public clients like SPAs or mobile apps
});

Initialization

The Defauth.create() static method is the only recommended way to create a Defauth instance. It returns a Promise that resolves with a fully initialized Defauth:

try {
  const auth = await Defauth.create({
    issuer: 'https://your-oidc-provider.com',
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret'
  });
  
  // Defauth is guaranteed to be fully initialized and ready to use
  const user = await auth.getUser(token);
  console.log('User:', user);
} catch (error) {
  // Handle initialization failures explicitly
  console.error('Failed to initialize authenticator:', error.message);
}

Key benefits of Defauth.create():

  • ✅ Explicit error handling during initialization
  • ✅ No race conditions when calling getUser() immediately
  • ✅ Promise-based API consistent with modern JavaScript patterns
  • ✅ Clear initialization lifecycle
  • ✅ Built-in validation of OIDC configuration

⚠️ Constructor is Private: The Defauth constructor is private and cannot be called directly (new Defauth() is a TypeScript compile error). Defauth.create() is the only way to obtain an instance.

Advanced Configuration

import { 
  Defauth, 
  InMemoryStorageAdapter,
  ConsoleLogger,
  defaultUserInfoRefreshCondition 
} from 'defauth';
import type { Logger, LogLevel } from 'defauth';

// Custom logger implementation
class CustomLogger implements Logger {
  log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
    const timestamp = new Date().toISOString();
    const contextStr = context ? ` [Context: ${JSON.stringify(context)}]` : '';
    console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}${contextStr}`);
  }
}

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',

  // Optional: Use plain OAuth2 discovery instead of OIDC (defaults to 'oidc')
  discoveryAlgorithm: 'oauth2',

  // Optional: Global JWT validation options (all overridable per `getUser` call)
  jwtValidationOptions: {
    audience: 'https://api.example.com', // defaults to clientId
    issuer: 'https://your-oidc-provider.com', // defaults to the configured issuer
    maxTokenAge: '1 hour',
    // Not set by default — see "Security Hardening" > "ID token / access
    // token confusion" for why, and when you should set this.
    typ: 'at+JWT'
  },

  // Optional: JWKS caching behavior
  jwksOptions: {
    cooldownDuration: 30000, // ms between refetches (default: 30000)
    cacheMaxAge: 600000 // ms JWKS cache lifetime (default: 600000)
  },

  // Optional: HTTP timeout (seconds) for discovery, introspection, UserInfo, and JWKS calls
  httpTimeout: 10,

  // Optional: Custom fetch implementation for all outbound HTTP calls (retries, proxy, mTLS, etc.)
  customFetch: fetch,

  // Optional: Custom storage adapter
  storageAdapter: new InMemoryStorageAdapter(),
  
  // Optional: Custom logger (defaults to ConsoleLogger)
  logger: new CustomLogger(),
  
  // Optional: Throw on UserInfo failure instead of logging warnings (defaults to false)
  throwOnUserInfoFailure: true,
  
  // Optional: Control automatic introspection fallback for failed JWT verification (defaults to false)
  enableIntrospectionFallthrough: true,

  // Optional: Require aud/exp in the introspection response any time
  // introspection is used — opaque tokens, JWT fallthrough, or forceIntrospection
  // (defaults to true)
  requireIntrospectionBinding: true,

  // Optional: Also validate the introspection response's client_id against the
  // expected audience, for opaque and fallthrough/forced introspection alike
  // (defaults to false — see "Introspection Response Validation" below for why
  // this isn't the same as `aud`)
  validateIntrospectionClientId: false,
  
  // Optional: Custom refresh condition
  userInfoRefreshCondition: (user, metadata) => {
    // Refresh user info every 30 minutes instead of default 1 hour
    const thirtyMinutesAgo = new Date(Date.now() - (30 * 60 * 1000));
    return !metadata.lastUserInfoRefresh || metadata.lastUserInfoRefresh <= thirtyMinutesAgo;
  }
});

Token Handling

The library automatically detects token types and handles them with a hybrid approach:

JWT Tokens

  • Verifies signature using OIDC provider's keys
  • Extracts user info from token claims
  • Checks storage for cached user data
  • Fetches additional data from UserInfo endpoint when conditions are met
  • By default, throws immediately if JWT verification fails
  • Can be configured to fall back to introspection for inconclusive failures (see enableIntrospectionFallthrough); policy failures (bad audience/issuer/expiry/algorithm/signature) never fall through
  • Optionally introspects when explicitly requested with forceIntrospection: true
  • Combines the current token's claims with the most recently fetched UserInfo claims (re-fetched per userInfoRefreshCondition), replacing the stored record each time — no claim outlives the source that asserted it

Opaque Tokens

  • Always introspects with the OIDC provider for validation
  • The introspection response is validated against the configured audience/issuer/clockTolerance and requireIntrospectionBinding/validateIntrospectionClientId — see Introspection Response Validation below; this applies identically to opaque tokens, not just JWT fallthrough
  • Per-call options.audience/options.issuer/options.clockTolerance passed to getUser() are honored for opaque tokens as well
  • Enhances with UserInfo endpoint data when available
  • Caches results in storage adapter
  • Updates both introspection and UserInfo refresh timestamps

Introspection Response Validation

Any time an introspection response is used to authenticate a token — whether that's an opaque token (always introspected), a JWT that fell through, or a JWT that used forceIntrospection — the response itself is validated against the configured audience/issuer/clockTolerance, not just active: true, when the corresponding claim (aud/iss/exp) is present in the response. RFC 7662 makes these optional, so two settings control how strictly a response is treated, uniformly across opaque and JWT tokens:

  • requireIntrospectionBinding (defaults to true): when true, reject any introspection response that omits aud or exp instead of silently accepting it on active: true alone. Keep this on unless you have a specific reason to trust a minimal authorization server response.
  • validateIntrospectionClientId (defaults to false): also validate the response's client_id against the expected audience. This is off by default on purposeclient_id (the client that requested the token) and aud (the token's intended audience) are different claims per RFC 7662, and in the common topology where a frontend client obtains a token audienced to a separate API, they legitimately differ. Only enable this if your deployment genuinely expects them to match.

Introspection Fallback Control

By default, enableIntrospectionFallthrough is false: JWT verification failures throw immediately. You can opt in to introspection fallback for failures that are inconclusive about the token's validity (e.g. the JWKS endpoint is temporarily unreachable, or the token is structurally malformed):

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  enableIntrospectionFallthrough: true // Fall back to introspection on inconclusive JWT failures
});

try {
  const user = await auth.getUser(jwtToken);
} catch (error) {
  if (error instanceof JwtVerificationError) {
    // JWT verification failed and no fallback occurred (or fallback also failed)
    console.error('JWT is invalid:', error.message);
  }
}

Important: fallthrough only ever applies to inconclusive failures (JWKS unreachable, no matching key, malformed token). A signed JWT that fails a local policy check — wrong audience, wrong issuer, expired, disallowed algorithm, bad signature — is rejected outright and never falls through to introspection, regardless of this setting. This is what makes fallthrough safe to enable: it can't be used to bypass your configured audience/issuer/maxTokenAge/typ/requiredClaims checks by presenting a token that's merely "still active" at the authorization server.

When fallthrough does trigger, the introspection response is validated the same way described in Introspection Response Validation above.

Deprecated: disableIntrospectionFallthrough (inverse boolean) still works but is deprecated in favor of enableIntrospectionFallthrough. When both are set, enableIntrospectionFallthrough takes precedence.

When to enable introspection fallback:

  • Resilience against temporary JWKS unavailability
  • Mixed environments with varying token types where some tokens can't be locally verified

When to keep it disabled (default):

  • Strict security requirements where only local JWT verification is acceptable
  • Performance-critical scenarios where introspection latency is unacceptable
  • Testing/debugging to ensure JWTs are always valid

Custom Validation

You can apply custom authentication or authorization logic to validate specific claim values before returning user data. This is useful for multi-tenant applications or request-specific validation:

// Example: Validate organization ID from token matches request header
app.get('/api/resource', async (req, res) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const requestOrgId = req.headers['x-organization-id'];
  
  try {
    const user = await auth.getUser(token, {
      customValidator: async (claims) => {
        // Validate organizationId claim matches request header
        if (claims.organizationId !== requestOrgId) {
          throw new Error('Token organization does not match request');
        }
        
        // Additional validation logic as needed
        if (!claims.email_verified) {
          throw new Error('Email must be verified');
        }
      }
    });
    
    res.json({ user });
  } catch (error) {
    if (error instanceof CustomValidationError) {
      res.status(403).json({ error: 'Forbidden', message: error.message });
    } else {
      res.status(401).json({ error: 'Unauthorized' });
    }
  }
});

Key features:

  • Runs after all claims are gathered (including UserInfo data)
  • Prevents user storage if validation fails
  • Works with both JWT and opaque tokens
  • Supports both synchronous and asynchronous validators
  • Throws CustomValidationError when validation fails

Common use cases:

  • Multi-tenant applications validating tenant IDs
  • Validating claims against request headers or context
  • Enforcing custom authorization rules (roles, permissions, etc.)
  • Checking claim combinations or business logic constraints

Security Hardening

This library ships secure defaults for the things that are unambiguous (asymmetric-only signature algorithms, aud/iss binding, mandatory requireIntrospectionBinding), but a few residual risks are not defaulted away because doing so would either be unreliable or would silently break integrations that currently work. This section lists every such risk, what you can do about it, and why the safer behavior isn't automatic.

ID token / access token confusion

Risk: An OIDC ID token from the same provider can satisfy the default JWT validation checks used for access tokens. ID tokens carry aud = clientId by spec (more reliably than access tokens do) and, with default config, no typ header check distinguishes them. ID tokens are handled by front-channel code, appear in browser history and redirect URLs, and are not intended to grant API access — accepting one as an access token widens the population of tokens that can authenticate a request beyond what was issued for that purpose.

Default behavior: getUser() / jwtValidationOptions do not check the JWT typ header by default. A well-formed ID token that has the correct aud/iss/sub/exp will authenticate successfully, indistinguishably from an access token.

Why this isn't the default: RFC 9068 (typ: 'at+JWT') enforcement requires the authorization server to actually set that header on the access tokens it issues, and many authorization servers — particularly older or non-compliant IdPs — do not. Defaulting typ validation on would silently break authentication for every integration whose IdP doesn't set it, with no way to detect the breakage ahead of time. A heuristic based on the presence of a nonce claim (an ID-token marker) was considered and rejected: nonce is optional on ID tokens and its absence/presence is not a reliable signal in either direction, producing both false positives and false negatives.

Mitigation: If your authorization server issues RFC 9068-compliant access tokens (or you control the token issuance and can guarantee a typ value), set typ explicitly:

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  jwtValidationOptions: {
    typ: 'at+JWT', // reject any JWT whose header "typ" isn't this value
  },
});

If your IdP doesn't support RFC 9068 typ, consider an application-level mitigation instead: add a customValidator that rejects tokens carrying ID-token-only claims your access tokens never contain (e.g. nonce, or an azp your access tokens don't set), if you can establish such a claim is never present on genuine access tokens from your provider.

typ is the only spec-guaranteed signal for this distinction — there is no smaller, more-verifiable alternative check built into the library. Anything else (checking for absence of nonce, presence or shape of scope or aud) is exactly as heuristic and unreliable as the nonce check rejected above, which is why defauth doesn't offer a rejectIdTokens-style flag as a "safer" substitute for typ.

Introspection has no built-in rate limiting

Risk: Any string — including structurally invalid or unverifiable tokens — passed to getUser() on the opaque-token or fallthrough path triggers a synchronous HTTP call to your authorization server's introspection endpoint, with no caching and no negative-result memoization. An attacker can send a flood of garbage tokens to your service to indirectly hammer your IdP's introspection endpoint, each request costing a full outbound round-trip.

Default behavior: Every opaque token, every JWT that falls through to introspection, and every forceIntrospection: true call results in a live introspection request. There is no request coalescing, caching, or rate limiting of any kind.

Why this isn't the default: Rate limiting and negative-result caching are deployment-specific concerns — the right limits depend on your authorization server's capacity, your traffic patterns, and whether you're front-ended by a gateway that already rate-limits. Baking in a fixed policy would be wrong for some deployments and insufficient for others.

Mitigation: Rate limit getUser() calls (or the endpoints that call it) at the application or gateway layer — e.g. per-IP or per-session throttling in front of any endpoint that accepts a bearer token from an untrusted caller. If your authorization server or HTTP client library supports response caching, consider caching introspection responses for a short TTL via customFetch:

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  // Wrap fetch with your own short-TTL cache/rate-limiter for introspection calls
  customFetch: rateLimitedFetch,
});

Already mitigated by default

The following related risks are handled automatically and don't require opt-in configuration, listed here for completeness:

  • Introspection fallthrough never bypasses local JWT policy checks. A signed JWT that fails audience, issuer, maxTokenAge, typ, or requiredClaims is rejected immediately — it is never sent to introspection, and no config option re-enables that path (see Introspection Fallback Control).
  • requireIntrospectionBinding defaults to true, so introspection responses missing aud/exp are rejected rather than accepted on active: true alone (see Introspection Response Validation).
  • validateIntrospectionClientId is available (default false) if your deployment expects client_id and aud to match on introspection responses; see the same section above for why it isn't on by default.

Custom Logging

The library supports custom logging implementations for better integration with your application's logging system:

import { Defauth, Logger, LogLevel } from 'defauth';

// Custom logger that integrates with your logging framework
class MyAppLogger implements Logger {
  log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
    // Integration with your preferred logging library (Winston, Pino, etc.)
    myAppLoggingFramework.log({
      level,
      message,
      context,
      timestamp: new Date().toISOString(),
      service: 'defauth'
    });
  }
}

const auth = await Defauth.create({
  // ... other config
  logger: new MyAppLogger(),
  
  // Control error handling behavior
  throwOnUserInfoFailure: false // Log warnings instead of throwing errors
});

Error Handling Options

You can configure how the library handles UserInfo endpoint failures:

  • throwOnUserInfoFailure: false (default): Logs warnings and continues with available data
  • throwOnUserInfoFailure: true: Throws errors when UserInfo endpoint fails
// Strict mode - throws on any UserInfo failure
const strictAuth = await Defauth.create({
  // ... config
  throwOnUserInfoFailure: true
});

// Resilient mode - logs warnings and continues (default)
const resilientAuth = await Defauth.create({
  // ... config
  throwOnUserInfoFailure: false
});

Custom Storage Adapters

Implement the StorageAdapter interface for your own storage solution. Token claims and UserInfo claims are passed to storeUser separately, not pre-merged — combining them into your TUser entity, and deciding how (or whether) to persist and retain each source, is entirely up to your adapter:

  • tokenClaims are freshly re-verified every call — always current, never stale.
  • userInfoClaims is undefined when UserInfo wasn't refetched this call. If your adapter wants UserInfo-derived data to survive calls that don't refetch it, retain it yourself (e.g. in a separate column) and reapply it when userInfoClaims is undefined; when a real value is passed, it's a full refetch, so replace whatever you'd retained before. Reuse combineClaimsWithPriority (exported from defauth) if you want the same "UserInfo wins on conflict" composition the built-in InMemoryStorageAdapter uses:
import { StorageAdapter, StorageMetadata, TokenContext, UserClaims, combineClaimsWithPriority } from 'defauth';

class DatabaseStorageAdapter<TUser = UserClaims> implements StorageAdapter<TUser> {
  async findUser(context: TokenContext): Promise<{
    user: TUser;
    metadata: StorageMetadata;
  } | null> {
    // Your database lookup logic
    const result = await db.users.findOne({ sub: context.sub });
    if (!result) return null;

    return { user: result.user, metadata: result.metadata };
  }

  async storeUser(
    user: TUser | null,
    tokenClaims: UserClaims,
    userInfoClaims: UserClaims | undefined,
    metadata: StorageMetadata
  ): Promise<TUser> {
    // Retain your own previously stored UserInfo claims when this call didn't refetch
    const existing = await db.users.findOne({ sub: tokenClaims.sub });
    const effectiveUserInfoClaims = userInfoClaims ?? existing?.userInfoClaims;

    const updatedUser = (
      effectiveUserInfoClaims
        ? combineClaimsWithPriority(tokenClaims, effectiveUserInfoClaims)
        : tokenClaims
    ) as TUser;

    // Your database storage logic — userInfoClaims is your own column,
    // not part of the StorageAdapter contract
    await db.users.upsert(
      { sub: tokenClaims.sub },
      { user: updatedUser, userInfoClaims: effectiveUserInfoClaims, metadata }
    );

    return updatedUser;
  }

  // Optional: enables auth.clearCache() to clear this adapter's data too
  async clear(): Promise<void> {
    await db.users.deleteMany({});
  }
}

const auth = await Defauth.create({
  // ... other config
  storageAdapter: new DatabaseStorageAdapter()
});

Custom UserInfo Refresh Conditions

Control when the library should refresh user information:

import { UserInfoRefreshCondition } from 'defauth';

// Never refresh UserInfo (rely only on token/cached data)
const neverRefresh: UserInfoRefreshCondition = () => false;

// Always refresh UserInfo
const alwaysRefresh: UserInfoRefreshCondition = () => true;

// Custom time-based condition
const customCondition: UserInfoRefreshCondition = (user, metadata) => {
  if (!metadata.lastUserInfoRefresh) return true;
  
  // Refresh every 15 minutes
  const fifteenMinutesAgo = new Date(Date.now() - (15 * 60 * 1000));
  return metadata.lastUserInfoRefresh <= fifteenMinutesAgo;
};

const auth = await Defauth.create({
  // ... other config
  userInfoRefreshCondition: customCondition
});

Client Authentication Methods

By default, confidential clients authenticate with client_secret_post. Set authenticationMethod to use client_secret_basic or client_secret_jwt instead, or use private_key_jwt for asymmetric-key authentication where no client secret is ever transmitted or stored by the authorization server:

import { Defauth, importClientPrivateKey } from 'defauth';

const auth = await Defauth.create({
  issuer: 'https://your-oidc-provider.com',
  clientId: 'your-client-id',
  authenticationMethod: 'private_key_jwt',

  // Provide a PEM-encoded PKCS8 key...
  clientPrivateKey: {
    pem: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
    alg: 'RS256',
    kid: 'my-key-id' // optional
  }

  // ...or a JWK: { jwk: { ... }, alg: 'RS256' }
  // ...or a pre-constructed WebCrypto key: { key: cryptoKeyInstance }
});

// `importClientPrivateKey` resolves any of the above input shapes into a
// WebCrypto CryptoKey directly, if you need to validate or reuse the key yourself.
const cryptoKey = await importClientPrivateKey({
  pem: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
  alg: 'RS256'
});

API Reference

Defauth Class

Static Factory Method

static async create<TUser>(config: DefauthConfig<TUser>): Promise<Defauth<TUser>>

Creates and initializes a new Defauth instance. This is the only way to create instances since the constructor is private.

Methods

getUser(token: string, options?: JwtValidationOptions): Promise<TUser>

Main method to extract user information from any token type. TUser is the type parameter supplied to Defauth.create<TUser>(); it defaults to UserClaims.

Options:

  • forceIntrospection?: boolean - Force token introspection even for valid JWTs
  • clockTolerance?: string - Clock tolerance for JWT expiration validation (default: '1 minute')
  • requiredClaims?: string[] - Required claims that must be present in the JWT (default: ['sub', 'exp'])
  • algorithms?: string[] - Accepted JWS algorithms for JWT signature verification (default: asymmetric algorithms only — RS*, PS*, ES*, EdDSA)
  • audience?: string | string[] - Expected JWT "aud" claim (default: clientId)
  • issuer?: string - Expected JWT "iss" claim (default: the configured issuer)
  • maxTokenAge?: string | number - Maximum age of the token since it was issued (iat claim); no default
  • typ?: string - Expected JWT "typ" header parameter (e.g. 'at+JWT' per RFC 9068); no default — see Security Hardening for when to set this
  • customValidator?: (claims: UserClaims) => Promise<void> | void - Custom validation function

Example:

// Basic usage
const user = await auth.getUser(token);

// With custom validation
const user = await auth.getUser(token, {
  customValidator: async (claims) => {
    if (claims.organizationId !== requestOrgId) {
      throw new Error('Organization mismatch');
    }
  }
});

// Force introspection
const user = await auth.getUser(token, { forceIntrospection: true });
clearCache(): Promise<void>

Clears all cached user data via the configured storage adapter's clear() method, if it implements one (a no-op otherwise). Useful for testing.

Types

UserClaims

Standard OIDC user claims interface. Requires only sub; all other claims are indexed as [key: string]: unknown.

StorageMetadata

Metadata stored alongside user data in storage adapters.

  • lastUserInfoRefresh?: Date - Timestamp of last UserInfo endpoint refresh
  • lastIntrospection?: Date - Timestamp of last token introspection

UserRecord

Type alias combining UserClaims & StorageMetadata — a user's claims together with its storage metadata in a single object.

DefauthConfig<TUser>

Configuration object for the authenticator. A discriminated union over authenticationMethod/clientSecret/clientPrivateKey (public client / confidential client / private_key_jwt client — see Client Authentication Methods). Common fields:

  • issuer: string - OIDC issuer URL used for discovery
  • clientId: string - Client ID
  • clientSecret?: string - Client secret (confidential clients only)
  • authenticationMethod?: AuthenticationMethod - Defaults to 'client_secret_post' when a secret is provided, 'none' otherwise
  • clientPrivateKey?: ClientPrivateKeyInput - Private key for private_key_jwt clients
  • discoveryAlgorithm?: 'oidc' | 'oauth2' - Discovery mode passed to openid-client (default: 'oidc')
  • jwtValidationOptions?: JwtValidationOptions - Global JWT validation defaults, overridable per getUser call
  • jwksOptions?: JwksOptions - JWKS caching behavior
  • enableIntrospectionFallthrough?: boolean - See Introspection Fallback Control (default: false)
  • requireIntrospectionBinding?: boolean - Require aud/exp in the introspection response any time introspection is used — opaque tokens, JWT fallthrough, or forceIntrospection (default: true)
  • validateIntrospectionClientId?: boolean - Also validate the introspection response's client_id against the expected audience (default: false)
  • disableIntrospectionFallthrough?: boolean - Deprecated, inverse of enableIntrospectionFallthrough; ignored when enableIntrospectionFallthrough is set
  • userInfoStrategy?: UserInfoStrategy - When to fetch UserInfo: 'afterUserRetrieval' (default), 'beforeUserRetrieval', or 'none'
  • userInfoRefreshCondition?: UserInfoRefreshCondition<TUser> - Defaults to defaultUserInfoRefreshCondition (refresh after 1 hour)
  • throwOnUserInfoFailure?: boolean - Default: false
  • storageAdapter?: StorageAdapter<TUser> - Default: InMemoryStorageAdapter
  • logger?: Logger - Default: ConsoleLogger
  • allowInsecureRequests?: boolean - Allow HTTP instead of HTTPS (development/testing only)
  • httpTimeout?: number - HTTP timeout in seconds for discovery, introspection, UserInfo, and JWKS calls
  • customFetch?: typeof fetch - Custom fetch implementation for all outbound HTTP calls

AuthenticationMethod

Union type: 'client_secret_post' | 'client_secret_basic' | 'client_secret_jwt' | 'private_key_jwt' | 'none'.

ClientPrivateKeyInput

Union of the three private-key input shapes accepted for private_key_jwt authentication:

  • ClientPrivateKeyRaw: { key: CryptoKey; kid?: string }
  • ClientPrivateKeyJwk: { jwk: JWK; alg?: string; kid?: string } (alg required unless present on the JWK)
  • ClientPrivateKeyPem: { pem: string; alg: string; kid?: string }

JwksOptions

Options controlling remote JWKS caching (values in milliseconds):

  • cooldownDuration?: number - Minimum time between JWKS refetches (default: 30000)
  • cacheMaxAge?: number - Maximum JWKS cache lifetime (default: 600000)

StorageAdapter<TUser>

Generic interface for implementing custom storage solutions. Methods:

  • findUser(context: TokenContext): Promise<{user: TUser; metadata: StorageMetadata} | null>
  • storeUser(user: TUser | null, tokenClaims: UserClaims, userInfoClaims: UserClaims | undefined, metadata: StorageMetadata): Promise<TUser> - tokenClaims are always current; userInfoClaims is undefined when not refetched this call, in which case retain whatever your adapter previously stored, if it chooses to (see Custom Storage Adapters)
  • clear?(): Promise<void> - Optional; called by Defauth.clearCache() if implemented

TokenContext

Context object containing token validation information passed to storage adapters.

  • sub: string - Subject identifier from the token
  • jwtPayload?: UserClaims - Full validated JWT payload (JWT tokens only)
  • introspectionResponse?: IntrospectionResponse - Full introspection response (when introspection was performed)
  • userInfoResult?: UserClaims - UserInfo result (when fetched before user retrieval)
  • metadata?: { validatedAt?: Date; forcedIntrospection?: boolean }

UserInfoRefreshCondition<TUser>

Function type (user: TUser, metadata: StorageMetadata) => boolean for determining when to refresh user information from UserInfo endpoint.

UserInfoStrategy

Union type: 'afterUserRetrieval' | 'beforeUserRetrieval' | 'none'.

CustomValidator

Function type (userClaims: UserClaims) => Promise<void> | void; throw to reject validation (see Custom Validation).

Logger

Interface for implementing custom logging solutions: log(level: LogLevel, message: string, context?: Record<string, unknown>): void.

LogLevel

Type for log levels: 'error' | 'warn' | 'info' | 'debug'.

Utility Exports

For advanced usage, the library also exports:

  • TokenType (enum): TokenType.JWT / TokenType.OPAQUE
  • isJwtToken(token: string): boolean - Checks whether a token decodes as a structurally valid JWT
  • getTokenType(token: string): TokenType - Returns TokenType.JWT or TokenType.OPAQUE for a given token
  • DEFAULT_JWT_ALGORITHMS: string[] - The default accepted JWS algorithms (asymmetric only: RS*, PS*, ES*, EdDSA)
  • defaultUserInfoRefreshCondition<TUser>(user: TUser, metadata: StorageMetadata): boolean - The built-in refresh condition (refresh after 1 hour)
  • ConsoleLogger - The default Logger implementation, logs to the console
  • InMemoryStorageAdapter<TUser extends UserClaims = UserClaims> - The default StorageAdapter implementation; also exposes getAllUsers(): Array<{user: TUser; tokenClaims: UserClaims; userInfoClaims?: UserClaims; metadata: StorageMetadata}> as a non-interface convenience method
  • importClientPrivateKey(input: ClientPrivateKeyInput): Promise<CryptoKey> - Resolves any ClientPrivateKeyInput shape into a WebCrypto CryptoKey (see Client Authentication Methods)
  • combineClaimsWithPriority(tokenClaims: UserClaims, userInfoClaims: UserClaims): UserClaims - Combines token and UserInfo claims, with UserInfo claims winning on conflicts; useful when implementing a custom StorageAdapter (see Custom Storage Adapters)

Zod Schemas

The library exports Zod schemas for validation:

  • UserClaimsSchema: Validates user claims (requires only sub field)
  • UserRecordSchema: Validates UserRecord objects (includes Date objects for timestamps)
  • IntrospectionResponseSchema: Validates introspection responses from OIDC providers

Error Handling

The library provides structured error handling with custom error classes for different scenarios:

Error Types

DefAuth exports the following custom error classes:

  • DefauthError: Base error class for all Defauth errors
  • InitializationError: Thrown when OIDC client initialization fails
  • TokenValidationError: Thrown when token validation fails
  • JwtVerificationError: Thrown when JWT verification fails (extends TokenValidationError)
  • JwtPolicyViolationError: Thrown when a signed JWT fails a local policy check — audience, issuer, expiry, algorithm, or signature (extends JwtVerificationError). Never falls through to introspection.
  • JwtInconclusiveError: Thrown when JWT verification fails for a reason that says nothing about the token's validity — key resolution failure, malformed token (extends JwtVerificationError). Eligible for introspection fallthrough when enabled.
  • JwtClaimsShapeError: Thrown when a JWT's signature verifies but its payload can't be parsed into usable claims, e.g. missing sub (extends JwtInconclusiveError). Eligible for introspection fallthrough when enabled.
  • CustomValidationError: Thrown when custom validation fails (extends TokenValidationError)
  • UserInfoError: Thrown when UserInfo endpoint fails (when throwOnUserInfoFailure: true)
  • IntrospectionError: Thrown when token introspection fails
  • StorageError: Thrown when the storage adapter's findUser/storeUser/clear calls fail

Usage Examples

import { 
  Defauth, 
  InitializationError, 
  TokenValidationError, 
  JwtVerificationError,
  CustomValidationError,
  UserInfoError,
  IntrospectionError,
  StorageError
} from 'defauth';

try {
  const user = await auth.getUser(token, {
    customValidator: async (claims) => {
      if (claims.organizationId !== requestOrgId) {
        throw new Error('Organization mismatch');
      }
    }
  });
} catch (error) {
  if (error instanceof InitializationError) {
    // Handle OIDC client initialization failure
    console.error('Failed to initialize OIDC client:', error.message);
  } else if (error instanceof JwtVerificationError) {
    // Handle JWT signature verification failure (when enableIntrospectionFallthrough: false)
    console.error('JWT signature verification failed:', error.message);
  } else if (error instanceof CustomValidationError) {
    // Handle custom validation failure
    console.error('Custom validation failed:', error.message);
  } else if (error instanceof UserInfoError) {
    // Handle UserInfo endpoint failure
    console.error('UserInfo fetch failed:', error.message);
  } else if (error instanceof IntrospectionError) {
    // Handle introspection failure
    console.error('Token introspection failed:', error.message);
  } else if (error instanceof StorageError) {
    // Handle storage adapter failure
    console.error('Storage operation failed:', error.message);
  } else if (error instanceof TokenValidationError) {
    // Handle general token validation failure
    console.error('Token validation failed:', error.message);
  } else {
    // Handle other errors
    console.error('Unexpected error:', error.message);
  }
}

Error Context

All custom errors preserve the original error as the cause property and include it in the error message for better debugging:

try {
  const user = await auth.getUser(token);
} catch (error) {
  console.error('Error:', error.message); // Includes cause message
  console.error('Original cause:', error.cause); // Access original error
}

License

MIT

About

A TypeScript OIDC authentication library with support for JWT and opaque tokens

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages