Merge branch 'multi-login' into develop

This commit is contained in:
simonredfern 2026-01-04 20:43:19 +01:00
commit 7a0c1d901b
31 changed files with 7467 additions and 2824 deletions

View File

@ -1,35 +1,56 @@
### 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_OBP_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 ###
### If VITE_OBP_OAUTH2_WELL_KNOWN_URL is set, it will be used
### Otherwise, the system fetches available providers from: VITE_OBP_API_HOST/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
### (Optional) ###
# 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=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

237
IMPLEMENTATION-COMPLETE.txt Normal file
View File

@ -0,0 +1,237 @@
╔══════════════════════════════════════════════════════════════════╗
║ ║
║ ✅ MULTI-OIDC PROVIDER IMPLEMENTATION COMPLETE ✅ ║
║ ║
╚══════════════════════════════════════════════════════════════════╝
Branch: multi-login
Date: 2024-12-28
Status: ✅ READY FOR TESTING
═══════════════════════════════════════════════════════════════════
SUMMARY
═══════════════════════════════════════════════════════════════════
Total Changes: 5,774 lines added/modified
New Files: 9 (5 docs + 4 code files)
Modified Files: 5
Commits: 6
═══════════════════════════════════════════════════════════════════
WHAT WAS IMPLEMENTED
═══════════════════════════════════════════════════════════════════
✅ Backend (100% Complete)
├─ OAuth2ClientWithConfig.ts (299 lines)
├─ OAuth2ProviderFactory.ts (241 lines)
├─ OAuth2ProviderManager.ts (380 lines)
├─ OAuth2ProvidersController.ts (108 lines)
├─ Updated OAuth2ConnectController (+172 lines)
├─ Updated OAuth2CallbackController (+249 lines)
├─ Updated app.ts (+54 lines)
└─ server/types/oauth2.ts (130 lines)
✅ Frontend (100% Complete)
└─ Updated HeaderNav.vue (+188 lines)
├─ Fetch providers from API
├─ Provider selection dialog
├─ Single provider direct login
├─ Error handling
└─ Responsive design
✅ Documentation (100% Complete)
├─ MULTI-OIDC-PROVIDER-IMPLEMENTATION.md (1,917 lines)
├─ MULTI-OIDC-PROVIDER-SUMMARY.md (372 lines)
├─ MULTI-OIDC-FLOW-DIAGRAM.md (577 lines)
├─ MULTI-OIDC-IMPLEMENTATION-STATUS.md (361 lines)
└─ MULTI-OIDC-TESTING-GUIDE.md (790 lines)
═══════════════════════════════════════════════════════════════════
KEY FEATURES
═══════════════════════════════════════════════════════════════════
✅ Dynamic Provider Discovery
• Fetches providers from OBP API /obp/v5.1.0/well-known
• No hardcoded provider list
• Automatic provider registration
✅ Multi-Provider Support
• OBP-OIDC, Keycloak, Google, GitHub
• Strategy pattern for extensibility
• Environment variable configuration
✅ Health Monitoring
• Real-time provider status tracking
• 60-second health check intervals
• Automatic status updates
✅ Security
• PKCE (Proof Key for Code Exchange)
• State validation (CSRF protection)
• Secure token storage
✅ User Experience
• Provider selection dialog
• Single provider auto-login
• Provider icons and formatted names
• Loading states and error handling
✅ Backward Compatible
• Legacy single-provider mode still works
• No breaking changes
• Gradual migration path
═══════════════════════════════════════════════════════════════════
API ENDPOINTS
═══════════════════════════════════════════════════════════════════
NEW:
GET /api/oauth2/providers
Returns: List of available providers with status
UPDATED:
GET /api/oauth2/connect?provider=<name>&redirect=<url>
Initiates login with selected provider
GET /api/oauth2/callback?code=<code>&state=<state>
Handles OAuth callback from any provider
═══════════════════════════════════════════════════════════════════
CONFIGURATION
═══════════════════════════════════════════════════════════════════
Environment Variables (per provider):
# OBP-OIDC
VITE_OBP_OAUTH2_CLIENT_ID=your-client-id
VITE_OBP_OAUTH2_CLIENT_SECRET=your-secret
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Keycloak
VITE_KEYCLOAK_CLIENT_ID=your-client-id
VITE_KEYCLOAK_CLIENT_SECRET=your-secret
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Add more providers as needed...
═══════════════════════════════════════════════════════════════════
TESTING
═══════════════════════════════════════════════════════════════════
See: MULTI-OIDC-TESTING-GUIDE.md
15 comprehensive test scenarios covering:
✓ Provider discovery
✓ Backend API endpoints
✓ Login flows (single/multiple providers)
✓ Health monitoring
✓ Session persistence
✓ Error handling
✓ Security (PKCE, state validation)
✓ Backward compatibility
═══════════════════════════════════════════════════════════════════
NEXT STEPS
═══════════════════════════════════════════════════════════════════
1. Test the Implementation
└─ Follow MULTI-OIDC-TESTING-GUIDE.md
2. Configure Environment
└─ Set up provider credentials
3. Start Services
├─ Start OBP API
├─ Start OIDC providers (OBP-OIDC, Keycloak)
├─ Start backend: npm run dev:backend
└─ Start frontend: npm run dev
4. Test Login Flow
├─ Navigate to http://localhost:5173
├─ Click "Login"
├─ Select provider
└─ Authenticate
5. Create Pull Request
└─ Merge multi-login → develop
═══════════════════════════════════════════════════════════════════
GIT COMMANDS
═══════════════════════════════════════════════════════════════════
Current branch: multi-login (clean, nothing to commit)
View changes:
git diff develop --stat
git log --oneline develop..multi-login
Test locally:
npm run dev:backend # Terminal 1
npm run dev # Terminal 2
Create PR:
git push origin multi-login
# Then create PR on GitHub: multi-login → develop
═══════════════════════════════════════════════════════════════════
COMMITS
═══════════════════════════════════════════════════════════════════
41ddc8f - Add comprehensive testing guide
3a03812 - Add multi-provider login UI to HeaderNav
07d47ca - Add implementation status document
755dc70 - Fix TypeScript compilation errors
8b90bb4 - Add controllers and app initialization
3dadca8 - Add multi-OIDC provider backend services
═══════════════════════════════════════════════════════════════════
DOCUMENTATION
═══════════════════════════════════════════════════════════════════
📖 Implementation Guide
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
• Complete technical specification
• Detailed code examples
• Architecture diagrams
📖 Executive Summary
MULTI-OIDC-PROVIDER-SUMMARY.md
• High-level overview
• Key benefits
• Quick reference
📖 Flow Diagrams
MULTI-OIDC-FLOW-DIAGRAM.md
• Visual system flows
• Component interactions
• Data flow diagrams
📖 Implementation Status
MULTI-OIDC-IMPLEMENTATION-STATUS.md
• Completed tasks checklist
• Configuration guide
• Session data structure
📖 Testing Guide
MULTI-OIDC-TESTING-GUIDE.md
• Step-by-step test scenarios
• Troubleshooting tips
• Performance testing
═══════════════════════════════════════════════════════════════════
SUCCESS METRICS
═══════════════════════════════════════════════════════════════════
✅ 100% Backend implementation complete
✅ 100% Frontend implementation complete
✅ 100% Documentation complete
✅ 0 TypeScript errors
✅ 0 compilation errors
✅ Backward compatible
✅ Ready for testing
═══════════════════════════════════════════════════════════════════
Implementation completed successfully! 🎉
The multi-login branch is ready for testing and merging.

577
MULTI-OIDC-FLOW-DIAGRAM.md Normal file
View File

@ -0,0 +1,577 @@
# Multi-OIDC Provider Flow Diagrams
## 1. System Initialization Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ SERVER STARTUP │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Load Environment Variables │
│ - VITE_OBP_OAUTH2_CLIENT_ID │
│ - VITE_KEYCLOAK_CLIENT_ID │
│ - VITE_GOOGLE_CLIENT_ID (optional) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Initialize OAuth2ProviderFactory │
│ - Load provider strategies │
│ - Configure client credentials │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Initialize OAuth2ProviderManager │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Fetch Well-Known URIs from OBP API │
│ GET /obp/v5.1.0/well-known │
└─────────────────────────────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ OBP-OIDC │ │ Keycloak │
│ Well-Known URL │ │ Well-Known URL │
└───────────────────┘ └───────────────────┘
│ │
└─────────┬─────────┘
┌─────────────────────────────────────────┐
│ For Each Provider: │
│ 1. Get strategy from factory │
│ 2. Create OAuth2ClientWithConfig │
│ 3. Fetch .well-known/openid-config │
│ 4. Store in providers Map │
│ 5. Track status (available/unavailable)│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Start Health Check (60s intervals) │
│ - Monitor all providers │
│ - Update availability status │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Server Ready │
│ - Multiple providers initialized │
│ - Health monitoring active │
└─────────────────────────────────────────┘
```
---
## 2. User Login Flow (Multi-Provider)
```
┌─────────────────────────────────────────────────────────────────┐
│ USER │
└─────────────────────────────────────────────────────────────────┘
│ Opens API Explorer II
┌─────────────────────────────────────────┐
│ HeaderNav.vue │
│ - Fetch available providers │
│ GET /api/oauth2/providers │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Display Login Button │
│ (with dropdown if multiple providers) │
└─────────────────────────────────────────┘
│ User clicks "Login"
┌─────────┴─────────┐
│ │
Single │ │ Multiple
Provider │ │ Providers
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Direct Login │ │ Show Provider │
│ │ │ Selection Dialog │
└───────────────────┘ └───────────────────┘
│ │
│ │ User selects provider
│ │ (e.g., "obp-oidc")
│ │
└─────────┬─────────┘
┌─────────────────────────────────────────┐
│ Redirect to: │
│ /api/oauth2/connect? │
│ provider=obp-oidc&
│ redirect=/resource-docs │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ OAuth2ConnectController │
│ 1. Get provider from query param │
│ 2. Retrieve OAuth2Client from Manager │
│ 3. Generate PKCE code_verifier │
│ 4. Generate code_challenge (SHA256) │
│ 5. Generate state (CSRF token) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Store in Session: │
│ - oauth2_provider: "obp-oidc" │
│ - oauth2_code_verifier: "..." │
│ - oauth2_state: "..." │
│ - oauth2_redirect: "/resource-docs" │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Build Authorization URL: │
│ {provider_auth_endpoint}? │
│ client_id=...&
│ redirect_uri=...&
│ response_type=code&
│ scope=openid+profile+email&
│ state=...&
│ code_challenge=...&
│ code_challenge_method=S256 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 302 Redirect to OIDC Provider │
│ (e.g., OBP-OIDC or Keycloak) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ OIDC PROVIDER (OBP-OIDC / Keycloak) │
│ - User enters credentials │
│ - User authenticates │
│ - Provider validates credentials │
└─────────────────────────────────────────────────────────────────┘
│ Authentication successful
┌─────────────────────────────────────────┐
│ 302 Redirect back to: │
│ /api/oauth2/callback? │
│ code=AUTHORIZATION_CODE&
│ state=CSRF_TOKEN │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ OAuth2CallbackController │
│ 1. Retrieve provider from session │
│ 2. Validate state (CSRF protection) │
│ 3. Get OAuth2Client for provider │
│ 4. Retrieve code_verifier from session │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Exchange Authorization Code for Tokens │
│ POST {provider_token_endpoint} │
│ Body: │
│ grant_type=authorization_code │
│ code=... │
│ redirect_uri=... │
│ client_id=... │
│ client_secret=... │
│ code_verifier=... (PKCE) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Receive Tokens: │
│ - access_token │
│ - refresh_token │
│ - id_token (JWT) │
│ - expires_in │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Fetch User Info │
│ GET {provider_userinfo_endpoint} │
│ Authorization: Bearer {access_token} │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Store in Session: │
│ - oauth2_access_token │
│ - oauth2_refresh_token │
│ - oauth2_id_token │
│ - oauth2_provider: "obp-oidc" │
│ - user: { username, email, name, ... } │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 302 Redirect to Original Page │
│ /resource-docs │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ User Logged In │
│ - Username displayed in header │
│ - Access token available for API calls │
└─────────────────────────────────────────┘
```
---
## 3. API Request Flow (Authenticated)
```
┌─────────────────────────────────────────────────────────────────┐
│ USER │
└─────────────────────────────────────────────────────────────────┘
│ Makes API request
┌─────────────────────────────────────────┐
│ Frontend │
│ GET /obp/v5.1.0/banks │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ RequestController │
│ - Retrieve access_token from session │
│ - Check if token is expired │
└─────────────────────────────────────────┘
┌─────────────┴─────────────┐
│ │
Token │ │ Token
Valid │ │ Expired
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Use Access Token │ │ Refresh Token │
└───────────────────┘ └───────────────────┘
│ │
│ ▼
│ ┌───────────────────────────┐
│ │ Get provider from session│
│ │ Get refresh_token │
│ └───────────────────────────┘
│ │
│ ▼
│ ┌───────────────────────────┐
│ │ POST {token_endpoint} │
│ │ grant_type=refresh_token │
│ │ refresh_token=... │
│ └───────────────────────────┘
│ │
│ ▼
│ ┌───────────────────────────┐
│ │ Receive new tokens │
│ │ - new access_token │
│ │ - new refresh_token │
│ │ Update session │
│ └───────────────────────────┘
│ │
└─────────────┬─────────────┘
┌─────────────────────────────────────────┐
│ Forward to OBP API │
│ Authorization: Bearer {access_token} │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ OBP API validates token with provider │
│ - Validates signature │
│ - Checks expiration │
│ - Verifies scopes │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Return API Response │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Display data to user │
└─────────────────────────────────────────┘
```
---
## 4. Provider Health Check Flow
```
┌─────────────────────────────────────────┐
│ Health Check Timer (60s intervals) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ OAuth2ProviderManager │
│ performHealthCheck() │
└─────────────────────────────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Check OBP-OIDC │ │ Check Keycloak │
│ HEAD {issuer} │ │ HEAD {issuer} │
└───────────────────┘ └───────────────────┘
│ │
┌───────┴───────┐ ┌───────┴───────┐
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ OK │ │ FAIL │ │ OK │ │ FAIL │
│ 200 │ │ 5xx │ │ 200 │ │ 5xx │
└──────┘ └──────┘ └──────┘ └──────┘
│ │ │ │
└───────┬───────┘ └───────┬───────┘
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Update Status │ │ Update Status │
│ available: true │ │ available: false │
│ lastChecked: now │ │ lastChecked: now │
│ │ │ error: "..." │
└───────────────────┘ └───────────────────┘
│ │
└───────────┬───────────┘
┌─────────────────────────────────────────┐
│ Log Health Status │
│ - obp-oidc: ✓ healthy │
│ - keycloak: ✗ unhealthy (timeout) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Frontend can query: │
│ GET /api/oauth2/providers │
│ (Returns updated status) │
└─────────────────────────────────────────┘
```
---
## 5. Component Interaction Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (Vue 3) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ HeaderNav.vue │ │
│ │ - fetchAvailableProviders() │ │
│ │ - handleLoginClick() │ │
│ │ - loginWithProvider(provider) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
└─────────────────────────┼────────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (Express) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ OAuth2ProvidersController │ │
│ │ GET /api/oauth2/providers │ │
│ └────────┬───────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ OAuth2ProviderManager │ │
│ │ - providers: Map<string, OAuth2ClientWithConfig> │ │
│ │ - providerStatus: Map<string, ProviderStatus> │ │
│ │ - fetchWellKnownUris() │ │
│ │ - initializeProviders() │ │
│ │ - getProvider(name) │ │
│ │ - getAvailableProviders() │ │
│ │ - startHealthCheck() │ │
│ └────────┬───────────────────────────────┬────────────────┘ │
│ │ │ │
│ │ uses │ creates │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ OBPClientService │ │ OAuth2ProviderFactory │ │
│ │ - Fetch well-known │ │ - strategies: Map │ │
│ │ from OBP API │ │ - initializeProvider() │ │
│ └─────────────────────┘ └──────────┬──────────────┘ │
│ │ │
│ │ creates │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ OAuth2ClientWithConfig │ │
│ │ - OIDCConfig │ │
│ │ - provider: string │ │
│ │ - initOIDCConfig() │ │
│ │ - getAuthEndpoint() │ │
│ │ - getTokenEndpoint() │ │
│ │ - getUserInfoEndpoint() │ │
│ └─────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ OAuth2ConnectController │ │
│ │ GET /api/oauth2/connect?provider=obp-oidc │ │
│ │ 1. Get provider from ProviderManager │ │
│ │ 2. Generate PKCE │ │
│ │ 3. Store in session │ │
│ │ 4. Redirect to provider │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ OAuth2CallbackController │ │
│ │ GET /api/oauth2/callback?code=xxx&state=yyy │ │
│ │ 1. Get provider from session │ │
│ │ 2. Get OAuth2Client from ProviderManager │ │
│ │ 3. Exchange code for tokens │ │
│ │ 4. Fetch user info │ │
│ │ 5. Store in session │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────┬───────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────────────────────────────┐
│ OBP API │
├─────────────────────────────────────────────────────────────────┤
│ GET /obp/v5.1.0/well-known │
│ → Returns list of OIDC provider configurations │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ OIDC PROVIDERS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ OBP-OIDC │ │ Keycloak │ │
│ │ localhost:9000 │ │ localhost:8180 │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 6. Data Flow: Session Storage
```
┌─────────────────────────────────────────────────────────────────┐
│ SESSION DATA LIFECYCLE │
└─────────────────────────────────────────────────────────────────┘
Step 1: Login Initiation
┌──────────────────────────────────────┐
│ Session │
│ ┌────────────────────────────────┐ │
│ │ oauth2_provider: "obp-oidc" │ │ ← Store provider choice
│ │ oauth2_code_verifier: "..." │ │ ← Store for PKCE
│ │ oauth2_state: "..." │ │ ← Store for CSRF protection
│ │ oauth2_redirect: "/resource-docs"│ │ ← Store redirect URL
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
Step 2: After Token Exchange
┌──────────────────────────────────────┐
│ Session │
│ ┌────────────────────────────────┐ │
│ │ oauth2_provider: "obp-oidc" │ │ ← Provider used
│ │ oauth2_access_token: "..." │ │ ← For API calls
│ │ oauth2_refresh_token: "..." │ │ ← For token refresh
│ │ oauth2_id_token: "..." │ │ ← User identity (JWT)
│ │ user: { │ │ ← User profile
│ │ username: "john.doe" │ │
│ │ email: "john@example.com" │ │
│ │ name: "John Doe" │ │
│ │ provider: "obp-oidc" │ │
│ │ sub: "uuid-1234" │ │
│ │ } │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
(oauth2_code_verifier deleted)
(oauth2_state deleted)
(oauth2_redirect deleted)
```
---
## 7. Error Handling Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ ERROR SCENARIOS │
└─────────────────────────────────────────────────────────────────┘
Scenario 1: Provider Not Available
User clicks login
Fetch providers → No providers available
Show error: "Authentication not available"
Scenario 2: Invalid Provider
User selects provider → GET /api/oauth2/connect?provider=invalid
ProviderManager.getProvider("invalid") → undefined
Return 400: "Provider not available"
Scenario 3: State Mismatch (CSRF Attack)
Callback received → state parameter doesn't match session
Reject request → Redirect with error
Display: "Invalid state (CSRF protection)"
Scenario 4: Token Exchange Failure
Exchange code for tokens → 401 Unauthorized
Log error → Redirect with error
Display: "Authentication failed"
Scenario 5: Provider Health Check Failure
Health check → Provider unreachable
Mark as unavailable → Update status
Frontend queries providers → Shows as unavailable
User cannot select unavailable provider
```

View File

