npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@maroonedsoftware/authentication

v2.2.0

Published

Authentication utilities for ServerKit.

Readme

@maroonedsoftware/authentication

Authentication utilities for ServerKit. Provides scheme-based handler dispatch, session management, JWT issuance, OTP generation, password strength checking, password factor flows, email factor flows, authenticator app (TOTP/HOTP) factor flows, phone number factor flows, FIDO2/WebAuthn factor flows, OpenID Connect SSO (Google, Microsoft, LinkedIn, Apple, …), and OAuth 2.0 SSO (GitHub, Discord, Twitter/X, …) — all with full dependency injection support via injectkit.

Installation

pnpm add @maroonedsoftware/authentication

Features

  • Scheme-based dispatch — register a handler per Authorization scheme (Bearer, Basic, or any custom scheme) and the right one is called automatically
  • AuthenticationContext — a typed context object carrying session metadata, satisfied MFA factors, and arbitrary credential claims
  • Safe defaultsinvalidAuthenticationContext is a well-typed sentinel for unauthenticated state that can be safely checked without null handling
  • Server-side sessionsAuthenticationSessionService manages session lifecycle in any cache backend, with JWT issuance and revocation support
  • Built-in JWT supportJwtAuthenticationHandler and JwtAuthenticationIssuer for multi-issuer Bearer token validation
  • Built-in Basic supportBasicAuthenticationHandler and BasicAuthenticationIssuer for username/password flows
  • OTP/TOTP — RFC 4226/6238 compliant HOTP and TOTP generation and validation, plus otpauth:// URI generation for QR codes
  • Password strength — zxcvbn-ts powered strength checking with HaveIBeenPwned integration
  • Password factors — strength-validated, PBKDF2-hashed, rate-limited password factor lifecycle via PasswordFactorService
  • Email factors — two-step email factor registration and verification via OTP code or magic link
  • Authenticator app factors — TOTP/HOTP registration with QR code provisioning via AuthenticatorFactorService
  • Phone number factors — two-step phone factor registration via PhoneFactorService (send the OTP out-of-band via SMS)
  • FIDO2/WebAuthn factors — passkey/security-key registration and sign-in via FidoFactorService (built on fido2-lib)
  • OpenID Connect factors — sign-in, account linking, and refresh-token rotation via OidcFactorService (built on openid-client) with public-client support and verified-email auto-linking
  • OAuth 2.0 factors — non-OIDC sign-in (GitHub, Discord, Twitter/X, …) via OAuth2FactorService with an adapter interface that pairs cleanly with arctic
  • DI-friendly — all classes are decorated with @Injectable() and designed for an injectkit container

Usage

Scheme-based dispatch

1. Implement a handler for your scheme

import { Injectable } from 'injectkit';
import { AuthenticationHandler, AuthenticationContext, invalidAuthenticationContext } from '@maroonedsoftware/authentication';
import { DateTime } from 'luxon';

@Injectable()
class MyJwtHandler implements AuthenticationHandler {
  async authenticate(scheme: string, value: string): Promise<AuthenticationContext> {
    const payload = await verifyJwt(value); // your JWT verification logic

    return {
      actorId: payload.sub,
      actorType: 'user',
      issuedAt: DateTime.fromSeconds(payload.iat),
      lastAccessedAt: DateTime.now(),
      expiresAt: DateTime.fromSeconds(payload.exp),
      factors: [{ method: 'password', lastAuthenticated: DateTime.fromSeconds(payload.iat), kind: 'knowledge' }],
      claims: payload,
      roles: [],
    };
  }
}

2. Register the handler map in your DI container

import { AuthenticationHandlerMap, AuthenticationSchemeHandler } from '@maroonedsoftware/authentication';

registry.register(AuthenticationHandlerMap).useMap().set('bearer', MyJwtHandler);
registry.register(MyJwtHandler).useClass(MyJwtHandler).asSingleton();
registry.register(AuthenticationSchemeHandler).useClass(AuthenticationSchemeHandler).asSingleton();

3. Resolve the authentication context

const schemeHandler = container.get(AuthenticationSchemeHandler);

const ctx = await schemeHandler.handle('Bearer eyJhbGci...');
console.log(ctx.actorId);  // 'user-123'
console.log(ctx.claims);   // { sub: 'user-123', ... }

// Missing or malformed header
const ctx = await schemeHandler.handle(undefined);
console.log(ctx === invalidAuthenticationContext); // true

JWT authentication (multi-issuer Bearer)

Use JwtAuthenticationHandler when you want the package to handle Bearer token dispatch, with per-issuer validation logic plugged in via JwtAuthenticationIssuer.

import {
  JwtAuthenticationHandler,
  JwtAuthenticationIssuerMap,
  JwtAuthenticationIssuer,
} from '@maroonedsoftware/authentication';
import type { JwtPayload } from 'jsonwebtoken';

@Injectable()
class MyIssuer extends JwtAuthenticationIssuer {
  async parse(payload: JwtPayload): Promise<AuthenticationContext> {
    // Verify signature, expiry, audience, etc.
    return { actorId: payload.sub!, actorType: 'user', ... };
  }
}

// Register in DI
registry.register(JwtAuthenticationIssuerMap).useMap().set('https://auth.example.com', MyIssuer);
registry.register(JwtAuthenticationHandler).useClass(JwtAuthenticationHandler).asSingleton();

// Register as the bearer handler
registry.register(AuthenticationHandlerMap).useMap().set('bearer', JwtAuthenticationHandler);

Basic authentication

import { BasicAuthenticationHandler, BasicAuthenticationIssuer } from '@maroonedsoftware/authentication';

@Injectable()
class MyBasicIssuer extends BasicAuthenticationIssuer {
  async verify(username: string, password: string): Promise<AuthenticationContext> {
    const user = await db.users.findByUsername(username);
    if (!user || !await bcrypt.compare(password, user.passwordHash)) {
      return invalidAuthenticationContext;
    }
    return { actorId: user.id, actorType: 'user', ... };
  }
}

