Skip to content

Commit 95c7a37

Browse files
feature: Add Berlin Group PSD2 signature support
Files Created 1. server/types/berlin-group.ts - TypeScript interfaces: BerlinGroupConfig, BerlinGroupHeaders, BerlinGroupSessionData 2. server/services/BerlinGroupSignatureService.ts - New @service() class that: - Loads private key and certificate PEM files from VITE_BG_PRIVATE_KEY_PATH and VITE_BG_CERTIFICATE_PATH at construction - isEnabled() checks if certificates are loaded - static isBerlinGroupPath(path) detects "/berlin-group/" in request paths - generateHeaders(method, body, consentId?) generates required PSD2 headers: Date X-Request-ID Digest (SHA-256) Signature (RSA-SHA256) TPP-Signature-Certificate PSU headers Redirect URIs (POST only) Optional Consent-ID Files Modified 3. server/services/OBPClientService.ts - get(), create(), update(), discard() now check isBerlinGroupPath() && isEnabled() - Routes Berlin Group requests to new private method requestWithBerlinGroupHeaders() - Merges signature headers with op
1 parent ecb8ab4 commit 95c7a37

7 files changed

Lines changed: 375 additions & 9 deletions

File tree

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id
4848
# VITE_CUSTOM_OIDC_CLIENT_ID=your-custom-client-id
4949
# VITE_CUSTOM_OIDC_CLIENT_SECRET=your-custom-client-secret
5050

51+
### Berlin Group TPP Signature Certificate Configuration (Optional) ###
52+
# VITE_BG_PRIVATE_KEY_PATH=./certs/private_key.pem
53+
# VITE_BG_CERTIFICATE_PATH=./certs/certificate.pem
54+
# VITE_BG_KEY_ID=SN=1082, CA=CN=Your Name, O=YourOrg
55+
# VITE_BG_API_VERSION=v1.3
56+
# VITE_BG_PSU_DEVICE_ID=device-1234567890
57+
# VITE_BG_PSU_DEVICE_NAME=API-Explorer-II
58+
# VITE_BG_PSU_IP_ADDRESS=127.0.0.1
59+
# VITE_BG_TPP_REDIRECT_URI=https://your-app.com/berlin-group/redirect
60+
# VITE_BG_TPP_NOK_REDIRECT_URI=https://your-app.com/berlin-group/error
61+
5162
### Chatbot Configuration (Optional) ###
5263
VITE_CHATBOT_ENABLED=false
5364
# VITE_CHATBOT_URL=http://localhost:5000

server/app.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { Container } from 'typedi'
3636
import path from 'path'
3737
import { execSync } from 'child_process'
3838
import { OAuth2ProviderManager } from './services/OAuth2ProviderManager.js'
39+
import { BerlinGroupSignatureService } from './services/BerlinGroupSignatureService.js'
3940
import { fileURLToPath } from 'url'
4041
import { dirname } from 'path'
4142

@@ -169,6 +170,18 @@ let instance: any
169170
}
170171
console.log(`-----------------------------------------------------------------`)
171172

173+
// Berlin Group TPP Signature Certificate Setup
174+
console.log('--- Berlin Group TPP Signature Certificate -----------------------')
175+
const bgService = Container.get(BerlinGroupSignatureService)
176+
if (bgService.isEnabled()) {
177+
console.log('OK Berlin Group TPP Signature Certificate is configured and loaded')
178+
console.log(` API Version: ${bgService.getApiVersion()}`)
179+
} else {
180+
console.log('Berlin Group TPP Signature Certificate is NOT configured')
181+
console.log(' Set VITE_BG_PRIVATE_KEY_PATH and VITE_BG_CERTIFICATE_PATH to enable')
182+
}
183+
console.log(`-----------------------------------------------------------------`)
184+
172185
const routePrefix = '/api'
173186

174187
// Register all routes (plain Express)

