mirror of
https://github.com/OpenBankProject/API-Explorer-II.git
synced 2026-02-06 10:47:04 +00:00
Merge branch 'multi-login' into develop
This commit is contained in:
commit
7a0c1d901b
79
.env.example
79
.env.example
@ -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
237
IMPLEMENTATION-COMPLETE.txt
Normal 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
577
MULTI-OIDC-FLOW-DIAGRAM.md
Normal 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
|
||||
```
|
||||
361
MULTI-OIDC-IMPLEMENTATION-STATUS.md
Normal file
361
MULTI-OIDC-IMPLEMENTATION-STATUS.md
Normal 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 ✅
|
||||
1917
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
Normal file
1917
MULTI-OIDC-PROVIDER-IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
372
MULTI-OIDC-PROVIDER-SUMMARY.md
Normal file
372
MULTI-OIDC-PROVIDER-SUMMARY.md
Normal 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
790
MULTI-OIDC-TESTING-GUIDE.md
Normal 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
67
env_ai
@ -1,26 +1,63 @@
|
||||
### OBP-API Configuration ###
|
||||
VITE_OBP_API_PORTAL_HOST=http://127.0.0.1:8080
|
||||
### OBP API Configuration ###
|
||||
VITE_OBP_API_HOST=http://127.0.0.1:8080
|
||||
VITE_OBP_API_VERSION=v5.1.0
|
||||
VITE_OBP_API_MANAGER_HOST=https://apimanagersandbox.openbankproject.com
|
||||
VITE_OBP_API_EXPLORER_HOST=http://localhost:5174
|
||||
VITE_OBP_API_VERSION=v6.0.0
|
||||
VITE_OBP_API_EXPLORER_HOST=http://localhost:5173
|
||||
|
||||
### Session Configuration ###
|
||||
VITE_OPB_SERVER_SESSION_PASSWORD=asidudhiuh33875
|
||||
|
||||
### OAuth2 Redirect URL (shared by all providers) ###
|
||||
VITE_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
|
||||
### Redis Configuration ###
|
||||
VITE_OBP_REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
### Opey Configuration ###
|
||||
### Chatbot Configuration ###
|
||||
VITE_CHATBOT_ENABLED=false
|
||||
VITE_CHATBOT_URL=http://localhost:5000
|
||||
|
||||
### OAuth2/OIDC Configuration ###
|
||||
# OAuth2 Client Credentials (from OBP-OIDC)
|
||||
VITE_OBP_OAUTH2_CLIENT_ID=48ac28e9-9ee3-47fd-8448-69a62764b779
|
||||
VITE_OBP_OAUTH2_CLIENT_SECRET=fOTQF7jfg8C74u7ZhSjVQpoBYvD0KpWfM5UsEZBSFFM
|
||||
VITE_OBP_OAUTH2_REDIRECT_URL=http://localhost:5173/api/oauth2/callback
|
||||
### Multi-Provider OAuth2/OIDC Configuration ###
|
||||
### The system fetches available providers from: http://localhost:8080/obp/v5.1.0/well-known
|
||||
### Configure credentials below for each provider you want to support
|
||||
|
||||
# OIDC Well-Known Configuration URL
|
||||
VITE_OBP_OAUTH2_WELL_KNOWN_URL=http://127.0.0.1:9000/obp-oidc/.well-known/openid-configuration
|
||||
### OBP-OIDC Provider ###
|
||||
VITE_OBP_OIDC_CLIENT_ID=c2ea173e-8c1a-43c4-ba62-19738f27c43e
|
||||
VITE_OBP_OIDC_CLIENT_SECRET=1E7zsN47Xp4VTb28xEv5ZK4vcX8XMsYIH3IsnjQTYk8
|
||||
|
||||
# Optional: Token refresh threshold (seconds before expiry)
|
||||
VITE_OBP_OAUTH2_TOKEN_REFRESH_THRESHOLD=300
|
||||
### OBP Consumer Key (for API calls) ###
|
||||
VITE_OBP_CONSUMER_KEY=c2ea173e-8c1a-43c4-ba62-19738f27c43e
|
||||
|
||||
### Keycloak Provider (Optional) ###
|
||||
# VITE_KEYCLOAK_CLIENT_ID=obp-api-explorer
|
||||
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-secret-here
|
||||
|
||||
### Google Provider (Optional) ###
|
||||
# VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
# VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
### GitHub Provider (Optional) ###
|
||||
# VITE_GITHUB_CLIENT_ID=your-github-client-id
|
||||
# VITE_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
### Custom OIDC Provider (Optional) ###
|
||||
# VITE_CUSTOM_OIDC_PROVIDER_NAME=my-custom-provider
|
||||
# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id
|
||||
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret
|
||||
|
||||
### Opey Configuration ###
|
||||
VITE_OPEY_CONSUMER_ID=74545fb7-9a1f-4ee0-beb4-6e5b7ee50076
|
||||
|
||||
### Resource Docs Version ###
|
||||
VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv6.0.0
|
||||
|
||||
### HOW IT WORKS ###
|
||||
# 1. Backend fetches provider list from OBP API: GET /obp/v5.1.0/well-known
|
||||
# 2. OBP API returns available providers with their .well-known URLs
|
||||
# 3. Backend matches providers with credentials configured above
|
||||
# 4. Only providers with both (API registration + credentials) will be available
|
||||
# 5. Users see provider selection if 2+ providers configured (or auto-login if only 1)
|
||||
|
||||
### VERIFY YOUR SETUP ###
|
||||
# curl http://localhost:8080/obp/v5.1.0/well-known
|
||||
# curl http://localhost:8085/api/oauth2/providers
|
||||
# Visit: http://localhost:5173/debug/providers-status
|
||||
|
||||
110
server/app.ts
110
server/app.ts
@ -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`
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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' });
|
||||
// }
|
||||
|
||||
// }
|
||||
}
|
||||
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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({})
|
||||
}
|
||||
}
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
}
|
||||
285
server/routes/oauth2.ts
Normal file
285
server/routes/oauth2.ts
Normal 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
160
server/routes/obp.ts
Normal 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
280
server/routes/opey.ts
Normal 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
221
server/routes/status.ts
Normal 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
139
server/routes/user.ts
Normal 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
|
||||
302
server/services/OAuth2ClientWithConfig.ts
Normal file
302
server/services/OAuth2ClientWithConfig.ts
Normal 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
|
||||
}
|
||||
}
|
||||
238
server/services/OAuth2ProviderFactory.ts
Normal file
238
server/services/OAuth2ProviderFactory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
493
server/services/OAuth2ProviderManager.ts
Normal file
493
server/services/OAuth2ProviderManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
130
server/types/oauth2.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
476
src/views/ProvidersStatusView.vue
Normal file
476
src/views/ProvidersStatusView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user