registry.register(MyBasicIssuer).useClass(MyBasicIssuer).asSingleton();
registry.register(BasicAuthenticationHandler).useClass(BasicAuthenticationHandler).asSingleton();
registry.register(AuthenticationHandlerMap).useMap().set('basic', BasicAuthenticationHandler);

Session management

AuthenticationSessionService stores sessions in a cache and issues JWTs that reference them. Revoking a session invalidates all tokens derived from it.

import { AuthenticationSessionService } from '@maroonedsoftware/authentication';
import { DateTime } from 'luxon';

const sessionService = container.get(AuthenticationSessionService);

// Create a session after the user authenticates
const now = DateTime.utc();
const session = await sessionService.createSession(
  user.id,
  { plan: 'pro' },
  { issuedAt: now, authenticatedAt: now, method: 'password', methodId: user.passwordFactorId, kind: 'knowledge' },
);

// Issue a signed JWT
const token = await sessionService.issueTokenForSession(session.sessionToken);
// token.accessToken → "eyJhbGci..."

// Validate a JWT and retrieve the session on subsequent requests
const { session, jwtPayload } = await sessionService.lookupSessionFromJwt(incomingJwt);

// Revoke (logout)
await sessionService.deleteSession(session.sessionToken);

OTP / TOTP

import { OtpProvider } from '@maroonedsoftware/authentication';

const otp = container.get(OtpProvider);

// Generate a secret for a user's authenticator app
const secret = otp.createSecret();

// Produce a QR-code URI
const uri = otp.generateURI(secret, { type: 'totp', algorithm: 'SHA1', periodSeconds: 30, tokenLength: 6, counter: 0 }, { issuer: 'MyApp', label: user.email });

// Validate a code submitted by the user
const valid = otp.validate(submittedCode, secret, { type: 'totp', periodSeconds: 30 });

Password strength

PasswordStrengthProvider wraps zxcvbn-ts with the English dictionary, common adjacency graphs, and a HaveIBeenPwned leak matcher. Scores range from 0 (very weak) to 4 (very strong); a score of 3 or higher is considered acceptable.

import { PasswordStrengthProvider } from '@maroonedsoftware/authentication';

const strength = container.get(PasswordStrengthProvider);

// Non-throwing check — pass user-specific context (email, name, etc.) so zxcvbn
// can penalise obvious substitutions like "alice123" for user "alice".
const result = await strength.checkStrength(password, user.email, user.name);
// result.valid    → boolean (score >= 3)
// result.score    → 0–4
// result.feedback → { warning: string, suggestions: string[] }

// Throwing check — throws HTTP 400 with
// { password: feedback.warning, suggestions: feedback.suggestions } when score < 3
await strength.ensureStrength(password, user.email);

To override the policy (e.g. raise the threshold or swap the matcher), subclass PasswordStrengthProvider and register your subclass in the DI container — PasswordFactorService resolves the base class.


PKCE state storage

PkceProvider is cache-backed storage for the OAuth 2.0 PKCE flow. It binds an arbitrary string value (a redirect URL, upstream auth params, a transaction id, etc.) to the code challenge a client sent at the start of an authorization flow, so the matching verifier can later retrieve it at the token endpoint. Entries are namespaced under the pkce_ cache key prefix and expire automatically via the cache TTL.

Each method comes in two forms — *Challenge takes the SHA-256/base64url challenge, *Verifier takes the verifier and derives the challenge for you (using pkceCreateChallenge from @maroonedsoftware/encryption).

import { PkceProvider } from '@maroonedsoftware/authentication';
import { pkceCreateChallenge, pkceCreateVerifier } from '@maroonedsoftware/encryption';
import { Duration } from 'luxon';

const pkce = container.get(PkceProvider);

// --- Authorization step (client → /authorize) ---
// The client generates a verifier and challenge, sends the challenge to us
const codeChallenge = ctx.query.code_challenge;
await pkce.storeChallenge(codeChallenge, JSON.stringify({ redirectUrl, scope }), Duration.fromObject({ minutes: 10 }));

// --- Token step (client → /token) ---
// The client sends back the verifier
const stateJson = await pkce.getVerifier(ctx.body.code_verifier);
if (!stateJson) throw httpError(400);
await pkce.deleteVerifier(ctx.body.code_verifier); // single-use

If you control both halves of the pair (e.g. issuing your own download links), use storeVerifier / getVerifier / deleteVerifier and skip the manual pkceCreateChallenge call.


Password factors

PasswordFactorService handles the full lifecycle of password-based factors: PBKDF2-SHA512 hashing, reuse prevention against the last 10 passwords, and rate-limited verification (via an injected RateLimiterCompatibleAbstract). Strength validation is delegated to an injected PasswordStrengthProvider (zxcvbn + HaveIBeenPwned by default) — register your own implementation to override the policy.

import { PasswordFactorService } from '@maroonedsoftware/authentication';

const passwordFactors = container.get(PasswordFactorService);

// Create a new factor (validates strength, throws 409 if one already exists)
const factorId = await passwordFactors.createPasswordFactor(user.id, password);

// Verify on sign-in (rate-limited; throws 401 on bad credentials, 429 if rate-limited)
await passwordFactors.verifyPassword(user.id, submittedPassword);

// Replace the password (validates strength, rejects reuse of the last 10)
await passwordFactors.updatePasswordFactor(user.id, newPassword);

// Change password and clear the `needsReset` flag
await passwordFactors.changePassword(user.id, newPassword);

// Remove the factor
await passwordFactors.deleteFactor(user.id);

For sign-up flows that collect a password before the actor record exists, use the two-step registration flow instead. registerPasswordFactor validates strength, hashes the password, and stages it in the cache for 10 minutes; createPasswordFactorFromRegistration binds it to an actor:

// Step 1: stage the password (e.g. while the user is also verifying their email).
// Idempotent — `alreadyRegistered` is true when a pending registration was
// already cached for the same password.
const { registrationId, expiresAt, alreadyRegistered } = await passwordFactors.registerPasswordFactor(password);

