diff --git a/README.md b/README.md index 986ee2c..b456aee 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,13 @@ OAuth 2.0 authentication plugin for Harper applications with support for multipl ## Features - 🔐 **Multi-provider support**: GitHub, Google, Azure AD, Auth0, and custom providers -- 🔄 **Seamless integration**: Works with Harper's session management system +- 🔄 **Automatic token refresh**: Proactive token renewal on every request (80% lifetime threshold) +- 🪝 **Lifecycle hooks**: Extensible hooks for user provisioning, logout, and token refresh events +- 🔄 **Seamless integration**: Works with Harper's session management system via HTTP middleware - 🛡️ **CSRF protection**: Distributed token storage for cluster support - 🎯 **ID token verification**: Full OIDC support for compatible providers - 🔧 **Environment variables**: Secure configuration via `${ENV_VAR}` syntax +- 🔥 **Hot-reloading**: Config changes applied automatically without restart - 📊 **Self-contained**: Includes its own database schema ## Installation @@ -61,17 +64,60 @@ For local development with non-standard ports: https://localhost:9953/oauth/{provider}/callback ``` +## Automatic Token Management + +The plugin automatically manages OAuth token lifecycle on **every HTTP request**: + +- ✅ **Validates sessions** - Checks OAuth session data on each request +- ✅ **Proactive refresh** - Refreshes tokens at 80% of their lifetime +- ✅ **On-demand refresh** - Refreshes expired tokens immediately (if refresh token available) +- ✅ **Auto-logout** - Logs out users when tokens expire and can't be refreshed +- ✅ **Zero configuration** - No code changes needed in your application + +**Token Expiration Behavior**: If an OAuth token expires and cannot be refreshed (no refresh token or refresh fails), the user is automatically logged out (entire session cleared). This ensures session state remains consistent. + +Simply configure the OAuth plugin and it handles everything automatically. Your application code can access the OAuth user via: + +```typescript +export function handleApplication(scope) { + const myResource = { + async get(target, request) { + // OAuth tokens are automatically validated and refreshed + if (request.session?.oauthUser) { + return { + message: 'Authenticated user', + user: request.session.oauthUser.username, + email: request.session.oauthUser.email, + }; + } + return { message: 'Not authenticated' }; + }, + }; + + scope.resources.set('api', myResource); +} +``` + +**Note**: The plugin doesn't enforce authentication - it only validates and refreshes tokens for sessions that have OAuth data. To require authentication, check for `request.session?.oauthUser` in your application code and return 401 if not present. + ## Endpoints -Each configured provider gets its own set of endpoints: +### Authentication Endpoints + +| Endpoint | Method | Description | +| ---------------------------- | ------ | -------------------------------------- | +| `/oauth/{provider}/login` | GET | Initiates OAuth flow | +| `/oauth/{provider}/callback` | GET | OAuth callback (configure in provider) | +| `/oauth/logout` | POST | Logs out the user (any provider) | -| Endpoint | Description | -| ---------------------------- | -------------------------------------- | -| `/oauth/{provider}/login` | Initiates OAuth flow | -| `/oauth/{provider}/callback` | OAuth callback (configure in provider) | -| `/oauth/{provider}/logout` | Logs out the user | -| `/oauth/{provider}/user` | Returns current user info (debug mode) | -| `/oauth/{provider}/test` | Test page for OAuth flow (debug mode) | +### Debug Endpoints (when `debug: true`) + +| Endpoint | Method | Description | +| --------------------------- | ------ | ----------------------------- | +| `/oauth/` | GET | List all configured providers | +| `/oauth/test` | GET | Interactive test page | +| `/oauth/{provider}/user` | GET | Current user info and tokens | +| `/oauth/{provider}/refresh` | GET | Check/trigger token refresh | ## Provider Configuration @@ -192,6 +238,18 @@ Each provider can override global defaults: | `postLoginRedirect` | URL to redirect after login | `'/'` | | `redirectUri` | Custom callback URL | Auto-generated | +### Configuration Hot-Reloading + +The plugin supports hot-reloading of configuration changes: + +- **Automatic Detection**: Config file changes are detected and applied automatically +- **Concurrency Protection**: Only one config update runs at a time to prevent race conditions +- **Queued Updates**: Rapid config changes are queued and processed sequentially +- **Error Handling**: Config errors are logged but don't crash the plugin +- **Active Sessions**: Existing OAuth sessions continue to work during reload + +You can safely edit `config.yaml` while the application is running, and changes will be applied without requiring a restart. + ## How It Works The OAuth plugin provides authentication without requiring you to manage Harper users manually. When a user authenticates via OAuth: @@ -243,23 +301,281 @@ The plugin integrates seamlessly with Harper's session management: // After successful OAuth login: request.session.user; // Harper username (from OAuth provider) request.session.oauthUser; // Full OAuth user details -request.session.oauthToken; // OAuth access token -request.session.oauthRefreshToken; // OAuth refresh token (if provided) +request.session.oauth; // OAuth metadata including tokens and expiration +``` + +### Automatic Token Refresh + +The plugin automatically manages OAuth token lifecycle: + +- **Proactive Refresh**: Tokens are automatically refreshed when they reach 80% of their lifetime +- **On-Demand Refresh**: Expired tokens are refreshed on the next request (if refresh token available) +- **Graceful Degradation**: If a token cannot be refreshed, the OAuth session data is cleared +- **Transparent**: Applications don't need to handle token refresh logic + +Token metadata stored in session: + +```javascript +request.session.oauth = { + provider: 'github', // OAuth provider name + accessToken: '...', // Current access token + refreshToken: '...', // Refresh token for renewals + expiresAt: 1234567890000, // Token expiration timestamp (ms) + refreshThreshold: 1234567800, // When to proactively refresh (80% of lifetime) + scope: 'openid profile email', // Granted scopes + tokenType: 'Bearer', // Token type + lastRefreshed: 1234567000000, // Last successful refresh +}; +``` + +## Lifecycle Hooks + +The OAuth plugin provides lifecycle hooks that allow you to customize behavior at key authentication events. This is useful for user provisioning, analytics, access control, and integrating with other systems. + +### Available Hooks + +| Hook | When Called | Use Cases | +| ---------------- | ----------------------------- | ------------------------------------------------ | +| `onLogin` | After successful OAuth login | User provisioning, role mapping, logging | +| `onLogout` | When user logs out | Cleanup, audit logging, revoke external sessions | +| `onTokenRefresh` | After automatic token refresh | Update cached data, log token refreshes | + +### Registering Hooks + +Register hooks programmatically using the `registerHooks()` function. This can be called at module load time (before the plugin initializes) or after the plugin has loaded: + +```typescript +import { registerHooks } from '@harperdb/oauth'; + +// Register hooks - can be called at module load time +registerHooks({ + onLogin: async (oauthUser, tokenResponse, session, request, provider) => { + console.log(`User ${oauthUser.username} logged in via ${provider}`); + // Add your custom logic here + }, + + onLogout: async (session, request) => { + console.log('User logged out'); + // Cleanup logic here + }, + + onTokenRefresh: async (session, refreshed, request) => { + if (refreshed) { + console.log('Token refreshed successfully'); + } + }, +}); +``` + +**Timing Note**: The `registerHooks()` function can be called before or after the plugin initializes: + +- **Before initialization** - Hooks are queued and applied when the plugin loads +- **After initialization** - Hooks are applied immediately + +This is a module-level singleton, shared across all OAuth plugin instances. For most applications with a single OAuth instance, this is the recommended approach. + +### Hook Signatures + +#### onLogin Hook + +Called after successful OAuth authentication, before the session is stored. Can return data to be merged into the session. + +```typescript +async function onLogin( + oauthUser: OAuthUser, // Mapped OAuth user data + tokenResponse: TokenResponse, // Raw OAuth token response + session: any, // Current Harper session + request: any, // Harper request object + provider: string // Provider name (e.g., 'github') +): Promise | void> { + // Example: Create/update user in database + const user = await tables.User.upsert({ + email: oauthUser.email, + name: oauthUser.name, + oauthProvider: provider, + lastLogin: new Date().toISOString(), + }); + + // Return data to merge into session + return { + userId: user.id, + roles: user.roles, + }; +} +``` + +**Available Data:** + +- `oauthUser.username` - Username from OAuth provider +- `oauthUser.email` - Email address +- `oauthUser.name` - Full name +- `oauthUser.role` - Assigned Harper role +- `tokenResponse.access_token` - OAuth access token +- `tokenResponse.id_token` - OIDC ID token (if available) + +#### onLogout Hook + +Called before the user's session is cleared. Use this for cleanup operations. + +```typescript +async function onLogout( + session: any, // Session being cleared + request: any // Harper request object +): Promise { + // Example: Audit log + logger.info('User logged out', { + username: session.oauthUser?.username, + timestamp: new Date().toISOString(), + }); + + // Example: Revoke external tokens + if (session.oauth?.accessToken) { + await externalService.revokeToken(session.oauth.accessToken); + } +} +``` + +#### onTokenRefresh Hook + +Called after the OAuth access token is automatically refreshed. + +```typescript +async function onTokenRefresh( + session: any, // Updated session with new tokens + refreshed: boolean, // Whether tokens were actually refreshed + request?: any // Harper request object (if available) +): Promise { + if (refreshed) { + logger.debug('Token refreshed', { + provider: session.oauth?.provider, + expiresAt: session.oauth?.expiresAt, + }); + + // Example: Update cached external data + await updateUserCache(session.oauthUser.username); + } +} +``` + +### Example: User Provisioning Plugin + +Here's a complete example of a user provisioning plugin that uses OAuth hooks: + +```typescript +// user-provisioning/src/index.ts +export async function handleApplication(scope) { + const logger = scope.logger; + const { User } = scope.tables; + + // Export hooks for OAuth plugin + scope.exports = { + // Called after successful OAuth login + async onLogin(oauthUser, tokenResponse, session, request, provider) { + logger.info('User logging in', { email: oauthUser.email, provider }); + + // Create or update user in database + const user = await User.upsert({ + email: oauthUser.email, + name: oauthUser.name, + oauthProvider: provider, + lastLoginDate: new Date().toISOString(), + isVerified: true, // OAuth email is verified + }); + + // Map user to organization based on email domain + const domain = oauthUser.email.split('@')[1]; + const org = await scope.tables.Organization.search({ + conditions: [{ attribute: 'emailDomain', value: domain }], + }).next(); + + // Assign roles + const roles = org ? await assignOrgRoles(user.id, org.id) : []; + + // Return data to merge into session + return { + userId: user.id, + organizationId: org?.id, + roles: roles.map((r) => r.name), + }; + }, + + // Called when user logs out + async onLogout(session, request) { + logger.info('User logging out', { + username: session.oauthUser?.username, + userId: session.userId, + }); + + // Record logout time + if (session.userId) { + await User.patch({ + id: session.userId, + lastLogoutDate: new Date().toISOString(), + }); + } + }, + + // Called after token refresh + async onTokenRefresh(session, refreshed, request) { + if (refreshed) { + logger.debug('Token refreshed for user', { + username: session.oauthUser?.username, + }); + } + }, + }; +} + +async function assignOrgRoles(userId, orgId) { + // Custom logic to assign roles based on organization + const defaultRole = await scope.tables.Role.search({ + conditions: [ + { attribute: 'organizationId', value: orgId }, + { attribute: 'isDefault', value: true }, + ], + }).next(); + + return defaultRole ? [defaultRole] : []; +} +``` + +**Configuration:** + +```yaml +# config.yaml +'@harperdb/oauth': + package: '@harperdb/oauth' + hooks: '@company/user-provisioning' + providers: + github: + clientId: ${OAUTH_GITHUB_CLIENT_ID} + clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET} + +'@company/user-provisioning': + package: '@company/user-provisioning' ``` +### Hook Best Practices + +1. **Keep hooks lightweight**: Hooks run on every login/logout/refresh - avoid expensive operations +2. **Handle errors gracefully**: Hook errors are logged but don't block the OAuth flow +3. **Don't modify session directly**: Return data from onLogin to merge into session +4. **Use onTokenRefresh sparingly**: This hook runs frequently (every token refresh) +5. **Log appropriately**: Use logger for debugging but avoid sensitive data + ## Current Limitations & Future Plans ### Current Limitations -- **No Harper User Creation**: OAuth users are session-only and not persisted as Harper database users -- **No Role Mapping**: All users get the same default role - no mapping from OAuth provider roles/groups -- **Session-Only**: Authentication is lost when the session expires (no persistent user records) -- **No Authorization Rules**: Cannot define Harper-specific permissions based on OAuth attributes +- **No Built-in User Creation**: OAuth users are session-only by default (use hooks for persistence) +- **No Role Mapping**: All users get the same default role (use onLogin hook for custom logic) +- **Session-Based**: Authentication persists only while session is active +- **No Authorization Rules**: Cannot define Harper-specific permissions based on OAuth attributes (use hooks) ### Possible Future Enhancements -- **User Persistence**: Option to automatically create/update Harper users from OAuth profiles -- **Role Mapping**: Map OAuth provider groups/roles to Harper roles +- **Built-in User Persistence**: Optional automatic creation/update of Harper users from OAuth profiles +- **Built-in Role Mapping**: Map OAuth provider groups/roles to Harper roles - **Custom Claims Processing**: Transform OAuth claims into Harper user attributes - **Multi-Factor Authentication**: Additional security layers after OAuth - **Account Linking**: Link OAuth identities to existing Harper users @@ -271,7 +587,7 @@ Enable debug mode to access additional endpoints: ```yaml '@harperdb/oauth': package: '@harperdb/oauth' - debug: true + debug: true # or use ${OAUTH_DEBUG} for environment variable providers: github: provider: github @@ -279,12 +595,18 @@ Enable debug mode to access additional endpoints: clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET} ``` +**Note**: Debug mode can be controlled via environment variable: + +```bash +export OAUTH_DEBUG=true # Enable debug endpoints +``` + Debug endpoints: - `/oauth/` - List all configured providers - `/oauth/test` - Interactive test page -- `/oauth/{provider}/user` - Get current user info -- `/oauth/{provider}/refresh` - Refresh access token +- `/oauth/{provider}/user` - Get current user info and token status +- `/oauth/{provider}/refresh` - Check token status and trigger refresh if needed ## Database Schema diff --git a/assets/test.html b/assets/test.html index 4d33120..58f9f60 100644 --- a/assets/test.html +++ b/assets/test.html @@ -220,13 +220,8 @@

