API-Explorer-II/server/services/OAuth2ClientWithConfig.ts
simonredfern 743038953d 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
2026-01-14 13:00:45 +01:00

283 lines
9.0 KiB
TypeScript

/*
* 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
}
}
}