// Step 2: once the actor record exists, bind the cached hash to it.
const factor = await passwordFactors.createPasswordFactorFromRegistration(user.id, registrationId);

Email factors

import { EmailFactorService } from '@maroonedsoftware/authentication';

const emailFactors = container.get(EmailFactorService);

// --- Registration ---

// Step 1: generate a code and cache the registration. Idempotent — `alreadyRegistered`
// is true when a pending registration was already cached, so the caller can skip
// re-sending the email and avoid spamming the user during the registration window.
const { registrationId, code, expiresAt, alreadyRegistered } = await emailFactors.registerEmailFactor(
  '[email protected]',
  'code', // or 'magiclink'
);
if (!alreadyRegistered) {
  await mailer.sendOtp(user.email, code);
}

// Step 2: user submits the code; persist the factor
const factor = await emailFactors.createEmailFactorFromRegistration(user.id, registrationId, submittedCode);

// --- Challenge (sign-in) ---

// Step 1: send a challenge. Idempotent — `alreadyIssued` is true when a pending
// challenge was already cached, so the caller can skip re-sending the email.
const { email, challengeId, code, alreadyIssued } = await emailFactors.issueEmailChallenge(user.id, factor.id, 'code');
if (!alreadyIssued) {
  await mailer.sendOtp(email, code);
}

// Step 2: user submits the code
const { actorId, factorId } = await emailFactors.verifyEmailChallenge(challengeId, submittedCode);

registerEmailFactor rejects the request before issuing a code when:

  • the email format is invalid (HTTP 400),
  • the domain is on denyList (HTTP 400, e.g. disposable mail providers),
  • EmailFactorRepository.isDomainInviteOnly(domain) returns true (HTTP 403 — implement this to gate registration to allow-listed domains, e.g. workspaces that require an invite),
  • an active factor already exists for the email (HTTP 409).

For the magic link flow, after the server has verified the link, return the page produced by getRedirectHtml(redirectUrl) instead of an HTTP redirect. The inline script defers navigation until window.onload, which sidesteps mail-client URL pre-fetchers that would otherwise burn the one-time token before the user clicks. The returned nonce must be echoed in a Content-Security-Policy: script-src 'nonce-<nonce>' header on the same response.

