Skip to content

Latest commit

 

History

History
665 lines (517 loc) · 20.4 KB

File metadata and controls

665 lines (517 loc) · 20.4 KB

Lifecycle Hooks

Lifecycle hooks allow you to customize the OAuth authentication flow by executing custom logic at key events.

Available Hooks

onResolveProvider

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 AD

The 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.


onLogin

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.emailVerified is true/false when the provider attested the email's verified status, undefined when it didn't — gate provisioning on emailVerified === true, never on "not false". (Raw provider claims remain available at oauthUser.metadata.oauthClaims.)
  • tokenResponse - Complete OAuth token response from provider
  • session - Current session object
  • request - HTTP request object
  • provider - 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.

Controlling the login outcome

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 step
  • denied — the browser is sent to redirect when given, otherwise to the standard error redirect (postLoginRedirect with error=access_denied, plus reason from error). Use for unprovisioned, deactivated, or otherwise unapproved users.
  • needs_confirmation — the browser is sent to redirect (e.g. a "finish setup" page). Use when a first-time user must complete onboarding or confirmation before their first session.
  • redirect may be a relative path or an absolute http(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 denied and needs_confirmation status values change behavior — these two values are newly reserved: an enrichment object that previously happened to use status with exactly one of them would now gate the login instead of merging into the session. Any other status value 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_denied to the MCP client (an MCP client can't follow an interactive redirect). The error string is echoed to the MCP client verbatim as error_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),
	};
}

onLogout

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 data
  • request - 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(),
		});
	}
}

onTokenRefresh

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 token
  • refreshed - 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(),
		});
	}
}

onMCPTokenIssued

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 (access for the authorization-code grant, refresh for a rotation, client_credentials for the headless-agent grant — where sub is the client, not a user), client_id, sub, aud, scope (optional), and jti (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: event is sanitized — it carries only the jti (a token identifier, safe to log), never the access/refresh token strings. The request is not sanitized: on the refresh path its body carries the refresh_token the client presented, so do not log request wholesale.

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() });
}

Hook Registration

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.

Recommended Pattern (Separate File)

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,
};

Inline Pattern

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...

Session Data Structure

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 };
	}
}

Best Practices

Error Handling

  • 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
	}
}

Performance

  • 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 };
}

Security

  • 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);
}

Testing Hooks

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 session
  • GET /oauth/{provider}/refresh - Trigger token refresh

Next Steps