Add multi-OIDC provider backend services

- Add TypeScript interfaces for multi-provider OAuth2 support
- Create OAuth2ClientWithConfig extending arctic OAuth2Client with OIDC discovery
- Create OAuth2ProviderFactory with strategy pattern for different providers
- Create OAuth2ProviderManager for managing multiple providers with health checks
- Support for OBP-OIDC, Keycloak, Google, GitHub, and custom providers
This commit is contained in:
simonredfern 2025-12-28 15:23:49 +01:00
parent 7695d3c314
commit 743038953d
7 changed files with 3904 additions and 0 deletions

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,282 @@
/*
* 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 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
constructor(clientId: string, clientSecret: string, redirectUri: string, provider: string) {
super(clientId, clientSecret, redirectUri)
this.provider = provider
}
/**
* 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(oidcConfigUrl: string): Promise<void> {
console.log(
`OAuth2ClientWithConfig: Fetching OIDC config for ${this.provider} from:`,
oidcConfigUrl
)
try {
const response = await fetch(oidcConfigUrl)
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
}
/**
* Validate authorization code and exchange for tokens
*
* This method extends the base OAuth2Client functionality to support
* provider-specific token exchange requirements (e.g., Basic Auth vs form-based credentials)
*
* @param code - Authorization code from OIDC provider
* @param codeVerifier - PKCE code verifier
* @returns Token response with access token, refresh token, and ID token
*/
async validateAuthorizationCode(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 refreshAccessToken(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
}
}
}

View File

@ -0,0 +1,241 @@
/*
* 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_[PROVIDER]_REDIRECT_URL (optional, defaults to /api/oauth2/callback)
*/
private loadStrategies(): void {
console.log('OAuth2ProviderFactory: Loading provider strategies...')
// OBP-OIDC Strategy
if (process.env.VITE_OBP_OAUTH2_CLIENT_ID) {
this.strategies.set('obp-oidc', {
clientId: process.env.VITE_OBP_OAUTH2_CLIENT_ID,
clientSecret: process.env.VITE_OBP_OAUTH2_CLIENT_SECRET || '',
redirectUri:
process.env.VITE_OBP_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback',
scopes: ['openid', 'profile', 'email']
})
console.log(' ✓ 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:
process.env.VITE_KEYCLOAK_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback',
scopes: ['openid', 'profile', 'email']
})
console.log(' ✓ 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:
process.env.VITE_GOOGLE_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback',
scopes: ['openid', 'profile', 'email']
})
console.log(' ✓ 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:
process.env.VITE_GITHUB_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback',
scopes: ['read:user', 'user:email']
})
console.log(' ✓ 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:
process.env.VITE_CUSTOM_OIDC_REDIRECT_URL ||
'http://localhost:5173/api/oauth2/callback',
scopes: ['openid', 'profile', 'email']
})
console.log(` ✓ 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_OAUTH2_CLIENT_ID, VITE_OBP_OAUTH2_CLIENT_SECRET')
}
}
/**
* Initialize an OAuth2 client for a specific provider
*
* @param wellKnownUri - Provider information from OBP API
* @returns Initialized OAuth2 client or null if no strategy exists or initialization fails
*
* @example
* const client = await factory.initializeProvider({
* provider: 'obp-oidc',
* url: 'http://localhost:9000/obp-oidc/.well-known/openid-configuration'
* })
*/
async initializeProvider(wellKnownUri: WellKnownUri): Promise<OAuth2ClientWithConfig | null> {
console.log(`OAuth2ProviderFactory: Initializing provider: ${wellKnownUri.provider}`)
const strategy = this.strategies.get(wellKnownUri.provider)
if (!strategy) {
console.warn(
`OAuth2ProviderFactory: No strategy found for provider: ${wellKnownUri.provider}`
)
console.warn(
`OAuth2ProviderFactory: Available strategies: ${Array.from(this.strategies.keys()).join(', ')}`
)
return null
}
// Validate strategy configuration
if (!strategy.clientId) {
console.error(
`OAuth2ProviderFactory: Missing clientId for provider: ${wellKnownUri.provider}`
)
return null
}
if (!strategy.clientSecret) {
console.warn(
`OAuth2ProviderFactory: Missing clientSecret for provider: ${wellKnownUri.provider}`
)
console.warn(`OAuth2ProviderFactory: Some providers require a client secret`)
}
try {
const client = new OAuth2ClientWithConfig(
strategy.clientId,
strategy.clientSecret,
strategy.redirectUri,
wellKnownUri.provider
)
// Initialize OIDC configuration from discovery endpoint
await client.initOIDCConfig(wellKnownUri.url)
console.log(`OAuth2ProviderFactory: Successfully initialized ${wellKnownUri.provider}`)
return client
} catch (error) {
console.error(
`OAuth2ProviderFactory: Failed to initialize ${wellKnownUri.provider}:`,
error
)
return null
}
}
/**
* Get list of configured provider names
*
* @returns Array of provider names that have strategies configured
*/
getConfiguredProviders(): string[] {
return Array.from(this.strategies.keys())
}
/**
* Check if a provider strategy exists
*
* @param providerName - Name of the provider to check
* @returns True if strategy exists for this provider
*/
hasStrategy(providerName: string): boolean {
return this.strategies.has(providerName)
}
/**
* Get strategy for a specific provider (for debugging/testing)
*
* @param providerName - Name of the provider
* @returns Provider strategy or undefined if not found
*/
getStrategy(providerName: string): ProviderStrategy | undefined {
return this.strategies.get(providerName)
}
/**
* Get count of configured strategies
*
* @returns Number of provider strategies loaded
*/
getStrategyCount(): number {
return this.strategies.size
}
}

View File

@ -0,0 +1,385 @@
/*
* 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 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
*
* Calls: GET /obp/v5.1.0/well-known
*
* @returns Array of well-known URIs with provider names
*/
async fetchWellKnownUris(): Promise<WellKnownUri[]> {
console.log('OAuth2ProviderManager: Fetching well-known URIs from OBP API...')
try {
// Use OBPClientService to call the API
const response = await this.obpClientService.call<WellKnownResponse>(
'GET',
'/obp/v5.1.0/well-known',
null,
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) => {
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'
)
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: ✓ ${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: ✗ ${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: ✗ ${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'
)
}
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')
}
}
/**
* 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>[] = []
for (const [providerName, client] of this.providers.entries()) {
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 issuer endpoint to verify provider is reachable
const endpoint = client.OIDCConfig?.issuer
if (!endpoint) {
throw new Error('No issuer endpoint configured')
}
// Use HEAD request for efficiency
const response = await fetch(endpoint, {
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[] = []
for (const [name, status] of this.providerStatus.entries()) {
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: ✓ ${providerName} retry successful`)
return true
} else {
console.error(`OAuth2ProviderManager: ✗ ${providerName} retry failed`)
return false
}
} catch (error) {
console.error(`OAuth2ProviderManager: Error retrying ${providerName}:`, error)
return false
}
}
}

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

