A TypeScript library for handling OIDC authentication with support for both JWT and opaque tokens, intelligent caching, and customizable storage adapters.
- 🔐 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
Upgrading from an earlier major version? See MIGRATION.md for the v2 → v3 and v1 → v2 breaking changes and how to update your code.
npm install defauthimport { 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 (
Authenticator→Defauth), error class rename (DefAuthError→DefauthError), and private constructor (must useDefauth.create()). See the Migration Guide above for complete upgrade instructions.
const auth = await Defauth.create({
issuer: 'https://your-oidc-provider.com',
clientId: 'your-client-id',
clientSecret: 'your-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
});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: TheDefauthconstructor isprivateand cannot be called directly (new Defauth()is a TypeScript compile error).Defauth.create()is the only way to obtain an instance.
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;
}
});The library automatically detects token types and handles them with a hybrid approach:
- 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
- Always introspects with the OIDC provider for validation
- The introspection response is validated against the configured
audience/issuer/clockToleranceandrequireIntrospectionBinding/validateIntrospectionClientId— see Introspection Response Validation below; this applies identically to opaque tokens, not just JWT fallthrough - Per-call
options.audience/options.issuer/options.clockTolerancepassed togetUser()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
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 totrue): whentrue, reject any introspection response that omitsaudorexpinstead of silently accepting it onactive: truealone. Keep this on unless you have a specific reason to trust a minimal authorization server response.validateIntrospectionClientId(defaults tofalse): also validate the response'sclient_idagainst the expected audience. This is off by default on purpose —client_id(the client that requested the token) andaud(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.
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 ofenableIntrospectionFallthrough. When both are set,enableIntrospectionFallthroughtakes 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
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
CustomValidationErrorwhen 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
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.
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.
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,
});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, orrequiredClaimsis rejected immediately — it is never sent to introspection, and no config option re-enables that path (see Introspection Fallback Control). requireIntrospectionBindingdefaults totrue, so introspection responses missingaud/expare rejected rather than accepted onactive: truealone (see Introspection Response Validation).validateIntrospectionClientIdis available (defaultfalse) if your deployment expectsclient_idandaudto match on introspection responses; see the same section above for why it isn't on by default.
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
});You can configure how the library handles UserInfo endpoint failures:
throwOnUserInfoFailure: false(default): Logs warnings and continues with available datathrowOnUserInfoFailure: 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
});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:
tokenClaimsare freshly re-verified every call — always current, never stale.userInfoClaimsisundefinedwhen 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 whenuserInfoClaimsisundefined; when a real value is passed, it's a full refetch, so replace whatever you'd retained before. ReusecombineClaimsWithPriority(exported fromdefauth) if you want the same "UserInfo wins on conflict" composition the built-inInMemoryStorageAdapteruses:
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()
});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
});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'
});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.
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 JWTsclockTolerance?: 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 configuredissuer)maxTokenAge?: string | number- Maximum age of the token since it was issued (iatclaim); no defaulttyp?: string- Expected JWT "typ" header parameter (e.g.'at+JWT'per RFC 9068); no default — see Security Hardening for when to set thiscustomValidator?: (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 });Clears all cached user data via the configured storage adapter's clear() method, if it implements one (a no-op otherwise). Useful for testing.
Standard OIDC user claims interface. Requires only sub; all other claims are indexed as [key: string]: unknown.
Metadata stored alongside user data in storage adapters.
lastUserInfoRefresh?: Date- Timestamp of last UserInfo endpoint refreshlastIntrospection?: Date- Timestamp of last token introspection
Type alias combining UserClaims & StorageMetadata — a user's claims together with its storage metadata in a single object.
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 discoveryclientId: string- Client IDclientSecret?: string- Client secret (confidential clients only)authenticationMethod?: AuthenticationMethod- Defaults to'client_secret_post'when a secret is provided,'none'otherwiseclientPrivateKey?: ClientPrivateKeyInput- Private key forprivate_key_jwtclientsdiscoveryAlgorithm?: 'oidc' | 'oauth2'- Discovery mode passed to openid-client (default:'oidc')jwtValidationOptions?: JwtValidationOptions- Global JWT validation defaults, overridable pergetUsercalljwksOptions?: JwksOptions- JWKS caching behaviorenableIntrospectionFallthrough?: boolean- See Introspection Fallback Control (default:false)requireIntrospectionBinding?: boolean- Requireaud/expin the introspection response any time introspection is used — opaque tokens, JWT fallthrough, orforceIntrospection(default:true)validateIntrospectionClientId?: boolean- Also validate the introspection response'sclient_idagainst the expected audience (default:false)disableIntrospectionFallthrough?: boolean- Deprecated, inverse ofenableIntrospectionFallthrough; ignored whenenableIntrospectionFallthroughis setuserInfoStrategy?: UserInfoStrategy- When to fetch UserInfo:'afterUserRetrieval'(default),'beforeUserRetrieval', or'none'userInfoRefreshCondition?: UserInfoRefreshCondition<TUser>- Defaults todefaultUserInfoRefreshCondition(refresh after 1 hour)throwOnUserInfoFailure?: boolean- Default:falsestorageAdapter?: StorageAdapter<TUser>- Default:InMemoryStorageAdapterlogger?: Logger- Default:ConsoleLoggerallowInsecureRequests?: boolean- Allow HTTP instead of HTTPS (development/testing only)httpTimeout?: number- HTTP timeout in seconds for discovery, introspection, UserInfo, and JWKS callscustomFetch?: typeof fetch- Custom fetch implementation for all outbound HTTP calls
Union type: 'client_secret_post' | 'client_secret_basic' | 'client_secret_jwt' | 'private_key_jwt' | 'none'.
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 }(algrequired unless present on the JWK)ClientPrivateKeyPem:{ pem: string; alg: string; kid?: string }
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)
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>-tokenClaimsare always current;userInfoClaimsisundefinedwhen 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 byDefauth.clearCache()if implemented
Context object containing token validation information passed to storage adapters.
sub: string- Subject identifier from the tokenjwtPayload?: 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 }
Function type (user: TUser, metadata: StorageMetadata) => boolean for determining when to refresh user information from UserInfo endpoint.
Union type: 'afterUserRetrieval' | 'beforeUserRetrieval' | 'none'.
Function type (userClaims: UserClaims) => Promise<void> | void; throw to reject validation (see Custom Validation).
Interface for implementing custom logging solutions: log(level: LogLevel, message: string, context?: Record<string, unknown>): void.
Type for log levels: 'error' | 'warn' | 'info' | 'debug'.
For advanced usage, the library also exports:
TokenType(enum):TokenType.JWT/TokenType.OPAQUEisJwtToken(token: string): boolean- Checks whether a token decodes as a structurally valid JWTgetTokenType(token: string): TokenType- ReturnsTokenType.JWTorTokenType.OPAQUEfor a given tokenDEFAULT_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 defaultLoggerimplementation, logs to the consoleInMemoryStorageAdapter<TUser extends UserClaims = UserClaims>- The defaultStorageAdapterimplementation; also exposesgetAllUsers(): Array<{user: TUser; tokenClaims: UserClaims; userInfoClaims?: UserClaims; metadata: StorageMetadata}>as a non-interface convenience methodimportClientPrivateKey(input: ClientPrivateKeyInput): Promise<CryptoKey>- Resolves anyClientPrivateKeyInputshape into a WebCryptoCryptoKey(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 customStorageAdapter(see Custom Storage Adapters)
The library exports Zod schemas for validation:
UserClaimsSchema: Validates user claims (requires onlysubfield)UserRecordSchema: ValidatesUserRecordobjects (includesDateobjects for timestamps)IntrospectionResponseSchema: Validates introspection responses from OIDC providers
The library provides structured error handling with custom error classes for different scenarios:
DefAuth exports the following custom error classes:
DefauthError: Base error class for all Defauth errorsInitializationError: Thrown when OIDC client initialization failsTokenValidationError: Thrown when token validation failsJwtVerificationError: 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. missingsub(extends JwtInconclusiveError). Eligible for introspection fallthrough when enabled.CustomValidationError: Thrown when custom validation fails (extends TokenValidationError)UserInfoError: Thrown when UserInfo endpoint fails (whenthrowOnUserInfoFailure: true)IntrospectionError: Thrown when token introspection failsStorageError: Thrown when the storage adapter'sfindUser/storeUser/clearcalls fail
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);
}
}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
}MIT