diff --git a/.env.example b/.env.example index 4b953cf..3efc8b9 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,17 @@ VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id # VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id # VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret +### Berlin Group TPP Signature Certificate Configuration (Optional) ### +# VITE_BG_PRIVATE_KEY_PATH=./certs/private_key.pem +# VITE_BG_CERTIFICATE_PATH=./certs/certificate.pem +# VITE_BG_KEY_ID=SN=1082, CA=CN=Your Name, O=YourOrg +# VITE_BG_API_VERSION=v1.3 +# VITE_BG_PSU_DEVICE_ID=device-1234567890 +# VITE_BG_PSU_DEVICE_NAME=API-Explorer-II +# VITE_BG_PSU_IP_ADDRESS=127.0.0.1 +# VITE_BG_TPP_REDIRECT_URI=https://your-app.com/berlin-group/redirect +# VITE_BG_TPP_NOK_REDIRECT_URI=https://your-app.com/berlin-group/error + ### Chatbot Configuration (Optional) ### VITE_CHATBOT_ENABLED=false # VITE_CHATBOT_URL=http://localhost:5000 diff --git a/docs/Berlin-Group-TPP-Signature-Certificate.md b/docs/Berlin-Group-TPP-Signature-Certificate.md new file mode 100644 index 0000000..df98a74 --- /dev/null +++ b/docs/Berlin-Group-TPP-Signature-Certificate.md @@ -0,0 +1,197 @@ +# Berlin Group TPP Signature Certificate Support + +## Overview + +API Explorer II now supports the Berlin Group NextGenPSD2 standard. When configured with a TPP (Third Party Provider) certificate, the server automatically signs every request to a Berlin Group API endpoint. This enables PSD2-compliant access to Account Information Services (AIS) and Payment Initiation Services (PIS) through banks that implement the Berlin Group standard. + +The feature is entirely optional. Without certificate configuration, the application behaves exactly as before. + +## What You Need Before Starting + +### 1. TPP Certificate Files + +You need two PEM files issued by your bank or a qualified trust service provider (QTSP): + +- **Private key** (`private_key.pem`) -- RSA private key used to sign requests +- **Certificate** (`certificate.pem`) -- The corresponding TPP certificate that the bank uses to verify your signatures + +These are typically provided as part of the TPP onboarding process with the bank. + +### 2. Key Identifier + +The `keyId` string that identifies your certificate to the bank. This is part of the Signature header and typically looks like: + +``` +SN=1082, CA=CN=Your Name, O=YourOrg +``` + +Your bank will tell you what value to use, or it can be derived from the certificate's serial number and issuer. + +### 3. OBP-API Backend with Berlin Group Support + +The OBP-API instance must serve Berlin Group endpoints at paths like: + +``` +/berlin-group/v1.3/consents +/berlin-group/v1.3/accounts +/berlin-group/v1.3/payments/sepa-credit-transfers +``` + +### 4. Redirect URIs (for consent and payment flows) + +Two redirect URLs that the bank will use during PSU (Payment Service User) authorization: + +- **Success redirect** -- where the user returns after authorizing a consent or payment +- **Error redirect** -- where the user returns if authorization fails or is cancelled + +## Setup + +### Step 1: Place Certificate Files + +Put your PEM files in a location accessible to the server, for example: + +``` +certs/ + private_key.pem + certificate.pem +``` + +### Step 2: Configure Environment Variables + +Add these to your `.env` file: + +```bash +# Required -- paths to your certificate files +VITE_BG_PRIVATE_KEY_PATH=./certs/private_key.pem +VITE_BG_CERTIFICATE_PATH=./certs/certificate.pem + +# Required -- your certificate's key identifier +VITE_BG_KEY_ID=SN=1082, CA=CN=Your Name, O=YourOrg + +# Berlin Group API version (default: v1.3) +VITE_BG_API_VERSION=v1.3 + +# PSU device identification +VITE_BG_PSU_DEVICE_ID=device-1234567890 +VITE_BG_PSU_DEVICE_NAME=API-Explorer-II +VITE_BG_PSU_IP_ADDRESS=192.168.1.42 + +# Redirect URIs for consent/payment authorization flows +VITE_BG_TPP_REDIRECT_URI=https://your-app.com/berlin-group/redirect +VITE_BG_TPP_NOK_REDIRECT_URI=https://your-app.com/berlin-group/error +``` + +Only `VITE_BG_PRIVATE_KEY_PATH` and `VITE_BG_CERTIFICATE_PATH` are required to enable the feature. Everything else has sensible defaults. + +### Step 3: Start the Server + +On startup, the console confirms whether the feature is active: + +``` +--- Berlin Group TPP Signature Certificate ----------------------- +OK Berlin Group TPP Signature Certificate is configured and loaded + API Version: v1.3 +----------------------------------------------------------------- +``` + +If not configured, you will see: + +``` +--- Berlin Group TPP Signature Certificate ----------------------- +Berlin Group TPP Signature Certificate is NOT configured + Set VITE_BG_PRIVATE_KEY_PATH and VITE_BG_CERTIFICATE_PATH to enable +----------------------------------------------------------------- +``` + +## How It Works + +``` +Browser API Explorer II OBP-API / Bank + | | | + | GET /api/get?path= | | + | /berlin-group/v1.3/accounts | | + | (+ optional X-BG-Consent-ID) | | + | -------------------------------->| | + | | | + | Detects "/berlin-group/" in path | + | Generates signature headers: | + | - SHA-256 body digest | + | - RSA-SHA256 digital signature | + | - TPP certificate | + | - PSU identification | + | - Consent-ID (if provided) | + | Includes OAuth2 Bearer token (if logged in) | + | | | + | | GET /berlin-group/v1.3/accounts | + | | + Digest, Signature, Certificate | + | | + Consent-ID, PSU headers | + | | ------------------------------------>| + | | | + | | JSON response | + | |<------------------------------------ | + | JSON response | | + |<---------------------------------| | +``` + +Key points: + +- **Automatic detection** -- Any request whose path contains `/berlin-group/` triggers the signing. Standard OBP paths (e.g., `/obp/v5.1.0/banks`) are unaffected. +- **Transparent to the frontend** -- The browser uses the same proxy endpoints (`/api/get`, `/api/create`, `/api/update`, `/api/delete`) as for any other API call. +- **OAuth2 and TPP coexist** -- If the user is logged in via OAuth2, the Bearer token is sent alongside the TPP signature headers. Both authentication mechanisms can be active on the same request. +- **Consent-ID passthrough** -- For endpoints that require a consent (e.g., reading accounts), the frontend includes an `X-BG-Consent-ID` header on its request to the proxy. The server forwards it as the standard `Consent-ID` header to the bank. + +## Typical PSD2 Workflows + +### Account Information (AIS) + +1. **Create a consent** -- POST to `/berlin-group/v1.3/consents` with the desired account access scope. The bank returns a `consentId` and a redirect link for the PSU to authorize. +2. **PSU authorizes** -- The user is redirected to the bank's authorization page, then back to your redirect URI. +3. **Check consent status** -- GET `/berlin-group/v1.3/consents/{consentId}/status` to confirm it is `valid`. +4. **Read accounts** -- GET `/berlin-group/v1.3/accounts` with the `Consent-ID` header. +5. **Read transactions** -- GET `/berlin-group/v1.3/accounts/{accountId}/transactions` with the `Consent-ID` header. +6. **Delete consent** -- DELETE `/berlin-group/v1.3/consents/{consentId}` when access is no longer needed. + +### Payment Initiation (PIS) + +1. **Initiate payment** -- POST to `/berlin-group/v1.3/payments/sepa-credit-transfers` with debtor/creditor accounts and amount. The bank returns a `paymentId` and a redirect link. +2. **PSU authorizes** -- The user is redirected to the bank to authorize the payment, then back to your redirect URI. +3. **Check payment status** -- GET `/berlin-group/v1.3/payments/sepa-credit-transfers/{paymentId}/status`. + +## What Gets Signed + +Every outgoing Berlin Group request includes these headers, generated fresh per request: + +| Header | Purpose | +| ------------------------------------------------------ | ------------------------------------------------------------------- | +| `Date` | Timestamp in RFC 7231 format | +| `X-Request-ID` | Unique UUID per request for traceability | +| `Digest` | SHA-256 hash of the request body | +| `Signature` | RSA-SHA256 digital signature of the digest, date, and request ID | +| `TPP-Signature-Certificate` | The TPP certificate for the bank to verify the signature | +| `PSU-Device-ID` / `PSU-Device-Name` / `PSU-IP-Address` | PSU device identification | +| `TPP-Redirect-URI` / `TPP-Nok-Redirect-URI` | Where to redirect the user after authorization (POST requests only) | +| `Consent-ID` | References an existing consent (when provided by the frontend) | + +## Configuration Reference + +| Variable | Required | Default | Description | +| ------------------------------ | ----------- | --------------------------- | -------------------------------- | +| `VITE_BG_PRIVATE_KEY_PATH` | Yes | -- | Path to RSA private key PEM file | +| `VITE_BG_CERTIFICATE_PATH` | Yes | -- | Path to TPP certificate PEM file | +| `VITE_BG_KEY_ID` | Recommended | `SN=unknown, CA=CN=unknown` | Certificate key identifier | +| `VITE_BG_API_VERSION` | No | `v1.3` | Berlin Group API version | +| `VITE_BG_PSU_DEVICE_ID` | No | `device-api-explorer-ii` | PSU device identifier | +| `VITE_BG_PSU_DEVICE_NAME` | No | `API-Explorer-II` | PSU device name | +| `VITE_BG_PSU_IP_ADDRESS` | No | `127.0.0.1` | PSU IP address | +| `VITE_BG_TPP_REDIRECT_URI` | No | (empty) | Success redirect URI | +| `VITE_BG_TPP_NOK_REDIRECT_URI` | No | (empty) | Error redirect URI | + +## Troubleshooting + +| Symptom | Cause | Fix | +| ------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------- | +| Startup says "NOT configured" | Certificate env vars not set | Set `VITE_BG_PRIVATE_KEY_PATH` and `VITE_BG_CERTIFICATE_PATH` in `.env` | +| "Failed to load certificate files" in logs | File paths are wrong or files are unreadable | Check that the PEM files exist at the specified paths and have read permissions | +| Bank returns 401/403 on signed requests | Key ID mismatch or expired certificate | Verify `VITE_BG_KEY_ID` matches what the bank expects; check certificate validity | +| Berlin Group requests not being signed | Path doesn't contain `/berlin-group/` | Ensure the API path follows the pattern `/berlin-group/{version}/...` | +| Consent endpoints fail without Consent-ID | Frontend not sending the header | Frontend must include `X-BG-Consent-ID` header for account data endpoints | diff --git a/server/app.ts b/server/app.ts index 9bbdb8c..52b77b7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -36,6 +36,7 @@ import { Container } from 'typedi' import path from 'path' import { execSync } from 'child_process' import { OAuth2ProviderManager } from './services/OAuth2ProviderManager.js' +import { BerlinGroupSignatureService } from './services/BerlinGroupSignatureService.js' import { fileURLToPath } from 'url' import { dirname } from 'path' @@ -169,6 +170,18 @@ let instance: any } console.log(`-----------------------------------------------------------------`) + // Berlin Group TPP Signature Certificate Setup + console.log('--- Berlin Group TPP Signature Certificate -----------------------') + const bgService = Container.get(BerlinGroupSignatureService) + if (bgService.isEnabled()) { + console.log('OK Berlin Group TPP Signature Certificate is configured and loaded') + console.log(` API Version: ${bgService.getApiVersion()}`) + } else { + console.log('Berlin Group TPP Signature Certificate is NOT configured') + console.log(' Set VITE_BG_PRIVATE_KEY_PATH and VITE_BG_CERTIFICATE_PATH to enable') + } + console.log(`-----------------------------------------------------------------`) + const routePrefix = '/api' // Register all routes (plain Express) diff --git a/server/routes/obp.ts b/server/routes/obp.ts index 6421401..c3d5bd8 100644 --- a/server/routes/obp.ts +++ b/server/routes/obp.ts @@ -54,7 +54,11 @@ router.get('/get', async (req: Request, res: Response) => { const path = req.query.path as string const session = req.session as any - const oauthConfig = session.clientConfig + const oauthConfig = session.clientConfig || {} + const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined + if (bgConsentId) { + oauthConfig.berlinGroup = { consentId: bgConsentId } + } const result = await obpClientService.get(path, oauthConfig) res.json(result) @@ -85,7 +89,11 @@ router.post('/create', async (req: Request, res: Response) => { const data = req.body const session = req.session as any - const oauthConfig = session.clientConfig + const oauthConfig = session.clientConfig || {} + const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined + if (bgConsentId) { + oauthConfig.berlinGroup = { consentId: bgConsentId } + } // Debug logging to diagnose authentication issues console.log('OBP.create - Debug Info:') @@ -95,6 +103,7 @@ router.post('/create', async (req: Request, res: Response) => { 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') + console.log(' berlinGroup consentId:', bgConsentId || 'N/A') const result = await obpClientService.create(path, data, oauthConfig) res.json(result) @@ -120,7 +129,11 @@ router.put('/update', async (req: Request, res: Response) => { const data = req.body const session = req.session as any - const oauthConfig = session.clientConfig + const oauthConfig = session.clientConfig || {} + const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined + if (bgConsentId) { + oauthConfig.berlinGroup = { consentId: bgConsentId } + } const result = await obpClientService.update(path, data, oauthConfig) res.json(result) @@ -144,7 +157,11 @@ router.delete('/delete', async (req: Request, res: Response) => { const path = req.query.path as string const session = req.session as any - const oauthConfig = session.clientConfig + const oauthConfig = session.clientConfig || {} + const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined + if (bgConsentId) { + oauthConfig.berlinGroup = { consentId: bgConsentId } + } const result = await obpClientService.discard(path, oauthConfig) res.json(result) diff --git a/server/services/BerlinGroupSignatureService.ts b/server/services/BerlinGroupSignatureService.ts new file mode 100644 index 0000000..98bf213 --- /dev/null +++ b/server/services/BerlinGroupSignatureService.ts @@ -0,0 +1,184 @@ +/* + * 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 . + * + * 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 crypto from 'crypto' +import fs from 'fs' +import type { BerlinGroupConfig, BerlinGroupHeaders } from '../types/berlin-group.js' + +/** + * BerlinGroupSignatureService generates RSA-SHA256 digital signatures, + * SHA-256 body digests, and TPP certificate headers required by + * Berlin Group PSD2 APIs. + * + * Configuration is loaded from environment variables at construction. + * If certificate files are not configured, the service gracefully degrades + * and isEnabled() returns false. + */ +@Service() +export class BerlinGroupSignatureService { + private privateKey: string | null = null + private certificate: string | null = null + private config: BerlinGroupConfig | null = null + + constructor() { + this.loadConfig() + } + + private loadConfig(): void { + const privateKeyPath = process.env.VITE_BG_PRIVATE_KEY_PATH + const certificatePath = process.env.VITE_BG_CERTIFICATE_PATH + + if (!privateKeyPath || !certificatePath) { + console.log('BerlinGroupSignatureService: Certificate paths not configured, signing disabled') + return + } + + try { + this.privateKey = fs.readFileSync(privateKeyPath, 'utf8') + this.certificate = fs.readFileSync(certificatePath, 'utf8') + + this.config = { + privateKeyPath, + certificatePath, + keyId: process.env.VITE_BG_KEY_ID || 'SN=unknown, CA=CN=unknown', + apiVersion: process.env.VITE_BG_API_VERSION || 'v1.3', + psuDeviceId: process.env.VITE_BG_PSU_DEVICE_ID || 'device-api-explorer-ii', + psuDeviceName: process.env.VITE_BG_PSU_DEVICE_NAME || 'API-Explorer-II', + psuIpAddress: process.env.VITE_BG_PSU_IP_ADDRESS || '127.0.0.1', + tppRedirectUri: process.env.VITE_BG_TPP_REDIRECT_URI || '', + tppNokRedirectUri: process.env.VITE_BG_TPP_NOK_REDIRECT_URI || '' + } + + console.log('BerlinGroupSignatureService: Private key and certificate loaded successfully') + } catch (error: any) { + console.error('BerlinGroupSignatureService: Failed to load certificate files:', error.message) + this.privateKey = null + this.certificate = null + this.config = null + } + } + + /** + * Check if Berlin Group signing is enabled (certificates are loaded) + */ + isEnabled(): boolean { + return this.privateKey !== null && this.certificate !== null && this.config !== null + } + + /** + * Get the configured Berlin Group API version + */ + getApiVersion(): string { + return this.config?.apiVersion || 'v1.3' + } + + /** + * Detect whether a request path is a Berlin Group API path + */ + static isBerlinGroupPath(path: string): boolean { + return path.includes('/berlin-group/') + } + + /** + * Generate all required Berlin Group PSD2 headers for a request + * + * @param method - HTTP method (GET, POST, PUT, DELETE) + * @param body - Request body (empty string for GET/DELETE) + * @param consentId - Optional Consent-ID for account data endpoints + * @returns Object containing all required Berlin Group headers + */ + generateHeaders( + method: string, + body: string, + consentId?: string + ): BerlinGroupHeaders { + if (!this.privateKey || !this.certificate || !this.config) { + throw new Error('BerlinGroupSignatureService: Cannot generate headers - not configured') + } + + // Use empty string body for GET and DELETE requests + const effectiveBody = method === 'GET' || method === 'DELETE' ? '' : body + + // Generate Date in RFC 7231 format + const dateHeader = new Date().toUTCString() + + // Generate UUID v4 for X-Request-ID + const xRequestId = crypto.randomUUID() + + // Compute SHA-256 digest of the body + const digestValue = crypto.createHash('sha256').update(effectiveBody).digest('base64') + const digestHeader = `SHA-256=${digestValue}` + + // Create the string to sign per PSD2 spec + const dataToSign = `digest: ${digestHeader}\ndate: ${dateHeader}\nx-request-id: ${xRequestId}` + + // Sign with RSA-SHA256 + const sign = crypto.createSign('RSA-SHA256') + sign.update(dataToSign) + sign.end() + const signature = sign.sign(this.privateKey, 'base64') + + // Build Signature header + const signatureHeader = `keyId="${this.config.keyId}", algorithm="rsa-sha256", headers="digest date x-request-id", signature="${signature}"` + + // Base64-encode the certificate + const certificateBase64 = Buffer.from(this.certificate).toString('base64') + + // Build headers object + const headers: BerlinGroupHeaders = { + 'Content-Type': 'application/json', + Date: dateHeader, + 'X-Request-ID': xRequestId, + Digest: digestHeader, + Signature: signatureHeader, + 'TPP-Signature-Certificate': certificateBase64, + 'PSU-Device-ID': this.config.psuDeviceId, + 'PSU-Device-Name': this.config.psuDeviceName, + 'PSU-IP-Address': this.config.psuIpAddress + } + + // Add redirect URIs for POST requests + if (method === 'POST' && this.config.tppRedirectUri) { + headers['TPP-Redirect-URI'] = this.config.tppRedirectUri + } + if (method === 'POST' && this.config.tppNokRedirectUri) { + headers['TPP-Nok-Redirect-URI'] = this.config.tppNokRedirectUri + } + + // Add Consent-ID when provided + if (consentId) { + headers['Consent-ID'] = consentId + } + + console.log(`BerlinGroupSignatureService: Generated headers for ${method} request`) + console.log(` X-Request-ID: ${xRequestId}`) + console.log(` Digest: ${digestHeader}`) + + return headers + } +} diff --git a/server/services/OAuth2ProviderFactory.ts b/server/services/OAuth2ProviderFactory.ts index 7d95848..8ed80c8 100644 --- a/server/services/OAuth2ProviderFactory.ts +++ b/server/services/OAuth2ProviderFactory.ts @@ -72,10 +72,10 @@ export class OAuth2ProviderFactory { process.env.VITE_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback' // OBP-OIDC Strategy - if (process.env.VITE_OBP_OIDC_CLIENT_ID) { + if (process.env.VITE_OBP_OAUTH2_CLIENT_ID) { this.strategies.set('obp-oidc', { - clientId: process.env.VITE_OBP_OIDC_CLIENT_ID, - clientSecret: process.env.VITE_OBP_OIDC_CLIENT_SECRET || '', + clientId: process.env.VITE_OBP_OAUTH2_CLIENT_ID, + clientSecret: process.env.VITE_OBP_OAUTH2_CLIENT_SECRET || '', redirectUri: sharedRedirectUri, scopes: ['openid', 'profile', 'email'] }) @@ -133,7 +133,7 @@ export class OAuth2ProviderFactory { 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' + 'OAuth2ProviderFactory: Example: VITE_OBP_OAUTH2_CLIENT_ID, VITE_OBP_OAUTH2_CLIENT_SECRET' ) } } diff --git a/server/services/OBPClientService.ts b/server/services/OBPClientService.ts index 0c2fde6..ab788db 100644 --- a/server/services/OBPClientService.ts +++ b/server/services/OBPClientService.ts @@ -25,8 +25,10 @@ * */ -import { Service } from 'typedi' +import { Service, Container } from 'typedi' import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js' +import { BerlinGroupSignatureService } from './BerlinGroupSignatureService.js' +import type { BerlinGroupSessionData } from '../types/berlin-group.js' // Custom error class to preserve HTTP status codes class OBPAPIError extends Error { @@ -49,6 +51,7 @@ interface APIClientConfig { baseUri: string version: string oauth2?: OAuth2Config + berlinGroup?: BerlinGroupSessionData } @Service() @@ -81,6 +84,14 @@ export default class OBPClientService { async get(path: string, clientConfig: any): Promise { const config = this.getSessionConfig(clientConfig) + // Check if this is a Berlin Group path and signing is enabled + const bgService = Container.get(BerlinGroupSignatureService) + if (BerlinGroupSignatureService.isBerlinGroupPath(path) && bgService.isEnabled()) { + return await this.requestWithBerlinGroupHeaders( + path, 'GET', '', config, config?.berlinGroup?.consentId + ) + } + // If no config or no access token, make unauthenticated request if (!config || !config.oauth2?.accessToken) { return await this.getWithoutAuth(path) @@ -92,6 +103,14 @@ export default class OBPClientService { async create(path: string, body: any, clientConfig: any): Promise { const config = this.getSessionConfig(clientConfig) + // Check if this is a Berlin Group path and signing is enabled + const bgService = Container.get(BerlinGroupSignatureService) + if (BerlinGroupSignatureService.isBerlinGroupPath(path) && bgService.isEnabled()) { + return await this.requestWithBerlinGroupHeaders( + path, 'POST', JSON.stringify(body), config, config?.berlinGroup?.consentId + ) + } + if (!config || !config.oauth2?.accessToken) { throw new Error('Authentication required for creating resources.') } @@ -102,6 +121,14 @@ export default class OBPClientService { async update(path: string, body: any, clientConfig: any): Promise { const config = this.getSessionConfig(clientConfig) + // Check if this is a Berlin Group path and signing is enabled + const bgService = Container.get(BerlinGroupSignatureService) + if (BerlinGroupSignatureService.isBerlinGroupPath(path) && bgService.isEnabled()) { + return await this.requestWithBerlinGroupHeaders( + path, 'PUT', JSON.stringify(body), config, config?.berlinGroup?.consentId + ) + } + if (!config || !config.oauth2?.accessToken) { throw new Error('Authentication required for updating resources.') } @@ -112,12 +139,60 @@ export default class OBPClientService { async discard(path: string, clientConfig: any): Promise { const config = this.getSessionConfig(clientConfig) + // Check if this is a Berlin Group path and signing is enabled + const bgService = Container.get(BerlinGroupSignatureService) + if (BerlinGroupSignatureService.isBerlinGroupPath(path) && bgService.isEnabled()) { + return await this.requestWithBerlinGroupHeaders( + path, 'DELETE', '', config, config?.berlinGroup?.consentId + ) + } + if (!config || !config.oauth2?.accessToken) { throw new Error('Authentication required for deleting resources.') } return await this.discardWithBearer(path, config.oauth2.accessToken) } + /** + * Make a request to a Berlin Group API path with TPP signature headers. + * OAuth2 Bearer token is included when available alongside signature headers. + */ + private async requestWithBerlinGroupHeaders( + path: string, + method: string, + body: string, + clientConfig: APIClientConfig | null, + consentId?: string + ): Promise { + const bgService = Container.get(BerlinGroupSignatureService) + const bgHeaders = bgService.generateHeaders(method, body, consentId) + + // Merge with OAuth2 Bearer token if available + const headers: Record = { ...bgHeaders } + if (clientConfig?.oauth2?.accessToken) { + headers['Authorization'] = `Bearer ${clientConfig.oauth2.accessToken}` + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const url = `${this.clientConfig.baseUri}${normalizedPath}` + console.log(`OBPClientService: ${method} Berlin Group request to: ${url}`) + + const fetchOptions: RequestInit = { method, headers } + if (method === 'POST' || method === 'PUT') { + fetchOptions.body = body + } + + const response = await fetch(url, fetchOptions) + + if (!response.ok) { + const errorText = await response.text() + console.error(`[OBPClientService] Berlin Group ${method} request failed:`, response.status, errorText) + throw new OBPAPIError(response.status, errorText) + } + + return await response.json() + } + private getSessionConfig(clientConfig: APIClientConfig): APIClientConfig { return clientConfig || this.clientConfig } diff --git a/server/types/berlin-group.ts b/server/types/berlin-group.ts new file mode 100644 index 0000000..9adcad5 --- /dev/null +++ b/server/types/berlin-group.ts @@ -0,0 +1,66 @@ +/* + * 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 . + * + * Email: contact@tesobe.com + * TESOBE GmbH + * Osloerstrasse 16/17 + * Berlin 13359, Germany + * + * This product includes software developed at + * TESOBE (http://www.tesobe.com/) + * + */ + +/** + * Configuration loaded from environment variables for Berlin Group TPP signing + */ +export interface BerlinGroupConfig { + privateKeyPath: string + certificatePath: string + keyId: string + apiVersion: string + psuDeviceId: string + psuDeviceName: string + psuIpAddress: string + tppRedirectUri: string + tppNokRedirectUri: string +} + +/** + * Headers generated for a Berlin Group PSD2 API request + */ +export interface BerlinGroupHeaders { + Date: string + 'X-Request-ID': string + Digest: string + Signature: string + 'TPP-Signature-Certificate': string + 'PSU-Device-ID': string + 'PSU-Device-Name': string + 'PSU-IP-Address': string + 'Content-Type': string + 'TPP-Redirect-URI'?: string + 'TPP-Nok-Redirect-URI'?: string + 'Consent-ID'?: string +} + +/** + * Berlin Group session data passed from routes to OBPClientService + */ +export interface BerlinGroupSessionData { + consentId?: string +}