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
+}