const { html, nonce } = emailFactors.getRedirectHtml(new URL('https://app.example.com/welcome'));
ctx.set('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
ctx.body = html;

Authenticator app factors (TOTP/HOTP)

AuthenticatorFactorService manages the full lifecycle of authenticator app (TOTP/HOTP) factors. The secret is stored encrypted via EncryptionProvider and is never persisted in plaintext.

Registration

import { AuthenticatorFactorService } from '@maroonedsoftware/authentication';

const authenticatorFactors = container.get(AuthenticatorFactorService);

// Step 1: generate a secret and QR code, cache the pending registration.
// Idempotent — `alreadyRegistered` is true when a pending registration was
// already cached for the actor (or the supplied registrationId), in which
// case the cached secret/uri/qrCode are returned so the same QR code can
// be re-rendered without invalidating the secret the user may have scanned.
const { registrationId, secret, uri, qrCode, expiresAt, alreadyRegistered } = await authenticatorFactors.registerAuthenticatorFactor(user.id);
// Display qrCode (a data URL) to the user so they can scan it into their authenticator app.
// secret is also returned for manual entry.

// Step 2: user enters the code from their app; verify it and persist the factor
const factor = await authenticatorFactors.createAuthenticatorFactorFromRegistration(
  user.id,
  registrationId,
  submittedCode,
);

You can override OTP algorithm defaults per registration:

await authenticatorFactors.registerAuthenticatorFactor(user.id, {
  type: 'totp',
  algorithm: 'SHA256',
  periodSeconds: 60,
  tokenLength: 8,
  counter: 0,
});

Verification (sign-in)

// Throws HTTP 401 when the factor doesn't exist, is inactive, or the code is invalid
await authenticatorFactors.validateFactor(user.id, factorId, submittedCode);

Deletion

await authenticatorFactors.deleteFactor(user.id, factorId);

Phone number factors

PhoneFactorService handles two-step phone number factor registration. It caches a pending registration keyed by phone number and returns a registrationId — your application is responsible for sending an OTP to that number out-of-band (e.g. via SMS). The actor is bound at completion time, not at registration, so the same flow drives sign-up (no actor exists yet), profile updates, and recovery. Registration is idempotent: calling registerPhoneFactor again with the same number returns the existing pending registration with alreadyRegistered: true so the caller can skip a duplicate SMS send.

import { PhoneFactorService } from '@maroonedsoftware/authentication';

const phoneFactors = container.get(PhoneFactorService);

// --- Registration ---

// Step 1: cache a pending registration and get the registrationId. `alreadyRegistered`
// is true when a pending registration was already cached — skip the SMS to avoid duplicates.
const { value, registrationId, expiresAt, alreadyRegistered } = await phoneFactors.registerPhoneFactor('+12025550123');
if (!alreadyRegistered) {
  await sms.sendOtp(value, registrationId);
}

// Step 2: user confirms their number; bind the registration to an actor and persist the factor
const factor = await phoneFactors.createPhoneFactorFromRegistration(user.id, registrationId);

FIDO2 / WebAuthn factors

FidoFactorService handles passkey and security-key flows on top of fido2-lib. Relying party identifiers (rpId, rpOrigin, rpName) are passed per call rather than configured statically, so a single service can serve multiple hosts. Both registration and sign-in are two-step: the server emits a challenge, caches the expectations for FidoFactorServiceOptions.timeout (5 minutes by default), and verifies the browser's response in step two.

import { FidoFactorService } from '@maroonedsoftware/authentication';

const fidoFactors = container.get(FidoFactorService);

// --- Registration ---

// Step 1: emit an attestation challenge to the browser
const attestation = await fidoFactors.registerFidoFactor(user.id, {
  rpId: 'example.com',
  rpName: 'Example',
  rpOrigin: 'https://example.com',
  userName: user.email,
  userDisplayName: user.name,
});
// Send `attestation` to the browser; client decodes `challenge` and `user.id`
// from base64 to ArrayBuffers, calls navigator.credentials.create({ publicKey: ... }),
// and posts the resulting credential back.

// Step 2: verify the attestation and persist the factor
const factorId = await fidoFactors.createFidoFactorFromRegistration(user.id, credential);

// --- Authorization (sign-in) ---

// Step 1: emit an assertion challenge — `allowCredentials` is populated from
// the actor's active factors, so the browser only prompts for ones they have
const assertion = await fidoFactors.createFidoAuthorizationChallenge(user.id, {
  rpId: 'example.com',
  rpOrigin: 'https://example.com',
});
// Client decodes the challenge and each allowCredentials[].id to ArrayBuffers,
// calls navigator.credentials.get({ publicKey: ... }), and posts back.

// Step 2: verify the signature and bump the stored counter
const { actorId, factorId } = await fidoFactors.verifyFidoAuthorizationChallenge(user.id, credential);

Failed attestations and assertions both throw HTTP 401 with a WWW-Authenticate: Bearer error="invalid_credentials" header (or "invalid_registration" when no pending registration is cached). The original fido2-lib error is attached as the cause and the raw inputs as internal details for logging.


OpenID Connect factors

OidcFactorService orchestrates SSO sign-in, account linking, and refresh-token rotation against any OpenID Connect provider — Google, Microsoft, LinkedIn, Apple, etc. The id_token is validated end-to-end (signature against the provider's JWKS, iss / aud / exp, nonce, and PKCE) by openid-client.

Register one or more providers via OidcProviderRegistry. Discovery (.well-known/openid-configuration) is lazy and cached per provider per process. Public clients (mobile, SPA) are supported by omitting clientSecret — PKCE is mandatory in that mode.

import {
  OidcFactorService,
  OidcProviderRegistry,
  OidcProviderRegistryConfig,
  OidcActorEmailLookup,
} from '@maroonedsoftware/authentication';

// Wire providers via DI (sourced from AppConfig — keep clientSecret out of code)
registry.registerValue(OidcProviderRegistryConfig, new OidcProviderRegistryConfig([
  {
    name: 'google',
    issuer: new URL('https://accounts.google.com'),
    clientId: config.google.clientId,
    clientSecret: config.google.clientSecret,
    scopes: ['openid', 'profile', 'email'],
    redirectUri: new URL('https://app.example.com/auth/google/callback'),
    authorizeParams: { access_type: 'offline', prompt: 'consent' }, // needed for Google to issue a refresh token
    persistRefreshToken: true,
  },
]));
registry.register(OidcProviderRegistry).useClass(OidcProviderRegistry).asSingleton();
registry.register(OidcFactorService).useClass(OidcFactorService).asSingleton();

// Bridge OIDC sign-in to your existing account store. The lookup decides what
// counts as an "existing account with this email" — typically your email-factor
// table — and returns the actorId so OidcFactorService can auto-link.
@Injectable()
class MyEmailLookup extends OidcActorEmailLookup {
  constructor(private readonly emails: EmailFactorRepository) { super(); }
  async findActorByEmail(email: string) {
    const factor = await this.emails.lookupFactor(email);
    return factor?.active ? factor.actorId : undefined;
  }
}
registry.register(OidcActorEmailLookup).useClass(MyEmailLookup).asSingleton();

const oidc = container.get(OidcFactorService);

// Step 1 — redirect the browser to the IdP
const { url } = await oidc.beginAuthorization({
  provider: 'google',
  intent: 'sign-in',
  redirectAfter: '/welcome',
});
ctx.redirect(url.toString());

// Step 2 — handle the callback
const result = await oidc.completeAuthorization({ callbackUrl: new URL(ctx.href) });
switch (result.kind) {
  case 'signed-in':
  case 'linked':
    // Issue a session for result.actorId
    break;
  case 'new-user':
    if (result.emailConflict) {
      // An account with this email already exists but the IdP didn't claim it
      // verified — show "sign in to your existing account first to link this provider".
      break;
    }
    // Show a sign-up form, then call createFactorFromAuthorization with the new actorId.
    await oidc.createFactorFromAuthorization(newActorId, result.authorizationId);
    break;
}

Account linking. Pass intent: 'link' and an existing actorId on beginAuthorization to attach an additional provider to a signed-in user.

Auto-link by verified email. When sign-in finds no (provider, subject) mapping but the IdP returns a verified email matching an existing actor (via OidcActorEmailLookup), the service auto-creates the factor on that actor and returns kind: 'linked'. Unverified-email matches do not auto-link — they return kind: 'new-user' with emailConflict set so the UI can require sign-in to the existing account before linking.

Refresh tokens. Set persistRefreshToken: true on the provider config to envelope-encrypt and persist the refresh token (via @maroonedsoftware/encryption). Call oidc.refreshAccessToken(actorId, factorId) later for a fresh access token; rotated refresh tokens are re-encrypted automatically. Refresh tokens are dropped for public clients regardless of the flag, since safe handling requires DPoP or a backend proxy that this package doesn't implement.


OAuth 2.0 factors (non-OIDC)

OAuth2FactorService covers providers that expose OAuth 2.0 but not full OpenID Connect — GitHub, Discord, Twitter/X, etc. The public surface mirrors the OIDC service so callback handling can be shared, but the underlying provider client is supplied as an adapter (typically wrapping an arctic provider) plus a provider-specific fetchProfile that resolves the access token to a normalized profile.

import { GitHub } from 'arctic';
import {
  OAuth2FactorService,
  OAuth2ProviderRegistry,
  OAuth2ProviderRegistryConfig,
  OAuth2ProviderClient,
  OAuth2Tokens,
  OAuth2ActorEmailLookup,
} from '@maroonedsoftware/authentication';

// Adapt an arctic provider to OAuth2ProviderClient
const github = new GitHub(config.github.clientId, config.github.clientSecret, 'https://app.example.com/auth/github/callback');
const githubClient: OAuth2ProviderClient = {
  createAuthorizationURL: (state, _codeVerifier, scopes) => github.createAuthorizationURL(state, scopes),
  validateAuthorizationCode: async (code) => {
    const tokens = await github.validateAuthorizationCode(code);
    return {
      accessToken: tokens.accessToken(),
      refreshToken: tokens.hasRefreshToken() ? tokens.refreshToken() : undefined,
      expiresAt: tokens.hasRefreshToken() ? tokens.accessTokenExpiresAt() : undefined,
    } satisfies OAuth2Tokens;
  },
};

registry.registerValue(OAuth2ProviderRegistryConfig, new OAuth2ProviderRegistryConfig([
  {
    name: 'github',
    client: githubClient,
    scopes: ['read:user', 'user:email'],
    usesPKCE: false, // GitHub does not support PKCE
    fetchProfile: async (accessToken) => {
      const [user, emails] = await Promise.all([
        fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${accessToken}` } }).then((r) => r.json()),
        fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${accessToken}` } }).then((r) => r.json()),
      ]);
      const primary = emails.find((e: { primary: boolean }) => e.primary);
      return {
        subject: String(user.id),
        email: primary?.email,
        emailVerified: primary?.verified === true,
        name: user.name ?? user.login,
        picture: user.avatar_url,
        rawProfile: { user, emails },
      };
    },
  },
]));
registry.register(OAuth2ProviderRegistry).useClass(OAuth2ProviderRegistry).asSingleton();
registry.register(OAuth2FactorService).useClass(OAuth2FactorService).asSingleton();

