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
The returned config is merged with the plugin defaults and provider preset the
same way static providers config is: an explicit undefined value on any
field (e.g. scope: row.scope from a database row with no override) is
treated as "not specified" and does not clear the preset's default — pass
null or '' to explicitly blank most fields (redirectUri is an exception:
it falls back to the plugin-level default, or fails configuration if there
isn't one, rather than being blanked).
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,providerUserId).oauthUser.authEvidenceis the plugin's own trust determination — base account adoption on it, not onemailVerified. It is attached only when anonLoginhook is registered, and is a frozen login-time snapshot with:emailAuthenticated—trueonly when there is a usable, verified email from an authenticated source. It attestsauthEvidence.email— notoauthUser.username, which may be a reassignable handle (e.g. Oktapreferred_username, GitHublogin). So when it istrue, adopt the account you resolve fromauthEvidence.email, never{ user: oauthUser.username }.emailProvenance—'signed-oidc'(verified signature and validated issuer),'github-authenticated'(GitHub's authenticated email fetch), or'unauthenticated'. Normalized: a decoded-but-unverified id token is reported'unauthenticated'.signatureVerified/issuerValidated— id-token verification outcomes.issuerValidatedmeans theissmatched a configured issuer; which one isidTokenIssuer. Both describe the ID TOKEN only — they say nothing aboutemailoremailVerified, which can come from an unsigned UserInfo response (e.g.fetchEmail: truewith an id token lacking anemailclaim,preferIdToken: false, or a customgetUserInfoadapter). A custom policy built from the individual fields, rather thanemailAuthenticateditself, must still requireemailProvenance !== 'unauthenticated'—signatureVerified && issuerValidated && emailVerified === truealone re-creates the spoofable-email adoption bug this evidence exists to close.idTokenIssuer/idTokenSubject— the validated issuer and subject (both present only when signature+issuer verified). To bind by stable identity, key on the pair(idTokenIssuer, idTokenSubject)—idTokenSubjectalone is not unique when a provider config lists multiple issuers. Some providers accept issuer aliases (Google issuesissas eitherhttps://accounts.google.comoraccounts.google.com) — normalize known aliases before storing or comparing a binding, or the same account can mint two different keys.emailVerified(boolean | undefined) andemail(the address the evidence describes — use it, not a later-mutatedoauthUser.email).authEvidence.emailVerifiedis the same unauthenticated provider claim asoauthUser.emailVerifiedbelow — not attested bysignatureVerified/issuerValidated.
oauthUser.emailVerifiedmirrors the provider'semail_verifiedclaim and is not proof of an authenticated source — an unsigned UserInfo body can assertemail_verified: true. It's a reasonable signal for provisioning a brand-new low-privilege account, never for adopting/elevating an existing one, and never a "not false" check.- Returning
{ user }is authoritative and bypasses the account-adoption gate; for an existing account, resolve it fromauthEvidence.emailwhenemailAuthenticated === true, or bind on(authEvidence.idTokenIssuer, authEvidence.idTokenSubject). MissingauthEvidencemeans insufficient authentication — never fall back toemailVerified. Raw provider claims remain 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),
};
}An advanced escape hatch for choosing which of several provider-reported emails becomes the login identity (#228), for policies the plugin's own built-in default can't express. Called only when the provider's authenticated email fetch succeeded — currently GitHub's /user/emails, which can return more than one address with independent verified/primary flags. OIDC providers have a single email claim, so this hook never fires for them.
Most applications don't need this hook: GitHub logins with several verified addresses are already resolved by the plugin itself — the built-in default matches an existing Harper account first, refuses an ambiguous match, and otherwise falls back to the public profile email or primary address. Register onResolveEmail only when that built-in behavior isn't what you want — e.g. preferring a company domain even before any account exists under it, or applying your own policy that doesn't key on existing accounts at all.
Purpose: Let an application pick a specific verified address by its own policy, overriding both the provider's own primary/profile choice AND the plugin's built-in existing-account default.
Signature:
async function onResolveEmail(
candidates: ReadonlyArray<{ email: string; verified: boolean; primary: boolean; profile: boolean }>,
provider: string,
signal: AbortSignal
): Promise<string | null | undefined>;Parameters:
candidates- Every email the provider's authenticated fetch returned, verified or not (so you can see the whole picture — e.g. to prompt a user to verify a preferred but currently-unverified address, while still returning a verified one for this login).profilemarks the provider's public profile address (GitHub's/useremailfield) — the address the plugin's original default preferred overprimarywhen both exist. An immutable snapshot: mutating it has no effect on anything the plugin validates against.provider- Provider name (e.g.,'github')signal- Aborted when the 5-second deadline is reached. Cancellation is cooperative: passsignalto your ownfetch/database call so a timed-out lookup actually stops (e.g. releases a connection) instead of continuing to run after the login has already failed.
Returns: The chosen address, or null/undefined for "no preference" — the plugin falls back to its built-in default (existing-account match, then ambiguity refusal, then the original profile-or-primary default). The existing-account match step only runs when usernameClaim: 'email' is configured — hdb_user isn't keyed by email otherwise, so there is nothing to match against; a declining hook always falls straight to the original profile-or-primary default in that case.
Registering this hook is opt-in, but the built-in default it sits above is not (when usernameClaim: 'email') — a GitHub login with several verified addresses is resolved by the plugin itself whether or not any hook is registered. Leaving onResolveEmail unregistered costs nothing beyond that built-in default's own cost (no reads for zero verified addresses, or when the lone verified address already is the verified profile-or-primary default pick — otherwise one read per verified address); the candidate list itself is never built or retained for any purpose beyond resolving this one login.
SECURITY — enforced by the plugin, not by convention:
- The returned address must be one of
candidateswithverified === true, matched exactly (case-sensitive) — returncandidate.emailitself, not a re-typed or re-cased copy. This is checked against the exact snapshot handed to the hook, not a value the hook could have mutated. - An invalid result (not a string, not a verified candidate), a thrown error, or a hook that doesn't settle within 5 seconds, fails the login — it does not fall back to the default selection. Falling back silently on a failed pick could establish a session for a different account than the one the hook was trying to reach (e.g. selecting a work address to adopt an existing work account, but the lookup inside the hook fails) — so an unresolved pick is loud, not quiet.
- The resolved address is exactly what later becomes
authEvidence.email/oauthUser.email— there is no separate path that could key identity on a different, unvalidated address.
Error types: a failure anywhere in resolution (the hook, or the built-in default) throws one of ResolveEmailError (invalid/unverified pick, hook throw, hook timeout — reason=email_selection), AmbiguousEmailError (two or more existing-account matches where the verified profile-or-primary address isn't one of them — reason=email_ambiguous), or EmailLookupError (an hdb_user read failed mid-match — reason=email_lookup_failed, retryable). All three are exported from @harperfast/oauth for a custom getUserInfo adapter (or any code holding a reference to the thrown error) to classify with instanceof or by its reason property, rather than parsing the message.
GitHub's default usernameClaim is login (the handle), not email. Unless you also set usernameClaim: 'email' on the GitHub provider config, onResolveEmail changes oauthUser.email and authEvidence.email but not session.user (still the GitHub handle) — set usernameClaim: 'email', or map the chosen email to a username yourself in onLogin (oauthUser.email already reflects the resolved address there).
Example — prefer a company domain, else no preference:
async function resolveEmail(candidates, provider) {
const corporate = candidates.find((c) => c.verified && c.email.endsWith('@acme.example'));
return corporate?.email; // undefined → plugin's default selection
}Example — reject a login with no corporate-domain option instead of silently using a personal address, by throwing from the hook (returning an address that isn't one of the verified candidates fails the login the same way, but throwing documents the intent):
async function resolveEmail(candidates) {
const corporate = candidates.find((c) => c.verified && c.email.endsWith('@acme.example'));
if (!corporate) {
throw new Error('no verified @acme.example address on this GitHub account');
}
return corporate.email;
}Example — opt out of the built-in existing-account default and reconstruct the plugin's original profile-or-primary pick, using an AbortController wired to signal so an interrupted lookup actually stops. This reproduces the old pick whenever it was verified; it cannot reproduce an unverified profile/primary pick, because onResolveEmail is never allowed to return an unverified address — see the upgrade note for that edge case:
async function resolveEmail(candidates, provider, signal) {
const controller = new AbortController();
signal.addEventListener('abort', () => controller.abort());
await auditLookup(candidates, { signal: controller.signal }); // your own I/O, cancellable on timeout
const pick = candidates.find((c) => c.profile) ?? candidates.find((c) => c.primary);
return pick?.verified ? pick.email : undefined; // unverified pick: no preference, not a failed login
}Rollout note (for the hook itself): this hook is opt-in and additive on top of the plugin's own built-in default — an instance that never registers it still gets that default (see its own upgrade note), just never a hook-chosen override. Registering the hook can further change which Harper account a given GitHub login resolves to (relative to what the built-in default alone would have picked), so roll it out deliberately, not as an incidental part of an unrelated deploy:
- Enable it on every node at once, not progressively. During a rolling deploy, the same GitHub user hitting an old node (no hook) and a new node (hook registered) can resolve to two different addresses — and, with
usernameClaim: 'email', two different Harper accounts — for the same login attempt. A load balancer that isn't sticky per user across the deploy window can bounce between the two outcomes. - Decide what happens to an existing account keyed on the old (default) address before enabling. This hook changes selection going forward; it does not migrate or link any account already created under the previous default pick. If users should keep their existing account, either keep the hook returning the same address it would have defaulted to for already-provisioned users (e.g. look up the existing account by any of
candidatesfirst, fall back to your preferred address only for new users), or run your own one-time account-linking step. - Already-established sessions are unaffected either way — they are not re-evaluated until the next login, so this is only a concern for logins that happen during and after the rollout, not for users already signed in.
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