server/routes/obp.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ router.get('/get', async (req: Request, res: Response) => {
5454
const path = req.query.path as string
5555
const session = req.session as any
5656

57-
const oauthConfig = session.clientConfig
57+
const oauthConfig = session.clientConfig || {}
58+
const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined
59+
if (bgConsentId) {
60+
oauthConfig.berlinGroup = { consentId: bgConsentId }
61+
}
5862

5963
const result = await obpClientService.get(path, oauthConfig)
6064
res.json(result)
@@ -85,7 +89,11 @@ router.post('/create', async (req: Request, res: Response) => {
8589
const data = req.body
8690
const session = req.session as any
8791

88-
const oauthConfig = session.clientConfig
92+
const oauthConfig = session.clientConfig || {}
93+
const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined
94+
if (bgConsentId) {
95+
oauthConfig.berlinGroup = { consentId: bgConsentId }
96+
}
8997

9098
// Debug logging to diagnose authentication issues
9199
console.log('OBP.create - Debug Info:')
@@ -95,6 +103,7 @@ router.post('/create', async (req: Request, res: Response) => {
95103
console.log(' oauth2 exists:', oauthConfig?.oauth2 ? 'YES' : 'NO')
96104
console.log(' accessToken exists:', oauthConfig?.oauth2?.accessToken ? 'YES' : 'NO')
97105
console.log(' oauth2_user exists:', session?.oauth2_user ? 'YES' : 'NO')
106+
console.log(' berlinGroup consentId:', bgConsentId || 'N/A')
98107

99108
const result = await obpClientService.create(path, data, oauthConfig)
100109
res.json(result)
@@ -120,7 +129,11 @@ router.put('/update', async (req: Request, res: Response) => {
120129
const data = req.body
121130
const session = req.session as any
122131

123-
const oauthConfig = session.clientConfig
132+
const oauthConfig = session.clientConfig || {}
133+
const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined
134+
if (bgConsentId) {
135+
oauthConfig.berlinGroup = { consentId: bgConsentId }
136+
}
124137

125138
const result = await obpClientService.update(path, data, oauthConfig)
126139
res.json(result)
@@ -144,7 +157,11 @@ router.delete('/delete', async (req: Request, res: Response) => {
144157
const path = req.query.path as string
145158
const session = req.session as any
146159

147-
const oauthConfig = session.clientConfig
160+
const oauthConfig = session.clientConfig || {}
161+
const bgConsentId = req.headers['x-bg-consent-id'] as string | undefined
162+
if (bgConsentId) {
163+
oauthConfig.berlinGroup = { consentId: bgConsentId }
164+
}
148165

149166
const result = await obpClientService.discard(path, oauthConfig)
150167
res.json(result)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Open Bank Project - API Explorer II
3+
* Copyright (C) 2023-2025, TESOBE GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*
18+
* Email: contact@tesobe.com
19+
* TESOBE GmbH
20+
* Osloerstrasse 16/17
21+
* Berlin 13359, Germany
22+
*
23+
* This product includes software developed at
24+
* TESOBE (http://www.tesobe.com/)
25+
*
26+
*/
27+
28+
import { Service } from 'typedi'
29+
import crypto from 'crypto'
30+
import fs from 'fs'
31+
import type { BerlinGroupConfig, BerlinGroupHeaders } from '../types/berlin-group.js'
32+
33+
/**
34+
* BerlinGroupSignatureService generates RSA-SHA256 digital signatures,
35+
* SHA-256 body digests, and TPP certificate headers required by
36+
* Berlin Group PSD2 APIs.
37+
*
38+
* Configuration is loaded from environment variables at construction.
39+
* If certificate files are not configured, the service gracefully degrades
40+
* and isEnabled() returns false.
41+
*/
42+
@Service()
43+
export class BerlinGroupSignatureService {
44+
private privateKey: string | null = null
45+
private certificate: string | null = null
46+
private config: BerlinGroupConfig | null = null
47+
48+
constructor() {
49+
this.loadConfig()
50+
}
51+
52+
private loadConfig(): void {
53+
const privateKeyPath = process.env.VITE_BG_PRIVATE_KEY_PATH
54+
const certificatePath = process.env.VITE_BG_CERTIFICATE_PATH
55+
56+
if (!privateKeyPath || !certificatePath) {
57+
console.log('BerlinGroupSignatureService: Certificate paths not configured, signing disabled')
58+
return
59+
}
60+
61+
try {
62+
this.privateKey = fs.readFileSync(privateKeyPath, 'utf8')
63+
this.certificate = fs.readFileSync(certificatePath, 'utf8')
64+
65+
this.config = {
66+
privateKeyPath,
67+
certificatePath,
68+
keyId: process.env.VITE_BG_KEY_ID || 'SN=unknown, CA=CN=unknown',
69+
apiVersion: process.env.VITE_BG_API_VERSION || 'v1.3',
70+
psuDeviceId: process.env.VITE_BG_PSU_DEVICE_ID || 'device-api-explorer-ii',
71+
psuDeviceName: process.env.VITE_BG_PSU_DEVICE_NAME || 'API-Explorer-II',
72+
psuIpAddress: process.env.VITE_BG_PSU_IP_ADDRESS || '127.0.0.1',
73+
tppRedirectUri: process.env.VITE_BG_TPP_REDIRECT_URI || '',
74+
tppNokRedirectUri: process.env.VITE_BG_TPP_NOK_REDIRECT_URI || ''
75+
}
76+
77+
console.log('BerlinGroupSignatureService: Private key and certificate loaded successfully')
78+
} catch (error: any) {
79+
console.error('BerlinGroupSignatureService: Failed to load certificate files:', error.message)
80+
this.privateKey = null
81+
this.certificate = null
82+
this.config = null
83+
}
84+
}
85+
86+
/**
87+
* Check if Berlin Group signing is enabled (certificates are loaded)
88+
*/
89+
isEnabled(): boolean {
90+
return this.privateKey !== null && this.certificate !== null && this.config !== null
91+
}
92+
93+
/**
94+
* Get the configured Berlin Group API version
95+
*/
96+
getApiVersion(): string {
97+
return this.config?.apiVersion || 'v1.3'
98+
}
99+
100+
/**
101+
* Detect whether a request path is a Berlin Group API path
102+
*/
103+
static isBerlinGroupPath(path: string): boolean {
104+
return path.includes('/berlin-group/')
105+
}
106+
107+
/**
108+
* Generate all required Berlin Group PSD2 headers for a request
109+
*
110+
* @param method - HTTP method (GET, POST, PUT, DELETE)
111+
* @param body - Request body (empty string for GET/DELETE)
112+
* @param consentId - Optional Consent-ID for account data endpoints
113+
* @returns Object containing all required Berlin Group headers
114+
*/
115+
generateHeaders(
116+
method: string,
117+
body: string,
118+
consentId?: string
119+
): BerlinGroupHeaders {
120+
if (!this.privateKey || !this.certificate || !this.config) {
121+
throw new Error('BerlinGroupSignatureService: Cannot generate headers - not configured')
122+
}
123+
124+
// Use empty string body for GET and DELETE requests
125+
const effectiveBody = method === 'GET' || method === 'DELETE' ? '' : body
126+
127+
// Generate Date in RFC 7231 format
128+
const dateHeader = new Date().toUTCString()
129+
130+
// Generate UUID v4 for X-Request-ID
131+
const xRequestId = crypto.randomUUID()
132+
133+
// Compute SHA-256 digest of the body
134+
const digestValue = crypto.createHash('sha256').update(effectiveBody).digest('base64')
135+
const digestHeader = `SHA-256=${digestValue}`
136+
137+
// Create the string to sign per PSD2 spec
138+
const dataToSign = `digest: ${digestHeader}\ndate: ${dateHeader}\nx-request-id: ${xRequestId}`
139+
140+
// Sign with RSA-SHA256
141+
const sign = crypto.createSign('RSA-SHA256')
142+
sign.update(dataToSign)
143+
sign.end()
144+
const signature = sign.sign(this.privateKey, 'base64')
145+
146+
// Build Signature header
147+
const signatureHeader = `keyId="${this.config.keyId}", algorithm="rsa-sha256", headers="digest date x-request-id", signature="${signature}"`
148+
149+
// Base64-encode the certificate
150+
const certificateBase64 = Buffer.from(this.certificate).toString('base64')
151+
152+
// Build headers object
153+
const headers: BerlinGroupHeaders = {
154+
'Content-Type': 'application/json',
155+
Date: dateHeader,
156+
'X-Request-ID': xRequestId,
157+
Digest: digestHeader,
158+
Signature: signatureHeader,
159+
'TPP-Signature-Certificate': certificateBase64,
160+
'PSU-Device-ID': this.config.psuDeviceId,
161+
'PSU-Device-Name': this.config.psuDeviceName,
162+
'PSU-IP-Address': this.config.psuIpAddress
163+
}
164+
165+
// Add redirect URIs for POST requests
166+
if (method === 'POST' && this.config.tppRedirectUri) {
167+
headers['TPP-Redirect-URI'] = this.config.tppRedirectUri
168+
}
169+
if (method === 'POST' && this.config.tppNokRedirectUri) {
170+
headers['TPP-Nok-Redirect-URI'] = this.config.tppNokRedirectUri
171+
}
172+
173+
// Add Consent-ID when provided
174+
if (consentId) {
175+
headers['Consent-ID'] = consentId
176+
}
177+
178+
console.log(`BerlinGroupSignatureService: Generated headers for ${method} request`)
179+
console.log(` X-Request-ID: ${xRequestId}`)
180+
console.log(` Digest: ${digestHeader}`)
181+
182+
return headers
183+
}
184+
}

server/services/OAuth2ProviderFactory.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ export class OAuth2ProviderFactory {
7272
process.env.VITE_OAUTH2_REDIRECT_URL || 'http://localhost:5173/api/oauth2/callback'
7373

7474
// OBP-OIDC Strategy
75-
if (process.env.VITE_OBP_OIDC_CLIENT_ID) {
75+
if (process.env.VITE_OBP_OAUTH2_CLIENT_ID) {
7676
this.strategies.set('obp-oidc', {
77-
clientId: process.env.VITE_OBP_OIDC_CLIENT_ID,
78-
clientSecret: process.env.VITE_OBP_OIDC_CLIENT_SECRET || '',
77+
clientId: process.env.VITE_OBP_OAUTH2_CLIENT_ID,
78+
clientSecret: process.env.VITE_OBP_OAUTH2_CLIENT_SECRET || '',
7979
redirectUri: sharedRedirectUri,
8080
scopes: ['openid', 'profile', 'email']
8181
})
@@ -133,7 +133,7 @@ export class OAuth2ProviderFactory {
133133
console.warn('OAuth2ProviderFactory: WARNING - No provider strategies configured!')
134134
console.warn('OAuth2ProviderFactory: Set environment variables for at least one provider')
135135
console.warn(
136-
'OAuth2ProviderFactory: Example: VITE_OBP_OIDC_CLIENT_ID, VITE_OBP_OIDC_CLIENT_SECRET'
136+
'OAuth2ProviderFactory: Example: VITE_OBP_OAUTH2_CLIENT_ID, VITE_OBP_OAUTH2_CLIENT_SECRET'
137137
)
138138
}
139139
}

0 commit comments

Comments
 (0)