const oauth2 = container.get(OAuth2FactorService);
const { url } = await oauth2.beginAuthorization({ provider: 'github', intent: 'sign-in' });
// ...callback handling identical in shape to the OIDC service above.

PKCE. Set usesPKCE: true for providers that support it (Google in OAuth-2.0-only mode, Twitter/X, etc.) and the service generates a verifier per request, passing it to both createAuthorizationURL and validateAuthorizationCode. Set false for providers that don't (GitHub).

Email verification semantics. Unlike OIDC, most OAuth 2.0 providers don't expose an email_verified flag — the adapter is responsible for resolving it (e.g. GitHub's /user/emails returns a verified boolean per address). Default emailVerified: false rather than undefined if the provider gives no signal; the auto-link rules treat anything other than explicit true as unverified.

Refresh tokens. Same opt-in mechanism as OIDC: set persistRefreshToken: true and provide a refreshAccessToken method on the adapter. Rotated tokens are re-encrypted automatically.


API Reference

AuthenticationContext

The resolved context produced by a successful authentication check.

| Property | Type | Description | | ----------------- | ------------------------- | -------------------------------------------------------- | | actorId | string | Unique identifier for the authenticated actor | | actorType | string | Type of the actor (e.g. "user", "service") | | issuedAt | DateTime | When the session was originally issued | | lastAccessedAt | DateTime | When the session was last accessed | | expiresAt | DateTime | When the session expires | | factors | AuthenticationFactor[] | MFA factors satisfied in this session | | claims | Record<string, unknown> | Arbitrary key/value claims extracted from the credential | | roles | string[] | Roles assigned to the authenticated actor |

AuthenticationFactor

Describes a single satisfied authentication factor.

| Property | Type | Description | | ------------------- | -------------------------- | ----------------------------------------------------------------- | | method | string | Specific method used (e.g. "password", "totp", "webauthn") | | lastAuthenticated | DateTime | When this factor was last successfully authenticated | | kind | AuthenticationFactorKind | MFA category: "knowledge", "possession", or "biometric" |

AuthenticationFactorKind

type AuthenticationFactorKind = 'knowledge' | 'possession' | 'biometric';

| Value | Meaning | Examples | | ------------ | -------------------- | ------------------------------- | | knowledge | Something you know | Password, PIN | | possession | Something you have | TOTP app, hardware security key | | biometric | Something you are | Fingerprint, face ID |

invalidAuthenticationContext

A sentinel AuthenticationContext value representing an unauthenticated or failed state. All DateTime fields are invalid Luxon instances.

import { invalidAuthenticationContext } from '@maroonedsoftware/authentication';

if (ctx === invalidAuthenticationContext) {
  throw httpError(401);
}

AuthenticationSchemeHandler

| Method | Returns | Description | | ------------------------------ | -------------------------------- | --------------------------------------------------------------------------------------- | | handle(authorizationHeader?) | Promise<AuthenticationContext> | Parses the Authorization header and returns the resolved context, or invalidAuthenticationContext |

Returns invalidAuthenticationContext when the header is absent, malformed, or no handler is registered for the scheme.

AuthenticationHandlerMap

An injectable Map<AuthorizationScheme, AuthenticationHandler>. Register one entry per scheme.

AuthenticationHandler

interface AuthenticationHandler {
  authenticate(scheme: string, value: string): Promise<AuthenticationContext>;
}

AuthorizationScheme

type AuthorizationScheme = 'bearer' | 'basic' | string;

JwtAuthenticationHandler

Handles bearer tokens by decoding the JWT, extracting the iss claim, and delegating to the matching JwtAuthenticationIssuer.

JwtAuthenticationIssuer

Abstract base class. Implement parse(payload: JwtPayload): Promise<AuthenticationContext> to validate tokens from a specific issuer. Register instances in JwtAuthenticationIssuerMap keyed by the iss claim value.

BasicAuthenticationHandler

Handles basic tokens by base64-decoding the credential, splitting on :, and delegating to BasicAuthenticationIssuer.

BasicAuthenticationIssuer

Abstract base class. Implement verify(username: string, password: string): Promise<AuthenticationContext>.

AuthenticationSessionService