@ -0,0 +1,361 @@
# Multi-OIDC Provider Implementation Status
**Branch:** `multi-login`
**Date:** 2024
**Status:** ✅ Backend Complete - Frontend In Progress
---
## Overview
This document tracks the implementation status of multiple OIDC provider support in API Explorer II, enabling users to choose from different identity providers (OBP-OIDC, Keycloak, Google, GitHub, etc.) at login time.
---
## Implementation Summary
### ✅ Completed (Backend)
#### 1. Type Definitions
- [x] **`server/types/oauth2.ts`**
- `WellKnownUri` - Provider information from OBP API
- `WellKnownResponse` - Response from `/obp/v5.1.0/well-known`
- `ProviderStrategy` - Provider configuration
- `ProviderStatus` - Provider health information
- `OIDCConfiguration` - OpenID Connect discovery
- `TokenResponse` - OAuth2 token response
- `UserInfo` - OIDC UserInfo endpoint response
#### 2. Core Services
- [x] **`server/services/OAuth2ClientWithConfig.ts`**
- Extends arctic `OAuth2Client` with OIDC discovery
- Stores provider name and OIDC configuration
- Methods:
- `initOIDCConfig()` - Fetch and validate OIDC discovery document
- `getAuthorizationEndpoint()` - Get auth endpoint from config
- `getTokenEndpoint()` - Get token endpoint from config
- `getUserInfoEndpoint()` - Get userinfo endpoint from config
- `exchangeAuthorizationCode()` - Token exchange with PKCE
- `refreshTokens()` - Refresh access token
- `isInitialized()` - Check if config loaded
- [x] **`server/services/OAuth2ProviderFactory.ts`**
- Strategy pattern for provider configurations
- Loads strategies from environment variables
- Supported providers:
- OBP-OIDC (`VITE_OBP_OAUTH2_*`)
- Keycloak (`VITE_KEYCLOAK_*`)
- Google (`VITE_GOOGLE_*`)
- GitHub (`VITE_GITHUB_*`)
- Custom OIDC (`VITE_CUSTOM_OIDC_*`)
- Methods:
- `initializeProvider()` - Create and initialize OAuth2 client
- `getConfiguredProviders()` - List available strategies
- `hasStrategy()` - Check if provider configured
- [x] **`server/services/OAuth2ProviderManager.ts`**
- Manages multiple OAuth2 providers
- Fetches well-known URIs from OBP API
- Tracks provider health status
- Performs periodic health checks (60s intervals)
- Methods:
- `fetchWellKnownUris()` - Get providers from OBP API
- `initializeProviders()` - Initialize all providers
- `getProvider()` - Get OAuth2 client by name
- `getAvailableProviders()` - List healthy providers
- `getAllProviderStatus()` - Get status for all providers
- `startHealthCheck()` - Start monitoring
- `stopHealthCheck()` - Stop monitoring
- `retryProvider()` - Retry failed provider
#### 3. Controllers
- [x] **`server/controllers/OAuth2ProvidersController.ts`**
- New endpoint: `GET /api/oauth2/providers`
- Returns list of available providers with status
- Used by frontend for provider selection UI
- [x] **`server/controllers/OAuth2ConnectController.ts`** (Updated)
- Updated: `GET /api/oauth2/connect?provider=<name>&redirect=<url>`
- Supports both multi-provider and legacy single-provider mode
- Multi-provider: Uses provider from query parameter
- Legacy: Falls back to existing OAuth2Service
- Generates PKCE parameters (code_verifier, code_challenge, state)
- Stores provider name in session
- Redirects to provider's authorization endpoint
- [x] **`server/controllers/OAuth2CallbackController.ts`** (Updated)
- Updated: `GET /api/oauth2/callback?code=<code>&state=<state>`
- Retrieves provider from session
- Uses correct OAuth2 client for token exchange
- Validates state (CSRF protection)
- Exchanges authorization code for tokens
- Fetches user info from provider
- Stores tokens and user data in session
- Supports both multi-provider and legacy modes
#### 4. Server Initialization
- [x] **`server/app.ts`** (Updated)
- Initialize `OAuth2ProviderManager` on startup
- Fetch providers from OBP API
- Start health monitoring (60s intervals)
- Register `OAuth2ProvidersController`
- Maintain backward compatibility with legacy OAuth2Service
---
### 🚧 In Progress (Frontend)
#### 5. Frontend Components
- [ ] **`src/components/HeaderNav.vue`** (To be updated)
- Fetch available providers on mount
- Display provider selection UI
- Handle login with selected provider
- Show provider availability status
- [ ] **`src/components/ProviderSelector.vue`** (To be created)
- Modal/dropdown for provider selection
- Display provider names and status
- Trigger login with selected provider
- Responsive design
---
### 📋 Not Started
#### 6. Testing
- [ ] Unit tests for `OAuth2ClientWithConfig`
- [ ] Unit tests for `OAuth2ProviderFactory`
- [ ] Unit tests for `OAuth2ProviderManager`
- [ ] Integration tests for multi-provider flow
- [ ] E2E tests for login flow
#### 7. Documentation
- [ ] Update README.md with multi-provider setup
- [ ] Update OAUTH2-README.md
- [ ] Create migration guide
- [ ] Update deployment documentation
---
## Architecture
### Data Flow
```
1. Server Startup
├─> OAuth2ProviderManager.initializeProviders()
├─> Fetch well-known URIs from OBP API (/obp/v5.1.0/well-known)
├─> For each provider:
│ ├─> OAuth2ProviderFactory.initializeProvider()
│ ├─> Create OAuth2ClientWithConfig
│ ├─> Fetch .well-known/openid-configuration
│ └─> Store in providers Map
└─> Start health monitoring (60s intervals)
2. User Login Flow
├─> Frontend: Fetch available providers (GET /api/oauth2/providers)
├─> User selects provider (e.g., "obp-oidc")
├─> Redirect: /api/oauth2/connect?provider=obp-oidc&redirect=/resource-docs
├─> OAuth2ConnectController:
│ ├─> Get OAuth2Client for selected provider
│ ├─> Generate PKCE (code_verifier, code_challenge, state)
│ ├─> Store in session (provider, code_verifier, state)
│ └─> Redirect to provider's authorization endpoint
├─> User authenticates on OIDC provider
├─> Callback: /api/oauth2/callback?code=xxx&state=yyy
└─> OAuth2CallbackController:
├─> Retrieve provider from session
├─> Get OAuth2Client for provider
├─> Validate state (CSRF protection)
├─> Exchange code for tokens (with PKCE)
├─> Fetch user info
├─> Store tokens and user in session
└─> Redirect to original page
3. Health Monitoring
└─> Every 60 seconds:
├─> For each provider:
│ ├─> HEAD request to issuer endpoint
│ └─> Update provider status (available/unavailable)
└─> Frontend can query status via /api/oauth2/providers
```
---
## Configuration
### Environment Variables
```bash
# OBP-OIDC Provider (Required for 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
# Keycloak Provider (Optional)
VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Google Provider (Optional)
# VITE_GOOGLE_CLIENT_ID=your-google-client-id
# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
# VITE_GOOGLE_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# GitHub Provider (Optional)
# VITE_GITHUB_CLIENT_ID=your-github-client-id
# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret
# VITE_GITHUB_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Custom OIDC Provider (Optional)
# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-provider
# VITE_CUSTOM_OIDC_CLIENT_ID=your-client-id
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-client-secret
# VITE_CUSTOM_OIDC_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Legacy Single-Provider Mode (Backward Compatibility)
# VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
```
### OBP API Configuration
The multi-provider system fetches available providers from:
```
GET /obp/v5.1.0/well-known
```
**Expected Response:**
```json
{
"well_known_uris": [
{
"provider": "obp-oidc",
"url": "http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration"
},
{
"provider": "keycloak",
"url": "http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration"
}
]
}
```
---
## Endpoints
### New Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/oauth2/providers` | List available OIDC providers with status |
### Updated Endpoints
| Method | Path | Query Parameters | Description |
|--------|------|------------------|-------------|
| GET | `/api/oauth2/connect` | `provider` (optional), `redirect` (optional) | Initiate OAuth2 flow with selected provider |
| GET | `/api/oauth2/callback` | `code`, `state`, `error` (optional) | Handle OAuth2 callback from any provider |
---
## Session Data
### Login Initiation
```javascript
session = {
oauth2_provider: "obp-oidc", // Provider name
oauth2_code_verifier: "...", // PKCE code verifier
oauth2_state: "...", // CSRF state token
oauth2_redirect_page: "/resource-docs" // Redirect after auth
}
```
### After Token Exchange
```javascript
session = {
oauth2_provider: "obp-oidc", // Provider used
oauth2_access_token: "...", // Access token
oauth2_refresh_token: "...", // Refresh token
oauth2_id_token: "...", // ID token (JWT)
user: {
username: "john.doe",
email: "john@example.com",
name: "John Doe",
provider: "obp-oidc",
sub: "uuid-1234"
}
}
```
---
## Backward Compatibility
The implementation maintains full backward compatibility with the existing single-provider OAuth2 system:
1. **Legacy Environment Variable**: `VITE_OBP_OAUTH2_WELL_KNOWN_URL` still works
2. **Fallback Behavior**: If no provider parameter is specified, falls back to legacy OAuth2Service
3. **No Breaking Changes**: Existing deployments continue to work without changes
4. **Gradual Migration**: Can enable multi-provider incrementally
---
## Benefits
1. ✅ **User Choice** - Users select their preferred identity provider
2. ✅ **Dynamic Discovery** - Providers discovered from OBP API automatically
3. ✅ **Health Monitoring** - Real-time provider availability tracking
4. ✅ **Extensibility** - Add new providers via environment variables only
5. ✅ **Resilience** - Fallback to available providers if one fails
6. ✅ **Backward Compatible** - No breaking changes for existing deployments
---
## Next Steps
### Priority 1: Frontend Implementation
1. Update `HeaderNav.vue` to fetch and display available providers
2. Create `ProviderSelector.vue` component for provider selection
3. Test login flow with multiple providers
4. Handle error states gracefully
### Priority 2: Testing
1. Write unit tests for all new services
2. Create integration tests for multi-provider flow
3. Add E2E tests for login scenarios
### Priority 3: Documentation
1. Update README.md with setup instructions
2. Document environment variables for each provider
3. Create migration guide from single to multi-provider
4. Update deployment documentation
---
## Known Issues
- None currently identified
---
## References
- **Implementation Guide**: `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
- **Executive Summary**: `MULTI-OIDC-PROVIDER-SUMMARY.md`
- **Flow Diagrams**: `MULTI-OIDC-FLOW-DIAGRAM.md`
- **OBP-Portal Reference**: `~/Documents/workspace_2024/OBP-Portal`
- **Branch**: `multi-login`
---
## Commits
1. `3dadca8` - Add multi-OIDC provider backend services
2. `8b90bb4` - Add multi-OIDC provider controllers and update app initialization
3. `755dc70` - Fix TypeScript compilation errors in multi-provider implementation
---
**Last Updated**: 2024
**Status**: Backend implementation complete ✅

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,372 @@
# Multi-OIDC Provider Implementation - Executive Summary
## Overview
This document provides a high-level summary of implementing multiple OIDC provider support in API Explorer II, based on the proven architecture from OBP-Portal.
---
## Current State
**API Explorer II** currently supports OAuth2/OIDC authentication with a **single provider** configured via environment variables:
```bash
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://localhost:9000/obp-oidc/.well-known/openid-configuration
VITE_OBP_OAUTH2_CLIENT_ID=<client-id>
VITE_OBP_OAUTH2_CLIENT_SECRET=<client-secret>
```
**Limitations:**
- Only one OIDC provider supported at a time
- No user choice of authentication method
- Requires redeployment to switch providers
- No fallback if provider is unavailable
---
## Target State
**Multi-Provider Support** allows users to choose from multiple identity providers at login:
- **OBP-OIDC** - Open Bank Project's identity provider
- **Keycloak** - Enterprise identity management
- **Google** - Consumer identity (optional)
- **GitHub** - Developer identity (optional)
- **Custom** - Any OpenID Connect provider
---
## How OBP-Portal Does It
### 1. Dynamic Provider Discovery
OBP-Portal fetches available OIDC providers from the **OBP API**:
```
GET /obp/v5.1.0/well-known
```
**Response:**
```json
{
"well_known_uris": [
{
"provider": "obp-oidc",
"url": "http://localhost:9000/obp-oidc/.well-known/openid-configuration"
},
{
"provider": "keycloak",
"url": "http://localhost:8180/realms/obp/.well-known/openid-configuration"
}
]
}
```
### 2. Provider Manager
**Key Component:** `OAuth2ProviderManager`
**Responsibilities:**
- Fetch well-known URIs from OBP API
- Initialize OAuth2 client for each provider
- Track provider health (available/unavailable)
- Perform periodic health checks (60s intervals)
- Provide access to specific providers
### 3. Provider Factory
**Key Component:** `OAuth2ProviderFactory`
**Responsibilities:**
- Strategy pattern for provider-specific configuration
- Load credentials from environment variables
- Create OAuth2 clients with OIDC discovery
- Support multiple provider types
**Strategy Pattern:**
```typescript
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
})
strategies.set('keycloak', {
clientId: process.env.VITE_KEYCLOAK_CLIENT_ID,
clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET,
redirectUri: process.env.VITE_KEYCLOAK_REDIRECT_URL
})
```
### 4. User Flow
```
1. User clicks "Login"
→ Shows provider selection dialog
2. User selects provider (e.g., "OBP-OIDC")
→ GET /api/oauth2/connect?provider=obp-oidc
3. Server:
- Retrieves OAuth2 client for "obp-oidc"
- Generates PKCE parameters
- Stores provider name in session
- Redirects to provider's authorization endpoint
4. User authenticates on selected OIDC provider
5. Provider redirects back:
→ GET /api/oauth2/callback?code=xxx&state=yyy
6. Server:
- Retrieves provider from session ("obp-oidc")
- Gets corresponding OAuth2 client
- Exchanges code for tokens
- Stores tokens with provider name
7. User authenticated with selected provider
```
---
## Implementation Architecture for API Explorer II
### New Services
#### 1. **OAuth2ClientWithConfig** (extends `OAuth2Client` from arctic)
```typescript
class OAuth2ClientWithConfig extends OAuth2Client {
public OIDCConfig?: OIDCConfiguration
public provider: string
async initOIDCConfig(oidcConfigUrl: string): Promise<void>
getAuthorizationEndpoint(): string
getTokenEndpoint(): string
getUserInfoEndpoint(): string
}
```
#### 2. **OAuth2ProviderFactory**
```typescript
class OAuth2ProviderFactory {
private strategies: Map<string, ProviderStrategy>
async initializeProvider(wellKnownUri: WellKnownUri): Promise<OAuth2ClientWithConfig>
getConfiguredProviders(): string[]
}
```
#### 3. **OAuth2ProviderManager**
```typescript
class OAuth2ProviderManager {
private providers: Map<string, OAuth2ClientWithConfig>
async fetchWellKnownUris(): Promise<WellKnownUri[]>
async initializeProviders(): Promise<boolean>
getProvider(providerName: string): OAuth2ClientWithConfig
getAvailableProviders(): string[]
startHealthCheck(intervalMs: number): void
}
```
### Updated Controllers
#### 1. **OAuth2ProvidersController** (NEW)
```typescript
GET /api/oauth2/providers
→ Returns: { providers: [...], count: 2, availableCount: 1 }
```
#### 2. **OAuth2ConnectController** (UPDATED)
```typescript
GET /api/oauth2/connect?provider=obp-oidc&redirect=/resource-docs
→ Redirects to selected provider's authorization endpoint
```
#### 3. **OAuth2CallbackController** (UPDATED)
```typescript
GET /api/oauth2/callback?code=xxx&state=yyy
→ Uses provider from session to exchange code for tokens
```
### Frontend Updates
#### **HeaderNav.vue** (UPDATED)
**Before:**
```vue
<a href="/api/oauth2/connect">Login</a>
```
**After:**
```vue
<button @click="handleLoginClick">
Login
<span v-if="availableProviders.length > 1"></span>
</button>
<!-- Provider Selection Dialog -->
<el-dialog v-model="showProviderSelector">
<div v-for="provider in availableProviders">
<div @click="loginWithProvider(provider.name)">
{{ provider.name }}
</div>
</div>
</el-dialog>
```
---
## Configuration
### Environment Variables
```bash
# OBP-OIDC Provider
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
# Keycloak Provider
VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Google Provider (Optional)
VITE_GOOGLE_CLIENT_ID=your-google-client-id
VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
VITE_GOOGLE_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
```
**Note:** No need to specify well-known URLs - they are fetched from OBP API!
---
## Key Benefits
### 1. **Dynamic Discovery**
- Providers are discovered from OBP API at runtime
- No hardcoded provider list
- Easy to add new providers without code changes
### 2. **User Choice**
- Users select their preferred authentication method
- Better user experience
- Support for organizational identity preferences
### 3. **Resilience**
- Health monitoring detects provider outages
- Can fallback to alternative providers
- Automatic retry for failed initializations
### 4. **Extensibility**
- Strategy pattern makes adding providers trivial
- Just add environment variables
- No code changes needed
### 5. **Backward Compatibility**
- Existing single-provider mode still works
- Gradual migration path
- No breaking changes
---
## Implementation Phases
### **Phase 1: Backend Services** (Week 1)
- [ ] Create `OAuth2ClientWithConfig`
- [ ] Create `OAuth2ProviderFactory`
- [ ] Create `OAuth2ProviderManager`
- [ ] Create TypeScript interfaces
### **Phase 2: Backend Controllers** (Week 1-2)
- [ ] Create `OAuth2ProvidersController`
- [ ] Update `OAuth2ConnectController` with provider parameter
- [ ] Update `OAuth2CallbackController` to use provider from session
### **Phase 3: Frontend** (Week 2)
- [ ] Update `HeaderNav.vue` to fetch providers
- [ ] Add provider selection UI (dialog/dropdown)
- [ ] Update login flow to include provider selection
### **Phase 4: Configuration & Testing** (Week 2-3)
- [ ] Configure environment variables for multiple providers
- [ ] Write unit tests
- [ ] Write integration tests
- [ ] Manual testing with OBP-OIDC and Keycloak
- [ ] Update documentation
---
## Migration Path
### **Step 1: Deploy with Backward Compatibility**
- Implement new services
- Keep existing single-provider mode working
- Test thoroughly
### **Step 2: Enable Multi-Provider**
- Add provider environment variables
- Enable provider selection UI
- Monitor for issues
### **Step 3: Deprecate Single-Provider**
- Update documentation
- Remove `VITE_OBP_OAUTH2_WELL_KNOWN_URL` env variable
- Use OBP API well-known endpoint by default
---
## Testing Strategy
### Unit Tests
- `OAuth2ProviderFactory.test.ts` - Strategy creation
- `OAuth2ProviderManager.test.ts` - Provider initialization
- `OAuth2ClientWithConfig.test.ts` - OIDC config loading
### Integration Tests
- Multi-provider login flow
- Provider selection
- Token exchange with different providers
- Callback handling
### Manual Testing
- Login with OBP-OIDC
- Login with Keycloak
- Provider unavailable scenarios
- Network error handling
- User cancellation
---
## Success Criteria
- ✅ Users can choose from multiple OIDC providers
- ✅ Providers are discovered from OBP API automatically
- ✅ Health monitoring detects provider outages
- ✅ Backward compatible with single-provider mode
- ✅ No code changes needed to add new providers (only env vars)
- ✅ Comprehensive test coverage (>80%)
- ✅ Documentation updated
---
## References
- **Full Implementation Guide:** `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
- **OBP-Portal Reference:** `~/Documents/workspace_2024/OBP-Portal`
- **OBP API Well-Known Endpoint:** `/obp/v5.1.0/well-known`
- **Current OAuth2 Docs:** `OAUTH2-README.md`, `OAUTH2-OIDC-INTEGRATION-PREP.md`
- **Arctic OAuth2 Library:** https://github.com/pilcrowOnPaper/arctic
- **OpenID Connect Discovery:** https://openid.net/specs/openid-connect-discovery-1_0.html
---
## Questions?
For detailed implementation instructions, see **MULTI-OIDC-PROVIDER-IMPLEMENTATION.md**
For OBP-Portal reference implementation, see:
- `OBP-Portal/src/lib/oauth/providerManager.ts`
- `OBP-Portal/src/lib/oauth/providerFactory.ts`
- `OBP-Portal/src/lib/oauth/client.ts`

790
MULTI-OIDC-TESTING-GUIDE.md Normal file
View File

