Lifecycle hooks allow you to customize the OAuth authentication flow by executing custom logic at key events.
Called when a provider is not found in the static registry. Allows applications to implement multi-tenant OAuth by dynamically resolving provider configurations based on naming conventions.
Purpose: Multi-tenant SSO, organization-specific OAuth providers, database-backed provider configuration
Signature:
async function onResolveProvider(providerName: string, logger?: Logger): Promise<OAuthProviderConfig | null>;Parameters:
providerName- Provider name from URL path (e.g.,"okta-org_abc123")logger- Optional logger instance
Returns: Provider configuration object or null if provider not found
Example:
import {
getProvider,
validateTenantId,
validateDomainSafety,
validateDomainAllowlist,
validateAzureTenantId,
} from '@harperfast/oauth';
const { Organization } = tables;
async function resolveOAuthProvider(providerName, logger) {
// Parse provider name format: "{provider}-{tenantId}"
const match = providerName.match(/^(okta|azure|auth0)-(.+)$/);
if (!match) {
// Not a multi-tenant provider name
return null;
}
const [, provider, tenantId] = match;
// Validate tenant ID format BEFORE database lookup
try {
validateTenantId(tenantId);
} catch (error) {
logger?.warn?.(`Invalid tenant ID in provider name: ${providerName}`);
return null; // Return 404, not 500
}
// Query Organization table for OAuth config
const org = await Organization.get(tenantId);
// Check if OAuth is enabled for this organization
if (!org?.oauthConfig?.enabled || org.oauthConfig.status !== 'active') {
logger?.debug?.(`OAuth not enabled for tenant: ${tenantId}`);
return null;
}
const config = org.oauthConfig;
// Verify provider type matches
if (config.provider !== provider) {
logger?.warn?.(`Provider mismatch: URL has ${provider}, config has ${config.provider}`);
return null;
}
// Get base provider configuration from OAuth plugin
const baseProvider = getProvider(provider);
if (!baseProvider) {
logger?.error?.(`Unknown provider type: ${provider}`);
return null;
}
// Apply provider-specific configuration with validation
let providerSpecificConfig = {};
try {
if (baseProvider.configure) {
switch (provider) {
case 'okta':
case 'auth0':
if (!config.domain) {
throw new Error(`${provider} requires domain configuration`);
}
// Validate domain safety (SSRF protection)
const hostname = validateDomainSafety(config.domain, provider);
const allowedDomains = {
okta: ['.okta.com', '.okta-emea.com', '.oktapreview.com'],
auth0: ['.auth0.com', '.eu.auth0.com', '.au.auth0.com'],
};
validateDomainAllowlist(hostname, allowedDomains[provider], provider);
providerSpecificConfig = baseProvider.configure(config.domain);
break;
case 'azure':
if (!config.azureTenantId) {
throw new Error('Azure requires tenantId configuration');
}
// Validate Azure tenant ID format
validateAzureTenantId(config.azureTenantId);
providerSpecificConfig = baseProvider.configure(config.azureTenantId);
break;
}
}
} catch (error) {
logger?.error?.(`Invalid OAuth config for organization ${org.name}:`, error);
return null;
}
// Build complete provider configuration
const providerConfig = {
// Base provider properties
provider: config.provider,
scope: config.scope || baseProvider.scope,
usernameClaim: baseProvider.usernameClaim,
emailClaim: baseProvider.emailClaim,
nameClaim: baseProvider.nameClaim,
roleClaim: baseProvider.roleClaim,
defaultRole: baseProvider.defaultRole,
preferIdToken: baseProvider.preferIdToken,
// Provider-specific URLs from configure()
authorizationUrl: providerSpecificConfig.authorizationUrl,
tokenUrl: providerSpecificConfig.tokenUrl,
userInfoUrl: providerSpecificConfig.userInfoUrl,
jwksUri: providerSpecificConfig.jwksUri,
issuer: providerSpecificConfig.issuer,
// Tenant-specific credentials from database
clientId: config.clientId,
clientSecret: config.clientSecret,
};
return providerConfig;
}Caching Behavior:
The plugin caches each resolved provider config in memory so the hook (a database lookup, decryption, etc.) doesn't run on every request. The cache is in-memory and per-worker-thread, and freshness is controlled solely by a TTL — an entry is re-resolved once it expires, so a config change (disabled provider, rotated credentials, etc.) takes effect within one TTL window. There is no manual invalidation API: a per-thread evict would clear only one worker's copy, so the uniform TTL is the single, predictable mechanism.
Control caching with cacheDynamicProviders in your plugin config:
'@harperfast/oauth':
cacheDynamicProviders: 300 # Cache for 300 seconds (default)
cacheDynamicProviders: 30 # Lower TTL = fresher config (recommended for multi-tenant)
cacheDynamicProviders: false # Never cache — hook called on every request
cacheDynamicProviders: true # Cache forever (only if the config never changes at runtime)If your onResolveProvider already caches at the lookup layer (e.g. it memoizes its own DB reads and decryption), set cacheDynamicProviders: false and let that layer own caching — the plugin will simply call the hook each time.
Security Requirements:
- MUST validate tenant ID format before database lookup
- MUST validate domain safety (SSRF protection)
- MUST validate provider-specific configuration
- MUST NOT return configurations for disabled/inactive tenants
- SHOULD log all resolution attempts for audit trail
URL Structure:
When using onResolveProvider, users access tenant-specific login URLs:
/oauth/okta-org_abc123/login ← Acme Corp's Okta
/oauth/azure-org_xyz789/login ← Globex's Azure ADThe provider name (okta-org_abc123) is parsed by your hook to extract the provider type and tenant ID, then dynamically resolves the configuration from your database.
Called after successful OAuth authentication, before the session is created.
Purpose: User provisioning, role mapping, custom session data, analytics
Signature:
async function onLogin(
oauthUser: OAuthUserInfo,
tokenResponse: TokenResponse,
session: Session,
request: Request,
provider: string
): Promise<OnLoginResult | void>;Parameters:
oauthUser- OAuth user profile (username, email, name, role).oauthUser.emailVerifiedistrue/falsewhen the provider attested the email's verified status,undefinedwhen it didn't — gate provisioning onemailVerified === true, never on "not false". (Raw provider claims remain available atoauthUser.metadata.oauthClaims.)tokenResponse- Complete OAuth token response from providersession- Current session objectrequest- HTTP request objectprovider- Provider name (e.g., 'github', 'google')
Returns: Object to merge into session, or a structured outcome that controls the login (see below). Important: Return { user: userId } to set the Harper system username for authentication.
The return value decides whether a session is created (since v2.3.0, #174):
{ status: 'ok', user, ...data } // establish the session (same as a plain object or no return)
{ status: 'denied', error?, redirect? } // do NOT establish a session
{ status: 'needs_confirmation', redirect } // do NOT establish a session yet — send the user to finish a stepdenied— the browser is sent toredirectwhen given, otherwise to the standard error redirect (postLoginRedirectwitherror=access_denied, plusreasonfromerror). Use for unprovisioned, deactivated, or otherwise unapproved users.needs_confirmation— the browser is sent toredirect(e.g. a "finish setup" page). Use when a first-time user must complete onboarding or confirmation before their first session.redirectmay be a relative path or an absolutehttp(s)URL (the hook is trusted app code; other schemes are rejected).- Backward compatible: returning a plain object or nothing behaves exactly as before. Only the
deniedandneeds_confirmationstatus values change behavior — these two values are newly reserved: an enrichment object that previously happened to usestatuswith exactly one of them would now gate the login instead of merging into the session. Any otherstatusvalue is still treated as plain session data (a warning is logged, since it may be a typo'd gating attempt). - During an MCP OAuth flow, both gating outcomes fail the authorization cleanly with
access_deniedto the MCP client (an MCP client can't follow an interactive redirect). Theerrorstring is echoed to the MCP client verbatim aserror_description— keep it a terse reason code, never internal details.
async function handleLogin(oauthUser, tokenResponse, session, request, provider) {
const account = await findAccount(oauthUser.email);
if (!account) {
// Not provisioned — reject the login, no session is created
return { status: 'denied', error: 'not_provisioned' };
}
if (!account.onboardingComplete) {
// Defer the login until onboarding is done
return { status: 'needs_confirmation', redirect: `/onboarding?account=${account.id}` };
}
return { status: 'ok', user: String(account.id) };
}A thrown error does not gate the login. Unexpected hook errors are caught and logged, and the flow proceeds as if the hook returned nothing — deliberate gating must be expressed via the return value.
Example:
async function handleLogin(oauthUser, tokenResponse, session, request, provider) {
const { User } = tables;
const context = request.context || {};
// Validate email — return a denied outcome (a throw would be logged and
// the login would proceed; see "Controlling the login outcome")
if (!oauthUser?.email) {
return { status: 'denied', error: 'missing_email' };
}
// Find existing user by email
let user;
for await (const record of User.search([{ attribute: 'email', value: oauthUser.email }], context)) {
user = record;
break; // Take first match
}
if (!user) {
// New user - create database record
user = await User.create(
{
email: oauthUser.email,
name: oauthUser.name,
provider: provider,
createdAt: new Date().toISOString(),
},
context
);
} else {
// Update last login
await User.patch(
user.id,
{
lastLoginDate: new Date().toISOString(),
provider: provider,
},
context
);
}
// Return Harper system username for authentication
return {
user: String(user.id),
};
}Called before the session is cleared during logout.
Purpose: Cleanup, audit logging, revoke external tokens
Signature:
async function onLogout(session: Session, request: Request): Promise<void>;Parameters:
session- Current session object with user and OAuth datarequest- HTTP request object
Returns: void
Example:
async function handleLogout(session, request) {
// Log the logout event
logger.info('User logged out', {
userId: session.user,
email: session.oauthUser?.email,
});
// Optional: Create audit log
if (session.user) {
await tables.AuditLog.create({
userId: session.user,
action: 'logout',
timestamp: new Date().toISOString(),
});
}
}Called after an automatic token refresh (on every HTTP request).
Purpose: Update caches, log refresh events, sync external systems
Signature:
async function onTokenRefresh(session: Session, refreshed: boolean, request: Request): Promise<void>;Parameters:
session- Current session object with updated tokenrefreshed- Whether token was actually refreshed (true) or still valid (false)request- HTTP request object
Returns: void
Example:
async function handleTokenRefresh(session, refreshed, request) {
if (refreshed) {
logger.debug('OAuth token refreshed', {
userId: session.user,
provider: session.oauth?.provider,
expiresAt: new Date(session.oauth?.expiresAt).toISOString(),
});
}
}Called after an MCP access or refresh token is minted. Because it runs detached and is not awaited (fire-and-forget), it never delays the token response — its side effects may complete after the client has already received the token. Only fires when MCP OAuth is enabled. This is the MCP-client analog of onLogin — react in your own application when an MCP client gains access.
Purpose: Associate an MCP client_id with a user (sub) in your own data model, monitoring and security alerting on which clients obtain tokens, per-client rate-limiting
Signature:
async function onMCPTokenIssued(
event: {
type: 'access' | 'refresh' | 'client_credentials';
client_id: string;
sub: string;
aud: string;
scope?: string;
jti: string;
},
request: Request
): Promise<void>;Parameters:
event- Identifies the token issued:type(accessfor the authorization-code grant,refreshfor a rotation,client_credentialsfor the headless-agent grant — wheresubis the client, not a user),client_id,sub,aud,scope(optional), andjti(the token id)request- The HTTP request that triggered issuance
Returns: void. Fire-and-forget — the hook is not awaited (it runs detached, so it never delays or blocks token issuance); a throwing hook is caught and logged, never surfaced.
Security:
eventis sanitized — it carries only thejti(a token identifier, safe to log), never the access/refresh token strings. Therequestis not sanitized: on the refresh path its body carries therefresh_tokenthe client presented, so do not logrequestwholesale.
Example:
async function handleMCPTokenIssued(event, request) {
// Record which MCP client is acting for which user. `tables` is a Harper global
// (no import needed); `McpClient` is an example app-owned table — the plugin
// doesn't provide it, so define your own.
await tables.McpClient.put({ id: event.client_id, user: event.sub, lastSeen: Date.now() });
}Hooks are lazy-referenced - they are looked up when OAuth events occur (login, logout, token refresh), not when registered. This means you can call registerHooks() at any time, and there's no specific initialization window. The hooks are simply stored and referenced later when needed.
The typical pattern is to register hooks in your application's main entry point (e.g., resources.js), but the timing is flexible.
resources.js (application entry point):
import { registerHooks } from '@harperfast/oauth';
import { hooks } from './src/lib/oauthHooks.js';
// Register hooks at module load time
registerHooks(hooks);
// Export your resources...
export { User } from './src/resources/User.js';
export { Organization } from './src/resources/Organization.js';
// ...src/lib/oauthHooks.js:
const { User } = tables;
async function handleLogin(oauthUser, tokenResponse, session, request, provider) {
const context = request.context || {};
if (!oauthUser?.email) {
return { status: 'denied', error: 'missing_email' };
}
// Find existing user by email
let user;
for await (const record of User.search([{ attribute: 'email', value: oauthUser.email }], context)) {
user = record;
break; // Take first match
}
if (!user) {
// Create new user - ID will be auto-generated
user = await User.create(
{
email: oauthUser.email,
name: oauthUser.name,
provider: provider,
createdAt: new Date().toISOString(),
},
context
);
} else {
// Update existing user
await User.patch(
user.id,
{
lastLoginDate: new Date().toISOString(),
provider: provider,
},
context
);
}
// Return Harper system username for authentication
return { user: String(user.id) };
}
async function handleLogout(session, request) {
logger.info('User logged out', { userId: session.user });
}
async function handleTokenRefresh(session, refreshed, request) {
if (refreshed) {
logger.debug('Token refreshed', { userId: session.user });
}
}
// Export hooks object
export const hooks = {
onLogin: handleLogin,
onLogout: handleLogout,
onTokenRefresh: handleTokenRefresh,
};You can also register hooks inline:
resources.js:
import { registerHooks } from '@harperfast/oauth';
registerHooks({
onLogin: async (oauthUser, tokenResponse, session, request, provider) => {
const user = await tables.User.patch({
email: oauthUser.email,
name: oauthUser.name,
provider: provider,
});
return { user: String(user.id) };
},
onLogout: async (session, request) => {
logger.info('User logged out', { userId: session.user });
},
onTokenRefresh: async (session, refreshed, request) => {
if (refreshed) logger.debug('Token refreshed', { userId: session.user });
},
});
// Export your resources...After onLogin completes, the session contains:
{
user: 'guid-1234', // Harper system username (from onLogin hook)
oauthUser: { // OAuth user profile
username: 'oauth_username',
email: 'user@example.com',
name: 'User Name',
role: 'user'
},
oauth: { // Token metadata
provider: 'github',
accessToken: 'token_value',
refreshToken: 'refresh_value',
expiresAt: 1234567890,
refreshThreshold: 1234567800,
scope: 'user:email',
tokenType: 'Bearer',
lastRefreshed: 1234567890
},
// Additional custom data from onLogin hook return value
organizationId: 'org_456',
roles: ['admin']
}Accessing session in your code:
export class MyResource extends tables.Resource {
async get(target, request) {
// Check authentication
if (!request.session?.user) {
throw new ClientError('Not authenticated', 401);
}
// Access user data
const userId = request.session.user; // Harper system username
const email = request.session.oauthUser.email;
return { userId, email };
}
}- onLogin: Return
{ status: 'denied' }to prevent login (e.g., suspended accounts) — thrown errors are caught and logged and the login proceeds - onLogout/onTokenRefresh: Catch and log errors, don't throw (non-critical)
async function handleLogout(session, request) {
try {
await cleanupUserData(session.user);
} catch (error) {
logger.error('Logout cleanup failed', error);
// Don't throw - allow logout to proceed
}
}- Keep hooks fast - token refresh runs on every request
- Use background jobs for heavy operations
- Cache frequently accessed data
async function handleLogin(oauthUser, tokenResponse, session, request, provider) {
// Quick operation - runs inline
const user = await quickUserLookup(oauthUser.email);
// Heavy operation - queue for background processing
await queue.add('user-provisioning', {
userId: user.id,
oauthData: tokenResponse,
});
return { user: user.id };
}- Validate all input data
- Don't expose sensitive OAuth tokens
- Log authentication events for audit
async function handleLogin(oauthUser, tokenResponse, session, request, provider) {
// Validate email format — deny rather than throw (throws don't gate)
if (!isValidEmail(oauthUser.email)) {
return { status: 'denied', error: 'invalid_email' };
}
// Don't store raw OAuth tokens in logs
logger.info('Login successful', {
email: oauthUser.email,
provider: provider,
// Don't log: tokenResponse.access_token
});
return await provisionUser(oauthUser);
}Use debug mode to test hooks during development:
'@harperfast/oauth':
debug: true
providers:
github:
clientId: ${OAUTH_GITHUB_CLIENT_ID}
clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}Then monitor logs and test with debug endpoints:
GET /oauth/{provider}/user- View current sessionGET /oauth/{provider}/refresh- Trigger token refresh