| Method | Returns | Description | | ------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------- | | createSession(subject, claims, factors, expiration?) | Promise<AuthenticationSession> | Create and cache a new session | | updateSession(token, subject, expiration?, claims?, factor?) | Promise<AuthenticationSession> | Merge claims/factors and extend session expiry | | createOrUpdateSession(token?, subject, claims, factor, expiration?) | Promise<AuthenticationSession> | Create or update depending on whether the token resolves | | lookupSessionFromJwt(jwt, ignoreExpiration?) | Promise<{ session, jwtPayload }> | Validate a JWT and retrieve its session | | getSession(token) | Promise<AuthenticationSession \| undefined> | Retrieve a session by token | | getSessionsForSubject(subject) | Promise<AuthenticationSession[]> | Get all active sessions for a subject | | issueTokenForSession(sessionToken) | Promise<AuthenticationToken> | Issue a signed JWT for an existing session | | deleteSession(token) | Promise<void> | Revoke a session |

AuthenticationSession

Server-side session record stored in cache. Time fields are Luxon DateTime instances in your code; the service serializes them to Unix integers at the cache boundary.

| Field | Type | Description | | ---------------- | --------------------------------- | ------------------------------------------------------------------------ | | sessionToken | string | Opaque session token, also embedded in issued JWTs as sessionToken. | | subject | string | Subject identifier (typically a user id). | | issuedAt | DateTime | When the session was originally created. | | expiresAt | DateTime | When the session expires. | | lastAccessedAt | DateTime | When the session was most recently accessed. | | factors | AuthenticationSessionFactor[] | Factors satisfied during this session. | | claims | Record<string, unknown> | Arbitrary claims to embed in tokens issued from this session. |

AuthenticationSessionFactor

| Field | Type | Description | | ----------------- | --------------------------------------------------------------- | ------------------------------------------------------ | | issuedAt | DateTime | When this factor entry was first added to the session. | | authenticatedAt | DateTime | When the factor was most recently re-verified. | | method | 'phone' \| 'password' \| 'authenticator' \| 'email' \| 'fido' | The verification method used. | | methodId | string | Stable identifier for the specific factor record. | | kind | AuthenticationFactorKind | MFA category. |

AuthenticationToken

OAuth 2.0-style Bearer token response returned by issueTokenForSession.

| Field | Type | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | accessToken | string | The signed JWT to send as a Bearer credential. | | tokenType | string | Always "Bearer". | | expiresIn | number | Unix timestamp (seconds) at which the access token expires — taken directly from the JWT's exp claim, not seconds-from-now. | | refreshToken | string (optional) | A refresh token, when issued. | | scope | string | Space-separated list of granted scopes. |

CacheProvider

Abstract base class. Implement get, set, update, and delete to plug in any cache backend (Redis, in-memory, etc.).

JwtProvider

| Method | Returns | Description | | ------------------------------------------------------ | ----------------------------- | ------------------------------------ | | create(payload, subject, issuer, audience, expiresIn) | { token, decoded } | Sign an RS256 JWT | | decode(token, issuer, ignoreExpiration?, reThrow?) | JwtPayload \| undefined | Verify and decode an RS256 JWT |

OtpProvider

| Method | Returns | Description | | -------------------------------------------------------------------------- | --------- | -------------------------------------------------------- | | createSecret(numBytes?) | string | Generate a base32-encoded random secret | | generate(secret, options) | string | Generate an HOTP or TOTP value (RFC 4226/6238) | | validate(otp, secret, options, window?) | boolean | Validate an HOTP or TOTP value | | generateURI(secret, options, urlOptions) | string | Build an otpauth:// provisioning URI |

options is an OtpOptions object with type: 'hotp' | 'totp', plus algorithm, counter (HOTP), periodSeconds (TOTP), and tokenLength. urlOptions accepts issuer and an optional label.

PasswordStrengthProvider

Evaluates password strength via zxcvbn-ts with the HaveIBeenPwned matcher enabled. Subclass and register your subclass in the DI container to override the policy.

| Method | Returns | Description | | ------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | checkStrength(password, ...userInputs) | Promise<{ valid: boolean, score: 0–4, feedback: { warning, suggestions } }> | Evaluate strength without throwing. valid is true when score >= 3 | | ensureStrength(password, ...userInputs) | Promise<void> | Throw HTTP 400 with { password: warning, suggestions } when score < 3 |

userInputs are extra strings or numbers (name, email, date of birth, etc.) that zxcvbn penalises if they appear in the password.

PkceProvider

Cache-backed storage for PKCE state (RFC 7636). Wraps an injected CacheProvider; entries are namespaced under the pkce_ key prefix and expire on the cache TTL.

| Method | Returns | Description | | ------------------------------------------------------------ | ------------------------ | ---------------------------------------------------------------------------- | | storeChallenge(codeChallenge, value, expiration: Duration) | Promise<void> | Bind value to a code challenge for expiration | | storeVerifier(codeVerifier, value, expiration: Duration) | Promise<void> | Same as storeChallenge, but derives the challenge from the verifier | | getChallenge(codeChallenge) | Promise<string \| null> | Look up the stored value for a challenge; null when missing/expired | | getVerifier(codeVerifier) | Promise<string \| null> | Look up the value for the verifier-derived challenge — the standard PKCE op | | deleteChallenge(codeChallenge) | Promise<void> | Remove the entry — call after a successful exchange for single-use semantics | | deleteVerifier(codeVerifier) | Promise<void> | Same as deleteChallenge, but derives the challenge from the verifier |

PasswordFactorService

Manages password factors with PBKDF2-SHA512 hashing, password-reuse prevention, and rate-limited verification. Strength checks are delegated to an injected PasswordStrengthProvider. Requires that provider, a RateLimiterCompatibleAbstract (from rate-limiter-flexible), and a CacheProvider (used by the staged-registration flow) registered in the DI container.

