cleanup multi provider

This commit is contained in:
simonredfern 2025-12-29 20:04:47 +01:00
parent 0e8e7df8d5
commit d1fb24898c
11 changed files with 214 additions and 1154 deletions

View File

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

67
env_ai
View File

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

View File

@ -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(`-----------------------------------------------------------------`)

View File

@ -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,11 +98,7 @@ router.get('/oauth2/connect', async (req: Request, res: Response) => {
session.oauth2_code_verifier = codeVerifier
session.oauth2_state = state
let authUrl: string
if (provider) {
// Multi-provider mode
console.log(`OAuth2 Connect: Using multi-provider mode - ${provider}`)
console.log(`OAuth2 Connect: Using provider - ${provider}`)
const client = providerManager.getProvider(provider)
if (!client) {
@ -123,23 +126,7 @@ router.get('/oauth2/connect', async (req: Request, res: Response) => {
code_challenge_method: 'S256'
})
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()
}
const authUrl = `${authEndpoint}?${params.toString()}`
// Save session before redirect
session.save((err: any) => {
@ -211,15 +198,15 @@ 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) {
console.error('OAuth2 Callback: Provider not found in session')
return res.redirect('/?oauth2_error=missing_provider')
}
if (provider) {
// Multi-provider mode
console.log(`OAuth2 Callback: Multi-provider mode - ${provider}`)
console.log(`OAuth2 Callback: Processing callback for ${provider}`)
const client = providerManager.getProvider(provider)
if (!client) {
@ -229,7 +216,7 @@ router.get('/oauth2/callback', async (req: Request, res: Response) => {
// Exchange code for tokens
console.log('OAuth2 Callback: Exchanging authorization code for tokens')
tokens = await client.exchangeAuthorizationCode(code, codeVerifier)
const tokens = await client.exchangeAuthorizationCode(code, codeVerifier)
// Fetch user info
console.log('OAuth2 Callback: Fetching user info')
@ -245,20 +232,7 @@ router.get('/oauth2/callback', async (req: Request, res: Response) => {
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)
}
const userInfo = (await userInfoResponse.json()) as UserInfo
// Store tokens in session
session.oauth2_access_token = tokens.accessToken

View File

@ -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<boolean> {
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)

View File

@ -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,

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
*
* 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<void> {
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<boolean>} 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<boolean> {
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<TokenResponse>} 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<TokenResponse> {
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<TokenResponse>} The new tokens
* @throws {Error} If the token refresh fails
*/
async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
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<UserInfo>} 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<UserInfo> {
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
}
}
}

View File

@ -85,29 +85,8 @@ const availableProviders = ref<Array<{ name: string; available: boolean; lastChe
const showProviderSelector = ref(false)
const isLoadingProviders = ref(false)
// Check OAuth2 availability
let oauth2CheckInterval: number | null = null
async function checkOAuth2Availability() {
try {
const response = await fetch('/api/status/oauth2')
const data = await response.json()
const wasAvailable = oauth2Available.value
oauth2Available.value = data.available
oauth2StatusMessage.value = data.message || ''
// Log state changes
if (!wasAvailable && data.available) {
console.log('OAuth2 is now available')
} else if (wasAvailable && !data.available) {
console.warn('OAuth2 is no longer available!')
}
} catch (error) {
oauth2Available.value = false
oauth2StatusMessage.value = 'Failed to check OAuth2 status'
console.error('Error checking OAuth2 status:', error)
}
}
// OAuth2 availability is determined by provider availability
// No separate status check needed
// Fetch available OIDC providers
async function fetchAvailableProviders() {
@ -143,8 +122,9 @@ function handleLoginClick() {
// Direct login with single provider
loginWithProvider(available[0].name)
} else {
// Fallback to legacy login (no provider parameter)
window.location.href = '/api/oauth2/connect?redirect=' + encodeURIComponent(getCurrentPath())
// No providers available
console.error('No OAuth2 providers available. Check backend configuration.')
alert('Login is not available. Please check that OAuth2 providers are configured.')
}
}
@ -224,16 +204,9 @@ const handleMore = (command: string) => {
}
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(() => {

View File

@ -187,7 +187,7 @@ interface StatusResponse {
const loading = ref(true)
const error = ref<string | null>(null)
const status = ref<StatusResponse | null>(null)
const activeCollapse = ref<string[]>([])
const activeCollapse = ref<string[]>(['obpOidc', 'keycloak', 'google', 'github', 'custom'])
const fetchStatus = async () => {
loading.value = true