From d1fb24898c1537e27afd6a175cc7f21aa9d5bf02 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 29 Dec 2025 20:04:47 +0100 Subject: [PATCH] cleanup multi provider --- .env.example | 75 ++- env_ai | 67 ++- server/app.ts | 63 +-- server/routes/oauth2.ts | 164 +++--- server/routes/obp.ts | 92 +-- server/routes/status.ts | 98 +--- server/routes/user.ts | 38 +- server/services/OAuth2ProviderFactory.ts | 37 +- server/services/OAuth2Service.ts | 689 ----------------------- src/components/HeaderNav.vue | 43 +- src/views/ProvidersStatusView.vue | 2 +- 11 files changed, 214 insertions(+), 1154 deletions(-) delete mode 100644 server/services/OAuth2Service.ts diff --git a/.env.example b/.env.example index b0ae1f1..411e855 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +1,52 @@ -### OBP-API Configuration ### -VITE_OBP_API_PORTAL_HOST=http://127.0.0.1:8080 # OBP API Portal URL (for "Portal Home" navigation link) -VITE_OBP_API_HOST=http://127.0.0.1:8080 # OBP API server base URL (for all backend API requests) -# VITE_OBP_API_VERSION is NO LONGER USED - hardcoded to v5.1.0 in shared-constants.ts for stability -VITE_OBP_API_MANAGER_HOST=https://apimanagersandbox.openbankproject.com # OBP API Manager URL (optional - for navigation link) -VITE_OBP_API_EXPLORER_HOST=http://localhost:5173 # API Explorer application URL (used for OAuth2 redirects and internal routing) -VITE_OPB_SERVER_SESSION_PASSWORD=your-secret-session-password-here # Secret key for session encryption (keep this secure!) -VITE_SHOW_API_MANAGER_BUTTON=false # Show/hide API Manager button in navigation (true/false) +### OBP API Configuration ### +VITE_OBP_API_HOST=http://127.0.0.1:8080 +VITE_OBP_API_VERSION=v5.1.0 -### Redis Configuration ### -VITE_OBP_REDIS_URL=redis://127.0.0.1:6379 # Redis connection string for session storage (format: redis://host:port) +### API Explorer Host ### +VITE_OBP_API_EXPLORER_HOST=http://localhost:5173 -### Opey Configuration ### -VITE_CHATBOT_ENABLED=false # Enable/disable Opey chatbot widget (true/false) -VITE_CHATBOT_URL=http://localhost:5000 # Opey chatbot service URL (only needed if chatbot is enabled) +### Session Configuration ### +VITE_OPB_SERVER_SESSION_PASSWORD=change-me-to-a-secure-random-string -### OAuth2/OIDC Configuration ### -VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779 # OAuth2 client ID (UUID - must match OIDC server registration) -VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM # OAuth2 client secret (keep this secure!) -VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback # OAuth2 callback URL (must exactly match OIDC client registration) -VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://localhost:9000/obp-oidc/.well-known/openid-configuration # OIDC discovery endpoint URL -VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300 # Seconds before token expiry to trigger refresh (default: 300) +### OAuth2 Redirect URL (shared by all providers) ### +VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback -### Resource Documentation Version (Optional) ### -# VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv5.1.0 # Default resource docs version for frontend URLs (format: OBPv5.1.0 - with OBP prefix, auto-constructed if not set) +### Redis Configuration (Optional - uses localhost:6379 if not set) ### +# VITE_OBP_REDIS_URL=redis://127.0.0.1:6379 +# VITE_OBP_REDIS_PASSWORD= +# VITE_OBP_REDIS_USERNAME= -### Session Configuration (Optional) ### -# VITE_SESSION_MAX_AGE=3600 # Session timeout in seconds (default: 3600 = 1 hour) +### Multi-Provider OAuth2/OIDC Configuration ### +### The system fetches available providers from: http://localhost:8080/obp/v5.1.0/well-known +### Configure credentials below for each provider you want to support -### Styling Configuration (Optional) ### -# VITE_OBP_LOGO_URL=https://example.com/logo.png # Custom logo image URL (uses default OBP logo if not set) -# VITE_OBP_LINKS_COLOR=#3c8dbc # Primary link color (CSS color value) -# VITE_OBP_HEADER_LINKS_COLOR=#39455f # Header navigation link color -# VITE_OBP_HEADER_LINKS_HOVER_COLOR=#39455f # Header navigation link hover color -# VITE_OBP_HEADER_LINKS_BACKGROUND_COLOR=#eef0f4 # Header navigation active link background color +### OBP-OIDC Provider ### +VITE_OBP_OIDC_CLIENT_ID=your-obp-oidc-client-id +VITE_OBP_OIDC_CLIENT_SECRET=your-obp-oidc-client-secret + +### OBP Consumer Key (for API calls) ### +VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id + +### Keycloak Provider (Optional) ### +# VITE_KEYCLOAK_CLIENT_ID=your-keycloak-client-id +# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret + +### Google Provider (Optional) ### +# VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret + +### GitHub Provider (Optional) ### +# VITE_GITHUB_CLIENT_ID=your-github-client-id +# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret + +### Custom OIDC Provider (Optional) ### +# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-custom-provider +# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id +# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret + +### Chatbot Configuration (Optional) ### +VITE_CHATBOT_ENABLED=false +# VITE_CHATBOT_URL=http://localhost:5000 + +### Resource Docs Version ### +VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0 diff --git a/env_ai b/env_ai index e588604..1399488 100644 --- a/env_ai +++ b/env_ai @@ -1,26 +1,63 @@ -### OBP-API Configuration ### -VITE_OBP_API_PORTAL_HOST=http://127.0.0.1:8080 +### OBP API Configuration ### VITE_OBP_API_HOST=http://127.0.0.1:8080 -VITE_OBP_API_VERSION=v5.1.0 -VITE_OBP_API_MANAGER_HOST=https://apimanagersandbox.openbankproject.com -VITE_OBP_API_EXPLORER_HOST=http://localhost:5174 +VITE_OBP_API_VERSION=v6.0.0 +VITE_OBP_API_EXPLORER_HOST=http://localhost:5173 + +### Session Configuration ### VITE_OPB_SERVER_SESSION_PASSWORD=asidudhiuh33875 +### OAuth2 Redirect URL (shared by all providers) ### +VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback + ### Redis Configuration ### VITE_OBP_REDIS_URL=redis://127.0.0.1:6379 -### Opey Configuration ### +### Chatbot Configuration ### VITE_CHATBOT_ENABLED=false VITE_CHATBOT_URL=http://localhost:5000 -### OAuth2/OIDC Configuration ### -# OAuth2 Client Credentials (from OBP-OIDC) -VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779 -VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM -VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback +### Multi-Provider OAuth2/OIDC Configuration ### +### The system fetches available providers from: http://localhost:8080/obp/v5.1.0/well-known +### Configure credentials below for each provider you want to support -# OIDC Well-Known Configuration URL -VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration +### OBP-OIDC Provider ### +VITE_OBP_OIDC_CLIENT_ID=c2ea173e-8c1a-43c4-ba62-19738f27c43e +VITE_OBP_OIDC_CLIENT_SECRET=1E7zsN47Xp4VTb28xEv5ZK4vcX8XMsYIH3IsnjQTYk8 -# Optional: Token refresh threshold (seconds before expiry) -VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300 +### OBP Consumer Key (for API calls) ### +VITE_OBP_CONSUMER_KEY=c2ea173e-8c1a-43c4-ba62-19738f27c43e + +### Keycloak Provider (Optional) ### +# VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer +# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret-here + +### Google Provider (Optional) ### +# VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret + +### GitHub Provider (Optional) ### +# VITE_GITHUB_CLIENT_ID=your-github-client-id +# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret + +### Custom OIDC Provider (Optional) ### +# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-custom-provider +# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id +# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret + +### Opey Configuration ### +VITE_OPEY_CONSUMER_ID=74545fb7-9a1f-4ee0-beb4-6e5b7ee50076 + +### Resource Docs Version ### +VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0 + +### HOW IT WORKS ### +# 1. Backend fetches provider list from OBP API: GET /obp/v5.1.0/well-known +# 2. OBP API returns available providers with their .well-known URLs +# 3. Backend matches providers with credentials configured above +# 4. Only providers with both (API registration + credentials) will be available +# 5. Users see provider selection if 2+ providers configured (or auto-login if only 1) + +### VERIFY YOUR SETUP ### +# curl http://localhost:8080/obp/v5.1.0/well-known +# curl http://localhost:8085/api/oauth2/providers +# Visit: http://localhost:5173/debug/providers-status diff --git a/server/app.ts b/server/app.ts index 9ead7aa..36c4d8f 100644 --- a/server/app.ts +++ b/server/app.ts @@ -35,7 +35,6 @@ import type { Application } from 'express' import { Container } from 'typedi' import path from 'path' import { execSync } from 'child_process' -import { OAuth2Service } from './services/OAuth2Service.js' import { OAuth2ProviderManager } from './services/OAuth2ProviderManager.js' import { fileURLToPath } from 'url' import { dirname } from 'path' @@ -137,9 +136,7 @@ if (app.get('env') === 'production') { } app.use(session(sessionObject)) -// Initialize OAuth2 Service -console.log(`--- OAuth2/OIDC setup -------------------------------------------`) -const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL +// OAuth2 Multi-Provider Setup only - no legacy fallback // Async IIFE to initialize OAuth2 and start server let instance: any @@ -160,61 +157,15 @@ let instance: any providerManager.startHealthCheck(60000) // Check every 60 seconds console.log('OK Provider health monitoring started (every 60s)') } else { - console.warn('WARNING No OAuth2 providers initialized from OBP API') - console.warn('WARNING Falling back to legacy single-provider mode...') + console.error('ERROR: No OAuth2 providers initialized from OBP API') + console.error( + 'ERROR: Check that OBP API is running and returns providers from /obp/v5.1.0/well-known' + ) + console.error('ERROR: Server will start but login will not work') } } catch (error) { console.error('ERROR Failed to initialize OAuth2 multi-provider:', error) - console.warn('WARNING Falling back to legacy single-provider mode...') - } - console.log(`-----------------------------------------------------------------`) - - // Initialize Legacy OAuth2 Service (for backward compatibility) - console.log(`--- OAuth2/OIDC Legacy Setup (Backward Compatibility) -----------`) - if (!wellKnownUrl) { - console.warn('VITE_OBP_OAUTH2_WELL_KNOWN_URL not set. Legacy OAuth2 will not function.') - console.warn('Server will rely on multi-provider mode from OBP API.') - } else { - console.log(`OIDC Well-Known URL (legacy): ${wellKnownUrl}`) - - // Get OAuth2Service from container - const oauth2Service = Container.get(OAuth2Service) - - // Initialize OAuth2 service with retry logic - const isProduction = process.env.NODE_ENV === 'production' - const maxRetries = Infinity // Retry indefinitely - const initialDelay = 1000 // 1 second, then exponential backoff - - console.log( - 'Attempting legacy OAuth2 initialization (will retry indefinitely with exponential backoff)...' - ) - const success = await oauth2Service.initializeWithRetry(wellKnownUrl, maxRetries, initialDelay) - - if (success) { - console.log('OAuth2Service (legacy): Initialization successful') - console.log(' Client ID:', process.env.VITE_OBP_OAUTH2_CLIENT_ID || 'NOT SET') - console.log(' Redirect URI:', process.env.VITE_OBP_OAUTH2_REDIRECT_URL || 'NOT SET') - console.log('Legacy OAuth2/OIDC ready for authentication') - - // Start continuous monitoring even when initially connected - oauth2Service.startHealthCheck(1000, 240000) // Monitor every 4 minutes - console.log('OAuth2Service (legacy): Starting continuous monitoring (every 4 minutes)') - } else { - console.error('OAuth2Service (legacy): Initialization failed after all retries') - - // Use graceful degradation for both development and production - const envMode = isProduction ? 'Production' : 'Development' - console.warn(`WARNING: ${envMode} mode: Server will start without legacy OAuth2`) - console.warn('WARNING: Legacy login will be unavailable until OIDC server is reachable') - console.warn('WARNING: Multi-provider mode will be used if available') - console.warn('Please check:') - console.warn(' 1. OBP-OIDC server is running') - console.warn(' 2. VITE_OBP_OAUTH2_WELL_KNOWN_URL is correct') - console.warn(' 3. Network connectivity to OIDC provider') - - // Start periodic health check to reconnect when OIDC becomes available - oauth2Service.startHealthCheck(1000, 240000) // Start with 1 second, monitor every 4 minutes when connected - } + console.error('ERROR: Server will start but login will not work') } console.log(`-----------------------------------------------------------------`) diff --git a/server/routes/oauth2.ts b/server/routes/oauth2.ts index 6ee3525..7fabb07 100644 --- a/server/routes/oauth2.ts +++ b/server/routes/oauth2.ts @@ -28,7 +28,6 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { Container } from 'typedi' -import { OAuth2Service } from '../services/OAuth2Service.js' import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js' import { PKCEUtils } from '../utils/pkce.js' import type { UserInfo } from '../types/oauth2.js' @@ -37,7 +36,6 @@ const router = Router() // Get services from container const providerManager = Container.get(OAuth2ProviderManager) -const legacyOAuth2Service = Container.get(OAuth2Service) /** * GET /oauth2/providers @@ -66,7 +64,7 @@ router.get('/oauth2/providers', async (req: Request, res: Response) => { * GET /oauth2/connect * Initiate OAuth2 authentication flow * Query params: - * - provider: Provider name (optional, uses legacy if not specified) + * - provider: Provider name (required) * - redirect: URL to redirect after auth (optional) */ router.get('/oauth2/connect', async (req: Request, res: Response) => { @@ -76,9 +74,18 @@ router.get('/oauth2/connect', async (req: Request, res: Response) => { const session = req.session as any console.log('OAuth2 Connect: Starting authentication flow') - console.log(` Provider: ${provider || '(legacy mode)'}`) + console.log(` Provider: ${provider || 'NOT SPECIFIED'}`) console.log(` Redirect: ${redirect}`) + // Provider is required + if (!provider) { + console.error('OAuth2 Connect: No provider specified') + return res.status(400).json({ + error: 'missing_provider', + message: 'Provider parameter is required' + }) + } + // Store redirect URL in session session.oauth2_redirect_page = redirect @@ -91,56 +98,36 @@ router.get('/oauth2/connect', async (req: Request, res: Response) => { session.oauth2_code_verifier = codeVerifier session.oauth2_state = state - let authUrl: string + console.log(`OAuth2 Connect: Using provider - ${provider}`) - if (provider) { - // Multi-provider mode - console.log(`OAuth2 Connect: Using multi-provider mode - ${provider}`) - - const client = providerManager.getProvider(provider) - if (!client) { - const availableProviders = providerManager.getAvailableProviders() - console.error(`OAuth2 Connect: Provider not found: ${provider}`) - return res.status(400).json({ - error: 'invalid_provider', - message: `Provider "${provider}" is not available`, - availableProviders - }) - } - - // Store provider name for callback - session.oauth2_provider = provider - - // Build authorization URL - const authEndpoint = client.getAuthorizationEndpoint() - const params = new URLSearchParams({ - client_id: client.clientId, - redirect_uri: client.getRedirectUri(), - response_type: 'code', - scope: 'openid profile email', - state: state, - code_challenge: codeChallenge, - code_challenge_method: 'S256' + const client = providerManager.getProvider(provider) + if (!client) { + const availableProviders = providerManager.getAvailableProviders() + console.error(`OAuth2 Connect: Provider not found: ${provider}`) + return res.status(400).json({ + error: 'invalid_provider', + message: `Provider "${provider}" is not available`, + availableProviders }) - - authUrl = `${authEndpoint}?${params.toString()}` - } else { - // Legacy single-provider mode - console.log('OAuth2 Connect: Using legacy single-provider mode') - - if (!legacyOAuth2Service.isInitialized()) { - console.error('OAuth2 Connect: OAuth2 service not initialized') - return res.status(503).json({ - error: 'oauth2_unavailable', - message: 'OAuth2 authentication is not available' - }) - } - - authUrl = legacyOAuth2Service - .createAuthorizationURL(state, ['openid', 'profile', 'email']) - .toString() } + // Store provider name for callback + session.oauth2_provider = provider + + // Build authorization URL + const authEndpoint = client.getAuthorizationEndpoint() + const params = new URLSearchParams({ + client_id: client.clientId, + redirect_uri: client.getRedirectUri(), + response_type: 'code', + scope: 'openid profile email', + state: state, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }) + + const authUrl = `${authEndpoint}?${params.toString()}` + // Save session before redirect session.save((err: any) => { if (err) { @@ -211,55 +198,42 @@ router.get('/oauth2/callback', async (req: Request, res: Response) => { return res.redirect('/?oauth2_error=missing_verifier') } - // Check if multi-provider mode + // Get provider from session const provider = session.oauth2_provider - let tokens: any - let userInfo: UserInfo - - if (provider) { - // Multi-provider mode - console.log(`OAuth2 Callback: Multi-provider mode - ${provider}`) - - const client = providerManager.getProvider(provider) - if (!client) { - console.error(`OAuth2 Callback: Provider not found: ${provider}`) - return res.redirect('/?oauth2_error=provider_not_found') - } - - // Exchange code for tokens - console.log('OAuth2 Callback: Exchanging authorization code for tokens') - tokens = await client.exchangeAuthorizationCode(code, codeVerifier) - - // Fetch user info - console.log('OAuth2 Callback: Fetching user info') - const userInfoEndpoint = client.getUserInfoEndpoint() - const userInfoResponse = await fetch(userInfoEndpoint, { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - Accept: 'application/json' - } - }) - - if (!userInfoResponse.ok) { - throw new Error(`UserInfo request failed: ${userInfoResponse.status}`) - } - - userInfo = (await userInfoResponse.json()) as UserInfo - - // Store provider in session - session.oauth2_provider = provider - } else { - // Legacy single-provider mode - console.log('OAuth2 Callback: Legacy single-provider mode') - - // Exchange code for tokens - tokens = await legacyOAuth2Service.exchangeCodeForTokens(code, codeVerifier) - - // Fetch user info - userInfo = await legacyOAuth2Service.getUserInfo(tokens.accessToken) + if (!provider) { + console.error('OAuth2 Callback: Provider not found in session') + return res.redirect('/?oauth2_error=missing_provider') } + console.log(`OAuth2 Callback: Processing callback for ${provider}`) + + const client = providerManager.getProvider(provider) + if (!client) { + console.error(`OAuth2 Callback: Provider not found: ${provider}`) + return res.redirect('/?oauth2_error=provider_not_found') + } + + // Exchange code for tokens + console.log('OAuth2 Callback: Exchanging authorization code for tokens') + const tokens = await client.exchangeAuthorizationCode(code, codeVerifier) + + // Fetch user info + console.log('OAuth2 Callback: Fetching user info') + const userInfoEndpoint = client.getUserInfoEndpoint() + const userInfoResponse = await fetch(userInfoEndpoint, { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + Accept: 'application/json' + } + }) + + if (!userInfoResponse.ok) { + throw new Error(`UserInfo request failed: ${userInfoResponse.status}`) + } + + const userInfo = (await userInfoResponse.json()) as UserInfo + // Store tokens in session session.oauth2_access_token = tokens.accessToken session.oauth2_refresh_token = tokens.refreshToken diff --git a/server/routes/obp.ts b/server/routes/obp.ts index bf55513..6421401 100644 --- a/server/routes/obp.ts +++ b/server/routes/obp.ts @@ -29,62 +29,18 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { Container } from 'typedi' import OBPClientService from '../services/OBPClientService.js' -import { OAuth2Service } from '../services/OAuth2Service.js' const router = Router() // Get services from container const obpClientService = Container.get(OBPClientService) -const oauth2Service = Container.get(OAuth2Service) /** - * Check if access token is expired and refresh it if needed - * This ensures API calls always use a valid token + * Check if user is authenticated + * TODO: Implement token refresh in multi-provider system */ -async function ensureValidToken(session: any): Promise { - const accessToken = session.oauth2_access_token - const refreshToken = session.oauth2_refresh_token - - // If no access token, user is not authenticated - if (!accessToken) { - return false - } - - // Check if token is expired - if (oauth2Service.isTokenExpired(accessToken)) { - console.log('OBP: Access token expired, attempting refresh') - - if (!refreshToken) { - console.log('OBP: No refresh token available') - return false - } - - try { - const newTokens = await oauth2Service.refreshAccessToken(refreshToken) - - // Update session with new tokens - session.oauth2_access_token = newTokens.accessToken - session.oauth2_refresh_token = newTokens.refreshToken || refreshToken - session.oauth2_id_token = newTokens.idToken - session.oauth2_token_timestamp = Date.now() - session.oauth2_expires_in = newTokens.expiresIn - - // Update clientConfig with new access token - if (session.clientConfig && session.clientConfig.oauth2) { - session.clientConfig.oauth2.accessToken = newTokens.accessToken - console.log('OBP: Updated clientConfig with refreshed token') - } - - console.log('OBP: Token refresh successful') - return true - } catch (error) { - console.error('OBP: Token refresh failed:', error) - return false - } - } - - // Token is still valid - return true +function isAuthenticated(session: any): boolean { + return !!session.oauth2_access_token && !!session.oauth2_user } /** @@ -98,16 +54,6 @@ router.get('/get', async (req: Request, res: Response) => { const path = req.query.path as string const session = req.session as any - // Ensure token is valid before making the request - const tokenValid = await ensureValidToken(session) - if (!tokenValid && session.oauth2_user) { - console.log('OBP: Token expired and refresh failed') - return res.status(401).json({ - code: 401, - message: 'Session expired. Please log in again.' - }) - } - const oauthConfig = session.clientConfig const result = await obpClientService.get(path, oauthConfig) @@ -139,16 +85,6 @@ router.post('/create', async (req: Request, res: Response) => { const data = req.body const session = req.session as any - // Ensure token is valid before making the request - const tokenValid = await ensureValidToken(session) - if (!tokenValid && session.oauth2_user) { - console.log('OBP: Token expired and refresh failed') - return res.status(401).json({ - code: 401, - message: 'Session expired. Please log in again.' - }) - } - const oauthConfig = session.clientConfig // Debug logging to diagnose authentication issues @@ -184,16 +120,6 @@ router.put('/update', async (req: Request, res: Response) => { const data = req.body const session = req.session as any - // Ensure token is valid before making the request - const tokenValid = await ensureValidToken(session) - if (!tokenValid && session.oauth2_user) { - console.log('OBP: Token expired and refresh failed') - return res.status(401).json({ - code: 401, - message: 'Session expired. Please log in again.' - }) - } - const oauthConfig = session.clientConfig const result = await obpClientService.update(path, data, oauthConfig) @@ -218,16 +144,6 @@ router.delete('/delete', async (req: Request, res: Response) => { const path = req.query.path as string const session = req.session as any - // Ensure token is valid before making the request - const tokenValid = await ensureValidToken(session) - if (!tokenValid && session.oauth2_user) { - console.log('OBP: Token expired and refresh failed') - return res.status(401).json({ - code: 401, - message: 'Session expired. Please log in again.' - }) - } - const oauthConfig = session.clientConfig const result = await obpClientService.discard(path, oauthConfig) diff --git a/server/routes/status.ts b/server/routes/status.ts index d88c40b..5454d60 100644 --- a/server/routes/status.ts +++ b/server/routes/status.ts @@ -29,7 +29,6 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { Container } from 'typedi' import OBPClientService from '../services/OBPClientService.js' -import { OAuth2Service } from '../services/OAuth2Service.js' import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js' import { commitId } from '../app.js' import { @@ -42,7 +41,6 @@ const router = Router() // Get services from container const obpClientService = Container.get(OBPClientService) -const oauth2Service = Container.get(OAuth2Service) const providerManager = Container.get(OAuth2ProviderManager) const connectors = [ @@ -158,80 +156,6 @@ router.get('/status', async (req: Request, res: Response) => { } }) -/** - * GET /status/oauth2 - * Get OAuth2/OIDC status - */ -router.get('/status/oauth2', (req: Request, res: Response) => { - try { - const isInitialized = oauth2Service.isInitialized() - const oidcConfig = oauth2Service.getOIDCConfiguration() - const healthCheckActive = oauth2Service.isHealthCheckActive() - const healthCheckAttempts = oauth2Service.getHealthCheckAttempts() - - res.json({ - available: isInitialized, - message: isInitialized - ? 'OAuth2/OIDC is ready for authentication' - : 'OAuth2/OIDC is not available', - issuer: oidcConfig?.issuer || null, - authorizationEndpoint: oidcConfig?.authorization_endpoint || null, - wellKnownUrl: process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL || null, - healthCheck: { - active: healthCheckActive, - attempts: healthCheckAttempts - } - }) - } catch (error) { - res.status(500).json({ - available: false, - message: 'Error checking OAuth2 status', - error: error instanceof Error ? error.message : 'Unknown error' - }) - } -}) - -/** - * GET /status/oauth2/reconnect - * Attempt to reconnect OAuth2/OIDC - */ -router.get('/status/oauth2/reconnect', async (req: Request, res: Response) => { - try { - if (oauth2Service.isInitialized()) { - return res.json({ - success: true, - message: 'OAuth2 is already connected', - alreadyConnected: true - }) - } - - const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL - if (!wellKnownUrl) { - return res.status(400).json({ - success: false, - message: 'VITE_OBP_OAUTH2_WELL_KNOWN_URL not configured' - }) - } - - console.log('Manual OAuth2 reconnection attempt triggered...') - await oauth2Service.initializeFromWellKnown(wellKnownUrl) - - console.log('Manual OAuth2 reconnection successful!') - res.json({ - success: true, - message: 'OAuth2 reconnection successful', - issuer: oauth2Service.getOIDCConfiguration()?.issuer || null - }) - } catch (error) { - console.error('Manual OAuth2 reconnection failed:', error) - res.status(500).json({ - success: false, - message: 'OAuth2 reconnection failed', - error: error instanceof Error ? error.message : 'Unknown error' - }) - } -}) - /** * GET /status/providers * Get configured OAuth2 providers (for debugging) @@ -251,30 +175,27 @@ router.get('/status/providers', (req: Request, res: Response) => { const availableProviders = providerManager.getAvailableProviders() const allProviderStatus = providerManager.getAllProviderStatus() + // Shared redirect URL + const sharedRedirectUrl = process.env.VITE_OAUTH2_REDIRECT_URL || 'not configured' + // Get env configuration (masked) const envConfig = { obpOidc: { consumerId: process.env.VITE_OBP_CONSUMER_KEY || 'not configured', - clientId: maskCredential(process.env.VITE_OBP_OAUTH2_CLIENT_ID), - wellKnownUrl: process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL || 'not configured', - redirectUrl: process.env.VITE_OBP_OAUTH2_REDIRECT_URL || 'not configured' + clientId: maskCredential(process.env.VITE_OBP_OIDC_CLIENT_ID) }, keycloak: { - clientId: maskCredential(process.env.VITE_KEYCLOAK_CLIENT_ID), - redirectUrl: process.env.VITE_KEYCLOAK_REDIRECT_URL || 'not configured' + clientId: maskCredential(process.env.VITE_KEYCLOAK_CLIENT_ID) }, google: { - clientId: maskCredential(process.env.VITE_GOOGLE_CLIENT_ID), - redirectUrl: process.env.VITE_GOOGLE_REDIRECT_URL || 'not configured' + clientId: maskCredential(process.env.VITE_GOOGLE_CLIENT_ID) }, github: { - clientId: maskCredential(process.env.VITE_GITHUB_CLIENT_ID), - redirectUrl: process.env.VITE_GITHUB_REDIRECT_URL || 'not configured' + clientId: maskCredential(process.env.VITE_GITHUB_CLIENT_ID) }, custom: { providerName: process.env.VITE_CUSTOM_OIDC_PROVIDER_NAME || 'not configured', - clientId: maskCredential(process.env.VITE_CUSTOM_OIDC_CLIENT_ID), - redirectUrl: process.env.VITE_CUSTOM_OIDC_REDIRECT_URL || 'not configured' + clientId: maskCredential(process.env.VITE_CUSTOM_OIDC_CLIENT_ID) } } @@ -282,7 +203,8 @@ router.get('/status/providers', (req: Request, res: Response) => { summary: { totalConfigured: availableProviders.length, availableProviders: availableProviders, - obpApiHost: process.env.VITE_OBP_API_HOST || 'not configured' + obpApiHost: process.env.VITE_OBP_API_HOST || 'not configured', + sharedRedirectUrl: sharedRedirectUrl }, providerStatus: allProviderStatus, environmentConfig: envConfig, diff --git a/server/routes/user.ts b/server/routes/user.ts index fd445d1..5e2397f 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -29,14 +29,12 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { Container } from 'typedi' import OBPClientService from '../services/OBPClientService.js' -import { OAuth2Service } from '../services/OAuth2Service.js' import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js' const router = Router() // Get services from container const obpClientService = Container.get(OBPClientService) -const oauth2Service = Container.get(OAuth2Service) const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST @@ -57,41 +55,9 @@ router.get('/user/current', async (req: Request, res: Response) => { console.log('User: Returning OAuth2 user info') const oauth2User = session.oauth2_user - const accessToken = session.oauth2_access_token - const refreshToken = session.oauth2_refresh_token - // Check if access token is expired and needs refresh - if (accessToken && oauth2Service.isTokenExpired(accessToken)) { - console.log('User: Access token expired') - - if (refreshToken) { - console.log('User: Attempting token refresh') - try { - const newTokens = await oauth2Service.refreshAccessToken(refreshToken) - - // Update session with new tokens - session.oauth2_access_token = newTokens.accessToken - session.oauth2_refresh_token = newTokens.refreshToken || refreshToken - session.oauth2_id_token = newTokens.idToken - session.oauth2_token_timestamp = Date.now() - session.oauth2_expires_in = newTokens.expiresIn - - // Update clientConfig with new access token - if (session.clientConfig && session.clientConfig.oauth2) { - session.clientConfig.oauth2.accessToken = newTokens.accessToken - console.log('User: Updated clientConfig with new access token') - } - - console.log('User: Token refresh successful') - } catch (error) { - console.error('User: Token refresh failed:', error) - return res.json({}) - } - } else { - console.log('User: No refresh token available, user needs to re-authenticate') - return res.json({}) - } - } + // TODO: Implement token refresh in multi-provider system + // For now, if token expires, user must re-login // Get actual user ID from OBP-API let obpUserId = oauth2User.sub // Default to sub if OBP call fails diff --git a/server/services/OAuth2ProviderFactory.ts b/server/services/OAuth2ProviderFactory.ts index 5069196..7d95848 100644 --- a/server/services/OAuth2ProviderFactory.ts +++ b/server/services/OAuth2ProviderFactory.ts @@ -62,18 +62,21 @@ export class OAuth2ProviderFactory { * Each provider requires: * - VITE_[PROVIDER]_CLIENT_ID * - VITE_[PROVIDER]_CLIENT_SECRET - * - VITE_[PROVIDER]_REDIRECT_URL (optional, defaults to /api/oauth2/callback) + * - VITE_OAUTH2_REDIRECT_URL (shared by all providers, defaults to /api/oauth2/callback) */ private loadStrategies(): void { console.log('OAuth2ProviderFactory: Loading provider strategies...') + // Shared redirect URL for all providers + const sharedRedirectUri = + process.env.VITE_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback' + // OBP-OIDC Strategy - if (process.env.VITE_OBP_OAUTH2_CLIENT_ID) { + if (process.env.VITE_OBP_OIDC_CLIENT_ID) { this.strategies.set('obp-oidc', { - clientId: process.env.VITE_OBP_OAUTH2_CLIENT_ID, - clientSecret: process.env.VITE_OBP_OAUTH2_CLIENT_SECRET || '', - redirectUri: - process.env.VITE_OBP_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback', + clientId: process.env.VITE_OBP_OIDC_CLIENT_ID, + clientSecret: process.env.VITE_OBP_OIDC_CLIENT_SECRET || '', + redirectUri: sharedRedirectUri, scopes: ['openid', 'profile', 'email'] }) console.log(' OK OBP-OIDC strategy loaded') @@ -84,8 +87,7 @@ export class OAuth2ProviderFactory { this.strategies.set('keycloak', { clientId: process.env.VITE_KEYCLOAK_CLIENT_ID, clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET || '', - redirectUri: - process.env.VITE_KEYCLOAK_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback', + redirectUri: sharedRedirectUri, scopes: ['openid', 'profile', 'email'] }) console.log(' OK Keycloak strategy loaded') @@ -96,8 +98,7 @@ export class OAuth2ProviderFactory { this.strategies.set('google', { clientId: process.env.VITE_GOOGLE_CLIENT_ID, clientSecret: process.env.VITE_GOOGLE_CLIENT_SECRET || '', - redirectUri: - process.env.VITE_GOOGLE_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback', + redirectUri: sharedRedirectUri, scopes: ['openid', 'profile', 'email'] }) console.log(' OK Google strategy loaded') @@ -108,8 +109,7 @@ export class OAuth2ProviderFactory { this.strategies.set('github', { clientId: process.env.VITE_GITHUB_CLIENT_ID, clientSecret: process.env.VITE_GITHUB_CLIENT_SECRET || '', - redirectUri: - process.env.VITE_GITHUB_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback', + redirectUri: sharedRedirectUri, scopes: ['read:user', 'user:email'] }) console.log(' OK GitHub strategy loaded') @@ -121,9 +121,7 @@ export class OAuth2ProviderFactory { this.strategies.set(providerName, { clientId: process.env.VITE_CUSTOM_OIDC_CLIENT_ID, clientSecret: process.env.VITE_CUSTOM_OIDC_CLIENT_SECRET || '', - redirectUri: - process.env.VITE_CUSTOM_OIDC_REDIRECT_URL || - 'http://localhost:5173/api/oauth2/callback', + redirectUri: sharedRedirectUri, scopes: ['openid', 'profile', 'email'] }) console.log(` OK Custom OIDC strategy loaded: ${providerName}`) @@ -134,7 +132,9 @@ export class OAuth2ProviderFactory { if (this.strategies.size === 0) { console.warn('OAuth2ProviderFactory: WARNING - No provider strategies configured!') console.warn('OAuth2ProviderFactory: Set environment variables for at least one provider') - console.warn('OAuth2ProviderFactory: Example: VITE_OBP_OAUTH2_CLIENT_ID, VITE_OBP_OAUTH2_CLIENT_SECRET') + console.warn( + 'OAuth2ProviderFactory: Example: VITE_OBP_OIDC_CLIENT_ID, VITE_OBP_OIDC_CLIENT_SECRET' + ) } } @@ -193,10 +193,7 @@ export class OAuth2ProviderFactory { console.log(`OAuth2ProviderFactory: Successfully initialized ${wellKnownUri.provider}`) return client } catch (error) { - console.error( - `OAuth2ProviderFactory: Failed to initialize ${wellKnownUri.provider}:`, - error - ) + console.error(`OAuth2ProviderFactory: Failed to initialize ${wellKnownUri.provider}:`, error) return null } } diff --git a/server/services/OAuth2Service.ts b/server/services/OAuth2Service.ts deleted file mode 100644 index 44c754f..0000000 --- a/server/services/OAuth2Service.ts +++ /dev/null @@ -1,689 +0,0 @@ -/* - * Open Bank Project - API Explorer II - * Copyright (C) 2023-2024, TESOBE GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Email: contact@tesobe.com - * TESOBE GmbH - * Osloerstrasse 16/17 - * Berlin 13359, Germany - * - * This product includes software developed at - * TESOBE (http://www.tesobe.com/) - * - */ - -import { OAuth2Client } from 'arctic' -import { Service } from 'typedi' -import jwt from 'jsonwebtoken' - -/** - * OpenID Connect Discovery Configuration - * As defined in OpenID Connect Discovery 1.0 - * @see https://openid.net/specs/openid-connect-discovery-1_0.html - */ -export interface OIDCConfiguration { - issuer: string - authorization_endpoint: string - token_endpoint: string - userinfo_endpoint: string - jwks_uri: string - registration_endpoint?: string - scopes_supported?: string[] - response_types_supported?: string[] - response_modes_supported?: string[] - grant_types_supported?: string[] - subject_types_supported?: string[] - id_token_signing_alg_values_supported?: string[] - token_endpoint_auth_methods_supported?: string[] - claims_supported?: string[] - code_challenge_methods_supported?: string[] -} - -/** - * Token response from OAuth2 token endpoint - */ -export interface TokenResponse { - accessToken: string - refreshToken?: string - idToken?: string - tokenType: string - expiresIn?: number - scope?: string -} - -/** - * User information from OIDC UserInfo endpoint - */ -export interface UserInfo { - sub: string - name?: string - given_name?: string - family_name?: string - middle_name?: string - nickname?: string - preferred_username?: string - profile?: string - picture?: string - website?: string - email?: string - email_verified?: boolean - gender?: string - birthdate?: string - zoneinfo?: string - locale?: string - phone_number?: string - phone_number_verified?: boolean - address?: { - formatted?: string - street_address?: string - locality?: string - region?: string - postal_code?: string - country?: string - } - updated_at?: number - [key: string]: any -} - -/** - * OAuth2/OIDC Service - * - * Handles OAuth2 Authorization Code Flow with PKCE and OpenID Connect integration. - * This service manages the complete OAuth2/OIDC authentication flow including: - * - OIDC Discovery (fetching .well-known/openid-configuration) - * - Authorization URL generation with PKCE - * - Token exchange (authorization code for tokens) - * - Token refresh - * - UserInfo endpoint access - * - * @example - * const oauth2Service = Container.get(OAuth2Service) - * await oauth2Service.initializeFromWellKnown('http://localhost:9000/obp-oidc/.well-known/openid-configuration') - * const authUrl = oauth2Service.createAuthorizationURL(state, ['openid', 'profile', 'email']) - */ -@Service() -export class OAuth2Service { - private client: OAuth2Client - private oidcConfig: OIDCConfiguration | null = null - private readonly clientId: string - private readonly clientSecret: string - private readonly redirectUri: string - private initialized: boolean = false - private wellKnownUrl: string = '' - private healthCheckInterval: NodeJS.Timeout | null = null - private healthCheckAttempts: number = 0 - private healthCheckActive: boolean = false - - constructor() { - // Load OAuth2 configuration from environment - this.clientId = process.env.VITE_OBP_OAUTH2_CLIENT_ID || '' - this.clientSecret = process.env.VITE_OBP_OAUTH2_CLIENT_SECRET || '' - this.redirectUri = process.env.VITE_OBP_OAUTH2_REDIRECT_URL || '' - - // Validate configuration - if (!this.clientId) { - console.warn('OAuth2Service: VITE_OBP_OAUTH2_CLIENT_ID not set') - } - if (!this.clientSecret) { - console.warn('OAuth2Service: VITE_OBP_OAUTH2_CLIENT_SECRET not set') - } - if (!this.redirectUri) { - console.warn('OAuth2Service: VITE_OBP_OAUTH2_REDIRECT_URL not set') - } - - // Initialize OAuth2 client - this.client = new OAuth2Client(this.clientId, this.clientSecret, this.redirectUri) - - console.log('OAuth2Service: Initialized with client ID:', this.clientId) - console.log('OAuth2Service: Redirect URI:', this.redirectUri) - } - - /** - * Initialize OIDC configuration from well-known discovery endpoint - * - * @param {string} wellKnownUrl - The .well-known/openid-configuration URL - * @throws {Error} If the discovery document cannot be fetched or is invalid - * - * @example - * await oauth2Service.initializeFromWellKnown( - * 'http://localhost:9000/obp-oidc/.well-known/openid-configuration' - * ) - */ - async initializeFromWellKnown(wellKnownUrl: string): Promise { - console.log('OAuth2Service: Fetching OIDC configuration from:', wellKnownUrl) - - // Store the well-known URL for potential retries - this.wellKnownUrl = wellKnownUrl - - try { - const response = await fetch(wellKnownUrl) - - if (!response.ok) { - throw new Error( - `Failed to fetch OIDC configuration: ${response.status} ${response.statusText}` - ) - } - - const config = (await response.json()) as OIDCConfiguration - - // Validate required endpoints - if (!config.authorization_endpoint) { - throw new Error('OIDC configuration missing authorization_endpoint') - } - if (!config.token_endpoint) { - throw new Error('OIDC configuration missing token_endpoint') - } - if (!config.userinfo_endpoint) { - throw new Error('OIDC configuration missing userinfo_endpoint') - } - - this.oidcConfig = config - this.initialized = true - - console.log('OAuth2Service: OIDC configuration loaded successfully') - console.log(' Issuer:', config.issuer) - console.log(' Authorization endpoint:', config.authorization_endpoint) - console.log(' Token endpoint:', config.token_endpoint) - console.log(' UserInfo endpoint:', config.userinfo_endpoint) - console.log(' JWKS URI:', config.jwks_uri) - - // Log supported features - if (config.code_challenge_methods_supported) { - console.log(' PKCE methods supported:', config.code_challenge_methods_supported.join(', ')) - } - } catch (error) { - console.error('OAuth2Service: Failed to initialize from well-known URL:', error) - throw error - } - } - - /** - * Start periodic health check to monitor OIDC availability - * Uses exponential backoff when reconnecting: 1s, 2s, 4s, up to 4min - * Switches to regular monitoring (every 4 minutes) once connected - * - * @param {number} initialIntervalMs - Initial interval in milliseconds (default: 1000 = 1 second) - * @param {number} monitoringIntervalMs - Interval for continuous monitoring when connected (default: 240000 = 4 minutes) - * - * @example - * oauth2Service.startHealthCheck(1000, 240000) // Start checking at 1 second, monitor every 4 minutes when connected - */ - startHealthCheck(initialIntervalMs: number = 1000, monitoringIntervalMs: number = 240000): void { - if (this.healthCheckInterval) { - console.log('OAuth2Service: Health check already running') - return - } - - if (!this.wellKnownUrl) { - console.warn('OAuth2Service: Cannot start health check - no well-known URL configured') - return - } - - this.healthCheckAttempts = 0 - this.healthCheckActive = true - console.log('OAuth2Service: Starting health check with exponential backoff') - - const scheduleNextCheck = () => { - if (!this.wellKnownUrl) { - return - } - - let delay: number - - if (this.initialized) { - // When connected, monitor every 4 minutes - delay = monitoringIntervalMs - } else { - // When disconnected, use exponential backoff - delay = Math.min(initialIntervalMs * Math.pow(2, this.healthCheckAttempts), 240000) - } - - const delayDisplay = - delay < 60000 - ? `${(delay / 1000).toFixed(0)} second(s)` - : `${(delay / 60000).toFixed(1)} minute(s)` - - if (this.initialized) { - console.log(`OAuth2Service: Monitoring scheduled in ${delayDisplay}`) - } else { - console.log( - `OAuth2Service: Health check scheduled in ${delayDisplay} (attempt ${this.healthCheckAttempts + 1})` - ) - } - - this.healthCheckInterval = setTimeout(async () => { - if (this.initialized) { - // When connected, verify OIDC is still available - console.log('OAuth2Service: Verifying OIDC availability...') - try { - const response = await fetch(this.wellKnownUrl) - if (!response.ok) { - throw new Error(`OIDC server returned ${response.status}`) - } - console.log('OAuth2Service: OIDC is available') - // Continue monitoring - scheduleNextCheck() - } catch (error) { - console.error('OAuth2Service: OIDC server is no longer available!') - this.initialized = false - this.oidcConfig = null - this.healthCheckAttempts = 0 - console.log('OAuth2Service: Attempting to reconnect...') - // Schedule reconnection with exponential backoff - scheduleNextCheck() - } - } else { - // When disconnected, attempt to reconnect - console.log('OAuth2Service: Health check - attempting to reconnect to OIDC server...') - try { - await this.initializeFromWellKnown(this.wellKnownUrl) - console.log('OAuth2Service: Successfully reconnected to OIDC server!') - this.healthCheckAttempts = 0 - // Switch to continuous monitoring - scheduleNextCheck() - } catch (error) { - this.healthCheckAttempts++ - // Schedule next reconnection attempt - scheduleNextCheck() - } - } - }, delay) - } - - // Start the first check - scheduleNextCheck() - } - - /** - * Stop the periodic health check - */ - stopHealthCheck(): void { - if (this.healthCheckInterval) { - clearTimeout(this.healthCheckInterval) - this.healthCheckInterval = null - this.healthCheckAttempts = 0 - this.healthCheckActive = false - console.log('OAuth2Service: Health check stopped') - } - } - - /** - * Check if health check is currently active - * - * @returns {boolean} True if health check is running - */ - isHealthCheckActive(): boolean { - return this.healthCheckActive - } - - /** - * Get the number of health check attempts so far - * - * @returns {number} Number of health check attempts - */ - getHealthCheckAttempts(): number { - return this.healthCheckAttempts - } - - /** - * Attempt to initialize with exponential backoff retry (continues indefinitely) - * - * @param {number} maxRetries - Maximum number of retry attempts (default: Infinity for continuous retries) - * @param {string} wellKnownUrl - The .well-known/openid-configuration URL - * @param {number} maxRetries - Maximum number of retry attempts (default: Infinity for continuous retries) - * @param {number} initialDelayMs - Initial delay in milliseconds (default: 1000 = 1 second) - * @returns {Promise} True if initialization succeeded, false if maxRetries reached - * - * @example - * const success = await oauth2Service.initializeWithRetry('http://localhost:9000/.well-known/openid-configuration', Infinity, 1000) - */ - async initializeWithRetry( - wellKnownUrl: string, - maxRetries: number = Infinity, - initialDelayMs: number = 1000 - ): Promise { - if (!wellKnownUrl) { - console.error('OAuth2Service: Cannot retry - no well-known URL configured') - return false - } - - // Store the well-known URL for retries and health checks - this.wellKnownUrl = wellKnownUrl - - let attempt = 0 - while (attempt < maxRetries) { - try { - await this.initializeFromWellKnown(wellKnownUrl) - console.log(`OAuth2Service: Initialized successfully on attempt ${attempt + 1}`) - return true - } catch (error: any) { - const delay = Math.min(initialDelayMs * Math.pow(2, attempt), 240000) // Cap at 4 minutes - const delayDisplay = - delay < 60000 - ? `${(delay / 1000).toFixed(0)} second(s)` - : `${(delay / 60000).toFixed(1)} minute(s)` - - if (maxRetries === Infinity || attempt < maxRetries - 1) { - console.log( - `OAuth2Service: Attempt ${attempt + 1} failed. Retrying in ${delayDisplay}...` - ) - await new Promise((resolve) => setTimeout(resolve, delay)) - attempt++ - } else { - console.error( - `OAuth2Service: Failed to initialize after ${maxRetries} attempts:`, - error.message - ) - return false - } - } - } - - return false - } - - /** - * Check if the service is initialized and ready to use - * - * @returns {boolean} True if initialized, false otherwise - */ - isInitialized(): boolean { - return this.initialized && this.oidcConfig !== null - } - - /** - * Get the OIDC configuration - * - * @returns {OIDCConfiguration | null} The OIDC configuration or null if not initialized - */ - getOIDCConfiguration(): OIDCConfiguration | null { - return this.oidcConfig - } - - /** - * Create an authorization URL for the OAuth2 flow - * - * @param {string} state - CSRF protection state parameter - * @param {string[]} scopes - OAuth2 scopes to request (default: ['openid', 'profile', 'email']) - * @returns {URL} The authorization URL to redirect the user to - * @throws {Error} If the service is not initialized - * - * @example - * const state = PKCEUtils.generateState() - * const authUrl = oauth2Service.createAuthorizationURL(state, ['openid', 'profile', 'email']) - * // Add PKCE challenge to URL - * authUrl.searchParams.set('code_challenge', codeChallenge) - * authUrl.searchParams.set('code_challenge_method', 'S256') - * response.redirect(authUrl.toString()) - */ - createAuthorizationURL(state: string, scopes: string[] = ['openid', 'profile', 'email']): URL { - if (!this.isInitialized() || !this.oidcConfig) { - throw new Error( - 'OAuth2Service: Service not initialized. Call initializeFromWellKnown() first' - ) - } - - console.log('OAuth2Service: Creating authorization URL') - console.log(' State:', state) - console.log(' Scopes:', scopes.join(' ')) - - const authUrl = this.client.createAuthorizationURL( - this.oidcConfig.authorization_endpoint, - state, - scopes - ) - - return authUrl - } - - /** - * Exchange an authorization code for tokens - * - * @param {string} code - The authorization code from the callback - * @param {string} codeVerifier - The PKCE code verifier - * @returns {Promise} The tokens (access, refresh, ID) - * @throws {Error} If the token exchange fails - * - * @example - * const tokens = await oauth2Service.exchangeCodeForTokens(code, codeVerifier) - * console.log('Access token:', tokens.accessToken) - * console.log('Refresh token:', tokens.refreshToken) - * console.log('ID token:', tokens.idToken) - */ - async exchangeCodeForTokens(code: string, codeVerifier: string): Promise { - if (!this.isInitialized() || !this.oidcConfig) { - throw new Error( - 'OAuth2Service: Service not initialized. Call initializeFromWellKnown() first' - ) - } - - console.log('OAuth2Service: Exchanging authorization code for tokens') - - try { - // Use arctic's validateAuthorizationCode which handles the token request - const tokens = await this.client.validateAuthorizationCode( - this.oidcConfig.token_endpoint, - code, - codeVerifier - ) - - console.log('OAuth2Service: Token exchange successful') - - // Arctic returns an object with accessor functions - const tokenResponse: TokenResponse = { - accessToken: tokens.accessToken(), - refreshToken: tokens.refreshToken ? tokens.refreshToken() : undefined, - idToken: tokens.idToken ? tokens.idToken() : undefined, - tokenType: 'Bearer', - expiresIn: tokens.accessTokenExpiresAt - ? Math.floor((tokens.accessTokenExpiresAt().getTime() - Date.now()) / 1000) - : undefined - } - - return tokenResponse - } catch (error: any) { - console.error('OAuth2Service: Token exchange failed:', error) - throw new Error(`Token exchange failed: ${error.message}`) - } - } - - /** - * Refresh an access token using a refresh token - * - * @param {string} refreshToken - The refresh token - * @returns {Promise} The new tokens - * @throws {Error} If the token refresh fails - */ - async refreshAccessToken(refreshToken: string): Promise { - if (!this.isInitialized() || !this.oidcConfig) { - throw new Error( - 'OAuth2Service: Service not initialized. Call initializeFromWellKnown() first' - ) - } - - console.log('OAuth2Service: Refreshing access token') - - try { - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: this.clientId, - client_secret: this.clientSecret - }) - - const response = await fetch(this.oidcConfig.token_endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - }, - body: body.toString() - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - console.error('OAuth2Service: Token refresh failed:', errorData) - throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - console.log('OAuth2Service: Token refresh successful') - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || refreshToken, - idToken: data.id_token, - tokenType: data.token_type || 'Bearer', - expiresIn: data.expires_in, - scope: data.scope - } - } catch (error: any) { - console.error('OAuth2Service: Token refresh failed:', error) - throw error - } - } - - /** - * Get user information from the UserInfo endpoint - * - * @param {string} accessToken - The access token - * @returns {Promise} The user information - * @throws {Error} If the UserInfo request fails - * - * @example - * const userInfo = await oauth2Service.getUserInfo(accessToken) - * console.log('User ID:', userInfo.sub) - * console.log('Email:', userInfo.email) - * console.log('Name:', userInfo.name) - */ - async getUserInfo(accessToken: string): Promise { - if (!this.isInitialized() || !this.oidcConfig) { - throw new Error( - 'OAuth2Service: Service not initialized. Call initializeFromWellKnown() first' - ) - } - - console.log('OAuth2Service: Fetching user info') - - try { - const response = await fetch(this.oidcConfig.userinfo_endpoint, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json' - } - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - console.error('OAuth2Service: UserInfo request failed:', errorData) - throw new Error(`UserInfo request failed: ${response.status} ${response.statusText}`) - } - - const userInfo = (await response.json()) as UserInfo - - console.log('OAuth2Service: User info retrieved successfully') - console.log(' User ID (sub):', userInfo.sub) - console.log(' Email:', userInfo.email) - console.log(' Name:', userInfo.name) - - return userInfo - } catch (error: any) { - console.error('OAuth2Service: UserInfo request failed:', error) - throw error - } - } - - /** - * Decode and validate an ID token (basic validation only) - * - * Note: This performs basic JWT decoding. For production use, implement - * full signature verification using the JWKS from the jwks_uri endpoint. - * - * @param {string} idToken - The ID token to decode - * @returns {any} The decoded token payload - */ - decodeIdToken(idToken: string): any { - try { - const decoded = jwt.decode(idToken, { complete: true }) - - if (!decoded) { - throw new Error('Failed to decode ID token') - } - - console.log('OAuth2Service: ID token decoded') - console.log(' Issuer (iss):', decoded.payload['iss']) - console.log(' Subject (sub):', decoded.payload['sub']) - console.log(' Audience (aud):', decoded.payload['aud']) - console.log(' Expiration (exp):', new Date(decoded.payload['exp'] * 1000).toISOString()) - - return decoded.payload - } catch (error) { - console.error('OAuth2Service: Failed to decode ID token:', error) - throw error - } - } - - /** - * Check if an access token is expired - * - * @param {string} accessToken - The access token (JWT) - * @returns {boolean} True if expired, false otherwise - */ - isTokenExpired(accessToken: string): boolean { - try { - const decoded: any = jwt.decode(accessToken) - - if (!decoded || !decoded.exp) { - console.warn('OAuth2Service: Token has no expiration claim') - return false - } - - const isExpired = Date.now() >= decoded.exp * 1000 - - if (isExpired) { - console.log('OAuth2Service: Access token is expired') - } - - return isExpired - } catch (error) { - console.error('OAuth2Service: Failed to check token expiration:', error) - return false - } - } - - /** - * Get token expiration time in seconds - * - * @param {string} accessToken - The access token (JWT) - * @returns {number | null} Seconds until expiration, or null if no expiration - */ - getTokenExpiresIn(accessToken: string): number | null { - try { - const decoded: any = jwt.decode(accessToken) - - if (!decoded || !decoded.exp) { - return null - } - - const expiresIn = Math.floor((decoded.exp * 1000 - Date.now()) / 1000) - return expiresIn > 0 ? expiresIn : 0 - } catch (error) { - console.error('OAuth2Service: Failed to get token expiration:', error) - return null - } - } -} diff --git a/src/components/HeaderNav.vue b/src/components/HeaderNav.vue index 12d9092..23c0a09 100644 --- a/src/components/HeaderNav.vue +++ b/src/components/HeaderNav.vue @@ -85,29 +85,8 @@ const availableProviders = ref { } onMounted(async () => { - // Initial OAuth2 availability check - await checkOAuth2Availability() - // Fetch available providers await fetchAvailableProviders() - // Start continuous polling every 4 minutes to detect OIDC outages - console.log('OAuth2: Starting continuous monitoring (every 4 minutes)...') - oauth2CheckInterval = window.setInterval(checkOAuth2Availability, 240000) // 4 minutes - const currentUser = await getCurrentUser() const currentResponseKeys = Object.keys(currentUser) if (currentResponseKeys.includes('username')) { @@ -247,11 +220,7 @@ onMounted(async () => { }) onUnmounted(() => { - // Clean up polling interval - if (oauth2CheckInterval) { - clearInterval(oauth2CheckInterval) - oauth2CheckInterval = null - } + // Cleanup hook }) watchEffect(() => { diff --git a/src/views/ProvidersStatusView.vue b/src/views/ProvidersStatusView.vue index f19356a..1fc0361 100644 --- a/src/views/ProvidersStatusView.vue +++ b/src/views/ProvidersStatusView.vue @@ -187,7 +187,7 @@ interface StatusResponse { const loading = ref(true) const error = ref(null) const status = ref(null) -const activeCollapse = ref([]) +const activeCollapse = ref(['obpOidc', 'keycloak', 'google', 'github', 'custom']) const fetchStatus = async () => { loading.value = true