@ -0,0 +1,130 @@
/*
* Open Bank Project - API Explorer II
* Copyright (C) 2023-2024, TESOBE GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Email: contact@tesobe.com
* TESOBE GmbH
* Osloerstrasse 16/17
* Berlin 13359, Germany
*
* This product includes software developed at
* TESOBE (http://www.tesobe.com/)
*
*/
/**
* Well-known URI from OBP API /obp/v[version]/well-known endpoint
*/
export interface WellKnownUri {
provider: string // e.g., "obp-oidc", "keycloak"
url: string // e.g., "http://localhost:9000/obp-oidc/.well-known/openid-configuration"
}
/**
* Response from OBP API well-known endpoint
*/
export interface WellKnownResponse {
well_known_uris: WellKnownUri[]
}
/**
* Provider configuration strategy
*/
export interface ProviderStrategy {
clientId: string
clientSecret: string
redirectUri: string
scopes?: string[]
}
/**
* Provider status information
*/
export interface ProviderStatus {
name: string
available: boolean
lastChecked: Date
error?: string
}
/**
* OpenID Connect Discovery Configuration
* As defined in OpenID Connect Discovery 1.0
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
*/
export interface OIDCConfiguration {
issuer: string
authorization_endpoint: string
token_endpoint: string
userinfo_endpoint: string
jwks_uri: string
registration_endpoint?: string
scopes_supported?: string[]
response_types_supported?: string[]
response_modes_supported?: string[]
grant_types_supported?: string[]
subject_types_supported?: string[]
id_token_signing_alg_values_supported?: string[]
token_endpoint_auth_methods_supported?: string[]
claims_supported?: string[]
code_challenge_methods_supported?: string[]
}
/**
* Token response from OAuth2 token endpoint
*/
export interface TokenResponse {
accessToken: string
refreshToken?: string
idToken?: string
tokenType: string
expiresIn?: number
scope?: string
}
/**
* User information from OIDC UserInfo endpoint
*/
export interface UserInfo {
sub: string
name?: string
given_name?: string
family_name?: string
middle_name?: string
nickname?: string
preferred_username?: string
profile?: string
picture?: string
website?: string
email?: string
email_verified?: boolean
gender?: string
birthdate?: string
zoneinfo?: string
locale?: string
phone_number?: string
phone_number_verified?: boolean
address?: {
formatted?: string
street_address?: string
locality?: string
region?: string
postal_code?: string
country?: string
}
updated_at?: number
[key: string]: any
}