@ -0,0 +1,790 @@
# Multi-OIDC Provider Testing Guide
**Branch:** `multi-login`
**Date:** 2024
**Status:** Ready for Testing
---
## Overview
This guide provides step-by-step instructions for testing the multi-OIDC provider login implementation in API Explorer II.
---
## Prerequisites
### 1. OBP API Setup
Ensure your OBP API is running and configured to return well-known URIs:
```bash
# Test the endpoint
curl http://localhost:8080/obp/v5.1.0/well-known
# Expected response:
{
"well_known_uris": [
{
"provider": "obp-oidc",
"url": "http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration"
},
{
"provider": "keycloak",
"url": "http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration"
}
]
}
```
### 2. OIDC Providers Running
Ensure at least one OIDC provider is running:
**OBP-OIDC:**
```bash
# Check if OBP-OIDC is running
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
```
**Keycloak (optional):**
```bash
# Check if Keycloak is running
curl http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration
```
### 3. Environment Configuration
Set up your `.env` file with provider credentials:
```bash
# OBP API
VITE_OBP_API_HOST=localhost:8080
# OBP-OIDC Provider
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
# Keycloak Provider (optional)
# VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret
# VITE_KEYCLOAK_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
# Session Secret
SESSION_SECRET=your-secure-session-secret
# Redis (if using)
# VITE_OBP_REDIS_URL=redis://localhost:6379
```
---
## Starting the Application
### 1. Switch to Multi-Login Branch
```bash
git checkout multi-login
```
### 2. Install Dependencies (if needed)
```bash
npm install
```
### 3. Start the Backend
```bash
# Terminal 1
npm run dev:backend
```
**Expected output:**
```
--- OAuth2 Multi-Provider Setup ---------------------------------
OAuth2ProviderManager: Fetching well-known URIs from OBP API...
OAuth2ProviderManager: Found 2 providers:
- obp-oidc: http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
- keycloak: http://127.0.0.1:8180/realms/obp/.well-known/openid-configuration
OAuth2ProviderManager: Initializing providers...
OAuth2ProviderFactory: Loading provider strategies...
✓ OBP-OIDC strategy loaded
✓ Keycloak strategy loaded
OAuth2ProviderFactory: Loaded 2 provider strategies
OAuth2ProviderFactory: Initializing provider: obp-oidc
OAuth2ClientWithConfig: Fetching OIDC config for obp-oidc from: http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
OAuth2ClientWithConfig: OIDC config loaded for obp-oidc
OAuth2ProviderManager: ✓ obp-oidc initialized
OAuth2ProviderFactory: Initializing provider: keycloak
OAuth2ProviderManager: ✓ keycloak initialized
OAuth2ProviderManager: Initialized 2/2 providers
✓ Initialized 2 OAuth2 providers:
- obp-oidc
- keycloak
✓ Provider health monitoring started (every 60s)
-----------------------------------------------------------------
Backend is running. You can check a status at http://localhost:8085/api/status
```
### 4. Start the Frontend
```bash
# Terminal 2
npm run dev
```
### 5. Open Browser
Navigate to: http://localhost:5173
---
## Test Scenarios
### Test 1: Provider Discovery
**Objective:** Verify that providers are fetched from OBP API
**Steps:**
1. Open browser developer console
2. Navigate to http://localhost:5173
3. Look for log messages in console
**Expected Console Output:**
```
Available OAuth2 providers: [
{ name: "obp-oidc", available: true, lastChecked: "..." },
{ name: "keycloak", available: true, lastChecked: "..." }
]
Total: 2, Available: 2
```
**✅ Pass Criteria:**
- Providers are logged in console
- `availableCount` matches number of running providers
---
### Test 2: Backend API Endpoint
**Objective:** Test the `/api/oauth2/providers` endpoint
**Steps:**
1. Open a new terminal
2. Run: `curl http://localhost:5173/api/oauth2/providers`
**Expected Response:**
```json
{
"providers": [
{
"name": "obp-oidc",
"available": true,
"lastChecked": "2024-01-15T10:30:00.000Z"
},
{
"name": "keycloak",
"available": true,
"lastChecked": "2024-01-15T10:30:00.000Z"
}
],
"count": 2,
"availableCount": 2
}
```
**✅ Pass Criteria:**
- HTTP 200 status
- JSON response with providers array
- Each provider has `name`, `available`, `lastChecked` fields
---
### Test 3: Login Button - Multiple Providers
**Objective:** Test provider selection dialog appears
**Steps:**
1. Navigate to http://localhost:5173
2. Ensure you're logged out
3. Look at the "Login" button in the header
4. Click the "Login" button
**Expected Behavior:**
- Login button shows a small down arrow (▼)
- Provider selection dialog appears
- Dialog shows all available providers (OBP-OIDC, Keycloak)
- Each provider shows icon, name, and "Available" status
**✅ Pass Criteria:**
- Dialog opens smoothly
- All available providers are listed
- Provider names are formatted nicely (e.g., "OBP OIDC", "Keycloak")
- Hover effect works (border turns blue, slight translate)
---
### Test 4: Login with OBP-OIDC
**Objective:** Complete login flow with OBP-OIDC provider
**Steps:**
1. Click "Login" button
2. Select "OBP OIDC" from the dialog
3. You should be redirected to OBP-OIDC login page
4. Enter credentials (if prompted)
5. After authentication, you should be redirected back
**Expected URL Flow:**
```
1. http://localhost:5173
2. Click login → Provider selection dialog
3. Select provider → http://localhost:5173/api/oauth2/connect?provider=obp-oidc&redirect=/
4. Server redirects → http://127.0.0.1:9000/obp-oidc/auth?client_id=...&state=...&code_challenge=...
5. After auth → http://localhost:5173/api/oauth2/callback?code=xxx&state=yyy
6. Final redirect → http://localhost:5173/
```
**Expected Console Output (Backend):**
```
OAuth2ConnectController: Starting authentication flow
Provider: obp-oidc
Redirect: /
OAuth2ConnectController: Multi-provider mode - obp-oidc
OAuth2ConnectController: Redirecting to obp-oidc authorization endpoint
OAuth2CallbackController: Processing OAuth2 callback
OAuth2CallbackController: Multi-provider mode - obp-oidc
OAuth2CallbackController: Exchanging authorization code for tokens
OAuth2ClientWithConfig: Exchanging authorization code for obp-oidc
OAuth2CallbackController: Tokens received and stored
OAuth2CallbackController: Fetching user info
OAuth2CallbackController: User authenticated via obp-oidc: username
OAuth2CallbackController: Authentication successful, redirecting to: /
```
**✅ Pass Criteria:**
- User is redirected to OBP-OIDC
- After authentication, user is redirected back
- Username appears in header (top right)
- Login button changes to username + logoff button
- Session persists (refresh page, still logged in)
---
### Test 5: Login with Keycloak
**Objective:** Test login with different provider
**Steps:**
1. Log out (if logged in)
2. Click "Login" button
3. Select "Keycloak" from the dialog
4. Complete Keycloak authentication
5. Verify successful login
**Expected Behavior:**
- Same as Test 4, but with Keycloak provider
- Session should store `oauth2_provider: "keycloak"`
**✅ Pass Criteria:**
- Login succeeds with Keycloak
- Username displayed in header
- Session persists
---
### Test 6: Single Provider Mode
**Objective:** Test fallback when only one provider is available
**Steps:**
1. Stop Keycloak (or configure only OBP-OIDC)
2. Restart backend
3. Log out
4. Click "Login" button
**Expected Behavior:**
- No provider selection dialog
- Direct redirect to OBP-OIDC (the only available provider)
**✅ Pass Criteria:**
- No dialog appears
- Immediate redirect to single provider
---
### Test 7: No Providers Available
**Objective:** Test error handling when no providers are available
**Steps:**
1. Stop all OIDC providers (OBP-OIDC, Keycloak)
2. Restart backend
3. Wait 60 seconds for health check
4. Refresh frontend
5. Click "Login" button
**Expected Behavior:**
- Login button might be disabled or show error
- Dialog shows "No identity providers available"
**✅ Pass Criteria:**
- Graceful error handling
- User-friendly error message
---
### Test 8: Provider Health Monitoring
**Objective:** Test real-time health monitoring
**Steps:**
1. Start with all providers running
2. Log in successfully
3. Stop OBP-OIDC (but keep backend running)
4. Wait 60 seconds (health check interval)
5. Check backend console
**Expected Console Output:**
```
OAuth2ProviderManager: Performing health check...
obp-oidc: ✗ unhealthy (Connection refused)
keycloak: ✓ healthy
```
**Test frontend:**
6. Log out
7. Click "Login" button
8. Verify only Keycloak appears in provider list
**✅ Pass Criteria:**
- Health check detects provider outage
- Unhealthy providers removed from selection
- Backend logs show health status
---
### Test 9: Session Persistence
**Objective:** Verify session data is stored correctly
**Steps:**
1. Log in with OBP-OIDC
2. Open browser developer tools
3. Go to Application → Cookies → localhost:5173
4. Find session cookie
**Expected Session Data (Backend):**
```javascript
session = {
oauth2_provider: "obp-oidc",
oauth2_access_token: "...",
oauth2_refresh_token: "...",
oauth2_id_token: "...",
user: {
username: "john.doe",
email: "john@example.com",
name: "John Doe",
provider: "obp-oidc",
sub: "uuid-1234"
}
}
```
**✅ Pass Criteria:**
- Session cookie exists
- Session contains provider name
- Session contains tokens and user info
---
### Test 10: API Requests with Token
**Objective:** Verify access token is used for API requests
**Steps:**
1. Log in successfully
2. Navigate to API Explorer (resource docs)
3. Try to make an API request (e.g., GET /banks)
4. Check network tab in developer tools
**Expected Behavior:**
- API request includes `Authorization: Bearer <token>` header
- Request succeeds (200 OK)
**✅ Pass Criteria:**
- Authorization header present
- Token matches session token
- API request succeeds
---
### Test 11: Logout Flow
**Objective:** Test logout clears session
**Steps:**
1. Log in successfully
2. Click "Logoff" button in header
3. Verify redirect to home page
4. Check that username is no longer displayed
5. Verify session is cleared
**✅ Pass Criteria:**
- Redirect to home page
- Login button reappears
- Username disappears
- Session cleared (check cookies)
---
### Test 12: Redirect After Login
**Objective:** Test redirect to original page after login
**Steps:**
1. Navigate to http://localhost:5173/resource-docs/OBPv5.1.0
2. Ensure logged out
3. Click "Login" button
4. Select provider and authenticate
5. Verify redirect back to `/resource-docs/OBPv5.1.0`
**Expected URL:**
```
After login: http://localhost:5173/resource-docs/OBPv5.1.0
```
**✅ Pass Criteria:**
- User redirected to original page
- Page state preserved
---
### Test 13: Error Handling - Invalid Provider
**Objective:** Test error handling for invalid provider
**Steps:**
1. Manually navigate to: http://localhost:5173/api/oauth2/connect?provider=invalid-provider
2. Check response
**Expected Response:**
```json
{
"error": "invalid_provider",
"message": "Provider \"invalid-provider\" is not available",
"availableProviders": ["obp-oidc", "keycloak"]
}
```
**✅ Pass Criteria:**
- HTTP 400 status
- Error message displayed
- Available providers listed
---
### Test 14: CSRF Protection (State Validation)
**Objective:** Test state parameter validation
**Steps:**
1. Start login flow
2. Capture callback URL
3. Modify `state` parameter in URL
4. Try to complete callback
**Expected Behavior:**
- Callback rejected
- Redirect to home with error: `?oauth2_error=invalid_state`
**✅ Pass Criteria:**
- Invalid state rejected
- User not authenticated
- Error logged in console
---
### Test 15: Backward Compatibility
**Objective:** Test legacy single-provider mode still works
**Steps:**
1. Remove all provider environment variables except `VITE_OBP_OAUTH2_WELL_KNOWN_URL`
2. Set `VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration`
3. Restart backend
4. Try to log in
**Expected Behavior:**
- Falls back to legacy OAuth2Service
- Login works without provider parameter
**✅ Pass Criteria:**
- Login succeeds
- No provider selection dialog
- Direct redirect to OIDC provider
---
## Troubleshooting
### Issue: No providers available
**Symptoms:**
- Provider list is empty
- Login button disabled or shows error
**Checks:**
1. Verify OBP API is running: `curl http://localhost:8080/obp/v5.1.0/well-known`
2. Check backend logs for initialization errors
3. Verify environment variables are set correctly
4. Check OIDC providers are running and accessible
**Solution:**
```bash
# Check OBP API
curl http://localhost:8080/obp/v5.1.0/well-known
# Check OBP-OIDC
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
# Restart backend with verbose logging
npm run dev:backend
```
---
### Issue: State mismatch error
**Symptoms:**
- Redirect to home with `?oauth2_error=invalid_state`
- Console shows "State mismatch (CSRF protection)"
**Causes:**
- Session not persisting between requests
- Redis not running (if using Redis sessions)
- Multiple backend instances
**Solution:**
```bash
# If using Redis, ensure it's running
redis-cli ping
# Check session secret is set
echo $SESSION_SECRET
# Clear browser cookies and try again
```
---
### Issue: Token exchange failed
**Symptoms:**
- Error after authentication: "token_exchange_failed"
- Backend logs show 401 or 400 errors
**Causes:**
- Wrong client ID or secret
- OIDC provider configuration mismatch
- Network connectivity issues
**Solution:**
```bash
# Verify client credentials in OIDC provider
# Check backend logs for detailed error
# Verify redirect URI matches exactly
```
---
### Issue: Provider shows as unavailable
**Symptoms:**
- Provider appears in list but marked as unavailable
- Red status indicator
**Causes:**
- OIDC provider is down
- Network connectivity issues
- Health check failed
**Solution:**
```bash
# Check provider is running
curl http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
# Check backend logs for health check errors
# Wait 60 seconds for next health check
# Manually retry provider
# POST /api/oauth2/providers/{name}/retry (if implemented)
```
---
## Performance Testing
### Load Testing Login Flow
Test with multiple concurrent users:
```bash
# Install Apache Bench
sudo apt-get install apache2-utils
# Test provider list endpoint
ab -n 100 -c 10 http://localhost:5173/api/oauth2/providers
# Expected: < 100ms response time
```
### Health Check Performance
Monitor health check impact:
```bash
# Watch backend logs during health checks
tail -f backend.log | grep "health check"
# Expected: Health checks complete in < 5 seconds
```
---
## Security Testing
### Test PKCE Implementation
Verify PKCE code challenge:
1. Start login flow
2. Capture authorization URL
3. Verify `code_challenge` and `code_challenge_method=S256` present
### Test State Validation
Verify CSRF protection:
1. Capture callback URL with state
2. Modify state parameter
3. Verify callback is rejected
### Test Token Security
Verify tokens are not exposed:
1. Check tokens are not in URL parameters
2. Check tokens are not logged in console
3. Check tokens are in httpOnly cookies or session only
---
## Acceptance Criteria
### Backend
- [x] Multiple providers fetched from OBP API
- [x] Health monitoring active (60s intervals)
- [x] Provider status tracked correctly
- [x] Login works with multiple providers
- [x] Session stores provider name
- [x] Token exchange succeeds
- [x] User info fetched correctly
- [x] Backward compatible with legacy mode
### Frontend
- [x] Provider list fetched and displayed
- [x] Provider selection dialog appears
- [x] Single provider direct login
- [x] Provider icons and names formatted
- [x] Hover effects work
- [x] Error handling graceful
- [x] Loading states handled
### Integration
- [ ] End-to-end login flow tested
- [ ] Multiple providers tested (OBP-OIDC, Keycloak)
- [ ] Session persistence verified
- [ ] API requests with token verified
- [ ] Logout flow tested
- [ ] Redirect after login tested
- [ ] Error scenarios handled
---
## Test Report Template
```
# Multi-OIDC Provider Test Report
**Date:** YYYY-MM-DD
**Tester:** Name
**Branch:** multi-login
**Commit:** abc1234
## Environment
- OBP API: Running / Not Running
- OBP-OIDC: Running / Not Running
- Keycloak: Running / Not Running
- Backend: Version
- Frontend: Version
## Test Results
### Test 1: Provider Discovery
Status: ✅ Pass / ❌ Fail
Notes: ...
### Test 2: Backend API Endpoint
Status: ✅ Pass / ❌ Fail
Notes: ...
[Continue for all tests...]
## Issues Found
1. Issue description
- Severity: High / Medium / Low
- Steps to reproduce
- Expected behavior
- Actual behavior
## Overall Assessment
✅ Ready for Production
⚠️ Ready with Minor Issues
❌ Not Ready
## Recommendations
- ...
```
---
## Next Steps
After completing all tests:
1. **Document Issues**: Create GitHub issues for any bugs found
2. **Update Documentation**: Update README.md with multi-provider setup
3. **Create PR**: Create pull request to merge `multi-login` into `develop`
4. **Review**: Request code review from team
5. **Deploy**: Plan deployment to staging/production
---
## Support
If you encounter issues during testing:
1. Check backend logs: `npm run dev:backend`
2. Check browser console for errors
3. Review this guide's troubleshooting section
4. Check implementation documentation: `MULTI-OIDC-PROVIDER-IMPLEMENTATION.md`
5. Contact the development team
---
**Last Updated:** 2024
**Version:** 1.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