| Method | Returns | Description | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | createPasswordFactor(actorId, password, needsReset?) | Promise<string> | Validate strength via PasswordStrengthProvider and persist a new factor; throws HTTP 409 if one already exists. Returns factorId | | registerPasswordFactor(password, registrationId?) | Promise<{ registrationId, expiresAt: DateTime, issuedAt: DateTime, alreadyRegistered: boolean }> | Validate strength and stage a hashed registration in the cache for 10 minutes (idempotent — alreadyRegistered is true on a cache hit) | | createPasswordFactorFromRegistration(actorId, registrationId) | Promise<PasswordFactor> | Complete a staged registration; throws HTTP 404 when the registration is missing or expired | | hasPendingRegistration(registrationId) | Promise<boolean> | Check whether a staged registration is still cached and unexpired | | updatePasswordFactor(actorId, password, needsReset?) | Promise<string> | Replace the password after strength check and reuse check against the last 10 passwords. Returns factorId | | verifyPassword(actorId, password) | Promise<string> | Verify against the stored hash with rate limiting; throws HTTP 401 on bad credentials, HTTP 429 if rate-limited. Returns factorId | | changePassword(actorId, password) | Promise<string> | Set a new password and clear the needsReset flag | | checkPasswordStrength(password, ...userInputs) | Promise<{ valid: boolean, score: number, feedback }> | Pass-through to PasswordStrengthProvider.checkStrength for live strength feedback (e.g. a sign-up form meter) | | ensurePasswordStrength(password, ...userInputs) | Promise<void> | Pass-through to PasswordStrengthProvider.ensureStrength; throws HTTP 400 when the password is below the strength threshold | | clearRateLimit(actorId) | Promise<void> | Reset the verify-password rate-limiter counter for an actor (e.g. after an out-of-band recovery) | | deleteFactor(actorId) | Promise<void> | Permanently remove the actor's password factor |

PasswordFactorRepository

Abstract base class. Extend and register a concrete implementation so that PasswordFactorService can resolve it at runtime.

| Method | Returns | Description | | ------------------------------------------------------- | -------------------------------- | -------------------------------------------------------- | | createFactor(subject, value, needsReset) | Promise<PasswordFactor> | Persist a new password factor | | updateFactor(actorId, value, needsReset) | Promise<PasswordFactor> | Replace the actor's current password factor value | | getFactor(actorId) | Promise<PasswordFactor> | Retrieve the active password factor for the actor | | listPreviousPasswords(actorId, limit) | Promise<PasswordValue[]> | Return the most recent limit historical password hashes | | deleteFactor(actorId) | Promise<void> | Permanently remove the actor's password factor |

PasswordFactor: { id: string; actorId: string; active: boolean; value: PasswordValue; needsReset: boolean }. PasswordValue: { hash: string; salt: string } — both base64-encoded.

EmailFactorService

| Method | Returns | Description | | ------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------- | | registerEmailFactor(value, verificationMethod, registrationId?) | Promise<{ registrationId, code, expiresAt: DateTime, issuedAt: DateTime, alreadyRegistered: boolean }> | Initiate email factor registration (idempotent — alreadyRegistered is true on a cache hit) | | createEmailFactorFromRegistration(actorId, registrationId, code) | Promise<EmailFactor> | Complete registration | | hasPendingRegistration(registrationId) | Promise<boolean> | Check whether a registration is still cached and unexpired | | issueEmailChallenge(actorId, factorId, issueMethod) | Promise<{ email, challengeId, code, expiresAt: DateTime, issuedAt: DateTime, alreadyIssued: boolean }> | Initiate a sign-in challenge (idempotent — alreadyIssued is true on a cache hit) | | verifyEmailChallenge(challengeId, code) | Promise<{ actorId, factorId }> | Complete a sign-in challenge | | hasPendingChallenge(challengeId) | Promise<boolean> | Check whether a challenge is still cached and unexpired | | getRedirectHtml(redirectUrl: URL) | { html, nonce } | Build a magic-link landing page that redirects to redirectUrl via a CSP-nonce-gated inline script (rejects non-http(s): URLs with HTTP 400) |

EmailFactorServiceOptions:

| Option | Type | Default | Description | | --------------------- | ---------- | ---------- | ---------------------------------------------------------------------------------------- | | denyList | string[] | [] | Sorted list of email domains to reject (checked via binary search; e.g. disposable mail) | | otpExpiration | Duration | 10 minutes | How long an OTP-code registration or sign-in challenge stays valid | | magiclinkExpiration | Duration | 30 minutes | How long a magic link token stays valid |

EmailFactorRepository

Abstract base class. Extend and register a concrete implementation so that EmailFactorService can resolve it at runtime.

| Method | Returns | Description | | ----------------------------------- | ------------------------ | ---------------------------------------------------- | | createFactor(actorId, value) | Promise<EmailFactor> | Persist a new email factor | | lookupFactor(value) | Promise<EmailFactor \| undefined> | Look up an email factor by email address | | isDomainInviteOnly(domain) | Promise<boolean> | Check whether a domain is invite-only (gates registration) | | getFactor(actorId, factorId) | Promise<EmailFactor> | Retrieve a factor by id | | deleteFactor(actorId, factorId) | Promise<void> | Remove a factor |

EmailFactor: { id: string; actorId: string; active: boolean; value: string } where value is the verified email address.

AuthenticatorFactorService

Manages TOTP/HOTP authenticator app factors. Requires an AuthenticatorFactorServiceOptions object with at minimum an issuer string.

| Method | Returns | Description | | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | registerAuthenticatorFactor(actorId, options?, registrationId?) | Promise<{ registrationId, secret, uri, qrCode, expiresAt: DateTime, issuedAt: DateTime, alreadyRegistered: boolean }> | Generate a secret, QR code, and cache the pending registration (idempotent — alreadyRegistered is true on a cache hit) | | createAuthenticatorFactorFromRegistration(actorId, registrationId, code) | Promise<AuthenticatorFactor> | Verify the first OTP code and persist the factor | | hasPendingRegistration(registrationId) | Promise<boolean> | Check whether a registration is still cached and unexpired | | validateFactor(actorId, factorId, code) | Promise<void> | Verify a TOTP/HOTP code; throws HTTP 401 on failure | | deleteFactor(actorId, factorId) | Promise<void> | Remove a factor |