Debug Endpoints

} async function logout() { - if (!currentProvider) { - showMessage('No provider selected', 'error'); - return; - } - try { - const response = await fetch(`/oauth/${currentProvider}/logout`, { + const response = await fetch('/oauth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/package.json b/package.json index c86b627..ec557e1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dev": "tsc --watch", "test": "npm run build && node --test 'test/**/*.test.js'", "test:coverage": "npm run build && node --test --experimental-test-coverage 'test/**/*.test.js'", - "lint": "eslint .", + "lint": "eslint . --ignore-pattern 'dist/**'", "format": "prettier .", "format:check": "npm run format -- --check", "format:write": "npm run format -- --write", diff --git a/src/index.ts b/src/index.ts index a0c51d0..e122787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,52 @@ import { initializeProviders } from './lib/config.ts'; import { createOAuthResource } from './lib/resource.ts'; -import type { Scope, OAuthPluginConfig, ProviderRegistry } from './types.ts'; +import { validateAndRefreshSession } from './lib/sessionValidator.ts'; +import { clearOAuthSession } from './lib/handlers.ts'; +import { HookManager } from './lib/hookManager.ts'; +import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts'; + +// Export HookManager class and types +export { HookManager } from './lib/hookManager.ts'; +export type { OAuthHooks, OAuthUser, TokenResponse } from './types.ts'; + +// Store hooks registered at module load time and active hookManager +let pendingHooks: OAuthHooks | null = null; +let activeHookManager: HookManager | null = null; + +/** + * Register OAuth hooks programmatically + * Call this from your application code to register lifecycle hooks + * + * This can be called: + * - At module load time (before the plugin initializes) - hooks will be queued + * - After plugin initialization - hooks will be applied immediately + * + * NOTE: This registers hooks at the module level, shared across all instances + * of the OAuth plugin. For most applications with a single OAuth plugin instance, + * this is the simplest and recommended approach. + * + * @example + * ```typescript + * import { registerHooks } from '@harperdb/oauth'; + * + * // Can be called at module load time or later + * registerHooks({ + * onLogin: async (oauthUser, tokenResponse, session, request, provider) => { + * console.log(`User logged in: ${oauthUser.username}`); + * } + * }); + * ``` + */ +export function registerHooks(hooks: OAuthHooks): void { + if (activeHookManager) { + // Plugin is already loaded - apply immediately + activeHookManager.register(hooks); + } else { + // Plugin not loaded yet - queue for later + pendingHooks = hooks; + } +} /** * Plugin entry point @@ -18,13 +63,36 @@ export async function handleApplication(scope: Scope): Promise { let debugMode = false; let isInitialized = false; + // Create hookManager instance scoped to this application + const hookManager = new HookManager(logger); + + // Set as active hookManager for late hook registration + activeHookManager = hookManager; + + // Apply any hooks that were registered at module load time + if (pendingHooks) { + hookManager.register(pendingHooks); + logger?.debug?.('Applied pending OAuth hooks'); + pendingHooks = null; // Clear pending hooks after applying + } + /** * Update OAuth configuration when options change */ - function updateConfiguration() { - const options = (scope.options.getAll() || {}) as OAuthPluginConfig; + async function updateConfiguration() { + const rawOptions = (scope.options.getAll() || {}) as OAuthPluginConfig; + + // Expand environment variables in plugin-level options + let debugValue = rawOptions.debug; + if (typeof debugValue === 'string' && debugValue.startsWith('${') && debugValue.endsWith('}')) { + const envVar = debugValue.slice(2, -1); + debugValue = process.env[envVar] || debugValue; + } + + const options = { ...rawOptions, debug: debugValue }; const previousDebugMode = debugMode; - debugMode = options.debug === true; + // Handle both boolean and string values (from environment variables) + debugMode = options.debug === true || options.debug === 'true'; // Log configuration update if (isInitialized) { @@ -64,7 +132,7 @@ export async function handleApplication(scope: Scope): Promise { }); } else { // Register the OAuth resource with configured providers - scope.resources.set('oauth', createOAuthResource(providers, debugMode, logger)); + scope.resources.set('oauth', createOAuthResource(providers, debugMode, hookManager, logger)); // Log all configured providers logger?.info?.('OAuth plugin ready:', { @@ -82,12 +150,76 @@ export async function handleApplication(scope: Scope): Promise { } } - // Initial configuration - updateConfiguration(); + // Register HTTP middleware for automatic OAuth session validation + // This runs on every HTTP request after authentication but before REST + scope.server.http(async (request: any, next: (req: any) => any) => { + // Only process requests with sessions that have OAuth data + if (!request.session?.oauth) { + return next(request); + } + + // Get the provider for this OAuth session + const providerName = request.session.oauth.provider; + const providerData = providers[providerName]; + + if (!providerData) { + logger?.warn?.(`OAuth provider '${providerName}' not found, logging out user`); + // Provider no longer exists - complete logout + await clearOAuthSession(request.session, logger); + return next(request); + } + + // Validate and refresh session automatically + const validation = await validateAndRefreshSession(request, providerData.provider, logger, hookManager); + + if (!validation.valid) { + // Session is no longer valid (already cleaned up by validator) + logger?.debug?.(`OAuth session invalidated: ${validation.error}`); + } else if (validation.refreshed) { + logger?.debug?.(`OAuth token auto-refreshed for ${providerName}`); + } + + // Continue with the request (session updated if refreshed) + return next(request); + }); + + // Concurrency control for configuration updates + let updating = false; + let pendingUpdate = false; + + /** + * Run configuration update with concurrency protection + */ + const runUpdate = async () => { + if (updating) { + pendingUpdate = true; + return; + } + + updating = true; + try { + await updateConfiguration(); + + // If another update was requested while we were running, run again + if (pendingUpdate) { + pendingUpdate = false; + await runUpdate(); + } + } catch (error) { + logger?.error?.('Failed to update OAuth configuration:', error); + } finally { + updating = false; + } + }; + + // Initial configuration (errors propagate to plugin loader) + await updateConfiguration(); - // Watch for configuration changes + // Watch for configuration changes (errors caught internally) scope.options.on('change', () => { - updateConfiguration(); + runUpdate().catch((error) => { + logger?.error?.('Unexpected error in OAuth config update:', error); + }); }); // Clean up on scope close diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index a83c298..a0ed57f 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -8,6 +8,7 @@ import { readFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Request, RequestTarget, Logger, IOAuthProvider, OAuthProviderConfig } from '../types.ts'; +import type { HookManager } from './hookManager.ts'; /** * Handle OAuth login initiation @@ -45,6 +46,7 @@ export async function handleCallback( target: RequestTarget, provider: IOAuthProvider, config: OAuthProviderConfig, + hookManager: HookManager, logger?: Logger ): Promise { // Get query parameters from target @@ -117,26 +119,49 @@ export async function handleCallback( // Map to Harper user const user = provider.mapUserToHarper(userInfo); + // Call onLogin hook before storing session + // This allows user provisioning plugins to create/update user records + const hookData = await hookManager.callOnLogin(user, tokenResponse, request.session, request, config.provider); + // Store in session if available if (request.session) { - // Store user info in session + // Calculate token expiration and refresh thresholds + const expiresIn = tokenResponse.expires_in || 3600; // Default 1 hour if not provided + const now = Date.now(); + const expiresAt = now + expiresIn * 1000; + const refreshThreshold = now + expiresIn * 800; // Refresh at 80% of lifetime + + // Prepare session data + const sessionData: any = { + user: hookData?.user || user.username, // Use hook's user if provided, otherwise OAuth username + oauthUser: user, // Store full OAuth user object separately + oauth: { + provider: config.provider, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt, + refreshThreshold, + scope: tokenResponse.scope, + tokenType: tokenResponse.token_type || 'Bearer', + lastRefreshed: now, + }, + }; + + // Merge remaining hook data into session if provided (excluding 'user' since we already used it) + if (hookData) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, sonarjs/no-unused-vars + const { user, ...remainingHookData } = hookData; + Object.assign(sessionData, remainingHookData); + } + + // Store user info and OAuth metadata in session if (typeof request.session.update === 'function') { - await request.session.update({ - user: user.username, // Harper expects just the username string - oauthUser: user, // Store full OAuth user object separately - oauthToken: tokenResponse.access_token, - oauthRefreshToken: tokenResponse.refresh_token, - }); + await request.session.update(sessionData); } else { - request.session.user = user.username; - request.session.oauthUser = user; - request.session.oauthToken = tokenResponse.access_token; - if (tokenResponse.refresh_token) { - request.session.oauthRefreshToken = tokenResponse.refresh_token; - } + Object.assign(request.session, sessionData); } - logger?.info?.(`OAuth login successful for user: ${user.username}`); + logger?.info?.(`OAuth login successful for user: ${user.username}, token expires in ${expiresIn}s`); } else { logger?.warn?.('No session available for OAuth user'); } @@ -161,26 +186,39 @@ export async function handleCallback( } /** - * Handle user logout + * Clear OAuth session data and log out the user + * Shared function for explicit logout and automatic logout on token expiration + * + * Deletes the session record from the hdb_session table, completely removing it + * rather than just clearing the user field. This ensures no orphaned sessions remain. */ -export async function handleLogout(request: Request, logger?: Logger): Promise { - if (request.session) { - if (typeof request.session.update === 'function') { - await request.session.update({ - user: undefined, - oauthUser: undefined, - oauthToken: undefined, - oauthRefreshToken: undefined, - }); - } else { - delete request.session.user; - delete request.session.oauthUser; - delete request.session.oauthToken; - delete request.session.oauthRefreshToken; - } - logger?.info?.('User logged out'); +export async function clearOAuthSession(session: any, logger?: Logger): Promise { + if (!session) return; + + // Delete the session record from the hdb_session table + // This completely removes the session on logout, rather than just nulling the user field + if (typeof session.delete === 'function') { + await session.delete(session.id); + } else { + // Fallback for sessions without delete method - clear in-memory + session.user = null; + delete session.oauth; + delete session.oauthUser; } + logger?.info?.('OAuth session cleared'); +} + +/** + * Handle user logout + */ +export async function handleLogout(request: Request, hookManager: HookManager, logger?: Logger): Promise { + // Call onLogout hook before clearing session + await hookManager.callOnLogout(request.session, request); + + // Clear the OAuth session + await clearOAuthSession(request.session, logger); + return { status: 200, body: { message: 'Logged out successfully' }, @@ -190,7 +228,7 @@ export async function handleLogout(request: Request, logger?: Logger): Promise { +export async function handleUserInfo(request: Request, tokenRefreshed = false): Promise { // Add debug logging if (!request) { return { @@ -201,6 +239,7 @@ export async function handleUserInfo(request: Request): Promise { // Check for OAuth user in session first, then Harper user const oauthUser = request?.session?.oauthUser; + const oauthMetadata = request?.session?.oauth; const username = request?.user || request?.session?.user; if (!username && !oauthUser) { @@ -224,6 +263,17 @@ export async function handleUserInfo(request: Request): Promise { email: oauthUser.email, name: oauthUser.name, provider: oauthUser.provider, + // Include OAuth token status in debug mode + oauth: oauthMetadata + ? { + provider: oauthMetadata.provider, + expiresAt: oauthMetadata.expiresAt, + refreshThreshold: oauthMetadata.refreshThreshold, + lastRefreshed: oauthMetadata.lastRefreshed, + hasRefreshToken: !!oauthMetadata.refreshToken, + tokenRefreshed, + } + : undefined, }, }; } @@ -249,7 +299,8 @@ export async function handleUserInfo(request: Request): Promise { } /** - * Refresh OAuth access token + * Refresh OAuth access token (legacy handler - not used, replaced by automatic refresh) + * Kept for backward compatibility */ export async function handleRefresh( request: Request, @@ -258,7 +309,7 @@ export async function handleRefresh( logger?: Logger ): Promise { // Get refresh token from session - const refreshToken = request?.session?.oauthRefreshToken; + const refreshToken = request?.session?.oauth?.refreshToken; if (!refreshToken) { return { @@ -279,18 +330,25 @@ export async function handleRefresh( const tokenResponse = await provider.refreshAccessToken(refreshToken); // Update session with new tokens - if (request.session) { + if (request.session?.oauth) { + const expiresIn = tokenResponse.expires_in || 3600; + const now = Date.now(); + + const updatedOAuth = { + ...request.session.oauth, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token || refreshToken, + expiresAt: now + expiresIn * 1000, + refreshThreshold: now + expiresIn * 800, + lastRefreshed: now, + }; + if (typeof request.session.update === 'function') { await request.session.update({ - oauthToken: tokenResponse.access_token, - // Update refresh token if a new one was provided - oauthRefreshToken: tokenResponse.refresh_token || refreshToken, + oauth: updatedOAuth, }); } else { - request.session.oauthToken = tokenResponse.access_token; - if (tokenResponse.refresh_token) { - request.session.oauthRefreshToken = tokenResponse.refresh_token; - } + request.session.oauth = updatedOAuth; } logger?.info?.('OAuth token refreshed successfully'); diff --git a/src/lib/hookManager.ts b/src/lib/hookManager.ts new file mode 100644 index 0000000..0e8594e --- /dev/null +++ b/src/lib/hookManager.ts @@ -0,0 +1,90 @@ +/** + * OAuth Hook Manager + * + * Manages loading and calling lifecycle hooks for the OAuth plugin + */ + +import type { OAuthHooks, OAuthUser, TokenResponse, Logger } from '../types.ts'; + +/** + * Hook Manager + * Loads and executes OAuth lifecycle hooks + */ +export class HookManager { + private hooks: OAuthHooks = {}; + private logger?: Logger; + + constructor(logger?: Logger) { + this.logger = logger; + } + + /** + * Register hooks programmatically + * Allows applications to register hooks directly without using config + */ + register(hooks: OAuthHooks): void { + this.hooks = hooks; + this.logger?.debug?.(`Registered OAuth hooks: ${Object.keys(hooks).filter(key => hooks[key as keyof OAuthHooks]).join(', ')}`); + } + + + /** + * Call onLogin hook + */ + async callOnLogin( + oauthUser: OAuthUser, + tokenResponse: TokenResponse, + session: any, + request: any, + provider: string + ): Promise | void> { + if (!this.hooks.onLogin) return; + + try { + this.logger?.debug?.(`Calling onLogin hook for provider: ${provider}`); + const result = await this.hooks.onLogin(oauthUser, tokenResponse, session, request, provider); + return result; + } catch (error) { + this.logger?.error?.('onLogin hook failed:', (error as Error).message); + // Don't throw - hooks should not break the OAuth flow + return; + } + } + + /** + * Call onLogout hook + */ + async callOnLogout(session: any, request: any): Promise { + if (!this.hooks.onLogout) return; + + try { + this.logger?.debug?.('Calling onLogout hook'); + await this.hooks.onLogout(session, request); + } catch (error) { + this.logger?.error?.('onLogout hook failed:', (error as Error).message); + // Don't throw - hooks should not break logout + } + } + + /** + * Call onTokenRefresh hook + */ + async callOnTokenRefresh(session: any, refreshed: boolean, request?: any): Promise { + if (!this.hooks.onTokenRefresh) return; + + try { + this.logger?.debug?.(`Calling onTokenRefresh hook (refreshed: ${refreshed})`); + await this.hooks.onTokenRefresh(session, refreshed, request); + } catch (error) { + this.logger?.error?.('onTokenRefresh hook failed:', (error as Error).message); + // Don't throw - hooks should not break token refresh + } + } + + /** + * Check if any hooks are loaded + */ + hasHooks(): boolean { + return Object.keys(this.hooks).length > 0; + } +} diff --git a/src/lib/resource.ts b/src/lib/resource.ts index 1876a44..e72b18d 100644 --- a/src/lib/resource.ts +++ b/src/lib/resource.ts @@ -10,21 +10,31 @@ import { handleCallback, handleLogout, handleUserInfo, - handleRefresh, handleTestPage, } from './handlers.ts'; +import { validateAndRefreshSession } from './sessionValidator.ts'; +import type { HookManager } from './hookManager.ts'; /** * Create an OAuth resource with the given configuration * Returns a resource object that Harper can use directly */ -export function createOAuthResource(providers: ProviderRegistry, debugMode: boolean, logger?: Logger): any { +export function createOAuthResource( + providers: ProviderRegistry, + debugMode: boolean, + hookManager: HookManager, + logger?: Logger +): any { const notFound = { status: 404, body: { error: 'Not found' }, }; - return { + const resource = { + // Expose hookManager for programmatic hook registration + hookManager, + // Expose providers for use with withOAuthValidation + providers, /** * Handle GET requests */ @@ -48,13 +58,13 @@ export function createOAuthResource(providers: ProviderRegistry, debugMode: bool // Debug mode: show full provider info return { message: 'OAuth providers', + logout: 'POST /oauth/logout', providers: Object.keys(providers).map((name) => ({ name, provider: providers[name].config.provider, endpoints: { login: `/oauth/${name}/login`, callback: `/oauth/${name}/callback`, - logout: `/oauth/${name}/logout`, user: `/oauth/${name}/user`, refresh: `/oauth/${name}/refresh`, test: `/oauth/${name}/test`, @@ -82,16 +92,51 @@ export function createOAuthResource(providers: ProviderRegistry, debugMode: bool return handleLogin(request, provider, config, logger); case 'callback': // Pass the target object directly - it should have a get() method for query params - return handleCallback(request, target as RequestTarget, provider, config, logger); + return handleCallback(request, target as RequestTarget, provider, config, hookManager, logger); case 'user': { // Debug mode only if (!debugMode) return notFound; - return handleUserInfo(request); + + // Validate and refresh session if needed before returning user info + const validation = await validateAndRefreshSession(request, provider, logger, hookManager); + if (!validation.valid) { + return { + status: 401, + body: { + authenticated: false, + error: validation.error || 'Session expired', + message: 'OAuth session is no longer valid. Please log in again.', + }, + }; + } + + return handleUserInfo(request, validation.refreshed); } case 'refresh': { // Debug mode only if (!debugMode) return notFound; - return handleRefresh(request, provider, config, logger); + + // Validate session before attempting refresh + const validation = await validateAndRefreshSession(request, provider, logger, hookManager); + if (!validation.valid) { + return { + status: 401, + body: { + error: validation.error || 'Session expired', + message: 'OAuth session is no longer valid. Please log in again.', + }, + }; + } + + // Return refresh status + return { + status: 200, + body: { + message: validation.refreshed ? 'Token refreshed successfully' : 'Token is still valid', + refreshed: validation.refreshed, + expiresAt: request.session?.oauth?.expiresAt, + }, + }; } case 'test': { // Debug mode only @@ -106,10 +151,10 @@ export function createOAuthResource(providers: ProviderRegistry, debugMode: bool message: `OAuth provider: ${providerName}`, provider: config.provider, configured: true, + logout: 'POST /oauth/logout', endpoints: { login: `/oauth/${providerName}/login`, callback: `/oauth/${providerName}/callback`, - logout: `/oauth/${providerName}/logout`, user: `/oauth/${providerName}/user`, refresh: `/oauth/${providerName}/refresh`, test: `/oauth/${providerName}/test`, @@ -127,30 +172,19 @@ export function createOAuthResource(providers: ProviderRegistry, debugMode: bool const id = typeof target === 'string' ? target : target?.id || target?.pathname || ''; const pathParts = (id || '').split('/').filter((p) => p); const providerName = pathParts[0]; - const action = pathParts[1]; - - // Check if provider exists - const providerData = providers[providerName]; - if (!providerData) { - return debugMode - ? { - status: 404, - body: { - error: 'Provider not found', - available: Object.keys(providers), - }, - } - : notFound; - } - if (action === 'logout') { - return handleLogout(request, logger); + // Handle generic logout endpoint (no provider required) + if (providerName === 'logout') { + return handleLogout(request, hookManager, logger); } + // For other POST endpoints, provider is required return { - status: 405, - body: { error: 'Method not allowed' }, + status: 404, + body: { error: 'Not found' }, }; }, }; + + return resource; } diff --git a/src/lib/sessionValidator.ts b/src/lib/sessionValidator.ts new file mode 100644 index 0000000..56a575e --- /dev/null +++ b/src/lib/sessionValidator.ts @@ -0,0 +1,164 @@ +/** + * OAuth Session Validation and Token Refresh + * + * Handles automatic validation and refresh of OAuth tokens in sessions + */ + +import type { Request, IOAuthProvider, Logger, OAuthSessionMetadata } from '../types.ts'; +import { clearOAuthSession } from './handlers.ts'; +import type { HookManager } from './hookManager.ts'; + +export interface SessionValidationResult { + /** Whether the session has valid OAuth data */ + valid: boolean; + /** Whether tokens were refreshed during validation */ + refreshed?: boolean; + /** Error message if validation failed */ + error?: string; +} + +/** + * Validate OAuth session and refresh tokens if needed + * + * This function checks if OAuth tokens in the session are expired or approaching + * expiration, and automatically refreshes them if possible. + * + * Token refresh strategy: + * - If token is expired (past expiresAt), refresh immediately + * - If token is approaching expiration (past refreshThreshold = 80% of lifetime), refresh proactively + * - If no refresh token available and token is expired, clear OAuth session data + * + * @param request - Harper request object with session + * @param provider - OAuth provider instance for token refresh + * @param logger - Optional logger for debugging + * @param hookManager - Optional hook manager for calling onTokenRefresh hook + * @returns Validation result indicating if session is valid and if refresh occurred + */ +export async function validateAndRefreshSession( + request: Request, + provider: IOAuthProvider, + logger?: Logger, + hookManager?: HookManager +): Promise { + const session = request.session; + + // No session available + if (!session) { + return { valid: false, error: 'No session available' }; + } + + // Check for OAuth metadata in session + const oauthMetadata = session.oauth as OAuthSessionMetadata | undefined; + + if (!oauthMetadata) { + // No OAuth data in session - not an OAuth session + return { valid: false, error: 'No OAuth data in session' }; + } + + // Validate required fields + if (!oauthMetadata.accessToken) { + logger?.warn?.('OAuth session missing access token, logging out'); + await clearOAuthSession(session, logger); + return { valid: false, error: 'OAuth session missing access token' }; + } + + const now = Date.now(); + const isExpired = oauthMetadata.expiresAt ? now >= oauthMetadata.expiresAt : false; + const needsRefresh = oauthMetadata.refreshThreshold ? now >= oauthMetadata.refreshThreshold : false; + + // Token is still valid and doesn't need refresh + if (!isExpired && !needsRefresh) { + return { valid: true, refreshed: false }; + } + + // Token needs refresh - check if refresh token is available + if (!oauthMetadata.refreshToken) { + if (isExpired) { + logger?.warn?.('OAuth token expired and no refresh token available, logging out'); + await clearOAuthSession(session, logger); + return { valid: false, error: 'Token expired and no refresh token available' }; + } + // Token approaching expiration but no refresh token - still valid for now + return { valid: true, refreshed: false }; + } + + // Attempt to refresh the token + logger?.debug?.( + isExpired + ? 'OAuth token expired, attempting refresh...' + : 'OAuth token approaching expiration (80% lifetime), refreshing proactively...' + ); + + try { + // Check if provider supports token refresh + if (!provider.refreshAccessToken) { + logger?.warn?.('OAuth provider does not support token refresh'); + if (isExpired) { + await clearOAuthSession(session, logger); + return { valid: false, error: 'Token expired and provider does not support refresh' }; + } + return { valid: true, refreshed: false }; + } + + // Perform token refresh + const tokenResponse = await provider.refreshAccessToken(oauthMetadata.refreshToken); + + // Calculate new expiration times + const expiresIn = tokenResponse.expires_in || 3600; // Default 1 hour + const newExpiresAt = now + expiresIn * 1000; + const newRefreshThreshold = now + expiresIn * 800; // Refresh at 80% of lifetime + + // Update session with new tokens and metadata + const updatedMetadata: OAuthSessionMetadata = { + ...oauthMetadata, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token || oauthMetadata.refreshToken, // Keep existing if not provided + expiresAt: newExpiresAt, + refreshThreshold: newRefreshThreshold, + scope: tokenResponse.scope || oauthMetadata.scope, + tokenType: tokenResponse.token_type || oauthMetadata.tokenType, + lastRefreshed: now, + }; + + // Update session + session.oauth = updatedMetadata; + if (typeof session.update === 'function') { + await session.update({ oauth: updatedMetadata }); + } + + logger?.info?.('OAuth token refreshed successfully'); + + // Call onTokenRefresh hook + if (hookManager) { + await hookManager.callOnTokenRefresh(session, true, request); + } + + return { valid: true, refreshed: true }; + } catch (error) { + logger?.error?.('OAuth token refresh failed:', (error as Error).message); + + // If token was expired and refresh failed, log out + if (isExpired) { + await clearOAuthSession(session, logger); + return { valid: false, error: `Token refresh failed: ${(error as Error).message}` }; + } + + // Token not yet expired, allow continued use + return { valid: true, refreshed: false }; + } +} + +/** + * Check if a session has valid OAuth authentication + * Does not refresh tokens, only checks validity + */ +export function hasValidOAuthSession(request: Request): boolean { + const session = request.session; + if (!session) return false; + + const oauthMetadata = session.oauth as OAuthSessionMetadata | undefined; + if (!oauthMetadata || !oauthMetadata.accessToken) return false; + + // Check if token is expired - return true if valid, false if expired + return !(oauthMetadata.expiresAt && Date.now() >= oauthMetadata.expiresAt); +} diff --git a/src/lib/withOAuthValidation.ts b/src/lib/withOAuthValidation.ts new file mode 100644 index 0000000..ab88bcf --- /dev/null +++ b/src/lib/withOAuthValidation.ts @@ -0,0 +1,218 @@ +/** + * OAuth Session Validation Wrapper + * + * Wraps Harper resources to add automatic OAuth session validation and token refresh + * before handling any request. This enables transparent token management for protected endpoints. + */ + +import type { Request, Logger, ProviderRegistry } from '../types.ts'; +import { validateAndRefreshSession } from './sessionValidator.ts'; + +export interface OAuthValidationOptions { + /** OAuth provider registry from plugin initialization */ + providers: ProviderRegistry; + /** Logger instance for debugging */ + logger?: Logger; + /** Whether to require OAuth authentication (401 if not present) */ + requireAuth?: boolean; + /** Custom error handler for validation failures */ + onValidationError?: (request: Request, error: string) => any; +} + +/** + * Wraps a Harper resource to add automatic OAuth session validation + * + * This wrapper intercepts all resource method calls (get, post, put, patch, delete) + * and validates/refreshes OAuth tokens before passing the request to the original resource. + * + * @example + * ```typescript + * // In your application component: + * import { withOAuthValidation } from '@harperdb/oauth'; + * + * export function handleApplication(scope) { + * // Get OAuth providers from the OAuth plugin + * const oauthPlugin = scope.parent.resources.get('oauth'); + * + * // Wrap your protected resource + * const myResource = { + * async get(target, request) { + * // This code only runs if OAuth session is valid + * return { user: request.session.oauthUser }; + * } + * }; + * + * scope.resources.set('protected', withOAuthValidation(myResource, { + * providers: oauthPlugin.providers, + * requireAuth: true, + * logger: scope.logger + * })); + * } + * ``` + */ +export function withOAuthValidation(resource: any, options: OAuthValidationOptions): any { + const { providers, logger, requireAuth = false, onValidationError } = options; + + // Create a proxy that wraps all resource methods + return new Proxy(resource, { + get(target, prop: string) { + const originalMethod = target[prop]; + + // Only wrap HTTP methods + if (!['get', 'post', 'put', 'patch', 'delete'].includes(prop)) { + return originalMethod; + } + + // Return wrapped method with OAuth validation + return async function (this: any, ...args: any[]) { + // Extract request from arguments (usually last or second argument) + const request: Request | undefined = args.find((arg) => arg?.session !== undefined); + + if (!request) { + // No request object found - just pass through + return originalMethod.apply(this, args); + } + + // Check if session has OAuth data + const hasOAuth = request.session?.oauth !== undefined; + + if (!hasOAuth) { + if (requireAuth) { + // OAuth authentication required but not present + const error = 'OAuth authentication required'; + if (onValidationError) { + return onValidationError(request, error); + } + return { + status: 401, + body: { + error: 'Unauthorized', + message: error, + }, + }; + } + // OAuth not required, pass through + return originalMethod.apply(this, args); + } + + // Get provider for this OAuth session + const providerName = request.session?.oauth?.provider; + if (!providerName) { + // No provider name in session - invalid OAuth data + if (request.session) { + delete request.session.oauth; + delete request.session.oauthUser; + } + if (requireAuth) { + const error = 'Invalid OAuth session data'; + if (onValidationError) { + return onValidationError(request, error); + } + return { + status: 401, + body: { + error: 'Unauthorized', + message: error, + }, + }; + } + return originalMethod.apply(this, args); + } + + const providerData = providers[providerName]; + + if (!providerData) { + logger?.warn?.(`OAuth provider '${providerName}' not found for session validation`); + // Provider not found - clear OAuth data and continue + if (request.session) { + delete request.session.oauth; + delete request.session.oauthUser; + } + if (requireAuth) { + const error = `OAuth provider '${providerName}' not configured`; + if (onValidationError) { + return onValidationError(request, error); + } + return { + status: 401, + body: { + error: 'Unauthorized', + message: error, + }, + }; + } + return originalMethod.apply(this, args); + } + + // Validate and refresh session + const validation = await validateAndRefreshSession(request, providerData.provider, logger); + + if (!validation.valid) { + // Session validation failed + logger?.info?.(`OAuth session validation failed: ${validation.error}`); + + if (requireAuth) { + const error = validation.error || 'OAuth session expired'; + if (onValidationError) { + return onValidationError(request, error); + } + return { + status: 401, + body: { + error: 'Unauthorized', + message: 'OAuth session expired. Please log in again.', + details: validation.error, + }, + }; + } + + // Not requiring auth, but validation failed - continue without OAuth + return originalMethod.apply(this, args); + } + + // Session is valid (and possibly refreshed) + if (validation.refreshed) { + logger?.debug?.(`OAuth token refreshed for ${providerName} session`); + } + + // Call original method with validated/refreshed session + return originalMethod.apply(this, args); + }; + }, + }); +} + +/** + * Helper to get OAuth providers from the OAuth plugin + * Call this from your application to access the provider registry + * + * @example + * ```typescript + * import { getOAuthProviders } from '@harperdb/oauth'; + * + * export function handleApplication(scope) { + * const providers = getOAuthProviders(scope); + * // Use providers with withOAuthValidation + * } + * ``` + */ +export function getOAuthProviders(scope: any): ProviderRegistry | null { + try { + // Try to get OAuth plugin from parent scope + const oauthResource = scope.parent?.resources?.get?.('oauth'); + if (oauthResource?.providers) { + return oauthResource.providers; + } + + // Try to get from same scope (if plugin is loaded at same level) + const localOAuth = scope.resources?.get?.('oauth'); + if (localOAuth?.providers) { + return localOAuth.providers; + } + + return null; + } catch { + // OAuth module not loaded or accessible + return null; + } +} diff --git a/src/types.ts b/src/types.ts index ae45fd5..f3c5a65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,8 +13,8 @@ import type { IncomingMessage } from 'node:http'; * Runtime configuration for the OAuth plugin */ export interface OAuthPluginConfig { - /** Enable debug mode to expose additional endpoints and information */ - debug?: boolean; + /** Enable debug mode to expose additional endpoints and information (can be boolean or string from env var) */ + debug?: boolean | string; /** OAuth provider configurations */ providers?: Record; /** Default redirect URI for all providers */ @@ -27,6 +27,48 @@ export interface OAuthPluginConfig { usernameClaim?: string; /** Default role assignment */ defaultRole?: string; + /** Lifecycle hooks */ + hooks?: OAuthHooks; +} + +/** + * OAuth Lifecycle Hooks + * Callbacks invoked at key points in the OAuth flow + */ +export interface OAuthHooks { + /** + * Called after successful OAuth login, before session is finalized + * Use this to provision users, assign roles, etc. + * @param oauthUser - The OAuth user information + * @param tokenResponse - The token response from the provider + * @param session - The current session object + * @param request - The HTTP request object + * @param provider - The provider name (e.g., 'github', 'google') + * @returns Optional data to merge into the session + */ + onLogin?: ( + oauthUser: OAuthUser, + tokenResponse: TokenResponse, + session: any, + request: any, + provider: string + ) => Promise | void>; + + /** + * Called before logout, before session is cleared + * Use this to clean up user-specific data + * @param session - The current session object + * @param request - The HTTP request object + */ + onLogout?: (session: any, request: any) => Promise; + + /** + * Called after token refresh completes + * @param session - The updated session with new tokens + * @param refreshed - Whether tokens were actually refreshed + * @param request - The HTTP request object (may be undefined for background refresh) + */ + onTokenRefresh?: (session: any, refreshed: boolean, request?: any) => Promise; } /** @@ -196,6 +238,10 @@ export interface Scope { resources: { set(name: string, resource: any): void; }; + /** HTTP server middleware registration */ + server: { + http(handler: (request: any, next: (req: any) => any) => Promise, options?: any): void; + }; /** Scope event handlers */ on(event: 'close', listener: () => void): void; } @@ -241,6 +287,29 @@ export interface Request extends IncomingMessage { headers: IncomingMessage['headers']; } +/** + * OAuth Session Metadata + * Token and expiration data stored in session for automatic refresh + */ +export interface OAuthSessionMetadata { + /** OAuth provider name (e.g., 'github', 'google') */ + provider: string; + /** Current access token */ + accessToken: string; + /** Refresh token for obtaining new access tokens */ + refreshToken?: string; + /** Unix timestamp (ms) when the access token expires */ + expiresAt?: number; + /** Unix timestamp (ms) when to proactively refresh (80% of lifetime) */ + refreshThreshold?: number; + /** Space-separated list of granted scopes */ + scope?: string; + /** Token type (usually 'Bearer') */ + tokenType?: string; + /** Unix timestamp (ms) of last successful token refresh */ + lastRefreshed?: number; +} + /** * Harper Session * Session data stored for authenticated users @@ -251,10 +320,8 @@ export interface Session { user?: string; /** Full OAuth user object */ oauthUser?: OAuthUser; - /** Current OAuth access token */ - oauthToken?: string; - /** OAuth refresh token for token renewal */ - oauthRefreshToken?: string; + /** OAuth session metadata for automatic token refresh */ + oauth?: OAuthSessionMetadata; /** Async session update method (when available) */ update?: (data: Partial) => Promise; } diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 46c0878..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Tests for main OAuth plugin exports - */ - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -// Mock Harper's Resource class for testing -global.Resource = class { - constructor() {} - static loadAsInstance = true; -}; - -import { handleApplication } from '../dist/index.js'; - -describe('OAuth Plugin Main Exports', () => { - describe('Module exports', () => { - it('should export handleApplication function', () => { - assert.ok(handleApplication); - assert.equal(typeof handleApplication, 'function'); - assert.equal(handleApplication.name, 'handleApplication'); - }); - }); - - describe('handleApplication', () => { - it('should be an async function', () => { - // Check that handleApplication returns a promise - assert.equal(handleApplication.constructor.name, 'AsyncFunction'); - }); - }); -}); diff --git a/test/lib/CSRFTokenManager.test.js b/test/lib/CSRFTokenManager.test.js index 1e31f07..1742a81 100644 --- a/test/lib/CSRFTokenManager.test.js +++ b/test/lib/CSRFTokenManager.test.js @@ -2,7 +2,7 @@ * Tests for CSRFTokenManager */ -import { describe, it, before, after, beforeEach, afterEach } from 'node:test'; +import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { CSRFTokenManager } from '../../dist/lib/CSRFTokenManager.js'; diff --git a/test/lib/OAuthProvider.test.js b/test/lib/OAuthProvider.test.js index 706b968..b7ed200 100644 --- a/test/lib/OAuthProvider.test.js +++ b/test/lib/OAuthProvider.test.js @@ -2,7 +2,7 @@ * Tests for OAuthProvider */ -import { describe, it, before, after, beforeEach, mock } from 'node:test'; +import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { OAuthProvider } from '../../dist/lib/OAuthProvider.js'; @@ -79,6 +79,7 @@ describe('OAuthProvider', () => { assert.throws( () => { + // eslint-disable-next-line sonarjs/constructor-for-side-effects new OAuthProvider(invalidConfig, mockLogger); }, { @@ -94,6 +95,7 @@ describe('OAuthProvider', () => { assert.throws( () => { + // eslint-disable-next-line sonarjs/constructor-for-side-effects new OAuthProvider(invalidConfig, mockLogger); }, { diff --git a/test/lib/handlers.test.js b/test/lib/handlers.test.js index 0ebc1b1..1a226cb 100644 --- a/test/lib/handlers.test.js +++ b/test/lib/handlers.test.js @@ -2,7 +2,7 @@ * Tests for OAuth Handlers */ -import { describe, it, before, after, beforeEach, mock } from 'node:test'; +import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { handleLogin, @@ -17,6 +17,7 @@ describe('OAuth Handlers', () => { let mockProvider; let mockConfig; let mockLogger; + let mockHookManager; let mockRequest; let mockTarget; @@ -29,6 +30,12 @@ describe('OAuth Handlers', () => { debug: mock.fn(), }; + mockHookManager = { + callOnLogin: mock.fn(async () => {}), + callOnLogout: mock.fn(async () => {}), + callOnTokenRefresh: mock.fn(async () => {}), + }; + mockConfig = { provider: 'test', clientId: 'test-client', @@ -98,7 +105,7 @@ describe('OAuth Handlers', () => { }); it('should use referer as original URL', async () => { - const result = await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); + await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); const csrfCall = mockProvider.generateCSRFToken.mock.calls[0]; assert.equal(csrfCall.arguments[0].originalUrl, 'https://app.example.com/page'); @@ -106,14 +113,14 @@ describe('OAuth Handlers', () => { it('should fall back to postLoginRedirect when no referer', async () => { delete mockRequest.headers.referer; - const result = await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); + await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); const csrfCall = mockProvider.generateCSRFToken.mock.calls[0]; assert.equal(csrfCall.arguments[0].originalUrl, '/dashboard'); }); it('should include session ID in CSRF token', async () => { - const result = await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); + await handleLogin(mockRequest, mockProvider, mockConfig, mockLogger); const csrfCall = mockProvider.generateCSRFToken.mock.calls[0]; assert.equal(csrfCall.arguments[0].sessionId, 'session-123'); @@ -122,7 +129,7 @@ describe('OAuth Handlers', () => { describe('handleCallback', () => { it('should handle successful OAuth callback', async () => { - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/dashboard'); @@ -133,13 +140,15 @@ describe('OAuth Handlers', () => { }); it('should update session with user data', async () => { - await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); const updateCall = mockRequest.session.update.mock.calls[0]; assert.equal(updateCall.arguments[0].user, 'user@example.com'); assert.ok(updateCall.arguments[0].oauthUser); - assert.equal(updateCall.arguments[0].oauthToken, 'access-token-123'); - assert.equal(updateCall.arguments[0].oauthRefreshToken, 'refresh-token-456'); + // Token data is now stored in oauth object + assert.ok(updateCall.arguments[0].oauth); + assert.equal(updateCall.arguments[0].oauth.accessToken, 'access-token-123'); + assert.equal(updateCall.arguments[0].oauth.refreshToken, 'refresh-token-456'); }); it('should handle OAuth error response', async () => { @@ -149,7 +158,7 @@ describe('OAuth Handlers', () => { return null; }); - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/dashboard?error=oauth_failed&reason=access_denied'); @@ -158,7 +167,7 @@ describe('OAuth Handlers', () => { it('should handle missing code parameter', async () => { mockTarget.get = mock.fn(() => null); - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/dashboard?error=invalid_request'); @@ -167,7 +176,7 @@ describe('OAuth Handlers', () => { it('should handle invalid CSRF token', async () => { mockProvider.verifyCSRFToken = mock.fn(async () => null); - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/oauth/test/login?error=session_expired'); @@ -181,7 +190,7 @@ describe('OAuth Handlers', () => { }); mockConfig.postLoginRedirect = '/app/home'; - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/app/home?error=oauth_failed&reason=invalid_scope'); @@ -191,7 +200,7 @@ describe('OAuth Handlers', () => { mockTarget.get = mock.fn(() => null); mockConfig.postLoginRedirect = '/app?tab=auth'; - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(result.headers.Location, '/app?tab=auth&error=invalid_request'); @@ -204,7 +213,7 @@ describe('OAuth Handlers', () => { id_token: 'id-token-jwt', })); - await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(mockProvider.verifyIdToken.mock.calls.length, 1); assert.equal(mockProvider.verifyIdToken.mock.calls[0].arguments[0], 'id-token-jwt'); @@ -219,7 +228,7 @@ describe('OAuth Handlers', () => { id_token: 'invalid-token', })); - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); // Should still succeed, falling back to userinfo endpoint assert.equal(result.status, 302); @@ -231,7 +240,7 @@ describe('OAuth Handlers', () => { throw new Error('Invalid client credentials'); }); - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 500); assert.equal(result.body.error, 'Authentication failed'); @@ -243,18 +252,20 @@ describe('OAuth Handlers', () => { id: 'session-123', }; - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); assert.equal(mockRequest.session.user, 'user@example.com'); assert.ok(mockRequest.session.oauthUser); - assert.equal(mockRequest.session.oauthToken, 'access-token-123'); + // Token data is now stored in oauth object + assert.ok(mockRequest.session.oauth); + assert.equal(mockRequest.session.oauth.accessToken, 'access-token-123'); }); it('should handle missing session', async () => { delete mockRequest.session; - const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockLogger); + const result = await handleCallback(mockRequest, mockTarget, mockProvider, mockConfig, mockHookManager, mockLogger); assert.equal(result.status, 302); // Should still complete but log warning @@ -264,39 +275,39 @@ describe('OAuth Handlers', () => { describe('handleLogout', () => { it('should clear session data', async () => { - const result = await handleLogout(mockRequest, mockLogger); + // Add delete method mock to session + mockRequest.session.delete = mock.fn(); + + const result = await handleLogout(mockRequest, mockHookManager, mockLogger); assert.equal(result.status, 200); assert.equal(result.body.message, 'Logged out successfully'); - const updateCall = mockRequest.session.update.mock.calls[0]; - assert.equal(updateCall.arguments[0].user, undefined); - assert.equal(updateCall.arguments[0].oauthUser, undefined); - assert.equal(updateCall.arguments[0].oauthToken, undefined); - assert.equal(updateCall.arguments[0].oauthRefreshToken, undefined); + // Should call session.delete with session ID + assert.equal(mockRequest.session.delete.mock.calls.length, 1); + assert.equal(mockRequest.session.delete.mock.calls[0].arguments[0], 'session-123'); }); - it('should handle session without update function', async () => { + it('should handle session without delete function', async () => { mockRequest.session = { user: 'test-user', oauthUser: { username: 'test' }, - oauthToken: 'token', - oauthRefreshToken: 'refresh', + oauth: { accessToken: 'token' }, }; - const result = await handleLogout(mockRequest, mockLogger); + const result = await handleLogout(mockRequest, mockHookManager, mockLogger); assert.equal(result.status, 200); - assert.equal(mockRequest.session.user, undefined); + // Falls back to clearing fields when delete method isn't available + assert.equal(mockRequest.session.user, null); + assert.equal(mockRequest.session.oauth, undefined); assert.equal(mockRequest.session.oauthUser, undefined); - assert.equal(mockRequest.session.oauthToken, undefined); - assert.equal(mockRequest.session.oauthRefreshToken, undefined); }); it('should handle missing session', async () => { delete mockRequest.session; - const result = await handleLogout(mockRequest, mockLogger); + const result = await handleLogout(mockRequest, mockHookManager, mockLogger); assert.equal(result.status, 200); assert.equal(result.body.message, 'Logged out successfully'); @@ -371,7 +382,7 @@ describe('OAuth Handlers', () => { describe('handleRefresh', () => { it('should refresh access token', async () => { - mockRequest.session.oauthRefreshToken = 'refresh-token-456'; + mockRequest.session.oauth = { refreshToken: 'refresh-token-456' }; const result = await handleRefresh(mockRequest, mockProvider, mockConfig, mockLogger); @@ -380,11 +391,11 @@ describe('OAuth Handlers', () => { assert.equal(result.body.expiresIn, 3600); const updateCall = mockRequest.session.update.mock.calls[0]; - assert.equal(updateCall.arguments[0].oauthToken, 'new-access-token'); + assert.equal(updateCall.arguments[0].oauth.accessToken, 'new-access-token'); }); it('should handle missing refresh token', async () => { - delete mockRequest.session.oauthRefreshToken; + mockRequest.session.oauth = {}; const result = await handleRefresh(mockRequest, mockProvider, mockConfig, mockLogger); @@ -393,7 +404,7 @@ describe('OAuth Handlers', () => { }); it('should handle provider without refresh support', async () => { - mockRequest.session.oauthRefreshToken = 'refresh-token'; + mockRequest.session.oauth = { refreshToken: 'refresh-token' }; delete mockProvider.refreshAccessToken; const result = await handleRefresh(mockRequest, mockProvider, mockConfig, mockLogger); @@ -403,7 +414,7 @@ describe('OAuth Handlers', () => { }); it('should handle refresh failure', async () => { - mockRequest.session.oauthRefreshToken = 'expired-refresh-token'; + mockRequest.session.oauth = { refreshToken: 'expired-refresh-token' }; mockProvider.refreshAccessToken = mock.fn(async () => { throw new Error('Refresh token expired'); }); @@ -416,7 +427,7 @@ describe('OAuth Handlers', () => { }); it('should update refresh token when new one provided', async () => { - mockRequest.session.oauthRefreshToken = 'old-refresh-token'; + mockRequest.session.oauth = { refreshToken: 'old-refresh-token' }; mockProvider.refreshAccessToken = mock.fn(async () => ({ access_token: 'new-access', refresh_token: 'new-refresh', @@ -427,7 +438,7 @@ describe('OAuth Handlers', () => { assert.equal(result.status, 200); const updateCall = mockRequest.session.update.mock.calls[0]; - assert.equal(updateCall.arguments[0].oauthRefreshToken, 'new-refresh'); + assert.equal(updateCall.arguments[0].oauth.refreshToken, 'new-refresh'); }); }); diff --git a/test/lib/hookManager.test.js b/test/lib/hookManager.test.js new file mode 100644 index 0000000..ef155b3 --- /dev/null +++ b/test/lib/hookManager.test.js @@ -0,0 +1,206 @@ +/** + * Tests for HookManager + */ + +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { HookManager } from '../../dist/lib/hookManager.js'; + +describe('HookManager', () => { + let hookManager; + let mockLogger; + + beforeEach(() => { + mockLogger = { + debug: mock.fn(), + error: mock.fn(), + info: mock.fn(), + warn: mock.fn(), + }; + hookManager = new HookManager(mockLogger); + }); + + describe('register', () => { + it('should register hooks', () => { + const hooks = { + onLogin: mock.fn(), + onLogout: mock.fn(), + }; + + hookManager.register(hooks); + assert.equal(hookManager.hasHooks(), true); + }); + + it('should log registered hooks', () => { + const hooks = { + onLogin: mock.fn(), + onLogout: mock.fn(), + onTokenRefresh: mock.fn(), + }; + + hookManager.register(hooks); + assert.equal(mockLogger.debug.mock.calls.length, 1); + const logMessage = mockLogger.debug.mock.calls[0].arguments[0]; + assert.ok(logMessage.includes('onLogin')); + assert.ok(logMessage.includes('onLogout')); + assert.ok(logMessage.includes('onTokenRefresh')); + }); + }); + + describe('hasHooks', () => { + it('should return false when no hooks registered', () => { + assert.equal(hookManager.hasHooks(), false); + }); + + it('should return true when hooks registered', () => { + hookManager.register({ onLogin: mock.fn() }); + assert.equal(hookManager.hasHooks(), true); + }); + }); + + describe('callOnLogin', () => { + it('should call onLogin hook with correct parameters', async () => { + const onLoginMock = mock.fn(async () => ({ customData: 'test' })); + hookManager.register({ onLogin: onLoginMock }); + + const oauthUser = { username: 'testuser', email: 'test@example.com' }; + const tokenResponse = { access_token: 'token123', refresh_token: 'refresh456' }; + const session = { id: 'session123' }; + const request = { url: '/test' }; + const provider = 'github'; + + const result = await hookManager.callOnLogin(oauthUser, tokenResponse, session, request, provider); + + assert.equal(onLoginMock.mock.calls.length, 1); + assert.equal(onLoginMock.mock.calls[0].arguments[0], oauthUser); + assert.equal(onLoginMock.mock.calls[0].arguments[1], tokenResponse); + assert.equal(onLoginMock.mock.calls[0].arguments[2], session); + assert.equal(onLoginMock.mock.calls[0].arguments[3], request); + assert.equal(onLoginMock.mock.calls[0].arguments[4], provider); + assert.deepEqual(result, { customData: 'test' }); + }); + + it('should return undefined when no onLogin hook registered', async () => { + const result = await hookManager.callOnLogin({}, {}, {}, {}, 'github'); + assert.equal(result, undefined); + }); + + it('should catch and log errors from onLogin hook', async () => { + const onLoginMock = mock.fn(async () => { + throw new Error('Hook error'); + }); + hookManager.register({ onLogin: onLoginMock }); + + const result = await hookManager.callOnLogin({}, {}, {}, {}, 'github'); + + assert.equal(result, undefined); + assert.equal(mockLogger.error.mock.calls.length, 1); + assert.ok(mockLogger.error.mock.calls[0].arguments[0].includes('onLogin hook failed')); + }); + + it('should log debug message when calling hook', async () => { + hookManager.register({ onLogin: mock.fn(async () => {}) }); + await hookManager.callOnLogin({}, {}, {}, {}, 'github'); + + assert.ok(mockLogger.debug.mock.calls.some(call => + call.arguments[0].includes('Calling onLogin hook') + )); + }); + }); + + describe('callOnLogout', () => { + it('should call onLogout hook with correct parameters', async () => { + const onLogoutMock = mock.fn(async () => {}); + hookManager.register({ onLogout: onLogoutMock }); + + const session = { id: 'session123', user: 'testuser' }; + const request = { url: '/logout' }; + + await hookManager.callOnLogout(session, request); + + assert.equal(onLogoutMock.mock.calls.length, 1); + assert.equal(onLogoutMock.mock.calls[0].arguments[0], session); + assert.equal(onLogoutMock.mock.calls[0].arguments[1], request); + }); + + it('should not throw when no onLogout hook registered', async () => { + await hookManager.callOnLogout({}, {}); + // Should complete without error + }); + + it('should catch and log errors from onLogout hook', async () => { + const onLogoutMock = mock.fn(async () => { + throw new Error('Logout hook error'); + }); + hookManager.register({ onLogout: onLogoutMock }); + + await hookManager.callOnLogout({}, {}); + + assert.equal(mockLogger.error.mock.calls.length, 1); + assert.ok(mockLogger.error.mock.calls[0].arguments[0].includes('onLogout hook failed')); + }); + }); + + describe('callOnTokenRefresh', () => { + it('should call onTokenRefresh hook with correct parameters', async () => { + const onTokenRefreshMock = mock.fn(async () => {}); + hookManager.register({ onTokenRefresh: onTokenRefreshMock }); + + const session = { id: 'session123', oauth: { accessToken: 'token' } }; + const request = { url: '/api' }; + + await hookManager.callOnTokenRefresh(session, true, request); + + assert.equal(onTokenRefreshMock.mock.calls.length, 1); + assert.equal(onTokenRefreshMock.mock.calls[0].arguments[0], session); + assert.equal(onTokenRefreshMock.mock.calls[0].arguments[1], true); + assert.equal(onTokenRefreshMock.mock.calls[0].arguments[2], request); + }); + + it('should handle refreshed=false', async () => { + const onTokenRefreshMock = mock.fn(async () => {}); + hookManager.register({ onTokenRefresh: onTokenRefreshMock }); + + await hookManager.callOnTokenRefresh({}, false); + + assert.equal(onTokenRefreshMock.mock.calls[0].arguments[1], false); + }); + + it('should work without request parameter', async () => { + const onTokenRefreshMock = mock.fn(async () => {}); + hookManager.register({ onTokenRefresh: onTokenRefreshMock }); + + await hookManager.callOnTokenRefresh({}, true); + + assert.equal(onTokenRefreshMock.mock.calls.length, 1); + assert.equal(onTokenRefreshMock.mock.calls[0].arguments[2], undefined); + }); + + it('should not throw when no onTokenRefresh hook registered', async () => { + await hookManager.callOnTokenRefresh({}, true); + // Should complete without error + }); + + it('should catch and log errors from onTokenRefresh hook', async () => { + const onTokenRefreshMock = mock.fn(async () => { + throw new Error('Refresh hook error'); + }); + hookManager.register({ onTokenRefresh: onTokenRefreshMock }); + + await hookManager.callOnTokenRefresh({}, true); + + assert.equal(mockLogger.error.mock.calls.length, 1); + assert.ok(mockLogger.error.mock.calls[0].arguments[0].includes('onTokenRefresh hook failed')); + }); + + it('should log debug message with refreshed status', async () => { + hookManager.register({ onTokenRefresh: mock.fn(async () => {}) }); + await hookManager.callOnTokenRefresh({}, true); + + assert.ok(mockLogger.debug.mock.calls.some(call => + call.arguments[0].includes('Calling onTokenRefresh hook') && + call.arguments[0].includes('refreshed: true') + )); + }); + }); +}); diff --git a/test/lib/providers/github.test.js b/test/lib/providers/github.test.js index 2268a81..f11768a 100644 --- a/test/lib/providers/github.test.js +++ b/test/lib/providers/github.test.js @@ -26,7 +26,7 @@ describe('GitHub Provider', () => { // Mock the helpers const mockHelpers = { - getUserInfo: async (token) => ({ + getUserInfo: async () => ({ login: 'testuser', name: 'Test User', email: null, // GitHub often returns null email diff --git a/test/lib/resource.test.js b/test/lib/resource.test.js index 435e75f..41b72ff 100644 --- a/test/lib/resource.test.js +++ b/test/lib/resource.test.js @@ -2,7 +2,7 @@ * Tests for OAuth Resource */ -import { describe, it, before, after, beforeEach, mock } from 'node:test'; +import { describe, it, beforeEach, mock } from 'node:test'; import assert from 'node:assert/strict'; // Mock Harper's Resource class for testing @@ -16,12 +16,7 @@ import { createOAuthResource } from '../../dist/lib/resource.js'; describe('OAuth Resource', () => { let mockProviders; let mockLogger; - let originalHandlers; - - before(() => { - // Save original handler functions to restore later - originalHandlers = {}; - }); + let mockHookManager; beforeEach(() => { mockLogger = { @@ -31,6 +26,12 @@ describe('OAuth Resource', () => { debug: mock.fn(), }; + mockHookManager = { + callOnLogin: mock.fn(async () => {}), + callOnLogout: mock.fn(async () => {}), + callOnTokenRefresh: mock.fn(async () => {}), + }; + mockProviders = { github: { provider: { @@ -60,7 +61,7 @@ describe('OAuth Resource', () => { let resource; beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockLogger); + resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); }); it('should return 404 for root path', async () => { @@ -132,7 +133,7 @@ describe('OAuth Resource', () => { let resource; beforeEach(() => { - resource = createOAuthResource(mockProviders, true, mockLogger); + resource = createOAuthResource(mockProviders, true, mockHookManager, mockLogger); }); it('should show provider list at root', async () => { @@ -194,7 +195,7 @@ describe('OAuth Resource', () => { let resource; beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockLogger); + resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); }); it('should handle string target', async () => { @@ -254,7 +255,7 @@ describe('OAuth Resource', () => { let resource; beforeEach(() => { - resource = createOAuthResource(mockProviders, false, mockLogger); + resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); }); it('should handle logout POST request', async () => { @@ -264,19 +265,19 @@ describe('OAuth Resource', () => { update: mock.fn(), }, }; - const result = await resource.post('github/logout', {}, request); + const result = await resource.post('logout', {}, request); assert.equal(result.status, 200); assert.equal(result.body.message, 'Logged out successfully'); }); it('should reject non-logout POST requests', async () => { const result = await resource.post('github/login', {}, {}); - assert.equal(result.status, 405); - assert.equal(result.body.error, 'Method not allowed'); + assert.equal(result.status, 404); + assert.equal(result.body.error, 'Not found'); }); - it('should handle unknown provider in POST', async () => { - const result = await resource.post('unknown/logout', {}, {}); + it('should handle other POST endpoints', async () => { + const result = await resource.post('github/something', {}, {}); assert.equal(result.status, 404); assert.equal(result.body.error, 'Not found'); }); @@ -285,15 +286,15 @@ describe('OAuth Resource', () => { describe('Resource Creation', () => { it('should create resource with providers', () => { - const resource = createOAuthResource(mockProviders, false, mockLogger); + const resource = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); assert.ok(resource); assert.equal(typeof resource.get, 'function'); assert.equal(typeof resource.post, 'function'); }); it('should create different instances with different configs', () => { - const resource1 = createOAuthResource(mockProviders, false, mockLogger); - const resource2 = createOAuthResource(mockProviders, true, mockLogger); + const resource1 = createOAuthResource(mockProviders, false, mockHookManager, mockLogger); + const resource2 = createOAuthResource(mockProviders, true, mockHookManager, mockLogger); // Each resource is a new object assert.notEqual(resource1, resource2); }); diff --git a/test/options-watcher.test.js b/test/options-watcher.test.js index d3379b1..1067e21 100644 --- a/test/options-watcher.test.js +++ b/test/options-watcher.test.js @@ -1,4 +1,4 @@ -import { describe, it, before, after, beforeEach } from 'node:test'; +import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { handleApplication } from '../dist/index.js'; @@ -44,6 +44,12 @@ describe('OAuth Plugin Options Watcher', () => { } }, }, + server: { + http(middleware) { + // Mock HTTP middleware registration + return middleware; + }, + }, resources: { set(name, resource) { resources[name] = resource; @@ -129,8 +135,10 @@ describe('OAuth Plugin Options Watcher', () => { providers: {}, }; - // Trigger change event + // Trigger change event and wait for async update configChangeListeners[0](); + // Give async config update time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); // Should set error resource when no providers assert.ok(resources.oauth, 'OAuth resource should still exist');