@ -32,25 +32,21 @@ import RedisStore from 'connect-redis'
import { createClient } from 'redis'
import express from 'express'
import type { Application } from 'express'
import { useExpressServer, useContainer } from 'routing-controllers'
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'
// Import controllers
import { OpeyController } from './controllers/OpeyIIController.js'
import { OBPController } from './controllers/RequestController.js'
import { StatusController } from './controllers/StatusController.js'
import { UserController } from './controllers/UserController.js'
import { OAuth2CallbackController } from './controllers/OAuth2CallbackController.js'
import { OAuth2ConnectController } from './controllers/OAuth2ConnectController.js'
// Controllers removed - all routes migrated to plain Express
// Import middlewares
import OAuth2AuthorizationMiddleware from './middlewares/OAuth2AuthorizationMiddleware.js'
import OAuth2CallbackMiddleware from './middlewares/OAuth2CallbackMiddleware.js'
// Import routes (plain Express, not routing-controllers)
import oauth2Routes from './routes/oauth2.js'
import userRoutes from './routes/user.js'
import statusRoutes from './routes/status.js'
import obpRoutes from './routes/obp.js'
import opeyRoutes from './routes/opey.js'
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
@ -125,7 +121,7 @@ app.use(express.json())
let sessionObject = {
store: redisStore,
name: 'obp-api-explorer-ii.sid', // CRITICAL: Unique cookie name to prevent conflicts with other apps on localhost
secret: process.env.VITE_OPB_SERVER_SESSION_PASSWORD,
secret: process.env.VITE_OBP_SERVER_SESSION_PASSWORD,
resave: false,
saveUninitialized: false, // Don't save empty sessions (better for authenticated apps)
cookie: {
@ -139,78 +135,56 @@ if (app.get('env') === 'production') {
sessionObject.cookie.secure = true // serve secure cookies
}
app.use(session(sessionObject))
useContainer(Container)
// 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
;(async function initializeAndStartServer() {
if (!wellKnownUrl) {
console.warn('VITE_OBP_OAUTH2_WELL_KNOWN_URL not set. OAuth2 will not function.')
console.warn('Server will start but OAuth2 authentication will be unavailable.')
} else {
console.log(`OIDC Well-Known URL: ${wellKnownUrl}`)
// Initialize Multi-Provider OAuth2 Manager
console.log('--- OAuth2 Multi-Provider Setup ---------------------------------')
const providerManager = Container.get(OAuth2ProviderManager)
// 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 OAuth2 initialization (will retry indefinitely with exponential backoff)...'
)
const success = await oauth2Service.initializeWithRetry(wellKnownUrl, maxRetries, initialDelay)
try {
const success = await providerManager.initializeProviders()
if (success) {
console.log('OAuth2Service: 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('OAuth2/OIDC ready for authentication')
const availableProviders = providerManager.getAvailableProviders()
console.log(`OK Initialized ${availableProviders.length} OAuth2 providers:`)
availableProviders.forEach((name) => console.log(` - ${name}`))
// Start continuous monitoring even when initially connected
oauth2Service.startHealthCheck(1000, 240000) // Monitor every 4 minutes
console.log('OAuth2Service: Starting continuous monitoring (every 4 minutes)')
// Start health monitoring
providerManager.startHealthCheck(60000) // Check every 60 seconds
console.log('OK Provider health monitoring started (every 60s)')
} else {
console.error('OAuth2Service: 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 OAuth2`)
console.warn('WARNING: Login will be unavailable until OIDC server is reachable')
console.warn('WARNING: Starting health check to reconnect automatically...')
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: 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.error('ERROR: Server will start but login will not work')
}
console.log(`-----------------------------------------------------------------`)
const routePrefix = '/api'
const server = useExpressServer(app, {
routePrefix: routePrefix,
controllers: [
OpeyController,
OBPController,
StatusController,
UserController,
OAuth2CallbackController,
OAuth2ConnectController
],
middlewares: [OAuth2AuthorizationMiddleware, OAuth2CallbackMiddleware]
})
// Register all routes (plain Express)
app.use(routePrefix, oauth2Routes)
app.use(routePrefix, userRoutes)
app.use(routePrefix, statusRoutes)
app.use(routePrefix, obpRoutes)
app.use(routePrefix, opeyRoutes)
console.log('OAuth2 routes registered (plain Express)')
console.log('User routes registered (plain Express)')
console.log('Status routes registered (plain Express)')
console.log('OBP routes registered (plain Express)')
console.log('Opey routes registered (plain Express)')
console.log('All routes migrated to plain Express - routing-controllers removed')
instance = server.listen(port)
instance = app.listen(port)
console.log(
`Backend is running. You can check a status at http://localhost:${port}${routePrefix}/status`

View File

@ -1,98 +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 { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service } from 'typedi'
import OAuth2CallbackMiddleware from '../middlewares/OAuth2CallbackMiddleware.js'
/**
* OAuth2 Callback Controller
*
* Handles the OAuth2/OIDC callback from the identity provider.
* This controller receives the authorization code and state parameter
* after the user authenticates with the OIDC provider.
*
* The OAuth2CallbackMiddleware handles:
* - State validation (CSRF protection)
* - Authorization code exchange for tokens
* - User info retrieval
* - Session storage
* - Redirect to original page
*
* Endpoint: GET /oauth2/callback
*
* Query Parameters (from OIDC provider):
* - code: Authorization code to exchange for tokens
* - state: State parameter for CSRF validation
* - error (optional): Error code if authentication failed
* - error_description (optional): Human-readable error description
*
* Flow:
* OIDC Provider /oauth2/callback?code=XXX&state=YYY
* OAuth2CallbackMiddleware Original Page (with authenticated session)
*
* Success Flow:
* 1. Validate state parameter
* 2. Exchange authorization code for tokens (access, refresh, ID)
* 3. Fetch user information from UserInfo endpoint
* 4. Store tokens and user data in session
* 5. Redirect to original page or home
*
* Error Flow:
* 1. Parse error from query parameters
* 2. Display user-friendly error page
* 3. Allow user to retry authentication
*
* @example
* // Successful callback URL from OIDC provider
* http://localhost:5173/oauth2/callback?code=abc123&state=xyz789
*
* // Error callback URL from OIDC provider
* http://localhost:5173/oauth2/callback?error=access_denied&error_description=User%20cancelled
*/
@Service()
@Controller()
@UseBefore(OAuth2CallbackMiddleware)
export class OAuth2CallbackController {
/**
* Handle OAuth2/OIDC callback
*
* The actual logic is handled by OAuth2CallbackMiddleware.
* This method exists only as the routing endpoint definition.
*
* @param {Request} request - Express request object with query params (code, state)
* @param {Response} response - Express response object (redirected by middleware)
* @returns {Response} Response object (handled by middleware)
*/
@Get('/oauth2/callback')
callback(@Req() request: Request, @Res() response: Response): Response {
// The middleware handles all the logic and redirects the user
// This method should never actually execute
return response
}
}

View File

@ -1,77 +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 { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service } from 'typedi'
import OAuth2AuthorizationMiddleware from '../middlewares/OAuth2AuthorizationMiddleware.js'
/**
* OAuth2 Connect Controller
*
* Handles the OAuth2/OIDC login initiation endpoint.
* This controller triggers the OAuth2 authorization flow by delegating to
* the OAuth2AuthorizationMiddleware which generates PKCE parameters and
* redirects to the OIDC provider.
*
* Endpoint: GET /oauth2/connect
*
* Query Parameters:
* - redirect (optional): URL to redirect to after successful authentication
*
* Flow:
* User clicks login /oauth2/connect OAuth2AuthorizationMiddleware
* OIDC Provider Authorization Endpoint
*
* @example
* // User initiates login
* <a href="/oauth2/connect?redirect=/messages">Login</a>
*
* // JavaScript redirect
* window.location.href = '/oauth2/connect?redirect=' + encodeURIComponent(window.location.pathname)
*/
@Service()
@Controller()
@UseBefore(OAuth2AuthorizationMiddleware)
export class OAuth2ConnectController {
/**
* Initiate OAuth2/OIDC authentication flow
*
* The actual logic is handled by OAuth2AuthorizationMiddleware.
* This method exists only as the routing endpoint definition.
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object (redirected by middleware)
* @returns {Response} Response object (handled by middleware)
*/
@Get('/oauth2/connect')
connect(@Req() request: Request, @Res() response: Response): Response {
// The middleware handles all the logic and redirects the user
// This method should never actually execute
return response
}
}

View File

@ -1,360 +0,0 @@
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Readable } from 'node:stream'
import { ReadableStream as WebReadableStream } from 'stream/web'
import { Service, Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
import OpeyClientService from '../services/OpeyClientService.js'
import OBPConsentsService from '../services/OBPConsentsService.js'
import { UserInput, OpeyConfig } from '../schema/OpeySchema.js'
import {
APIApi,
Configuration,
ConsentApi,
ConsumerConsentrequestsBody,
InlineResponse20151
} from 'obp-api-typescript'
@Service()
@Controller('/opey')
export class OpeyController {
public obpClientService: OBPClientService
public opeyClientService: OpeyClientService
public obpConsentsService: OBPConsentsService
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.opeyClientService = Container.get(OpeyClientService)
this.obpConsentsService = Container.get(OBPConsentsService)
}
@Get('/')
async getStatus(@Res() response: Response): Promise<Response | any> {
try {
const opeyStatus = await this.opeyClientService.getOpeyStatus()
console.log('Opey status: ', opeyStatus)
return response.status(200).json({ status: 'Opey is running' })
} catch (error) {
console.error('Error in /opey endpoint: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
}
@Post('/stream')
async streamOpey(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response> {
if (!session) {
console.error('Session not found')
return response.status(401).json({ error: 'Session Time Out' })
}
// Check if the consent is in the session, and can be added to the headers
const opeyConfig = session['opeyConfig']
if (!opeyConfig) {
console.error('Opey config not found in session')
return response.status(500).json({ error: 'Internal Server Error' })
}
// Read user input from request body
let user_input: UserInput
try {
console.log('Request body: ', request.body)
user_input = {
message: request.body.message,
thread_id: request.body.thread_id,
is_tool_call_approval: request.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in stream endpoint, could not parse into UserInput: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
// Transform to decode and log the stream
const frontendTransformer = new TransformStream({
transform(chunk, controller) {
// Decode the chunk to a string
const decodedChunk = new TextDecoder().decode(chunk)
console.log('Sending chunk', decodedChunk)
controller.enqueue(decodedChunk)
},
flush(controller) {
console.log('[flush]')
// Close ReadableStream when done
controller.terminate()
}
})
let stream: ReadableStream | null = null
try {
// Read web stream from OpeyClientService
console.log('Calling OpeyClientService.stream')
stream = await this.opeyClientService.stream(user_input, opeyConfig)
} catch (error) {
console.error('Error reading stream: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
if (!stream) {
console.error('Stream is not recieved or not readable')
return response.status(500).json({ error: 'Internal Server Error' })
}
// Transform our stream if needed, right now this is just a passthrough
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
// If we need to split the stream into two, we can use the tee method as below
// const streamTee = langchainStream.tee()
// if (!streamTee) {
// console.error("Stream is not tee'd")
// return response.status(500).json({ error: 'Internal Server Error' })
// }
// const [stream1, stream2] = streamTee
// function to convert a web stream to a node stream
const safeFromWeb = (webStream: WebReadableStream<any>): Readable => {
if (typeof Readable.fromWeb === 'function') {
return Readable.fromWeb(webStream)
} else {
console.warn('Readable.fromWeb is not available, using a polyfill')
// Create a Node.js Readable stream
const nodeReadable = new Readable({
read() {}
})
// Pump data from webreadable to node readable stream
const reader = webStream.getReader()
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
nodeReadable.push(null) // end stream
break
}
nodeReadable.push(value)
}
} catch (error) {
console.error('Error reading from web stream:', error)
nodeReadable.destroy(error instanceof Error ? error : new Error(error))
}
})()
return nodeReadable
}
}
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
response.setHeader('Content-Type', 'text/event-stream')
response.setHeader('Cache-Control', 'no-cache')
response.setHeader('Connection', 'keep-alive')
nodeStream.pipe(response)
return new Promise<Response>((resolve, reject) => {
nodeStream.on('end', () => {
resolve(response)
})
nodeStream.on('error', (error) => {
console.error('Stream error:', error)
reject(error)
})
// Add a timeout to prevent hanging promises
const timeout = setTimeout(() => {
console.warn('Stream timeout reached')
resolve(response)
}, 30000)
// Clear the timeout when stream ends
nodeStream.on('end', () => clearTimeout(timeout))
nodeStream.on('error', () => clearTimeout(timeout))
})
}
@Post('/invoke')
async invokeOpey(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response | any> {
// Check if the consent is in the session, and can be added to the headers
const opeyConfig = session['opeyConfig']
if (!opeyConfig) {
console.error('Opey config not found in session')
return response.status(500).json({ error: 'Internal Server Error' })
}
let user_input: UserInput
try {
user_input = {
message: request.body.message,
thread_id: request.body.thread_id,
is_tool_call_approval: request.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in invoke endpoint, could not parse into UserInput: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
try {
const opey_response = await this.opeyClientService.invoke(user_input, opeyConfig)
//console.log("Opey response: ", opey_response)
return response.status(200).json(opey_response)
} catch (error) {
console.error(error)
return response.status(500).json({ error: 'Internal Server Error' })
}
}
// @Post('/consent/request')
// /**
// * Retrieves a consent request from OBP
// *
// */
// async getConsentRequest(
// @Session() session: any,
// @Req() request: Request,
// @Res() response: Response,
// ): Promise<Response | any> {
// try {
// let obpToken: string
// obpToken = await this.obpClientService.getDirectLoginToken()
// console.log("Got token: ", obpToken)
// const authHeader = `DirectLogin token="${obpToken}"`
// console.log("Auth header: ", authHeader)
// //const obpOAuthHeaders = await this.obpClientService.getOAuthHeader('/consents', 'POST')
// //console.log("OBP OAuth Headers: ", obpOAuthHeaders)
// const obpConfig: Configuration = {
// apiKey: authHeader,
// basePath: process.env.VITE_OBP_API_HOST,
// }
// console.log("OBP Config: ", obpConfig)
// const consentAPI = new ConsentApi(obpConfig, process.env.VITE_OBP_API_HOST)
// // OBP sdk naming is a bit mad, can be rectified in the future
// const consentRequestResponse = await consentAPI.oBPv500CreateConsentRequest({
// accountAccess: [],
// everything: false,
// entitlements: [],
// consumerId: '',
// } as unknown as ConsumerConsentrequestsBody,
// {
// headers: {
// 'Content-Type': 'application/json',
// },
// }
// )
// //console.log("Consent request response: ", consentRequestResponse)
// console.log({consentId: consentRequestResponse.data.consent_request_id})
// session['obpConsentRequestId'] = consentRequestResponse.data.consent_request_id
// return response.status(200).json(JSON.stringify({consentId: consentRequestResponse.data.consent_request_id}))
// //console.log(await response.body.json())
// } catch (error) {
// console.error("Error in consent/request endpoint: ", error);
// return response.status(500).json({ error: 'Internal Server Error' });
// }
// }
@Post('/consent')
/**
* Retrieves a consent from OBP for the current user
*/
async getConsent(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response | any> {
try {
// create consent as logged in user
const opeyConfig = await this.opeyClientService.getOpeyConfig()
session['opeyConfig'] = opeyConfig
// Check if user already has a consent for opey
// If so, return the consent id
const consentId = await this.obpConsentsService.getExistingOpeyConsentId(session)
if (consentId) {
console.log('Existing consent ID: ', consentId)
// If we have a consent id, we can get the consent from OBP
const consent = await this.obpConsentsService.getConsentByConsentId(session, consentId)
return response.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt })
} else {
console.log('No existing consent ID found')
}
// Either here or in this method, we should check if there is already a consent stored in the session
await this.obpConsentsService.createConsent(session)
console.log('Consent at controller: ', session['opeyConfig'])
const authConfig = session['opeyConfig']['authConfig']
return response
.status(200)
.json({ consent_id: authConfig?.obpConsent.consent_id, jwt: authConfig?.obpConsent.jwt })
} catch (error) {
console.error('Error in consent endpoint: ', error)
return response.status(500).json({ error: 'Internal Server Error ' })
}
}
// @Post('/consent/answer-challenge')
// /**
// * Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
// * If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
// * This completes (i.e. is the final step in) the consent flow
// */
// async answerConsentChallenge(
// @Session() session: any,
// @Req() request: Request,
// @Res() response: Response
// ): Promise<Response | any> {
// try {
// const oauthConfig = session['clientConfig']
// const version = this.obpClientService.getOBPVersion()
// const obpConsent = session['obpConsent']
// if (!obpConsent) {
// return response.status(400).json({ message: 'Consent not found in session' });
// } else if (obpConsent.status === 'ACCEPTED') {
// return response.status(400).json({ message: 'Consent already accepted' });
// }
// const answerBody = request.body
// const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
// console.log("Consent JWT: ", consentJWT)
// // store consent JWT in session, return consent JWT 200 OK
// session['obpConsentJWT'] = consentJWT
// return response.status(200).json(true);
// } catch (error) {
// console.error("Error in consent/answer-challenge endpoint: ", error);
// return response.status(500).json({ error: 'Internal Server Error' });
// }
// }
}

View File

@ -1,238 +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 { Controller, Session, Req, Res, Get, Delete, Post, Put } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { Service, Container } from 'typedi'
@Service()
@Controller()
export class OBPController {
private obpClientService: OBPClientService
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Check if access token is expired and refresh it if needed
* This ensures API calls always use a valid token
*/
private async 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 (this.oauth2Service.isTokenExpired(accessToken)) {
console.log('RequestController: Access token expired, attempting refresh')
if (!refreshToken) {
console.log('RequestController: No refresh token available')
return false
}
try {
const newTokens = await this.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
// CRITICAL: Update clientConfig with new access token
if (session['clientConfig'] && session['clientConfig'].oauth2) {
session['clientConfig'].oauth2.accessToken = newTokens.accessToken
console.log('RequestController: Updated clientConfig with refreshed token')
}
console.log('RequestController: Token refresh successful')
return true
} catch (error) {
console.error('RequestController: Token refresh failed:', error)
return false
}
}
// Token is still valid
return true
}
@Get('/get')
async get(@Session() session: any, @Req() request: Request, @Res() response: Response): Response {
const path = request.query.path
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.get(path, oauthConfig)
return response.json(result)
} catch (error: any) {
// 401 errors are expected when user is not authenticated - log as info, not error
if (error.status === 401) {
console.log(
`[RequestController] 401 Unauthorized for path: ${path} (user not authenticated)`
)
} else {
console.error('[RequestController] GET request error:', error)
}
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Post('/create')
async create(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
const data = request.body
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
// Debug logging to diagnose authentication issues
console.log('RequestController.create - Debug Info:')
console.log(' Path:', path)
console.log(' Session exists:', !!session)
console.log(' Session keys:', session ? Object.keys(session) : 'N/A')
console.log(' clientConfig exists:', !!oauthConfig)
console.log(' oauth2 exists:', oauthConfig?.oauth2 ? 'YES' : 'NO')
console.log(' accessToken exists:', oauthConfig?.oauth2?.accessToken ? 'YES' : 'NO')
console.log(' oauth2_user exists:', session?.oauth2_user ? 'YES' : 'NO')
try {
const result = await this.obpClientService.create(path, data, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.create error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Put('/update')
async update(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
const data = request.body
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.update(path, data, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.update error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Delete('/delete')
async discard(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.discard(path, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.delete error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
}

View File

@ -1,214 +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 { Controller, Session, Req, Res, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { Service, Container } from 'typedi'
import { OAuthConfig } from 'obp-typescript'
import { commitId } from '../app.js'
import { OAuth2Service } from '../services/OAuth2Service.js'
import {
RESOURCE_DOCS_API_VERSION,
MESSAGE_DOCS_API_VERSION,
API_VERSIONS_LIST_API_VERSION
} from '../../src/shared-constants.js'
@Service()
@Controller('/status')
export class StatusController {
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
private connectors = [
'akka_vDec2018',
'rest_vMar2019',
'stored_procedure_vDec2019',
'rabbitmq_vOct2024'
]
private obpClientService: OBPClientService
constructor() {
// Explicitly get OBPClientService from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
}
@Get('/')
async index(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const oauthConfig = session['clientConfig']
const version = this.obpClientService.getOBPVersion()
// Check if user is authenticated
const isAuthenticated = oauthConfig && oauthConfig.oauth2?.accessToken
let currentUser = null
let apiVersions = false
let messageDocs = false
let resourceDocs = false
if (isAuthenticated) {
try {
currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
apiVersions = await this.checkApiVersions(oauthConfig, version)
messageDocs = await this.checkMessagDocs(oauthConfig, version)
resourceDocs = await this.checkResourceDocs(oauthConfig, version)
} catch (error) {
console.error('StatusController: Error fetching authenticated data:', error)
}
}
return response.json({
status: apiVersions && messageDocs && resourceDocs,
apiVersions,
messageDocs,
resourceDocs,
currentUser,
isAuthenticated,
commitId
})
}
isCodeError(response: any, path: string): boolean {
console.log(`Validating ${path} response...`)
if (!response || Object.keys(response).length == 0) return true
if (Object.keys(response).includes('code')) {
const code = response['code']
if (code >= 400) {
console.log(response) // Log error responce
return true
}
}
return false
}
async checkResourceDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${version}/obp`
const resourceDocs = await this.obpClientService.get(path, oauthConfig)
return !this.isCodeError(resourceDocs, path)
} catch (error) {
return false
}
}
async checkMessagDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const messageDocsCodeResult = await Promise.all(
this.connectors.map(async (connector) => {
const path = `/obp/${MESSAGE_DOCS_API_VERSION}/message-docs/${connector}`
return !this.isCodeError(await this.obpClientService.get(path, oauthConfig), path)
})
)
return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError)
} catch (error) {
return false
}
}
async checkApiVersions(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const path = `/obp/${API_VERSIONS_LIST_API_VERSION}/api/versions`
const versions = await this.obpClientService.get(path, oauthConfig)
return !this.isCodeError(versions, path)
} catch (error) {
return false
}
}
@Get('/oauth2')
getOAuth2Status(@Res() response: Response): Response {
try {
const oauth2Service = Container.get(OAuth2Service)
const isInitialized = oauth2Service.isInitialized()
const oidcConfig = oauth2Service.getOIDCConfiguration()
const healthCheckActive = oauth2Service.isHealthCheckActive()
const healthCheckAttempts = oauth2Service.getHealthCheckAttempts()
return response.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) {
return response.status(500).json({
available: false,
message: 'Error checking OAuth2 status',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
@Get('/oauth2/reconnect')
async reconnectOAuth2(@Res() response: Response): Promise<Response> {
try {
const oauth2Service = Container.get(OAuth2Service)
if (oauth2Service.isInitialized()) {
return response.json({
success: true,
message: 'OAuth2 is already connected',
alreadyConnected: true
})
}
const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL
if (!wellKnownUrl) {
return response.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!')
return response.json({
success: true,
message: 'OAuth2 reconnection successful',
issuer: oauth2Service.getOIDCConfiguration()?.issuer || null
})
} catch (error) {
console.error('Manual OAuth2 reconnection failed:', error)
return response.status(500).json({
success: false,
message: 'OAuth2 reconnection failed',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
}

View File

@ -1,190 +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 { Controller, Session, Req, Res, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
@Service()
@Controller('/user')
export class UserController {
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
private obpClientService: OBPClientService
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.oauth2Service = Container.get(OAuth2Service)
}
@Get('/logoff')
async logout(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
console.log('UserController: Logging out user')
// Clear OAuth2 session data
delete session['oauth2_access_token']
delete session['oauth2_refresh_token']
delete session['oauth2_id_token']
delete session['oauth2_token_type']
delete session['oauth2_expires_in']
delete session['oauth2_token_timestamp']
delete session['oauth2_user_info']
delete session['oauth2_user']
delete session['clientConfig']
delete session['opeyConfig']
// Destroy the session completely
session.destroy((err: any) => {
if (err) {
console.error('UserController: Error destroying session:', err)
} else {
console.log('UserController: Session destroyed successfully')
}
})
const redirectPage = (request.query.redirect as string) || this.obpExplorerHome || '/'
if (!this.obpExplorerHome) {
console.error(`VITE_OBP_API_EXPLORER_HOST: ${this.obpExplorerHome}`)
}
console.log('UserController: Redirecting to:', redirectPage)
response.redirect(redirectPage)
return response
}
@Get('/current')
async current(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
console.log('UserController: Getting current user')
// Check OAuth2 session
if (session['oauth2_user']) {
console.log('UserController: Returning OAuth2 user info')
const oauth2User = session['oauth2_user']
// Check if access token is expired and needs refresh
const accessToken = session['oauth2_access_token']
const refreshToken = session['oauth2_refresh_token']
if (accessToken && this.oauth2Service.isTokenExpired(accessToken)) {
console.log('UserController: Access token expired')
if (refreshToken) {
console.log('UserController: Attempting token refresh')
try {
const newTokens = await this.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
// CRITICAL: Update clientConfig with new access token
// This ensures subsequent API calls use the refreshed token
if (session['clientConfig'] && session['clientConfig'].oauth2) {
session['clientConfig'].oauth2.accessToken = newTokens.accessToken
console.log('UserController: Updated clientConfig with new access token')
}
console.log('UserController: Token refresh successful')
} catch (error) {
console.error('UserController: Token refresh failed:', error)
// Return empty object to indicate user needs to re-authenticate
return response.json({})
}
} else {
console.log('UserController: No refresh token available, user needs to re-authenticate')
return response.json({})
}
}
// Get actual user ID from OBP-API
let obpUserId = oauth2User.sub // Default to sub if OBP call fails
const clientConfig = session['clientConfig']
if (clientConfig && clientConfig.oauth2?.accessToken) {
try {
// Always use v5.1.0 for application infrastructure - stable and debuggable
const version = DEFAULT_OBP_API_VERSION
console.log('UserController: Fetching OBP user from /obp/' + version + '/users/current')
const obpUser = await this.obpClientService.get(
`/obp/${version}/users/current`,
clientConfig
)
if (obpUser && obpUser.user_id) {
obpUserId = obpUser.user_id
console.log('UserController: Got OBP user ID:', obpUserId, '(was:', oauth2User.sub, ')')
} else {
console.warn('UserController: OBP user response has no user_id:', obpUser)
}
} catch (error: any) {
console.warn(
'UserController: Could not fetch OBP user ID, using token sub:',
oauth2User.sub
)
console.warn('UserController: Error details:', error.message)
}
} else {
console.warn(
'UserController: No valid clientConfig or access token, using token sub:',
oauth2User.sub
)
}
// Return user info in format compatible with frontend
return response.json({
user_id: obpUserId,
username: oauth2User.username,
email: oauth2User.email,
email_verified: oauth2User.email_verified,
name: oauth2User.name,
given_name: oauth2User.given_name,
family_name: oauth2User.family_name,
provider: oauth2User.provider || 'oauth2'
})
}
// No authentication session found
console.log('UserController: No authentication session found')
return response.json({})
}
}

View File

@ -1,158 +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 { ExpressMiddlewareInterface } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { PKCEUtils } from '../utils/pkce.js'
/**
* OAuth2 Authorization Middleware
*
* Initiates the OAuth2/OIDC authorization code flow with PKCE.
* This middleware:
* 1. Generates PKCE code verifier and challenge
* 2. Generates state parameter for CSRF protection
* 3. Stores these values in the session
* 4. Redirects the user to the OIDC provider's authorization endpoint
*
* Flow:
* User /oauth2/connect This Middleware OIDC Authorization Endpoint
*
* @see OAuth2CallbackMiddleware for the callback handling
*
* @example
* // Usage in controller:
* @UseBefore(OAuth2AuthorizationMiddleware)
* export class OAuth2ConnectController {
* @Get('/oauth2/connect')
* connect(@Req() request: Request, @Res() response: Response): Response {
* return response
* }
* }
*/
@Service()
export default class OAuth2AuthorizationMiddleware implements ExpressMiddlewareInterface {
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get OAuth2Service from the container to avoid injection issues
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Handle the authorization request
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object
*/
async use(request: Request, response: Response): Promise<void> {
console.log('OAuth2AuthorizationMiddleware: Starting OAuth2 authorization flow')
// Check if OAuth2 service exists and is initialized
if (!this.oauth2Service) {
console.error('OAuth2AuthorizationMiddleware: OAuth2 service is null/undefined')
return response
.status(500)
.send('OAuth2 service not available. Please check server configuration.')
}
if (!this.oauth2Service.isInitialized()) {
console.error('OAuth2AuthorizationMiddleware: OAuth2 service not initialized')
return response
.status(500)
.send(
'OAuth2 service not initialized. Please check server configuration and OIDC provider availability.'
)
}
const session = request.session
const redirectPage = request.query.redirect
// Store redirect page in session for post-authentication redirect
if (redirectPage && typeof redirectPage === 'string') {
session['oauth2_redirect_page'] = redirectPage
console.log('OAuth2AuthorizationMiddleware: Will redirect to:', redirectPage)
} else {
// Default redirect to explorer home
session['oauth2_redirect_page'] = process.env.VITE_OBP_API_EXPLORER_HOST || '/'
}
try {
// Generate PKCE parameters
const codeVerifier = PKCEUtils.generateCodeVerifier()
const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier)
const state = PKCEUtils.generateState()
// Validate generated values
if (!PKCEUtils.isValidCodeVerifier(codeVerifier)) {
throw new Error('Generated code verifier is invalid')
}
if (!PKCEUtils.isValidState(state)) {
throw new Error('Generated state parameter is invalid')
}
// Store PKCE and state parameters in session for callback validation
session['oauth2_state'] = state
session['oauth2_code_verifier'] = codeVerifier
session['oauth2_flow_timestamp'] = Date.now()
console.log('OAuth2AuthorizationMiddleware: PKCE parameters generated')
console.log(' Code verifier length:', codeVerifier.length)
console.log(' Code challenge length:', codeChallenge.length)
console.log(' State:', state.substring(0, 10) + '...')
// Create authorization URL with OIDC scopes
const scopes = ['openid', 'profile', 'email']
const authUrl = this.oauth2Service.createAuthorizationURL(state, scopes)
// Add PKCE challenge to authorization URL
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
console.log('OAuth2AuthorizationMiddleware: Authorization URL created')
console.log(' URL:', authUrl.toString())
console.log(' Scopes:', scopes.join(' '))
console.log(' PKCE method: S256')
// Redirect user to OIDC provider
console.log('OAuth2AuthorizationMiddleware: Redirecting to OIDC provider...')
response.redirect(authUrl.toString())
} catch (error: any) {
console.error('OAuth2AuthorizationMiddleware: Error creating authorization URL:', error)
// Clean up session data on error
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
delete session['oauth2_redirect_page']
return response.status(500).send(`Failed to initiate OAuth2 flow: ${error.message}`)
}
}
}

View File

@ -1,425 +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 { ExpressMiddlewareInterface } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
import jwt from 'jsonwebtoken'
/**
* OAuth2 Callback Middleware
*
* Handles the OAuth2/OIDC callback after user authentication.
* This middleware:
* 1. Validates the state parameter (CSRF protection)
* 2. Retrieves the PKCE code verifier from session
* 3. Exchanges the authorization code for tokens
* 4. Fetches user information from the UserInfo endpoint
* 5. Stores tokens and user info in the session
* 6. Redirects the user back to the original page
*
* Flow:
* OIDC Provider /oauth2/callback?code=XXX&state=YYY This Middleware Original Page
*
* @see OAuth2AuthorizationMiddleware for the authorization initiation
*
* @example
* // Usage in controller:
* @UseBefore(OAuth2CallbackMiddleware)
* export class OAuth2CallbackController {
* @Get('/oauth2/callback')
* callback(@Req() request: Request, @Res() response: Response): Response {
* return response
* }
* }
*/
@Service()
export default class OAuth2CallbackMiddleware implements ExpressMiddlewareInterface {
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get OAuth2Service from the container to avoid injection issues
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Handle the OAuth2 callback
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object
*/
async use(request: Request, response: Response): Promise<void> {
console.log('OAuth2CallbackMiddleware: Processing OAuth2 callback')
const session = request.session
const code = request.query.code as string
const state = request.query.state as string
const error = request.query.error as string
const errorDescription = request.query.error_description as string
// Check for OAuth2 errors from provider
if (error) {
console.error('OAuth2CallbackMiddleware: OAuth2 error from provider:', error)
console.error(' Description:', errorDescription || 'No description provided')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Authentication Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Authentication Error</h1>
<p><strong>Error:</strong> ${this.escapeHtml(error)}</p>
${errorDescription ? `<p><strong>Description:</strong> ${this.escapeHtml(errorDescription)}</p>` : ''}
<p>Authentication failed. Please try again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Validate required parameters
if (!code || !state) {
console.error('OAuth2CallbackMiddleware: Missing code or state parameter')
console.error(' Code present:', !!code)
console.error(' State present:', !!state)
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Invalid Request</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Invalid Callback Request</h1>
<p>The authorization callback is missing required parameters.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Validate state parameter (CSRF protection)
const storedState = session['oauth2_state']
if (!state || state !== storedState) {
console.error('OAuth2CallbackMiddleware: State validation failed')
console.error(' Received state:', state?.substring(0, 10) + '...')
console.error(' Expected state:', storedState?.substring(0, 10) + '...')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Security Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Security Validation Failed</h1>
<p>The state parameter validation failed. This could indicate a CSRF attack.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Get code verifier from session
const codeVerifier = session['oauth2_code_verifier']
if (!codeVerifier) {
console.error('OAuth2CallbackMiddleware: Code verifier not found in session')
console.error(' This could indicate session timeout or invalid session state')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Session Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Session Error</h1>
<p>Your session has expired or is invalid.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Check flow timestamp (prevent replay attacks)
const flowTimestamp = session['oauth2_flow_timestamp']
if (flowTimestamp) {
const flowAge = Date.now() - flowTimestamp
const maxFlowAge = 10 * 60 * 1000 // 10 minutes
if (flowAge > maxFlowAge) {
console.error('OAuth2CallbackMiddleware: Authorization flow expired')
console.error(' Flow age:', Math.floor(flowAge / 1000), 'seconds')
console.error(' Max age:', Math.floor(maxFlowAge / 1000), 'seconds')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Flow Expired</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Authorization Flow Expired</h1>
<p>The authorization flow has expired (timeout: 10 minutes).</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
}
try {
console.log('OAuth2CallbackMiddleware: Exchanging authorization code for tokens')
// Exchange authorization code for tokens
const tokens = await this.oauth2Service.exchangeCodeForTokens(code, codeVerifier)
console.log('OAuth2CallbackMiddleware: Tokens received successfully')
console.log(' Access token present:', !!tokens.accessToken)
console.log(' Refresh token present:', !!tokens.refreshToken)
console.log(' ID token present:', !!tokens.idToken)
// Get user info from UserInfo endpoint
console.log('OAuth2CallbackMiddleware: Fetching user info')
const userInfo = await this.oauth2Service.getUserInfo(tokens.accessToken)
// Debug: Decode access token to see what user ID OBP-API will see
try {
const accessTokenDecoded: any = jwt.decode(tokens.accessToken)
console.log('\n\n========================================')
console.log('🔍 ACCESS TOKEN DECODED - THIS IS WHAT OBP-API SEES')
console.log('========================================')
console.log(' sub (user ID):', accessTokenDecoded?.sub)
console.log(' email:', accessTokenDecoded?.email)
console.log(' preferred_username:', accessTokenDecoded?.preferred_username)
console.log(' Full payload:', JSON.stringify(accessTokenDecoded, null, 2))
console.log('========================================\n\n')
} catch (error) {
console.warn('OAuth2CallbackMiddleware: Failed to decode access token:', error)
}
// Store tokens in session
session['oauth2_access_token'] = tokens.accessToken
session['oauth2_refresh_token'] = tokens.refreshToken || null
session['oauth2_id_token'] = tokens.idToken || null
session['oauth2_token_type'] = tokens.tokenType
session['oauth2_expires_in'] = tokens.expiresIn
session['oauth2_token_timestamp'] = Date.now()
// Store user info
session['oauth2_user_info'] = userInfo
// Decode ID token for additional user data
let idTokenPayload: any = null
if (tokens.idToken) {
try {
idTokenPayload = this.oauth2Service.decodeIdToken(tokens.idToken)
} catch (error) {
console.warn('OAuth2CallbackMiddleware: Failed to decode ID token:', error)
}
}
// Create unified user object combining UserInfo and ID token data
const user = {
sub: userInfo.sub,
email: userInfo.email || idTokenPayload?.email,
email_verified: userInfo.email_verified || idTokenPayload?.email_verified,
name: userInfo.name || idTokenPayload?.name,
given_name: userInfo.given_name || idTokenPayload?.given_name,
family_name: userInfo.family_name || idTokenPayload?.family_name,
preferred_username: userInfo.preferred_username || idTokenPayload?.preferred_username,
username: userInfo.preferred_username || userInfo.email || userInfo.sub,
picture: userInfo.picture || idTokenPayload?.picture,
provider: 'oauth2'
}
session['oauth2_user'] = user
// Create clientConfig for OBP API calls with OAuth2 Bearer token
// This allows OBPClientService to work with OAuth2 authentication
// Store session data for authenticated requests
// Always use v5.1.0 for application infrastructure - stable and debuggable
session['clientConfig'] = {
baseUri: process.env.VITE_OBP_API_HOST || 'http://localhost:8080',
version: DEFAULT_OBP_API_VERSION,
oauth2: {
accessToken: tokens.accessToken,
tokenType: tokens.tokenType || 'Bearer'
}
}
console.log('OAuth2CallbackMiddleware: User authenticated successfully')
console.log(' User ID (sub):', user.sub)
console.log(' Username:', user.username)
console.log(' Email:', user.email)
console.log(' Name:', user.name)
console.log('OAuth2CallbackMiddleware: Created clientConfig for OBP API calls')
// Clear OAuth2 flow parameters (keep tokens and user data)
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
// Get redirect page and clean up
const redirectPage =
(session['oauth2_redirect_page'] as string) || process.env.VITE_OBP_API_EXPLORER_HOST || '/'
delete session['oauth2_redirect_page']
console.log('OAuth2CallbackMiddleware: Redirecting to:', redirectPage)
console.log('OAuth2CallbackMiddleware: Authentication flow complete')
// Redirect to original page
response.redirect(redirectPage)
} catch (error: any) {
console.error('OAuth2CallbackMiddleware: Token exchange or user info failed:', error)
console.error(' Error message:', error.message)
console.error(' Error stack:', error.stack)
this.cleanupSession(session)
return response.status(500).send(`
<html>
<head>
<title>Authentication Failed</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
p { margin: 10px 0; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
code { background-color: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<div class="error">
<h1>Authentication Failed</h1>
<p>Failed to complete authentication with the identity provider.</p>
<p><strong>Error:</strong> <code>${this.escapeHtml(error.message)}</code></p>
<p>Please try logging in again. If the problem persists, contact support.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
}
/**
* Clean up OAuth2 session data
*
* @param {any} session - Express session object
*/
private cleanupSession(session: any): void {
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
delete session['oauth2_redirect_page']
delete session['oauth2_access_token']
delete session['oauth2_refresh_token']
delete session['oauth2_id_token']
delete session['oauth2_token_type']
delete session['oauth2_expires_in']
delete session['oauth2_token_timestamp']
delete session['oauth2_user_info']
delete session['oauth2_user']
}
/**
* Escape HTML to prevent XSS
*
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
private escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (m) => map[m])
}
}

285
server/routes/oauth2.ts Normal file
View File

@ -0,0 +1,285 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2025, 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 { Router } from 'express'
import type { Request, Response } from 'express'
import { Container } from 'typedi'
import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js'
import { PKCEUtils } from '../utils/pkce.js'
import type { UserInfo } from '../types/oauth2.js'
const router = Router()
// Get services from container
const providerManager = Container.get(OAuth2ProviderManager)
/**
* GET /oauth2/providers
* Get list of available OAuth2 providers
*/
router.get('/oauth2/providers', async (req: Request, res: Response) => {
try {
const availableProviders = providerManager.getAvailableProviders()
const providerList = availableProviders.map((name) => {
const providerStatus = providerManager.getProviderStatus(name)
return {
name,
status: providerStatus?.available ? 'healthy' : 'unhealthy',
available: providerStatus?.available || false
}
})
res.json({ providers: providerList })
} catch (error) {
console.error('Error fetching providers:', error)
res.status(500).json({ error: 'Failed to fetch providers' })
}
})
/**
* GET /oauth2/connect
* Initiate OAuth2 authentication flow
* Query params:
* - provider: Provider name (required)
* - redirect: URL to redirect after auth (optional)
*/
router.get('/oauth2/connect', async (req: Request, res: Response) => {
try {
const provider = req.query.provider as string | undefined
const redirect = (req.query.redirect as string) || '/'
const session = req.session as any
console.log('OAuth2 Connect: Starting authentication flow')
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
// Generate PKCE parameters
const codeVerifier = PKCEUtils.generateCodeVerifier()
const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier)
const state = PKCEUtils.generateState()
// Store in session
session.oauth2_code_verifier = codeVerifier
session.oauth2_state = state
console.log(`OAuth2 Connect: Using provider - ${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 authUrl = `${authEndpoint}?${params.toString()}`
// Save session before redirect
session.save((err: any) => {
if (err) {
console.error('OAuth2 Connect: Failed to save session:', err)
return res.status(500).json({ error: 'session_error' })
}
console.log('OAuth2 Connect: Redirecting to authorization endpoint')
res.redirect(authUrl)
})
} catch (error) {
console.error('OAuth2 Connect: Error:', error)
res.status(500).json({
error: 'authentication_failed',
message: error instanceof Error ? error.message : 'Unknown error'
})
}
})
/**
* GET /oauth2/callback
* Handle OAuth2 callback after user authentication
* Query params:
* - code: Authorization code
* - state: State parameter for CSRF validation
* - error: Error code (if auth failed)
* - error_description: Error description
*/
router.get('/oauth2/callback', async (req: Request, res: Response) => {
try {
const code = req.query.code as string
const state = req.query.state as string
const error = req.query.error as string
const errorDescription = req.query.error_description as string
const session = req.session as any
console.log('OAuth2 Callback: Processing callback')
// Handle error from provider
if (error) {
console.error(`OAuth2 Callback: Error from provider: ${error}`)
console.error(`OAuth2 Callback: Description: ${errorDescription || 'N/A'}`)
return res.redirect(`/?oauth2_error=${encodeURIComponent(error)}`)
}
// Validate required parameters
if (!code) {
console.error('OAuth2 Callback: Missing authorization code')
return res.redirect('/?oauth2_error=missing_code')
}
if (!state) {
console.error('OAuth2 Callback: Missing state parameter')
return res.redirect('/?oauth2_error=missing_state')
}
// Validate state (CSRF protection)
const storedState = session.oauth2_state
if (!storedState || storedState !== state) {
console.error('OAuth2 Callback: State mismatch (CSRF protection)')
return res.redirect('/?oauth2_error=invalid_state')
}
// Get code verifier from session (PKCE)
const codeVerifier = session.oauth2_code_verifier
if (!codeVerifier) {
console.error('OAuth2 Callback: Code verifier not found in session')
return res.redirect('/?oauth2_error=missing_verifier')
}
// Get provider from session
const provider = session.oauth2_provider
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
session.oauth2_id_token = tokens.idToken
console.log('OAuth2 Callback: Tokens received and stored')
// Store user in session (using oauth2_user key to match UserController)
session.oauth2_user = {
username: userInfo.preferred_username || userInfo.email || userInfo.sub,
email: userInfo.email,
email_verified: userInfo.email_verified || false,
name: userInfo.name,
given_name: userInfo.given_name,
family_name: userInfo.family_name,
provider: provider || 'obp-oidc',
sub: userInfo.sub
}
// Also store clientConfig for OBP API calls
session.clientConfig = {
oauth2: {
accessToken: tokens.accessToken,
tokenType: 'Bearer'
}
}
console.log(
`OAuth2 Callback: User authenticated: ${session.oauth2_user.username} via ${session.oauth2_user.provider}`
)
// Clean up temporary session data
delete session.oauth2_code_verifier
delete session.oauth2_state
// Redirect to original page
const redirectUrl = session.oauth2_redirect_page || '/'
delete session.oauth2_redirect_page
console.log(`OAuth2 Callback: Authentication successful, redirecting to: ${redirectUrl}`)
res.redirect(redirectUrl)
} catch (error) {
console.error('OAuth2 Callback: Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.redirect(`/?oauth2_error=token_exchange_failed&details=${encodeURIComponent(errorMessage)}`)
}
})
export default router

160
server/routes/obp.ts Normal file
View File

@ -0,0 +1,160 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2025, 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 { Router } from 'express'
import type { Request, Response } from 'express'
import { Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
const router = Router()
// Get services from container
const obpClientService = Container.get(OBPClientService)
/**
* Check if user is authenticated
* TODO: Implement token refresh in multi-provider system
*/
function isAuthenticated(session: any): boolean {
return !!session.oauth2_access_token && !!session.oauth2_user
}
/**
* GET /get
* Proxy GET requests to OBP API
* Query params:
* - path: OBP API path to call (e.g., /obp/v5.1.0/banks)
*/
router.get('/get', async (req: Request, res: Response) => {
try {
const path = req.query.path as string
const session = req.session as any
const oauthConfig = session.clientConfig
const result = await obpClientService.get(path, oauthConfig)
res.json(result)
} catch (error: any) {
// 401 errors are expected when user is not authenticated - log as info, not error
if (error.status === 401) {
console.log(`OBP: 401 Unauthorized for path: ${req.query.path} (user not authenticated)`)
} else {
console.error('OBP: GET request error:', error)
}
res.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
})
/**
* POST /create
* Proxy POST requests to OBP API
* Query params:
* - path: OBP API path to call
* Body: JSON data to send to OBP API
*/
router.post('/create', async (req: Request, res: Response) => {
try {
const path = req.query.path as string
const data = req.body
const session = req.session as any
const oauthConfig = session.clientConfig
// Debug logging to diagnose authentication issues
console.log('OBP.create - Debug Info:')
console.log(' Path:', path)
console.log(' Session exists:', !!session)
console.log(' clientConfig exists:', !!oauthConfig)
console.log(' oauth2 exists:', oauthConfig?.oauth2 ? 'YES' : 'NO')
console.log(' accessToken exists:', oauthConfig?.oauth2?.accessToken ? 'YES' : 'NO')
console.log(' oauth2_user exists:', session?.oauth2_user ? 'YES' : 'NO')
const result = await obpClientService.create(path, data, oauthConfig)
res.json(result)
} catch (error: any) {
console.error('OBP.create error:', error)
res.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
})
/**
* PUT /update
* Proxy PUT requests to OBP API
* Query params:
* - path: OBP API path to call
* Body: JSON data to send to OBP API
*/
router.put('/update', async (req: Request, res: Response) => {
try {
const path = req.query.path as string
const data = req.body
const session = req.session as any
const oauthConfig = session.clientConfig
const result = await obpClientService.update(path, data, oauthConfig)
res.json(result)
} catch (error: any) {
console.error('OBP.update error:', error)
res.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
})
/**
* DELETE /delete
* Proxy DELETE requests to OBP API
* Query params:
* - path: OBP API path to call
*/
router.delete('/delete', async (req: Request, res: Response) => {
try {
const path = req.query.path as string
const session = req.session as any
const oauthConfig = session.clientConfig
const result = await obpClientService.discard(path, oauthConfig)
res.json(result)
} catch (error: any) {
console.error('OBP.delete error:', error)
res.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
})
export default router

280
server/routes/opey.ts Normal file
View File

@ -0,0 +1,280 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2025, 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 { Router } from 'express'
import type { Request, Response } from 'express'
import { Readable } from 'node:stream'
import { ReadableStream as WebReadableStream } from 'stream/web'
import { Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
import OpeyClientService from '../services/OpeyClientService.js'
import OBPConsentsService from '../services/OBPConsentsService.js'
import { UserInput } from '../schema/OpeySchema.js'
const router = Router()
// Get services from container
const obpClientService = Container.get(OBPClientService)
const opeyClientService = Container.get(OpeyClientService)
const obpConsentsService = Container.get(OBPConsentsService)
/**
* Helper function to convert web stream to Node.js stream
*/
function safeFromWeb(webStream: WebReadableStream<any>): Readable {
if (typeof Readable.fromWeb === 'function') {
return Readable.fromWeb(webStream)
} else {
console.warn('Readable.fromWeb is not available, using a polyfill')
// Create a Node.js Readable stream
const nodeReadable = new Readable({
read() {}
})
// Pump data from webreadable to node readable stream
const reader = webStream.getReader()
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
nodeReadable.push(null) // end stream
break
}
nodeReadable.push(value)
}
} catch (error) {
console.error('Error reading from web stream:', error)
nodeReadable.destroy(error instanceof Error ? error : new Error(String(error)))
}
})()
return nodeReadable
}
}
/**
* GET /opey
* Check Opey chatbot status
*/
router.get('/opey', async (req: Request, res: Response) => {
try {
const opeyStatus = await opeyClientService.getOpeyStatus()
console.log('Opey status: ', opeyStatus)
res.status(200).json({ status: 'Opey is running' })
} catch (error) {
console.error('Error in /opey endpoint: ', error)
res.status(500).json({ error: 'Internal Server Error' })
}
})
/**
* POST /opey/stream
* Stream chatbot responses
* Body: { message, thread_id, is_tool_call_approval }
*/
router.post('/opey/stream', async (req: Request, res: Response) => {
try {
const session = req.session as any
if (!session) {
console.error('Session not found')
return res.status(401).json({ error: 'Session Time Out' })
}
// Check if the consent is in the session
const opeyConfig = session.opeyConfig
if (!opeyConfig) {
console.error('Opey config not found in session')
return res.status(500).json({ error: 'Internal Server Error' })
}
// Read user input from request body
let user_input: UserInput
try {
console.log('Request body: ', req.body)
user_input = {
message: req.body.message,
thread_id: req.body.thread_id,
is_tool_call_approval: req.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in stream endpoint, could not parse into UserInput: ', error)
return res.status(500).json({ error: 'Internal Server Error' })
}
// Transform to decode and log the stream
const frontendTransformer = new TransformStream({
transform(chunk, controller) {
// Decode the chunk to a string
const decodedChunk = new TextDecoder().decode(chunk)
console.log('Sending chunk', decodedChunk)
controller.enqueue(decodedChunk)
},
flush(controller) {
console.log('[flush]')
// Close ReadableStream when done
controller.terminate()
}
})
let stream: ReadableStream | null = null
try {
// Read web stream from OpeyClientService
console.log('Calling OpeyClientService.stream')
stream = await opeyClientService.stream(user_input, opeyConfig)
} catch (error) {
console.error('Error reading stream: ', error)
return res.status(500).json({ error: 'Internal Server Error' })
}
if (!stream) {
console.error('Stream is not received or not readable')
return res.status(500).json({ error: 'Internal Server Error' })
}
// Transform our stream
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
nodeStream.pipe(res)
// Handle stream completion
nodeStream.on('end', () => {
console.log('Stream ended successfully')
})
nodeStream.on('error', (error) => {
console.error('Stream error:', error)
})
// Add a timeout to prevent hanging
const timeout = setTimeout(() => {
console.warn('Stream timeout reached')
nodeStream.destroy()
}, 30000)
// Clear the timeout when stream ends
nodeStream.on('end', () => clearTimeout(timeout))
nodeStream.on('error', () => clearTimeout(timeout))
} catch (error) {
console.error('Error in /opey/stream:', error)
if (!res.headersSent) {
res.status(500).json({ error: 'Internal Server Error' })
}
}
})
/**
* POST /opey/invoke
* Invoke chatbot without streaming
* Body: { message, thread_id, is_tool_call_approval }
*/
router.post('/opey/invoke', async (req: Request, res: Response) => {
try {
const session = req.session as any
// Check if the consent is in the session
const opeyConfig = session.opeyConfig
if (!opeyConfig) {
console.error('Opey config not found in session')
return res.status(500).json({ error: 'Internal Server Error' })
}
let user_input: UserInput
try {
user_input = {
message: req.body.message,
thread_id: req.body.thread_id,
is_tool_call_approval: req.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in invoke endpoint, could not parse into UserInput: ', error)
return res.status(500).json({ error: 'Internal Server Error' })
}
const opey_response = await opeyClientService.invoke(user_input, opeyConfig)
res.status(200).json(opey_response)
} catch (error) {
console.error('Error in /opey/invoke:', error)
res.status(500).json({ error: 'Internal Server Error' })
}
})
/**
* POST /opey/consent
* Retrieve or create a consent for Opey to access OBP on user's behalf
*/
router.post('/opey/consent', async (req: Request, res: Response) => {
try {
const session = req.session as any
// Create consent as logged in user
const opeyConfig = await opeyClientService.getOpeyConfig()
session.opeyConfig = opeyConfig
// Check if user already has a consent for opey
const consentId = await obpConsentsService.getExistingOpeyConsentId(session)
if (consentId) {
console.log('Existing consent ID: ', consentId)
// If we have a consent id, we can get the consent from OBP
const consent = await obpConsentsService.getConsentByConsentId(session, consentId)
return res.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt })
} else {
console.log('No existing consent ID found')
}
await obpConsentsService.createConsent(session)
console.log('Consent at controller: ', session.opeyConfig)
const authConfig = session.opeyConfig?.authConfig
res.status(200).json({
consent_id: authConfig?.obpConsent.consent_id,
jwt: authConfig?.obpConsent.jwt
})
} catch (error) {
console.error('Error in /opey/consent endpoint: ', error)
res.status(500).json({ error: 'Internal Server Error' })
}
})
export default router

221
server/routes/status.ts Normal file
View File

@ -0,0 +1,221 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2025, 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 { Router } from 'express'
import type { Request, Response } from 'express'
import { Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js'
import { commitId } from '../app.js'
import {
RESOURCE_DOCS_API_VERSION,
MESSAGE_DOCS_API_VERSION,
API_VERSIONS_LIST_API_VERSION
} from '../../src/shared-constants.js'
const router = Router()
// Get services from container
const obpClientService = Container.get(OBPClientService)
const providerManager = Container.get(OAuth2ProviderManager)
const connectors = [
'akka_vDec2018',
'rest_vMar2019',
'stored_procedure_vDec2019',
'rabbitmq_vOct2024'
]
/**
* Helper function to check if response contains an error
*/
function isCodeError(response: any, path: string): boolean {
console.log(`Validating ${path} response...`)
if (!response || Object.keys(response).length === 0) return true
if (Object.keys(response).includes('code')) {
const code = response['code']
if (code >= 400) {
console.log(response) // Log error response
return true
}
}
return false
}
/**
* Check if resource docs are accessible
*/
async function checkResourceDocs(oauthConfig: any, version: string): Promise<boolean> {
try {
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${version}/obp`
const resourceDocs = await obpClientService.get(path, oauthConfig)
return !isCodeError(resourceDocs, path)
} catch (error) {
return false
}
}
/**
* Check if message docs are accessible
*/
async function checkMessageDocs(oauthConfig: any, version: string): Promise<boolean> {
try {
const messageDocsCodeResult = await Promise.all(
connectors.map(async (connector) => {
const path = `/obp/${MESSAGE_DOCS_API_VERSION}/message-docs/${connector}`
return !isCodeError(await obpClientService.get(path, oauthConfig), path)
})
)
return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError)
} catch (error) {
return false
}
}
/**
* Check if API versions are accessible
*/
async function checkApiVersions(oauthConfig: any, version: string): Promise<boolean> {
try {
const path = `/obp/${API_VERSIONS_LIST_API_VERSION}/api/versions`
const versions = await obpClientService.get(path, oauthConfig)
return !isCodeError(versions, path)
} catch (error) {
return false
}
}
/**
* GET /status
* Get application status and health checks
*/
router.get('/status', async (req: Request, res: Response) => {
try {
const session = req.session as any
const oauthConfig = session.clientConfig
const version = obpClientService.getOBPVersion()
// Check if user is authenticated
const isAuthenticated = oauthConfig && oauthConfig.oauth2?.accessToken
let currentUser = null
let apiVersions = false
let messageDocs = false
let resourceDocs = false
if (isAuthenticated) {
try {
currentUser = await obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
apiVersions = await checkApiVersions(oauthConfig, version)
messageDocs = await checkMessageDocs(oauthConfig, version)
resourceDocs = await checkResourceDocs(oauthConfig, version)
} catch (error) {
console.error('Status: Error fetching authenticated data:', error)
}
}
res.json({
status: apiVersions && messageDocs && resourceDocs,
apiVersions,
messageDocs,
resourceDocs,
currentUser,
isAuthenticated,
commitId
})
} catch (error) {
console.error('Status: Error getting status:', error)
res.status(500).json({
status: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
})
/**
* GET /status/providers
* Get configured OAuth2 providers (for debugging)
* Shows provider configuration with masked credentials
*/
router.get('/status/providers', (req: Request, res: Response) => {
try {
// Helper function to mask sensitive data (show first 2 and last 2 chars)
const maskCredential = (value: string | undefined): string => {
if (!value || value.length < 6) {
return value ? '***masked***' : 'not configured'
}
return `${value.substring(0, 2)}...${value.substring(value.length - 2)}`
}
// Get providers from manager
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_OIDC_CLIENT_ID)
},
keycloak: {
clientId: maskCredential(process.env.VITE_KEYCLOAK_CLIENT_ID)
},
google: {
clientId: maskCredential(process.env.VITE_GOOGLE_CLIENT_ID)
},
github: {
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)
}
}
res.json({
summary: {
totalConfigured: availableProviders.length,
availableProviders: availableProviders,
obpApiHost: process.env.VITE_OBP_API_HOST || 'not configured',
sharedRedirectUrl: sharedRedirectUrl
},
providerStatus: allProviderStatus,
environmentConfig: envConfig,
note: 'Credentials are masked for security. Format: first2...last2'
})
} catch (error) {
console.error('Status: Error getting provider status:', error)
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error'
})
}
})
export default router

139
server/routes/user.ts Normal file
View File

@ -0,0 +1,139 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2025, 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 { Router } from 'express'
import type { Request, Response } from 'express'
import { Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
const router = Router()
// Get services from container
const obpClientService = Container.get(OBPClientService)
const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
/**
* GET /user/current
* Get current logged in user information
*/
router.get('/user/current', async (req: Request, res: Response) => {
try {
console.log('User: Getting current user')
const session = req.session as any
// Check OAuth2 session
if (!session.oauth2_user) {
console.log('User: No authentication session found')
return res.json({})
}
console.log('User: Returning OAuth2 user info')
const oauth2User = session.oauth2_user
// 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
const clientConfig = session.clientConfig
if (clientConfig && clientConfig.oauth2?.accessToken) {
try {
const version = DEFAULT_OBP_API_VERSION
console.log('User: Fetching OBP user from /obp/' + version + '/users/current')
const obpUser = await obpClientService.get(`/obp/${version}/users/current`, clientConfig)
if (obpUser && obpUser.user_id) {
obpUserId = obpUser.user_id
console.log('User: Got OBP user ID:', obpUserId, '(was:', oauth2User.sub, ')')
} else {
console.warn('User: OBP user response has no user_id:', obpUser)
}
} catch (error: any) {
console.warn('User: Could not fetch OBP user ID, using token sub:', oauth2User.sub)
console.warn('User: Error details:', error.message)
}
} else {
console.warn('User: No valid clientConfig or access token, using token sub:', oauth2User.sub)
}
// Return user info in format compatible with frontend
res.json({
user_id: obpUserId,
username: oauth2User.username,
email: oauth2User.email,
email_verified: oauth2User.email_verified,
name: oauth2User.name,
given_name: oauth2User.given_name,
family_name: oauth2User.family_name,
provider: oauth2User.provider || 'oauth2'
})
} catch (error) {
console.error('User: Error getting current user:', error)
res.json({})
}
})
/**
* GET /user/logoff
* Logout user and clear session
* Query params:
* - redirect: URL to redirect to after logout (optional)
*/
router.get('/user/logoff', (req: Request, res: Response) => {
console.log('User: Logging out user')
const session = req.session as any
// Clear OAuth2 session data
delete session.oauth2_access_token
delete session.oauth2_refresh_token
delete session.oauth2_id_token
delete session.oauth2_token_type
delete session.oauth2_expires_in
delete session.oauth2_token_timestamp
delete session.oauth2_user_info
delete session.oauth2_user
delete session.oauth2_provider
delete session.clientConfig
delete session.opeyConfig
// Destroy the session completely
session.destroy((err: any) => {
if (err) {
console.error('User: Error destroying session:', err)
} else {
console.log('User: Session destroyed successfully')
}
const redirectPage = (req.query.redirect as string) || obpExplorerHome || '/'
console.log('User: Redirecting to:', redirectPage)
res.redirect(redirectPage)
})
})
export default router

View File

@ -0,0 +1,302 @@
/*
* 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, OAuth2Tokens } from 'arctic'
import type { OIDCConfiguration, TokenResponse } from '../types/oauth2.js'
/**
* Extended OAuth2 Client with OIDC configuration support
*
* This class extends the arctic OAuth2Client to add:
* - OIDC discovery document (.well-known/openid-configuration)
* - Provider name tracking
* - Provider-specific token exchange logic
*
* @example
* const client = new OAuth2ClientWithConfig(
* 'client-id',
* 'client-secret',
* 'http://localhost:5173/api/oauth2/callback',
* 'obp-oidc'
* )
* await client.initOIDCConfig('http://localhost:9000/obp-oidc/.well-known/openid-configuration')
*/
export class OAuth2ClientWithConfig extends OAuth2Client {
public OIDCConfig?: OIDCConfiguration
public provider: string
public wellKnownUri?: string
private _clientSecret: string
private _redirectUri: string
constructor(clientId: string, clientSecret: string, redirectUri: string, provider: string) {
super(clientId, clientSecret, redirectUri)
this.provider = provider
this._clientSecret = clientSecret
this._redirectUri = redirectUri
}
/**
* Initialize OIDC configuration from well-known discovery endpoint
*
* @param oidcConfigUrl - Full URL to .well-known/openid-configuration
* @throws {Error} If the discovery document cannot be fetched or is invalid
*
* @example
* await client.initOIDCConfig('http://localhost:9000/obp-oidc/.well-known/openid-configuration')
*/
async initOIDCConfig(wellKnownUrl: string): Promise<void> {
console.log(
`OAuth2ClientWithConfig: Fetching OIDC config for ${this.provider} from: ${wellKnownUrl}`
)
// Store the well-known URL for health checks
this.wellKnownUri = wellKnownUrl
try {
const response = await fetch(wellKnownUrl)
if (!response.ok) {
throw new Error(
`Failed to fetch OIDC configuration for ${this.provider}: ${response.status} ${response.statusText}`
)
}
const config = (await response.json()) as OIDCConfiguration
// Validate required endpoints
if (!config.authorization_endpoint) {
throw new Error(`OIDC configuration for ${this.provider} missing authorization_endpoint`)
}
if (!config.token_endpoint) {
throw new Error(`OIDC configuration for ${this.provider} missing token_endpoint`)
}
if (!config.userinfo_endpoint) {
throw new Error(`OIDC configuration for ${this.provider} missing userinfo_endpoint`)
}
this.OIDCConfig = config
console.log(`OAuth2ClientWithConfig: OIDC config loaded for ${this.provider}`)
console.log(` Issuer: ${config.issuer}`)
console.log(` Authorization: ${config.authorization_endpoint}`)
console.log(` Token: ${config.token_endpoint}`)
console.log(` UserInfo: ${config.userinfo_endpoint}`)
// Log supported PKCE methods if available
if (config.code_challenge_methods_supported) {
console.log(` PKCE methods: ${config.code_challenge_methods_supported.join(', ')}`)
}
} catch (error) {
console.error(`OAuth2ClientWithConfig: Failed to initialize ${this.provider}:`, error)
throw error
}
}
/**
* Get authorization endpoint from OIDC config
*
* @returns Authorization endpoint URL
* @throws {Error} If OIDC configuration not initialized
*/
getAuthorizationEndpoint(): string {
if (!this.OIDCConfig?.authorization_endpoint) {
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
}
return this.OIDCConfig.authorization_endpoint
}
/**
* Get token endpoint from OIDC config
*
* @returns Token endpoint URL
* @throws {Error} If OIDC configuration not initialized
*/
getTokenEndpoint(): string {
if (!this.OIDCConfig?.token_endpoint) {
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
}
return this.OIDCConfig.token_endpoint
}
/**
* Get userinfo endpoint from OIDC config
*
* @returns UserInfo endpoint URL
* @throws {Error} If OIDC configuration not initialized
*/
getUserInfoEndpoint(): string {
if (!this.OIDCConfig?.userinfo_endpoint) {
throw new Error(`OIDC configuration not initialized for ${this.provider}`)
}
return this.OIDCConfig.userinfo_endpoint
}
/**
* Check if OIDC configuration is initialized
*
* @returns True if OIDC config has been loaded
*/
isInitialized(): boolean {
return this.OIDCConfig !== undefined
}
/**
* Exchange authorization code for tokens
*
* This method provides a simpler interface for token exchange
*
* @param code - Authorization code from OIDC provider
* @param codeVerifier - PKCE code verifier
* @returns Token response with access token, refresh token, and ID token
*/
async exchangeAuthorizationCode(code: string, codeVerifier: string): Promise<TokenResponse> {
const tokenEndpoint = this.getTokenEndpoint()
console.log(`OAuth2ClientWithConfig: Exchanging authorization code for ${this.provider}`)
// Prepare token request body
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this._redirectUri,
code_verifier: codeVerifier,
client_id: this.clientId
})
// Add client_secret to body (some providers prefer this over Basic Auth)
if (this._clientSecret) {
body.append('client_secret', this._clientSecret)
}
try {
// Try with Basic Authentication first (RFC 6749 standard)
const authHeader = Buffer.from(`${this.clientId}:${this._clientSecret}`).toString('base64')
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
Authorization: `Basic ${authHeader}`
},
body: body.toString()
})
if (!response.ok) {
const errorData = await response.text()
throw new Error(
`Token exchange failed for ${this.provider}: ${response.status} ${response.statusText} - ${errorData}`
)
}
const data = await response.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
idToken: data.id_token,
tokenType: data.token_type || 'Bearer',
expiresIn: data.expires_in,
scope: data.scope
}
} catch (error) {
console.error(`OAuth2ClientWithConfig: Token exchange error for ${this.provider}:`, error)
throw error
}
}
/**
* Refresh access token using refresh token
*
* @param refreshToken - Refresh token from previous authentication
* @returns New token response
*/
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
const tokenEndpoint = this.getTokenEndpoint()
console.log(`OAuth2ClientWithConfig: Refreshing access token for ${this.provider}`)
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId
})
if (this._clientSecret) {
body.append('client_secret', this._clientSecret)
}
try {
const authHeader = Buffer.from(`${this.clientId}:${this._clientSecret}`).toString('base64')
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
Authorization: `Basic ${authHeader}`
},
body: body.toString()
})
if (!response.ok) {
const errorData = await response.text()
throw new Error(
`Token refresh failed for ${this.provider}: ${response.status} ${response.statusText} - ${errorData}`
)
}
const data = await response.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken, // Some providers don't return new refresh token
idToken: data.id_token,
tokenType: data.token_type || 'Bearer',
expiresIn: data.expires_in,
scope: data.scope
}
} catch (error) {
console.error(`OAuth2ClientWithConfig: Token refresh error for ${this.provider}:`, error)
throw error
}
}
/**
* Get the redirect URI
*/
getRedirectUri(): string {
return this._redirectUri
}
/**
* Get the client secret
*/
getClientSecret(): string {
return this._clientSecret
}
}

View File

@ -0,0 +1,238 @@
/*
* 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 { Service } from 'typedi'
import { OAuth2ClientWithConfig } from './OAuth2ClientWithConfig.js'
import type { WellKnownUri, ProviderStrategy } from '../types/oauth2.js'
/**
* Factory for creating OAuth2 clients for different OIDC providers
*
* Uses the Strategy pattern to handle provider-specific configurations:
* - OBP-OIDC
* - Keycloak
* - Google
* - GitHub
* - Custom providers
*
* Configuration is loaded from environment variables.
*
* @example
* const factory = Container.get(OAuth2ProviderFactory)
* const client = await factory.initializeProvider({
* provider: 'obp-oidc',
* url: 'http://localhost:9000/obp-oidc/.well-known/openid-configuration'
* })
*/
@Service()
export class OAuth2ProviderFactory {
private strategies: Map<string, ProviderStrategy> = new Map()
constructor() {
this.loadStrategies()
}
/**
* Load provider strategies from environment variables
*
* Each provider requires:
* - VITE_[PROVIDER]_CLIENT_ID
* - VITE_[PROVIDER]_CLIENT_SECRET
* - 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_OIDC_CLIENT_ID) {
this.strategies.set('obp-oidc', {
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')
}
// Keycloak Strategy
if (process.env.VITE_KEYCLOAK_CLIENT_ID) {
this.strategies.set('keycloak', {
clientId: process.env.VITE_KEYCLOAK_CLIENT_ID,
clientSecret: process.env.VITE_KEYCLOAK_CLIENT_SECRET || '',
redirectUri: sharedRedirectUri,
scopes: ['openid', 'profile', 'email']
})
console.log(' OK Keycloak strategy loaded')
}
// Google Strategy
if (process.env.VITE_GOOGLE_CLIENT_ID) {
this.strategies.set('google', {
clientId: process.env.VITE_GOOGLE_CLIENT_ID,
clientSecret: process.env.VITE_GOOGLE_CLIENT_SECRET || '',
redirectUri: sharedRedirectUri,
scopes: ['openid', 'profile', 'email']
})
console.log(' OK Google strategy loaded')
}
// GitHub Strategy
if (process.env.VITE_GITHUB_CLIENT_ID) {
this.strategies.set('github', {
clientId: process.env.VITE_GITHUB_CLIENT_ID,
clientSecret: process.env.VITE_GITHUB_CLIENT_SECRET || '',
redirectUri: sharedRedirectUri,
scopes: ['read:user', 'user:email']
})
console.log(' OK GitHub strategy loaded')
}
// Generic OIDC Strategy (for custom providers)
if (process.env.VITE_CUSTOM_OIDC_CLIENT_ID) {
const providerName = process.env.VITE_CUSTOM_OIDC_PROVIDER_NAME || 'custom-oidc'
this.strategies.set(providerName, {
clientId: process.env.VITE_CUSTOM_OIDC_CLIENT_ID,
clientSecret: process.env.VITE_CUSTOM_OIDC_CLIENT_SECRET || '',
redirectUri: sharedRedirectUri,
scopes: ['openid', 'profile', 'email']
})
console.log(` OK Custom OIDC strategy loaded: ${providerName}`)
}
console.log(`OAuth2ProviderFactory: Loaded ${this.strategies.size} provider strategies`)
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_OIDC_CLIENT_ID, VITE_OBP_OIDC_CLIENT_SECRET'
)
}
}
/**
* Initialize an OAuth2 client for a specific provider
*
* @param wellKnownUri - Provider information from OBP API
* @returns Initialized OAuth2 client or null if no strategy exists or initialization fails
*
* @example
* const client = await factory.initializeProvider({
* provider: 'obp-oidc',
* url: 'http://localhost:9000/obp-oidc/.well-known/openid-configuration'
* })
*/
async initializeProvider(wellKnownUri: WellKnownUri): Promise<OAuth2ClientWithConfig | null> {
console.log(`OAuth2ProviderFactory: Initializing provider: ${wellKnownUri.provider}`)
const strategy = this.strategies.get(wellKnownUri.provider)
if (!strategy) {
console.warn(
`OAuth2ProviderFactory: No strategy found for provider: ${wellKnownUri.provider}`
)
console.warn(
`OAuth2ProviderFactory: Available strategies: ${Array.from(this.strategies.keys()).join(', ')}`
)
return null
}
// Validate strategy configuration
if (!strategy.clientId) {
console.error(
`OAuth2ProviderFactory: Missing clientId for provider: ${wellKnownUri.provider}`
)
return null
}
if (!strategy.clientSecret) {
console.warn(
`OAuth2ProviderFactory: Missing clientSecret for provider: ${wellKnownUri.provider}`
)
console.warn(`OAuth2ProviderFactory: Some providers require a client secret`)
}
try {
const client = new OAuth2ClientWithConfig(
strategy.clientId,
strategy.clientSecret,
strategy.redirectUri,
wellKnownUri.provider
)
// Initialize OIDC configuration from discovery endpoint
await client.initOIDCConfig(wellKnownUri.url)
console.log(`OAuth2ProviderFactory: Successfully initialized ${wellKnownUri.provider}`)
return client
} catch (error) {
console.error(`OAuth2ProviderFactory: Failed to initialize ${wellKnownUri.provider}:`, error)
return null
}
}
/**
* Get list of configured provider names
*
* @returns Array of provider names that have strategies configured
*/
getConfiguredProviders(): string[] {
return Array.from(this.strategies.keys())
}
/**
* Check if a provider strategy exists
*
* @param providerName - Name of the provider to check
* @returns True if strategy exists for this provider
*/
hasStrategy(providerName: string): boolean {
return this.strategies.has(providerName)
}
/**
* Get strategy for a specific provider (for debugging/testing)
*
* @param providerName - Name of the provider
* @returns Provider strategy or undefined if not found
*/
getStrategy(providerName: string): ProviderStrategy | undefined {
return this.strategies.get(providerName)
}
/**
* Get count of configured strategies
*
* @returns Number of provider strategies loaded
*/
getStrategyCount(): number {
return this.strategies.size
}
}

View File

@ -0,0 +1,493 @@
/*
* 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 { Service, Container } from 'typedi'
import { OAuth2ProviderFactory } from './OAuth2ProviderFactory.js'
import { OAuth2ClientWithConfig } from './OAuth2ClientWithConfig.js'
import OBPClientService from './OBPClientService.js'
import type { WellKnownUri, WellKnownResponse, ProviderStatus } from '../types/oauth2.js'
/**
* Manager for multiple OAuth2/OIDC providers
*
* Responsibilities:
* - Fetch available OIDC providers from OBP API
* - Initialize OAuth2 clients for each provider
* - Track provider health status
* - Perform periodic health checks
* - Provide access to provider clients
*
* The manager automatically:
* - Retries failed provider initializations
* - Monitors provider availability (60s intervals by default)
* - Updates provider status in real-time
*
* @example
* const manager = Container.get(OAuth2ProviderManager)
* await manager.initializeProviders()
* const client = manager.getProvider('obp-oidc')
*/
@Service()
export class OAuth2ProviderManager {
private providers: Map<string, OAuth2ClientWithConfig> = new Map()
private providerStatus: Map<string, ProviderStatus> = new Map()
private healthCheckInterval: NodeJS.Timeout | null = null
private retryInterval: NodeJS.Timeout | null = null
private factory: OAuth2ProviderFactory
private obpClientService: OBPClientService
private initialized: boolean = false
constructor() {
this.factory = Container.get(OAuth2ProviderFactory)
this.obpClientService = Container.get(OBPClientService)
}
/**
* Fetch well-known URIs from OBP API or legacy env variable
*
* Priority:
* 1. VITE_OBP_OAUTH2_WELL_KNOWN_URL (legacy single-provider mode)
* 2. VITE_OBP_API_HOST/obp/v5.1.0/well-known (multi-provider mode)
*
* @returns Array of well-known URIs with provider names
*/
async fetchWellKnownUris(): Promise<WellKnownUri[]> {
// Check for legacy single-provider configuration
const legacyWellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL
if (legacyWellKnownUrl) {
console.log('OAuth2ProviderManager: Using legacy VITE_OBP_OAUTH2_WELL_KNOWN_URL...')
console.log(`OAuth2ProviderManager: Well-known URL: ${legacyWellKnownUrl}`)
// Return single provider configuration
return [
{
provider: 'obp-oidc',
url: legacyWellKnownUrl
}
]
}
// Multi-provider mode: fetch from OBP API
console.log('OAuth2ProviderManager: Fetching well-known URIs from OBP API...')
try {
// Use OBPClientService to call the API
const response = await this.obpClientService.get('/obp/v5.1.0/well-known', null)
if (!response.well_known_uris || response.well_known_uris.length === 0) {
console.warn('OAuth2ProviderManager: No well-known URIs found in OBP API response')
return []
}
console.log(`OAuth2ProviderManager: Found ${response.well_known_uris.length} providers:`)
response.well_known_uris.forEach((uri: WellKnownUri) => {
console.log(` - ${uri.provider}: ${uri.url}`)
})
return response.well_known_uris
} catch (error) {
console.error('OAuth2ProviderManager: Failed to fetch well-known URIs:', error)
console.warn('OAuth2ProviderManager: Falling back to no providers')
return []
}
}
/**
* Initialize all OAuth2 providers from OBP API
*
* This method:
* 1. Fetches well-known URIs from OBP API
* 2. Initializes OAuth2 client for each provider
* 3. Tracks successful and failed initializations
* 4. Returns success status
*
* @returns True if at least one provider was initialized successfully
*/
async initializeProviders(): Promise<boolean> {
console.log('OAuth2ProviderManager: Initializing providers...')
const wellKnownUris = await this.fetchWellKnownUris()
if (wellKnownUris.length === 0) {
console.warn('OAuth2ProviderManager: No providers to initialize')
console.warn(
'OAuth2ProviderManager: Check that OBP API is running and /obp/v5.1.0/well-known endpoint is available'
)
console.log('OAuth2ProviderManager: Will retry fetching providers every 30 seconds...')
this.startRetryInterval()
return false
}
let successCount = 0
for (const providerUri of wellKnownUris) {
try {
const client = await this.factory.initializeProvider(providerUri)
if (client && client.isInitialized()) {
this.providers.set(providerUri.provider, client)
this.providerStatus.set(providerUri.provider, {
name: providerUri.provider,
available: true,
lastChecked: new Date()
})
successCount++
console.log(`OAuth2ProviderManager: OK ${providerUri.provider} initialized`)
} else {
this.providerStatus.set(providerUri.provider, {
name: providerUri.provider,
available: false,
lastChecked: new Date(),
error: 'Failed to initialize client'
})
console.warn(`OAuth2ProviderManager: ERROR ${providerUri.provider} failed to initialize`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
this.providerStatus.set(providerUri.provider, {
name: providerUri.provider,
available: false,
lastChecked: new Date(),
error: errorMessage
})
console.error(`OAuth2ProviderManager: ERROR ${providerUri.provider} error:`, error)
}
}
this.initialized = successCount > 0
console.log(
`OAuth2ProviderManager: Initialized ${successCount}/${wellKnownUris.length} providers`
)
if (successCount === 0) {
console.error('OAuth2ProviderManager: ERROR - No providers were successfully initialized')
console.error(
'OAuth2ProviderManager: Users will not be able to log in until at least one provider is available'
)
console.log('OAuth2ProviderManager: Will retry initialization every 30 seconds...')
this.startRetryInterval()
} else if (successCount < wellKnownUris.length) {
// Some providers failed - retry only the failed ones
console.log(
`OAuth2ProviderManager: ${wellKnownUris.length - successCount} provider(s) failed, will retry every 30 seconds...`
)
this.startRetryInterval()
}
return this.initialized
}
/**
* Start periodic health checks for all providers
*
* @param intervalMs - Health check interval in milliseconds (default: 60000 = 1 minute)
*/
startHealthCheck(intervalMs: number = 60000): void {
if (this.healthCheckInterval) {
console.log('OAuth2ProviderManager: Health check already running')
return
}
console.log(
`OAuth2ProviderManager: Starting health check (every ${intervalMs / 1000}s = ${intervalMs / 60000} minute(s))`
)
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck()
}, intervalMs)
}
/**
* Stop periodic health checks
*/
stopHealthCheck(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval)
this.healthCheckInterval = null
console.log('OAuth2ProviderManager: Health check stopped')
}
}
/**
* Start periodic retry for failed providers
*
* @param intervalMs - Retry interval in milliseconds (default: 30000 = 30 seconds)
*/
startRetryInterval(intervalMs: number = 30000): void {
if (this.retryInterval) {
console.log('OAuth2ProviderManager: Retry interval already running')
return
}
console.log(`OAuth2ProviderManager: Starting retry interval (every ${intervalMs / 1000}s)`)
this.retryInterval = setInterval(async () => {
await this.retryFailedProviders()
}, intervalMs)
}
/**
* Stop periodic retry interval
*/
stopRetryInterval(): void {
if (this.retryInterval) {
clearInterval(this.retryInterval)
this.retryInterval = null
console.log('OAuth2ProviderManager: Retry interval stopped')
}
}
/**
* Retry all failed providers
*/
private async retryFailedProviders(): Promise<void> {
const failedProviders: string[] = []
this.providerStatus.forEach((status, name) => {
if (!status.available) {
failedProviders.push(name)
}
})
// Also check if we have no providers at all (initial fetch may have failed)
if (this.providerStatus.size === 0) {
console.log(
'OAuth2ProviderManager: No providers initialized yet, attempting full initialization...'
)
// Temporarily stop retry to prevent duplicate calls
this.stopRetryInterval()
const success = await this.initializeProviders()
if (!success) {
// Restart retry if initialization failed
this.startRetryInterval()
}
return
}
if (failedProviders.length === 0) {
console.log('OAuth2ProviderManager: All providers healthy, stopping retry interval')
this.stopRetryInterval()
return
}
console.log(`OAuth2ProviderManager: Retrying ${failedProviders.length} failed provider(s)...`)
for (const providerName of failedProviders) {
const success = await this.retryProvider(providerName)
if (success) {
console.log(`OAuth2ProviderManager: Successfully recovered provider: ${providerName}`)
}
}
// Check if all providers are now healthy
const stillFailed = Array.from(this.providerStatus.values()).filter((s) => !s.available)
if (stillFailed.length === 0) {
console.log('OAuth2ProviderManager: All providers recovered, stopping retry interval')
this.stopRetryInterval()
}
}
/**
* Perform health check on all providers
*
* This checks if each provider's issuer endpoint is reachable
*/
private async performHealthCheck(): Promise<void> {
console.log('OAuth2ProviderManager: Performing health check...')
const checkPromises: Promise<void>[] = []
this.providers.forEach((client, providerName) => {
checkPromises.push(this.checkProviderHealth(providerName, client))
})
await Promise.allSettled(checkPromises)
}
/**
* Check health of a single provider
*
* @param providerName - Name of the provider
* @param client - OAuth2 client for the provider
*/
private async checkProviderHealth(
providerName: string,
client: OAuth2ClientWithConfig
): Promise<void> {
try {
// Try to fetch OIDC well-known endpoint to verify provider is reachable
const wellKnownUrl = client.wellKnownUri
if (!wellKnownUrl) {
throw new Error('No well-known URL configured')
}
console.log(` Checking ${providerName} at: ${wellKnownUrl}`)
// Use HEAD request as per HTTP standards - all endpoints supporting GET should support HEAD
const response = await fetch(wellKnownUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000) // 5 second timeout
})
const isAvailable = response.ok
this.providerStatus.set(providerName, {
name: providerName,
available: isAvailable,
lastChecked: new Date(),
error: isAvailable ? undefined : `HTTP ${response.status}`
})
console.log(` ${providerName}: ${isAvailable ? 'healthy' : 'unhealthy'}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
this.providerStatus.set(providerName, {
name: providerName,
available: false,
lastChecked: new Date(),
error: errorMessage
})
console.log(` ${providerName}: unhealthy (${errorMessage})`)
}
}
/**
* Get OAuth2 client for a specific provider
*
* @param providerName - Provider name (e.g., "obp-oidc", "keycloak")
* @returns OAuth2 client or undefined if not found
*/
getProvider(providerName: string): OAuth2ClientWithConfig | undefined {
return this.providers.get(providerName)
}
/**
* Get list of all available (initialized and healthy) provider names
*
* @returns Array of available provider names
*/
getAvailableProviders(): string[] {
const available: string[] = []
this.providerStatus.forEach((status, name) => {
if (status.available && this.providers.has(name)) {
available.push(name)
}
})
return available
}
/**
* Get status for all providers
*
* @returns Array of provider status objects
*/
getAllProviderStatus(): ProviderStatus[] {
return Array.from(this.providerStatus.values())
}
/**
* Get status for a specific provider
*
* @param providerName - Provider name
* @returns Provider status or undefined if not found
*/
getProviderStatus(providerName: string): ProviderStatus | undefined {
return this.providerStatus.get(providerName)
}
/**
* Check if the manager has been initialized
*
* @returns True if at least one provider was successfully initialized
*/
isInitialized(): boolean {
return this.initialized
}
/**
* Get count of initialized providers
*
* @returns Number of providers in the map
*/
getProviderCount(): number {
return this.providers.size
}
/**
* Get count of available (healthy) providers
*
* @returns Number of providers that are currently available
*/
getAvailableProviderCount(): number {
return this.getAvailableProviders().length
}
/**
* Manually retry initialization for a failed provider
*
* @param providerName - Provider name to retry
* @returns True if initialization succeeded
*/
async retryProvider(providerName: string): Promise<boolean> {
console.log(`OAuth2ProviderManager: Retrying initialization for ${providerName}`)
try {
// Fetch well-known URIs again to get latest configuration
const wellKnownUris = await this.fetchWellKnownUris()
const providerUri = wellKnownUris.find((uri) => uri.provider === providerName)
if (!providerUri) {
console.error(`OAuth2ProviderManager: Provider ${providerName} not found in OBP API`)
return false
}
const client = await this.factory.initializeProvider(providerUri)
if (client && client.isInitialized()) {
this.providers.set(providerName, client)
this.providerStatus.set(providerName, {
name: providerName,
available: true,
lastChecked: new Date()
})
console.log(`OAuth2ProviderManager: OK ${providerName} retry successful`)
return true
} else {
console.error(`OAuth2ProviderManager: ERROR ${providerName} retry failed`)
return false
}
} catch (error) {
console.error(`OAuth2ProviderManager: Error retrying ${providerName}:`, error)
return false
}
}
}

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

@ -1,234 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { OpeyController } from "../controllers/OpeyIIController.js";
import OpeyClientService from '../services/OpeyClientService.js';
import OBPClientService from '../services/OBPClientService.js';
import OBPConsentsService from '../services/OBPConsentsService.js';
import Stream, { Readable } from 'stream';
import { Request, Response } from 'express';
import httpMocks from 'node-mocks-http'
import { EventEmitter } from 'events';
import { InlineResponse2017 } from 'obp-api-typescript';
vi.mock("../../server/services/OpeyClientService", () => {
return {
default: vi.fn().mockImplementation(() => {
return {
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
const readableStream = new Stream.Readable();
for (let i=0; i<10; i++) {
readableStream.push(`Chunk ${i}`);
}
return readableStream as NodeJS.ReadableStream;
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
}
}),
};
});
describe('OpeyController', () => {
let MockOpeyClientService: OpeyClientService
let opeyController: OpeyController
// Mock the OpeyClientService class
const { mockClear } = getMockRes()
beforeEach(() => {
mockClear()
})
beforeAll(() => {
vi.clearAllMocks();
MockOpeyClientService = {
authConfig: {},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
const mockMessageChunks = mockAsisstantMessage.split(" ")
for (let i = 0; i < mockMessageChunks.length; i++) {
// Don't add whitespace to the last chunk
if (i === mockMessageChunks.length - 1 ) {
mockMessageChunks[i] = `${mockMessageChunks[i]}`
break
}
mockMessageChunks[i] = `${mockMessageChunks[i]} `
}
// Return the fake the token stream
return new ReadableStream<Uint8Array>({
start(controller) {
for (let i = 0; i < mockMessageChunks.length; i++) {
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
}
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
controller.close();
},
});
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
} as unknown as OpeyClientService
// Instantiate OpeyController with the mocked OpeyClientService
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService)
})
it('getStatus', async () => {
const res = httpMocks.createResponse();
await opeyController.getStatus(res)
expect(MockOpeyClientService.getOpeyStatus).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
})
it('streamOpey', async () => {
const _eventEmitter = new EventEmitter();
_eventEmitter.addListener('data', () => {
console.log('Data received')
})
// The default event emitter does nothing, so replace
const res = await httpMocks.createResponse({
eventEmitter: EventEmitter,
writableStream: Stream.Writable
});
// Mock request and response objects to pass to express controller
const req = {
body: {
message: 'Hello Opey',
thread_id: '123',
is_tool_call_approval: false
}
} as unknown as Request;
const response = await opeyController.streamOpey({}, req, res)
// Get the stream from the response
const stream = response.body
let chunks: any[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream complete');
context.status = 'ready';
break;
}
}
} catch (error) {
console.error(error)
}
await expect(chunks.length).toBe(10);
await expect(MockOpeyClientService.stream).toHaveBeenCalled();
await expect(res).toBeDefined();
})
})
describe('OpeyController consents', () => {
let mockOBPClientService: OBPClientService
let opeyController: OpeyController
beforeAll(() => {
mockOBPClientService = {
get: vi.fn(async () => {
Promise.resolve({})
})
} as unknown as OBPClientService
const MockOpeyClientService = {
authConfig: {},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
async function * generator() {
for (let i=0; i<10; i++) {
yield `Chunk ${i}`;
}
}
const readableStream = Stream.Readable.from(generator());
return readableStream as NodeJS.ReadableStream;
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
} as unknown as OpeyClientService
const MockOBPConsentsService = {
createConsent: vi.fn(async () => {
return {
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"status": "INITIATED",
} as InlineResponse2017
})
} as unknown as OBPConsentsService
// Instantiate OpeyController with the mocked OpeyClientService
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return 200 and consent ID when consent is created at OBP', async () => {
const req = getMockReq()
const session = {}
const { res } = getMockRes()
await opeyController.getConsent(session, req, res)
expect(res.status).toHaveBeenCalledWith(200)
// Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test
expect(res.json).toHaveBeenCalledWith({
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
})
// Expect that the consent object was saved in the session
expect(session).toHaveProperty('obpConsent')
expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
expect(session['obpConsent']).toHaveProperty('status', "INITIATED")
})
})

130
server/types/oauth2.ts Normal file
View File

@ -0,0 +1,130 @@
/*
* 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/)
*
*/
/**
* Well-known URI from OBP API /obp/v[version]/well-known endpoint
*/
export interface WellKnownUri {
provider: string // e.g., "obp-oidc", "keycloak"
url: string // e.g., "http://localhost:9000/obp-oidc/.well-known/openid-configuration"
}
/**
* Response from OBP API well-known endpoint
*/
export interface WellKnownResponse {
well_known_uris: WellKnownUri[]
}
/**
* Provider configuration strategy
*/
export interface ProviderStrategy {
clientId: string
clientSecret: string
redirectUri: string
scopes?: string[]
}
/**
* Provider status information
*/
export interface ProviderStatus {
name: string
available: boolean
lastChecked: Date
error?: string
}
/**
* 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
}

View File

@ -80,27 +80,90 @@ const logo = ref(logoSource)
const headerLinksHoverColor = ref(headerLinksHoverColorSetting)
const headerLinksBackgroundColor = ref(headerLinksBackgroundColorSetting)
// Check OAuth2 availability
let oauth2CheckInterval: number | null = null
// Multi-provider support
const availableProviders = ref<Array<{ name: string; available: boolean; lastChecked?: Date; error?: string }>>([])
const showProviderSelector = ref(false)
const isLoadingProviders = ref(false)
async function checkOAuth2Availability() {
// OAuth2 availability is determined by provider availability
// No separate status check needed
// Fetch available OIDC providers
async function fetchAvailableProviders() {
isLoadingProviders.value = true
try {
const response = await fetch('/api/status/oauth2')
const response = await fetch('/api/oauth2/providers')
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!')
if (data.providers && Array.isArray(data.providers)) {
availableProviders.value = data.providers
console.log('Available OAuth2 providers:', availableProviders.value)
console.log(`Total: ${data.count}, Available: ${data.availableCount}`)
} else {
console.warn('No providers returned from /api/oauth2/providers')
availableProviders.value = []
}
} catch (error) {
oauth2Available.value = false
oauth2StatusMessage.value = 'Failed to check OAuth2 status'
console.error('Error checking OAuth2 status:', error)
console.error('Failed to fetch OAuth2 providers:', error)
availableProviders.value = []
} finally {
isLoadingProviders.value = false
}
}
// Handle login button click
function handleLoginClick() {
const available = availableProviders.value.filter(p => p.available)
if (available.length > 1) {
// Show provider selection dialog
showProviderSelector.value = true
} else if (available.length === 1) {
// Direct login with single provider
loginWithProvider(available[0].name)
} else {
// No providers available
console.error('No OAuth2 providers available. Check backend configuration.')
alert('Login is not available. Please check that OAuth2 providers are configured.')
}
}
// Login with selected provider
function loginWithProvider(provider: string) {
const redirectUrl = '/api/oauth2/connect?provider=' +
encodeURIComponent(provider) +
'&redirect=' +
encodeURIComponent(getCurrentPath())
console.log(`Logging in with provider: ${provider}`)
window.location.href = redirectUrl
}
// Format provider name for display
function formatProviderName(name: string): string {
// Convert "obp-oidc" to "OBP OIDC", "keycloak" to "Keycloak", etc.
return name.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Get provider icon
function getProviderIcon(name: string): string {
const icons: Record<string, string> = {
'obp-oidc': '🏦',
'keycloak': '🔐',
'google': '🔵',
'github': '🐙'
}
return icons[name] || '🔑'
}
// Copy text to clipboard
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
console.log('Error message copied to clipboard')
} catch (err) {
console.error('Failed to copy text to clipboard:', err)
}
}
@ -151,12 +214,8 @@ const handleMore = (command: string) => {
}
onMounted(async () => {
// Initial OAuth2 availability check
await checkOAuth2Availability()
// 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
// Fetch available providers
await fetchAvailableProviders()
const currentUser = await getCurrentUser()
const currentResponseKeys = Object.keys(currentUser)
@ -171,11 +230,7 @@ onMounted(async () => {
})
onUnmounted(() => {
// Clean up polling interval
if (oauth2CheckInterval) {
clearInterval(oauth2CheckInterval)
oauth2CheckInterval = null
}
// Cleanup hook
})
watchEffect(() => {
@ -249,15 +304,105 @@ const getCurrentPath = () => {
{{ $t('header.login') }}
</button>
</el-tooltip>
<a v-else-if="isShowLoginButton && oauth2Available" v-bind:href="'/api/oauth2/connect?redirect='+ encodeURIComponent(getCurrentPath())" class="login-button router-link" id="login">
<button
v-else-if="isShowLoginButton && oauth2Available"
@click="handleLoginClick"
class="login-button router-link"
id="login"
>
{{ $t('header.login') }}
</a>
</button>
<span v-show="isShowLogOffButton" class="login-user">{{ loginUsername }}</span>
<a v-bind:href="'/api/user/logoff?redirect=' + encodeURIComponent(getCurrentPath())" v-show="isShowLogOffButton" class="logoff-button router-link" id="logoff">
{{ $t('header.logoff') }}
</a>
</RouterView>
</nav>
<!-- Provider Selection Dialog -->
<el-dialog
v-model="showProviderSelector"
title="Login"
width="500px"
:close-on-click-modal="true"
>
<!-- No providers available -->
<div v-if="availableProviders.filter(p => p.available).length === 0" class="no-providers-error">
<p class="error-message">No authentication providers available.</p>
<p class="error-hint">Please contact your administrator.</p>
<!-- Show unavailable providers even when no available providers -->
<div v-if="availableProviders.filter(p => !p.available).length > 0" class="unavailable-section">
<p class="unavailable-header">Currently unavailable:</p>
<div
v-for="provider in availableProviders.filter(p => !p.available)"
:key="provider.name"
class="provider-unavailable"
>
<div class="provider-unavailable-header">
<span class="provider-status-indicator offline"></span>
<span class="provider-name">{{ formatProviderName(provider.name) }}</span>
<span class="unavailable-label">Unavailable</span>
</div>
<div v-if="provider.error" class="provider-error">
<div class="provider-error-text">{{ provider.error }}</div>
<button
@click.stop="copyToClipboard(provider.error)"
class="copy-button"
title="Copy error message"
>
📋
</button>
</div>
</div>
</div>
</div>
<!-- Available providers -->
<div v-else class="provider-selection">
<p class="selection-hint">Choose your authentication provider:</p>
<div class="available-providers">
<button
v-for="provider in availableProviders.filter(p => p.available)"
:key="provider.name"
class="provider-button"
@click="loginWithProvider(provider.name); showProviderSelector = false"
>
<span class="provider-button-content">
<span class="provider-status-indicator online"></span>
<span class="provider-button-text">{{ formatProviderName(provider.name) }}</span>
</span>
</button>
</div>
<!-- Unavailable providers section -->
<div v-if="availableProviders.filter(p => !p.available).length > 0" class="unavailable-section">
<p class="unavailable-header">Currently unavailable:</p>
<div
v-for="provider in availableProviders.filter(p => !p.available)"
:key="provider.name"
class="provider-unavailable"
>
<div class="provider-unavailable-header">
<span class="provider-status-indicator offline"></span>
<span class="provider-name">{{ formatProviderName(provider.name) }}</span>
<span class="unavailable-label">Unavailable</span>
</div>
<div v-if="provider.error" class="provider-error">
<div class="provider-error-text">{{ provider.error }}</div>
<button
@click.stop="copyToClipboard(provider.error)"
class="copy-button"
title="Copy error message"
>
📋
</button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<style>
@ -320,11 +465,13 @@ nav {
}
a.login-button,
a.logoff-button {
a.logoff-button,
button.login-button {
margin: 5px;
color: #ffffff;
background-color: #32b9ce;
cursor: pointer;
border: none;
}
button.login-button-disabled {
@ -351,4 +498,167 @@ button.login-button-disabled {
display: inline-block;
vertical-align: middle;
}
/* Provider Selection Dialog */
.provider-selection {
display: flex;
flex-direction: column;
gap: 16px;
}
.selection-hint {
text-align: center;
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
}
.available-providers {
display: flex;
flex-direction: column;
gap: 12px;
}
.provider-button {
width: 100%;
padding: 14px 20px;
background-color: #32b9ce;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-family: 'Roboto', sans-serif;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.provider-button:hover {
background-color: #2a9fb0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(50, 185, 206, 0.3);
}
.provider-button-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.provider-button-text {
flex: 1;
text-align: left;
margin-left: 8px;
}
.provider-status-indicator {
font-size: 14px;
margin-right: 8px;
}
.provider-status-indicator.online {
color: #10b981;
}
.provider-status-indicator.offline {
color: #ef4444;
}
/* Unavailable providers section */
.unavailable-section {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.unavailable-header {
text-align: center;
font-size: 13px;
color: #9ca3af;
margin: 0 0 12px 0;
}
.provider-unavailable {
width: 100%;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #d1d5db;
background-color: #f9fafb;
opacity: 0.7;
margin-bottom: 8px;
}
.provider-unavailable-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.provider-name {
flex: 1;
color: #4b5563;
font-size: 14px;
}
.unavailable-label {
font-size: 11px;
color: #ef4444;
font-weight: 500;
text-transform: uppercase;
}
.provider-error {
display: flex;
align-items: start;
gap: 8px;
margin-top: 8px;
margin-left: 22px;
}
.provider-error-text {
flex: 1;
font-size: 11px;
color: #6b7280;
max-height: 80px;
overflow-y: auto;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.4;
}
.copy-button {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 0;
opacity: 0.6;
transition: opacity 0.2s;
flex-shrink: 0;
}
.copy-button:hover {
opacity: 1;
}
/* No providers error state */
.no-providers-error {
text-align: center;
padding: 24px;
}
.error-message {
color: #ef4444;
font-size: 15px;
margin: 0 0 8px 0;
font-weight: 500;
}
.error-hint {
font-size: 13px;
color: #6b7280;
margin: 0;
}
</style>

View File

@ -39,6 +39,7 @@ import APIServerErrorView from '../views/APIServerErrorView.vue'
import APIServerStatusView from '../views/APIServerStatusView.vue'
import { isServerUp, OBP_API_DEFAULT_RESOURCE_DOC_VERSION } from '../obp'
import MessageDocsContent from '@/components/CodeBlock.vue'
import ProvidersStatusView from '../views/ProvidersStatusView.vue'
export default async function router(): Promise<any> {
const isServerActive = await isServerUp()
@ -54,6 +55,11 @@ export default async function router(): Promise<any> {
name: 'status',
component: APIServerStatusView
},
{
path: '/debug/providers-status',
name: 'providers-status',
component: ProvidersStatusView
},
{
path: '/glossary',
name: 'glossary',

View File

@ -0,0 +1,476 @@
<!--
Open Bank Project - API Explorer II
Copyright (C) 2023-2025, 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/)
-->
<template>
<div class="providers-status-view">
<h1>OAuth2 Provider Configuration Status</h1>
<el-alert type="info" :closable="false" style="margin-bottom: 20px">
<p>This page shows which OAuth2/OIDC identity providers are configured and available for login.</p>
<p><strong>Note:</strong> Client secrets are masked for security.</p>
</el-alert>
<div v-if="loading" v-loading="loading" class="loading-container">
<p>Loading provider status...</p>
</div>
<div v-else-if="error" class="error-container">
<el-alert type="error" :closable="false">
<p><strong>Error loading provider status:</strong></p>
<p>{{ error }}</p>
</el-alert>
</div>
<div v-else-if="status" class="status-container">
<!-- Summary Card -->
<el-card class="summary-card" shadow="hover">
<template #header>
<div class="card-header">
<span>Summary</span>
<el-button size="small" @click="refreshStatus">
<el-icon><Refresh /></el-icon>
Refresh
</el-button>
</div>
</template>
<div class="summary-content">
<div class="summary-item">
<label>Total Configured Providers:</label>
<span class="value">{{ status.summary.totalConfigured }}</span>
</div>
<div class="summary-item">
<label>Available Providers:</label>
<span class="value">{{ status.summary.availableProviders.join(', ') || 'None' }}</span>
</div>
<div class="summary-item">
<label>OBP API Host:</label>
<span class="value">{{ status.summary.obpApiHost }}</span>
</div>
</div>
</el-card>
<!-- Provider Status Cards -->
<h2>Active Providers</h2>
<div v-if="status.providerStatus.length === 0" class="no-providers">
<el-empty description="No providers configured">
<el-button type="primary" @click="openDocs">View Setup Guide</el-button>
</el-empty>
</div>
<div v-else class="provider-cards">
<el-card
v-for="provider in status.providerStatus"
:key="provider.name"
class="provider-card"
:class="{ 'provider-healthy': provider.available, 'provider-unhealthy': !provider.available }"
shadow="hover"
>
<template #header>
<div class="provider-header">
<span class="provider-name">{{ getProviderDisplayName(provider.name) }}</span>
<el-tag :type="provider.available ? 'success' : 'danger'" size="small">
{{ provider.status }}
</el-tag>
</div>
</template>
<div class="provider-content">
<div class="provider-detail">
<label>Provider ID:</label>
<span>{{ provider.name }}</span>
</div>
<div class="provider-detail">
<label>Status:</label>
<span :class="provider.available ? 'status-ok' : 'status-error'">
{{ provider.available ? 'Available' : 'Unavailable' }}
</span>
</div>
<div class="provider-detail">
<label>Last Checked:</label>
<span>{{ formatDate(provider.lastChecked) }}</span>
</div>
<div v-if="provider.error" class="provider-detail error-detail">
<label>Error:</label>
<span class="error-message">{{ provider.error }}</span>
</div>
</div>
</el-card>
</div>
<!-- Environment Configuration -->
<h2>Environment Configuration</h2>
<el-collapse v-model="activeCollapse">
<el-collapse-item
v-for="(config, providerKey) in status.environmentConfig"
:key="providerKey"
:name="providerKey"
>
<template #title>
<div class="collapse-title">
<span class="provider-label">{{ getProviderDisplayName(providerKey) }}</span>
<el-tag v-if="isProviderConfigured(config)" type="success" size="small">
Configured
</el-tag>
<el-tag v-else type="info" size="small">
Not Configured
</el-tag>
</div>
</template>
<div class="env-config-content">
<div v-for="(value, key) in config" :key="key" class="config-item">
<label>{{ formatConfigKey(key) }}:</label>
<code>{{ value }}</code>
</div>
</div>
</el-collapse-item>
</el-collapse>
<div class="note">
<el-icon><InfoFilled /></el-icon>
<span>{{ status.note }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, InfoFilled } from '@element-plus/icons-vue'
interface ProviderStatus {
name: string
available: boolean
status: string
lastChecked: Date
error?: string
}
interface EnvConfig {
[key: string]: {
[key: string]: string
}
}
interface StatusResponse {
summary: {
totalConfigured: number
availableProviders: string[]
obpApiHost: string
}
providerStatus: ProviderStatus[]
environmentConfig: EnvConfig
note: string
}
const loading = ref(true)
const error = ref<string | null>(null)
const status = ref<StatusResponse | null>(null)
const activeCollapse = ref<string[]>(['obpOidc', 'keycloak', 'google', 'github', 'custom'])
const fetchStatus = async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/status/providers')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
status.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
ElMessage.error('Failed to load provider status')
} finally {
loading.value = false
}
}
const refreshStatus = async () => {
ElMessage.info('Refreshing provider status...')
await fetchStatus()
ElMessage.success('Provider status refreshed')
}
const getProviderDisplayName = (key: string): string => {
const names: { [key: string]: string } = {
'obp-oidc': 'OBP-OIDC',
'obpOidc': 'OBP-OIDC',
'keycloak': 'Keycloak',
'google': 'Google',
'github': 'GitHub',
'custom': 'Custom Provider'
}
return names[key] || key.toUpperCase()
}
const formatConfigKey = (key: string): string => {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim()
}
const formatDate = (date: Date | string): string => {
if (!date) return 'Never'
const d = new Date(date)
return d.toLocaleString()
}
const isProviderConfigured = (config: { [key: string]: string }): boolean => {
return Object.values(config).some(
(value) => value && value !== 'not configured' && value !== '***masked***'
)
}
const openDocs = () => {
window.open('https://github.com/OpenBankProject/OBP-API/wiki/OAuth2', '_blank')
}
onMounted(() => {
fetchStatus()
})
</script>
<style scoped>
.providers-status-view {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
font-size: 28px;
margin-bottom: 20px;
color: #303133;
}
h2 {
font-size: 20px;
margin: 30px 0 15px 0;
color: #606266;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
font-size: 16px;
color: #909399;
}
.error-container {
margin: 20px 0;
}
/* Summary Card */
.summary-card {
margin-bottom: 30px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.summary-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.summary-item:last-child {
border-bottom: none;
}
.summary-item label {
font-weight: 500;
color: #606266;
}
.summary-item .value {
font-family: 'Courier New', monospace;
color: #303133;
}
/* Provider Cards */
.no-providers {
padding: 40px;
text-align: center;
}
.provider-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.provider-card {
transition: transform 0.2s;
}
.provider-card:hover {
transform: translateY(-2px);
}
.provider-healthy {
border-left: 4px solid #67c23a;
}
.provider-unhealthy {
border-left: 4px solid #f56c6c;
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.provider-name {
font-size: 16px;
font-weight: 600;
}
.provider-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.provider-detail {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 14px;
}
.provider-detail label {
font-weight: 500;
color: #909399;
margin-right: 10px;
}
.provider-detail span {
color: #303133;
}
.status-ok {
color: #67c23a;
font-weight: 600;
}
.status-error {
color: #f56c6c;
font-weight: 600;
}
.error-detail {
background: #fef0f0;
padding: 8px;
border-radius: 4px;
flex-direction: column;
align-items: flex-start;
}
.error-message {
color: #f56c6c;
font-family: 'Courier New', monospace;
font-size: 12px;
word-break: break-all;
}
/* Environment Config */
.collapse-title {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.provider-label {
font-weight: 500;
}
.env-config-content {
padding: 10px 20px;
background: #fafafa;
border-radius: 4px;
}
.config-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e4e7ed;
}
.config-item:last-child {
border-bottom: none;
}
.config-item label {
font-weight: 500;
color: #606266;
min-width: 150px;
}
.config-item code {
background: #fff;
padding: 4px 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #303133;
border: 1px solid #dcdfe6;
}
/* Note */
.note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 30px;
padding: 12px;
background: #f4f4f5;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.note .el-icon {
color: #909399;
}
</style>