AuthenticatorFactorServiceOptions:

| Option | Type | Default | Description | | ------------------------ | ---------- | ---------- | -------------------------------------------------------- | | issuer | string | — | Issuer name shown in the authenticator app | | registrationExpiration | Duration | 30 minutes | How long a pending registration stays valid | | factorExpiration | Duration | 4 hours | How long a validated factor session remains cached | | defaults | OtpOptions | TOTP SHA1 30s 6-digit | Default OTP options used when none are supplied per-call |

AuthenticatorFactorRepository

Abstract base class. Extend and register a concrete implementation (e.g. backed by a PostgreSQL table) so that AuthenticatorFactorService can resolve it at runtime.

| Method | Returns | Description | | ----------------------------------------------- | -------------------------------------------- | ------------------------------------------- | | createFactor(actorId, options) | Promise<AuthenticatorFactor> | Persist a new factor for an actor | | getFactor(actorId, factorId) | Promise<AuthenticatorFactor \| undefined> | Retrieve a factor by id | | deleteFactor(actorId, factorId) | Promise<void> | Remove a factor |

AuthenticatorFactor extends OtpOptions with id: string, actorId: string, active: boolean, and secretHash: string (the encrypted OTP secret).

PhoneFactorService

Manages phone number factor registration. Requires a PhoneFactorServiceOptions object with an otpExpiration duration.

| Method | Returns | Description | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | registerPhoneFactor(value, registrationId?) | Promise<{ value, registrationId, expiresAt: DateTime, issuedAt: DateTime, alreadyRegistered: boolean }> | Validate the E.164 number and cache a pending registration (idempotent — alreadyRegistered is true on a cache hit) | | createPhoneFactorFromRegistration(actorId, registrationId) | Promise<PhoneFactor> | Bind the cached phone number to actorId and persist the factor | | hasPendingRegistration(registrationId) | Promise<boolean> | Check whether a registration is still cached and unexpired |

PhoneFactorServiceOptions:

| Option | Type | Description | | --------------- | ---------- | ----------------------------------------------------- | | otpExpiration | Duration | How long a pending registration stays valid |

registerPhoneFactor throws HTTP 400 for invalid E.164 numbers. createPhoneFactorFromRegistration throws HTTP 404 when the registration has expired. The actor is bound at completion time, so callers that need to enforce uniqueness against existing factors should do so themselves before calling createPhoneFactorFromRegistration.

PhoneFactorRepository

Abstract base class. Extend and register a concrete implementation so that PhoneFactorService can resolve it at runtime.

| Method | Returns | Description | | ----------------------------------- | ------------------------------------- | ------------------------------------------------- | | createFactor(actorId, value) | Promise<PhoneFactor> | Persist a new factor for an actor | | findFactor(actorId, value) | Promise<PhoneFactor \| undefined> | Look up a factor by actor and phone number | | getFactor(actorId, factorId) | Promise<PhoneFactor \| undefined> | Look up a factor by id | | deleteFactor(actorId, factorId) | Promise<void> | Remove a factor |

PhoneFactor: { id: string; actorId: string; active: boolean; value: string } where value is the E.164-formatted phone number.

FidoFactorService

Manages FIDO2/WebAuthn factors. Wraps fido2-lib and accepts relying party identifiers per call.

| Method | Returns | Description | | -------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | | registerFidoFactor(actorId, options) | Promise<FidoAttestation> | Generate an attestation challenge and cache the expectations | | createFidoFactorFromRegistration(actorId, credential) | Promise<string> | Verify the attestation and persist the factor; returns factorId | | createFidoAuthorizationChallenge(actorId, options) | Promise<{ challenge, allowCredentials, ... }> | Emit an assertion challenge (allowCredentials from the actor's active factors) | | verifyFidoAuthorizationChallenge(actorId, credential) | Promise<{ actorId, factorId }> | Verify the assertion signature and bump the stored counter |

FidoFactorServiceOptions:

| Option | Type | Default | Description | | --------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------- | | timeout | Duration | 5 minutes | How long a pending registration or authorization challenge stays valid (also forwarded to the browser) |

RegisterFidoFactorOptions: { rpId, rpName, rpOrigin, rpIcon?, userName, userDisplayName }.

AuthorizeFidoFactorOptions: { rpId, rpOrigin }.

createFidoFactorFromRegistration throws HTTP 401 with WWW-Authenticate: Bearer error="invalid_registration" when no pending registration is cached, or error="invalid_credentials" on attestation failure. createFidoAuthorizationChallenge throws HTTP 404 when the actor has no active factors. verifyFidoAuthorizationChallenge throws HTTP 401 with error="invalid_credentials" when the challenge is missing/expired, the credential id is unknown, or the signature is invalid.

FidoFactorRepository

Abstract base class. Extend and register a concrete implementation so that FidoFactorService can resolve it at runtime.

| Method | Returns | Description | | --------------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- | | createFactor(actorId, publicKey, publicKeyId, counter, active) | Promise<FidoFactor> | Persist a new factor | | listFactors(actorId, active) | Promise<FidoFactor[]> | List the actor's factors, used to populate allowCredentials | | getFactor(actorId, factorId) | Promise<FidoFactor \| undefined> | Look up a factor by credential id (the id field of PublicKeyCredential) | | updateFactorCounter(actorId, factorId, counter) | Promise<void> | Persist the latest signature counter; must be strictly increasing (regression = cloned authenticator) | | deleteFactor(actorId, factorId) | Promise<void> | Remove a factor |

FidoFactor: { id: string; actorId: string; active: boolean; publicKey: string; publicKeyId: string; counter: number }.

OidcProviderRegistry

Holds the configured OIDC providers and lazily resolves an openid-client Configuration per provider on first use. Constructed from an injected OidcProviderRegistryConfig.

| Method | Returns | Description | | ---------------------------- | -------------------------------------- | -----------------------------------------------------