use plain express 4 with cleanup

This commit is contained in:
simonredfern 2025-12-29 09:02:37 +01:00
parent 52dfe6fb6b
commit 94fc898f5d
11 changed files with 7 additions and 2120 deletions

View File

@ -32,7 +32,6 @@ import RedisStore from 'connect-redis'
import { createClient } from 'redis'
import express from 'express'
import type { Application } from 'express'
import { useExpressServer, useContainer } from 'routing-controllers'
import { Container } from 'typedi'
import path from 'path'
import { execSync } from 'child_process'
@ -41,20 +40,14 @@ import { OAuth2ProviderManager } from './services/OAuth2ProviderManager.js'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
// Import controllers
import { OpeyController } from './controllers/OpeyIIController.js'
import { OBPController } from './controllers/RequestController.js'
import { StatusController } from './controllers/StatusController.js'
import { UserController } from './controllers/UserController.js'
import { OAuth2CallbackController } from './controllers/OAuth2CallbackController.js'
import { OAuth2ConnectController } from './controllers/OAuth2ConnectController.js'
import { OAuth2ProvidersController } from './controllers/OAuth2ProvidersController.js'
// Controllers removed - all routes migrated to plain Express
// Import routes (plain Express, not routing-controllers)
import oauth2Routes from './routes/oauth2.js'
import userRoutes from './routes/user.js'
import statusRoutes from './routes/status.js'
import obpRoutes from './routes/obp.js'
import opeyRoutes from './routes/opey.js'
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
@ -143,7 +136,6 @@ if (app.get('env') === 'production') {
sessionObject.cookie.secure = true // serve secure cookies
}
app.use(session(sessionObject))
useContainer(Container)
// Initialize OAuth2 Service
console.log(`--- OAuth2/OIDC setup -------------------------------------------`)
@ -228,23 +220,20 @@ let instance: any
const routePrefix = '/api'
// Register routes BEFORE routing-controllers (plain Express)
// Register all routes (plain Express)
app.use(routePrefix, oauth2Routes)
app.use(routePrefix, userRoutes)
app.use(routePrefix, statusRoutes)
app.use(routePrefix, obpRoutes)
app.use(routePrefix, opeyRoutes)
console.log('OAuth2 routes registered (plain Express)')
console.log('User routes registered (plain Express)')
console.log('Status routes registered (plain Express)')
console.log('OBP routes registered (plain Express)')
console.log('Opey routes registered (plain Express)')
console.log('All routes migrated to plain Express - routing-controllers removed')
const server = useExpressServer(app, {
routePrefix: routePrefix,
controllers: [OpeyController],
middlewares: []
})
instance = server.listen(port)
instance = app.listen(port)
console.log(
`Backend is running. You can check a status at http://localhost:${port}${routePrefix}/status`

View File

@ -1,98 +0,0 @@
/*
* 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 { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service } from 'typedi'
import OAuth2CallbackMiddleware from '../middlewares/OAuth2CallbackMiddleware.js'
/**
* OAuth2 Callback Controller
*
* Handles the OAuth2/OIDC callback from the identity provider.
* This controller receives the authorization code and state parameter
* after the user authenticates with the OIDC provider.
*
* The OAuth2CallbackMiddleware handles:
* - State validation (CSRF protection)
* - Authorization code exchange for tokens
* - User info retrieval
* - Session storage
* - Redirect to original page
*
* Endpoint: GET /oauth2/callback
*
* Query Parameters (from OIDC provider):
* - code: Authorization code to exchange for tokens
* - state: State parameter for CSRF validation
* - error (optional): Error code if authentication failed
* - error_description (optional): Human-readable error description
*
* Flow:
* OIDC Provider /oauth2/callback?code=XXX&state=YYY
* OAuth2CallbackMiddleware Original Page (with authenticated session)
*
* Success Flow:
* 1. Validate state parameter
* 2. Exchange authorization code for tokens (access, refresh, ID)
* 3. Fetch user information from UserInfo endpoint
* 4. Store tokens and user data in session
* 5. Redirect to original page or home
*
* Error Flow:
* 1. Parse error from query parameters
* 2. Display user-friendly error page
* 3. Allow user to retry authentication
*
* @example
* // Successful callback URL from OIDC provider
* http://localhost:5173/oauth2/callback?code=abc123&state=xyz789
*
* // Error callback URL from OIDC provider
* http://localhost:5173/oauth2/callback?error=access_denied&error_description=User%20cancelled
*/
@Service()
@Controller()
@UseBefore(OAuth2CallbackMiddleware)
export class OAuth2CallbackController {
/**
* Handle OAuth2/OIDC callback
*
* The actual logic is handled by OAuth2CallbackMiddleware.
* This method exists only as the routing endpoint definition.
*
* @param {Request} request - Express request object with query params (code, state)
* @param {Response} response - Express response object (redirected by middleware)
* @returns {Response} Response object (handled by middleware)
*/
@Get('/oauth2/callback')
callback(@Req() request: Request, @Res() response: Response): Response {
// The middleware handles all the logic and redirects the user
// This method should never actually execute
return response
}
}

View File

@ -1,77 +0,0 @@
/*
* 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 { Controller, Req, Res, Get, UseBefore } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service } from 'typedi'
import OAuth2AuthorizationMiddleware from '../middlewares/OAuth2AuthorizationMiddleware.js'
/**
* OAuth2 Connect Controller
*
* Handles the OAuth2/OIDC login initiation endpoint.
* This controller triggers the OAuth2 authorization flow by delegating to
* the OAuth2AuthorizationMiddleware which generates PKCE parameters and
* redirects to the OIDC provider.
*
* Endpoint: GET /oauth2/connect
*
* Query Parameters:
* - redirect (optional): URL to redirect to after successful authentication
*
* Flow:
* User clicks login /oauth2/connect OAuth2AuthorizationMiddleware
* OIDC Provider Authorization Endpoint
*
* @example
* // User initiates login
* <a href="/oauth2/connect?redirect=/messages">Login</a>
*
* // JavaScript redirect
* window.location.href = '/oauth2/connect?redirect=' + encodeURIComponent(window.location.pathname)
*/
@Service()
@Controller()
@UseBefore(OAuth2AuthorizationMiddleware)
export class OAuth2ConnectController {
/**
* Initiate OAuth2/OIDC authentication flow
*
* The actual logic is handled by OAuth2AuthorizationMiddleware.
* This method exists only as the routing endpoint definition.
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object (redirected by middleware)
* @returns {Response} Response object (handled by middleware)
*/
@Get('/oauth2/connect')
connect(@Req() request: Request, @Res() response: Response): Response {
// The middleware handles all the logic and redirects the user
// This method should never actually execute
return response
}
}

View File

@ -1,108 +0,0 @@
/*
* 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 { Controller, Get } from 'routing-controllers'
import { Service, Container } from 'typedi'
import { OAuth2ProviderManager } from '../services/OAuth2ProviderManager.js'
/**
* OAuth2 Providers Controller
*
* Provides endpoints to query available OIDC providers
*
* Endpoints:
* GET /api/oauth2/providers - List available OIDC providers
*
* @example
* // Fetch available providers
* const response = await fetch('/api/oauth2/providers')
* const data = await response.json()
* // {
* // providers: [
* // { name: "obp-oidc", available: true, lastChecked: "2024-01-15T10:30:00Z" },
* // { name: "keycloak", available: false, lastChecked: "2024-01-15T10:30:00Z", error: "Connection timeout" }
* // ],
* // count: 2,
* // availableCount: 1
* // }
*/
@Service()
@Controller()
export class OAuth2ProvidersController {
private providerManager: OAuth2ProviderManager
constructor() {
this.providerManager = Container.get(OAuth2ProviderManager)
}
/**
* Get list of available OAuth2/OIDC providers
*
* Returns provider names and availability status for all configured providers.
* This endpoint is used by the frontend to display provider selection UI.
*
* @returns JSON response with providers array, total count, and available count
*
* @example
* GET /api/oauth2/providers
*
* Response:
* {
* "providers": [
* {
* "name": "obp-oidc",
* "available": true,
* "lastChecked": "2024-01-15T10:30:00.000Z"
* },
* {
* "name": "keycloak",
* "available": false,
* "lastChecked": "2024-01-15T10:30:00.000Z",
* "error": "Connection timeout"
* }
* ],
* "count": 2,
* "availableCount": 1
* }
*/
@Get('/api/oauth2/providers')
async getProviders(): Promise<any> {
console.log('OAuth2ProvidersController: Fetching provider list')
const allStatus = this.providerManager.getAllProviderStatus()
const availableProviders = this.providerManager.getAvailableProviders()
console.log(`OAuth2ProvidersController: Total providers: ${allStatus.length}`)
console.log(`OAuth2ProvidersController: Available providers: ${availableProviders.length}`)
return {
providers: allStatus,
count: allStatus.length,
availableCount: availableProviders.length
}
}
}

View File

@ -1,360 +0,0 @@
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Readable } from 'node:stream'
import { ReadableStream as WebReadableStream } from 'stream/web'
import { Service, Container } from 'typedi'
import OBPClientService from '../services/OBPClientService.js'
import OpeyClientService from '../services/OpeyClientService.js'
import OBPConsentsService from '../services/OBPConsentsService.js'
import { UserInput, OpeyConfig } from '../schema/OpeySchema.js'
import {
APIApi,
Configuration,
ConsentApi,
ConsumerConsentrequestsBody,
InlineResponse20151
} from 'obp-api-typescript'
@Service()
@Controller('/opey')
export class OpeyController {
public obpClientService: OBPClientService
public opeyClientService: OpeyClientService
public obpConsentsService: OBPConsentsService
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.opeyClientService = Container.get(OpeyClientService)
this.obpConsentsService = Container.get(OBPConsentsService)
}
@Get('/')
async getStatus(@Res() response: Response): Promise<Response | any> {
try {
const opeyStatus = await this.opeyClientService.getOpeyStatus()
console.log('Opey status: ', opeyStatus)
return response.status(200).json({ status: 'Opey is running' })
} catch (error) {
console.error('Error in /opey endpoint: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
}
@Post('/stream')
async streamOpey(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response> {
if (!session) {
console.error('Session not found')
return response.status(401).json({ error: 'Session Time Out' })
}
// Check if the consent is in the session, and can be added to the headers
const opeyConfig = session['opeyConfig']
if (!opeyConfig) {
console.error('Opey config not found in session')
return response.status(500).json({ error: 'Internal Server Error' })
}
// Read user input from request body
let user_input: UserInput
try {
console.log('Request body: ', request.body)
user_input = {
message: request.body.message,
thread_id: request.body.thread_id,
is_tool_call_approval: request.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in stream endpoint, could not parse into UserInput: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
// Transform to decode and log the stream
const frontendTransformer = new TransformStream({
transform(chunk, controller) {
// Decode the chunk to a string
const decodedChunk = new TextDecoder().decode(chunk)
console.log('Sending chunk', decodedChunk)
controller.enqueue(decodedChunk)
},
flush(controller) {
console.log('[flush]')
// Close ReadableStream when done
controller.terminate()
}
})
let stream: ReadableStream | null = null
try {
// Read web stream from OpeyClientService
console.log('Calling OpeyClientService.stream')
stream = await this.opeyClientService.stream(user_input, opeyConfig)
} catch (error) {
console.error('Error reading stream: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
if (!stream) {
console.error('Stream is not recieved or not readable')
return response.status(500).json({ error: 'Internal Server Error' })
}
// Transform our stream if needed, right now this is just a passthrough
const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer)
// If we need to split the stream into two, we can use the tee method as below
// const streamTee = langchainStream.tee()
// if (!streamTee) {
// console.error("Stream is not tee'd")
// return response.status(500).json({ error: 'Internal Server Error' })
// }
// const [stream1, stream2] = streamTee
// function to convert a web stream to a node stream
const safeFromWeb = (webStream: WebReadableStream<any>): Readable => {
if (typeof Readable.fromWeb === 'function') {
return Readable.fromWeb(webStream)
} else {
console.warn('Readable.fromWeb is not available, using a polyfill')
// Create a Node.js Readable stream
const nodeReadable = new Readable({
read() {}
})
// Pump data from webreadable to node readable stream
const reader = webStream.getReader()
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
nodeReadable.push(null) // end stream
break
}
nodeReadable.push(value)
}
} catch (error) {
console.error('Error reading from web stream:', error)
nodeReadable.destroy(error instanceof Error ? error : new Error(error))
}
})()
return nodeReadable
}
}
const nodeStream = safeFromWeb(frontendStream as WebReadableStream<any>)
response.setHeader('Content-Type', 'text/event-stream')
response.setHeader('Cache-Control', 'no-cache')
response.setHeader('Connection', 'keep-alive')
nodeStream.pipe(response)
return new Promise<Response>((resolve, reject) => {
nodeStream.on('end', () => {
resolve(response)
})
nodeStream.on('error', (error) => {
console.error('Stream error:', error)
reject(error)
})
// Add a timeout to prevent hanging promises
const timeout = setTimeout(() => {
console.warn('Stream timeout reached')
resolve(response)
}, 30000)
// Clear the timeout when stream ends
nodeStream.on('end', () => clearTimeout(timeout))
nodeStream.on('error', () => clearTimeout(timeout))
})
}
@Post('/invoke')
async invokeOpey(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response | any> {
// Check if the consent is in the session, and can be added to the headers
const opeyConfig = session['opeyConfig']
if (!opeyConfig) {
console.error('Opey config not found in session')
return response.status(500).json({ error: 'Internal Server Error' })
}
let user_input: UserInput
try {
user_input = {
message: request.body.message,
thread_id: request.body.thread_id,
is_tool_call_approval: request.body.is_tool_call_approval
}
} catch (error) {
console.error('Error in invoke endpoint, could not parse into UserInput: ', error)
return response.status(500).json({ error: 'Internal Server Error' })
}
try {
const opey_response = await this.opeyClientService.invoke(user_input, opeyConfig)
//console.log("Opey response: ", opey_response)
return response.status(200).json(opey_response)
} catch (error) {
console.error(error)
return response.status(500).json({ error: 'Internal Server Error' })
}
}
// @Post('/consent/request')
// /**
// * Retrieves a consent request from OBP
// *
// */
// async getConsentRequest(
// @Session() session: any,
// @Req() request: Request,
// @Res() response: Response,
// ): Promise<Response | any> {
// try {
// let obpToken: string
// obpToken = await this.obpClientService.getDirectLoginToken()
// console.log("Got token: ", obpToken)
// const authHeader = `DirectLogin token="${obpToken}"`
// console.log("Auth header: ", authHeader)
// //const obpOAuthHeaders = await this.obpClientService.getOAuthHeader('/consents', 'POST')
// //console.log("OBP OAuth Headers: ", obpOAuthHeaders)
// const obpConfig: Configuration = {
// apiKey: authHeader,
// basePath: process.env.VITE_OBP_API_HOST,
// }
// console.log("OBP Config: ", obpConfig)
// const consentAPI = new ConsentApi(obpConfig, process.env.VITE_OBP_API_HOST)
// // OBP sdk naming is a bit mad, can be rectified in the future
// const consentRequestResponse = await consentAPI.oBPv500CreateConsentRequest({
// accountAccess: [],
// everything: false,
// entitlements: [],
// consumerId: '',
// } as unknown as ConsumerConsentrequestsBody,
// {
// headers: {
// 'Content-Type': 'application/json',
// },
// }
// )
// //console.log("Consent request response: ", consentRequestResponse)
// console.log({consentId: consentRequestResponse.data.consent_request_id})
// session['obpConsentRequestId'] = consentRequestResponse.data.consent_request_id
// return response.status(200).json(JSON.stringify({consentId: consentRequestResponse.data.consent_request_id}))
// //console.log(await response.body.json())
// } catch (error) {
// console.error("Error in consent/request endpoint: ", error);
// return response.status(500).json({ error: 'Internal Server Error' });
// }
// }
@Post('/consent')
/**
* Retrieves a consent from OBP for the current user
*/
async getConsent(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Promise<Response | any> {
try {
// create consent as logged in user
const opeyConfig = await this.opeyClientService.getOpeyConfig()
session['opeyConfig'] = opeyConfig
// Check if user already has a consent for opey
// If so, return the consent id
const consentId = await this.obpConsentsService.getExistingOpeyConsentId(session)
if (consentId) {
console.log('Existing consent ID: ', consentId)
// If we have a consent id, we can get the consent from OBP
const consent = await this.obpConsentsService.getConsentByConsentId(session, consentId)
return response.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt })
} else {
console.log('No existing consent ID found')
}
// Either here or in this method, we should check if there is already a consent stored in the session
await this.obpConsentsService.createConsent(session)
console.log('Consent at controller: ', session['opeyConfig'])
const authConfig = session['opeyConfig']['authConfig']
return response
.status(200)
.json({ consent_id: authConfig?.obpConsent.consent_id, jwt: authConfig?.obpConsent.jwt })
} catch (error) {
console.error('Error in consent endpoint: ', error)
return response.status(500).json({ error: 'Internal Server Error ' })
}
}
// @Post('/consent/answer-challenge')
// /**
// * Endpoint to answer the consent challenge with code i.e. SMS or email OTP for SCA
// * If successful, returns a Consent-JWT for use by Opey to access endpoints/ roles that the consenting user has
// * This completes (i.e. is the final step in) the consent flow
// */
// async answerConsentChallenge(
// @Session() session: any,
// @Req() request: Request,
// @Res() response: Response
// ): Promise<Response | any> {
// try {
// const oauthConfig = session['clientConfig']
// const version = this.obpClientService.getOBPVersion()
// const obpConsent = session['obpConsent']
// if (!obpConsent) {
// return response.status(400).json({ message: 'Consent not found in session' });
// } else if (obpConsent.status === 'ACCEPTED') {
// return response.status(400).json({ message: 'Consent already accepted' });
// }
// const answerBody = request.body
// const consentJWT = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/consents/${obpConsent.consent_id}/challenge`, answerBody, oauthConfig)
// console.log("Consent JWT: ", consentJWT)
// // store consent JWT in session, return consent JWT 200 OK
// session['obpConsentJWT'] = consentJWT
// return response.status(200).json(true);
// } catch (error) {
// console.error("Error in consent/answer-challenge endpoint: ", error);
// return response.status(500).json({ error: 'Internal Server Error' });
// }
// }
}

View File

@ -1,238 +0,0 @@
/*
* 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 { Controller, Session, Req, Res, Get, Delete, Post, Put } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { Service, Container } from 'typedi'
@Service()
@Controller()
export class OBPController {
private obpClientService: OBPClientService
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Check if access token is expired and refresh it if needed
* This ensures API calls always use a valid token
*/
private async ensureValidToken(session: any): Promise<boolean> {
const accessToken = session['oauth2_access_token']
const refreshToken = session['oauth2_refresh_token']
// If no access token, user is not authenticated
if (!accessToken) {
return false
}
// Check if token is expired
if (this.oauth2Service.isTokenExpired(accessToken)) {
console.log('RequestController: Access token expired, attempting refresh')
if (!refreshToken) {
console.log('RequestController: No refresh token available')
return false
}
try {
const newTokens = await this.oauth2Service.refreshAccessToken(refreshToken)
// Update session with new tokens
session['oauth2_access_token'] = newTokens.accessToken
session['oauth2_refresh_token'] = newTokens.refreshToken || refreshToken
session['oauth2_id_token'] = newTokens.idToken
session['oauth2_token_timestamp'] = Date.now()
session['oauth2_expires_in'] = newTokens.expiresIn
// CRITICAL: Update clientConfig with new access token
if (session['clientConfig'] && session['clientConfig'].oauth2) {
session['clientConfig'].oauth2.accessToken = newTokens.accessToken
console.log('RequestController: Updated clientConfig with refreshed token')
}
console.log('RequestController: Token refresh successful')
return true
} catch (error) {
console.error('RequestController: Token refresh failed:', error)
return false
}
}
// Token is still valid
return true
}
@Get('/get')
async get(@Session() session: any, @Req() request: Request, @Res() response: Response): Response {
const path = request.query.path
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.get(path, oauthConfig)
return response.json(result)
} catch (error: any) {
// 401 errors are expected when user is not authenticated - log as info, not error
if (error.status === 401) {
console.log(
`[RequestController] 401 Unauthorized for path: ${path} (user not authenticated)`
)
} else {
console.error('[RequestController] GET request error:', error)
}
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Post('/create')
async create(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
const data = request.body
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
// Debug logging to diagnose authentication issues
console.log('RequestController.create - Debug Info:')
console.log(' Path:', path)
console.log(' Session exists:', !!session)
console.log(' Session keys:', session ? Object.keys(session) : 'N/A')
console.log(' clientConfig exists:', !!oauthConfig)
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')
try {
const result = await this.obpClientService.create(path, data, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.create error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Put('/update')
async update(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
const data = request.body
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.update(path, data, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.update error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
@Delete('/delete')
async discard(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const path = request.query.path
// Ensure token is valid before making the request
const tokenValid = await this.ensureValidToken(session)
if (!tokenValid && session['oauth2_user']) {
console.log('RequestController: Token expired and refresh failed')
return response.status(401).json({
code: 401,
message: 'Session expired. Please log in again.'
})
}
const oauthConfig = session['clientConfig']
try {
const result = await this.obpClientService.discard(path, oauthConfig)
return response.json(result)
} catch (error: any) {
console.error('RequestController.delete error:', error)
return response.status(error.status || 500).json({
code: error.status || 500,
message: error.message || 'Internal server error'
})
}
}
}

View File

@ -1,214 +0,0 @@
/*
* 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 { Controller, Session, Req, Res, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { Service, Container } from 'typedi'
import { OAuthConfig } from 'obp-typescript'
import { commitId } from '../app.js'
import { OAuth2Service } from '../services/OAuth2Service.js'
import {
RESOURCE_DOCS_API_VERSION,
MESSAGE_DOCS_API_VERSION,
API_VERSIONS_LIST_API_VERSION
} from '../../src/shared-constants.js'
@Service()
@Controller('/status')
export class StatusController {
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
private connectors = [
'akka_vDec2018',
'rest_vMar2019',
'stored_procedure_vDec2019',
'rabbitmq_vOct2024'
]
private obpClientService: OBPClientService
constructor() {
// Explicitly get OBPClientService from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
}
@Get('/')
async index(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
const oauthConfig = session['clientConfig']
const version = this.obpClientService.getOBPVersion()
// Check if user is authenticated
const isAuthenticated = oauthConfig && oauthConfig.oauth2?.accessToken
let currentUser = null
let apiVersions = false
let messageDocs = false
let resourceDocs = false
if (isAuthenticated) {
try {
currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig)
apiVersions = await this.checkApiVersions(oauthConfig, version)
messageDocs = await this.checkMessagDocs(oauthConfig, version)
resourceDocs = await this.checkResourceDocs(oauthConfig, version)
} catch (error) {
console.error('StatusController: Error fetching authenticated data:', error)
}
}
return response.json({
status: apiVersions && messageDocs && resourceDocs,
apiVersions,
messageDocs,
resourceDocs,
currentUser,
isAuthenticated,
commitId
})
}
isCodeError(response: any, path: string): boolean {
console.log(`Validating ${path} response...`)
if (!response || Object.keys(response).length == 0) return true
if (Object.keys(response).includes('code')) {
const code = response['code']
if (code >= 400) {
console.log(response) // Log error responce
return true
}
}
return false
}
async checkResourceDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${version}/obp`
const resourceDocs = await this.obpClientService.get(path, oauthConfig)
return !this.isCodeError(resourceDocs, path)
} catch (error) {
return false
}
}
async checkMessagDocs(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const messageDocsCodeResult = await Promise.all(
this.connectors.map(async (connector) => {
const path = `/obp/${MESSAGE_DOCS_API_VERSION}/message-docs/${connector}`
return !this.isCodeError(await this.obpClientService.get(path, oauthConfig), path)
})
)
return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError)
} catch (error) {
return false
}
}
async checkApiVersions(oauthConfig: OAuthConfig, version: string): Promise<boolean> {
try {
const path = `/obp/${API_VERSIONS_LIST_API_VERSION}/api/versions`
const versions = await this.obpClientService.get(path, oauthConfig)
return !this.isCodeError(versions, path)
} catch (error) {
return false
}
}
@Get('/oauth2')
getOAuth2Status(@Res() response: Response): Response {
try {
const oauth2Service = Container.get(OAuth2Service)
const isInitialized = oauth2Service.isInitialized()
const oidcConfig = oauth2Service.getOIDCConfiguration()
const healthCheckActive = oauth2Service.isHealthCheckActive()
const healthCheckAttempts = oauth2Service.getHealthCheckAttempts()
return response.json({
available: isInitialized,
message: isInitialized
? 'OAuth2/OIDC is ready for authentication'
: 'OAuth2/OIDC is not available',
issuer: oidcConfig?.issuer || null,
authorizationEndpoint: oidcConfig?.authorization_endpoint || null,
wellKnownUrl: process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL || null,
healthCheck: {
active: healthCheckActive,
attempts: healthCheckAttempts
}
})
} catch (error) {
return response.status(500).json({
available: false,
message: 'Error checking OAuth2 status',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
@Get('/oauth2/reconnect')
async reconnectOAuth2(@Res() response: Response): Promise<Response> {
try {
const oauth2Service = Container.get(OAuth2Service)
if (oauth2Service.isInitialized()) {
return response.json({
success: true,
message: 'OAuth2 is already connected',
alreadyConnected: true
})
}
const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL
if (!wellKnownUrl) {
return response.status(400).json({
success: false,
message: 'VITE_OBP_OAUTH2_WELL_KNOWN_URL not configured'
})
}
console.log('Manual OAuth2 reconnection attempt triggered...')
await oauth2Service.initializeFromWellKnown(wellKnownUrl)
console.log('Manual OAuth2 reconnection successful!')
return response.json({
success: true,
message: 'OAuth2 reconnection successful',
issuer: oauth2Service.getOIDCConfiguration()?.issuer || null
})
} catch (error) {
console.error('Manual OAuth2 reconnection failed:', error)
return response.status(500).json({
success: false,
message: 'OAuth2 reconnection failed',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
}

View File

@ -1,190 +0,0 @@
/*
* 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 { Controller, Session, Req, Res, Get } from 'routing-controllers'
import type { Request, Response } from 'express'
import OBPClientService from '../services/OBPClientService.js'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
@Service()
@Controller('/user')
export class UserController {
private obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST
private obpClientService: OBPClientService
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get services from the container to avoid injection issues
this.obpClientService = Container.get(OBPClientService)
this.oauth2Service = Container.get(OAuth2Service)
}
@Get('/logoff')
async logout(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
console.log('UserController: Logging out user')
// Clear OAuth2 session data
delete session['oauth2_access_token']
delete session['oauth2_refresh_token']
delete session['oauth2_id_token']
delete session['oauth2_token_type']
delete session['oauth2_expires_in']
delete session['oauth2_token_timestamp']
delete session['oauth2_user_info']
delete session['oauth2_user']
delete session['clientConfig']
delete session['opeyConfig']
// Destroy the session completely
session.destroy((err: any) => {
if (err) {
console.error('UserController: Error destroying session:', err)
} else {
console.log('UserController: Session destroyed successfully')
}
})
const redirectPage = (request.query.redirect as string) || this.obpExplorerHome || '/'
if (!this.obpExplorerHome) {
console.error(`VITE_OBP_API_EXPLORER_HOST: ${this.obpExplorerHome}`)
}
console.log('UserController: Redirecting to:', redirectPage)
response.redirect(redirectPage)
return response
}
@Get('/current')
async current(
@Session() session: any,
@Req() request: Request,
@Res() response: Response
): Response {
console.log('UserController: Getting current user')
// Check OAuth2 session
if (session['oauth2_user']) {
console.log('UserController: Returning OAuth2 user info')
const oauth2User = session['oauth2_user']
// Check if access token is expired and needs refresh
const accessToken = session['oauth2_access_token']
const refreshToken = session['oauth2_refresh_token']
if (accessToken && this.oauth2Service.isTokenExpired(accessToken)) {
console.log('UserController: Access token expired')
if (refreshToken) {
console.log('UserController: Attempting token refresh')
try {
const newTokens = await this.oauth2Service.refreshAccessToken(refreshToken)
// Update session with new tokens
session['oauth2_access_token'] = newTokens.accessToken
session['oauth2_refresh_token'] = newTokens.refreshToken || refreshToken
session['oauth2_id_token'] = newTokens.idToken
session['oauth2_token_timestamp'] = Date.now()
session['oauth2_expires_in'] = newTokens.expiresIn
// CRITICAL: Update clientConfig with new access token
// This ensures subsequent API calls use the refreshed token
if (session['clientConfig'] && session['clientConfig'].oauth2) {
session['clientConfig'].oauth2.accessToken = newTokens.accessToken
console.log('UserController: Updated clientConfig with new access token')
}
console.log('UserController: Token refresh successful')
} catch (error) {
console.error('UserController: Token refresh failed:', error)
// Return empty object to indicate user needs to re-authenticate
return response.json({})
}
} else {
console.log('UserController: No refresh token available, user needs to re-authenticate')
return response.json({})
}
}
// Get actual user ID from OBP-API
let obpUserId = oauth2User.sub // Default to sub if OBP call fails
const clientConfig = session['clientConfig']
if (clientConfig && clientConfig.oauth2?.accessToken) {
try {
// Always use v5.1.0 for application infrastructure - stable and debuggable
const version = DEFAULT_OBP_API_VERSION
console.log('UserController: Fetching OBP user from /obp/' + version + '/users/current')
const obpUser = await this.obpClientService.get(
`/obp/${version}/users/current`,
clientConfig
)
if (obpUser && obpUser.user_id) {
obpUserId = obpUser.user_id
console.log('UserController: Got OBP user ID:', obpUserId, '(was:', oauth2User.sub, ')')
} else {
console.warn('UserController: OBP user response has no user_id:', obpUser)
}
} catch (error: any) {
console.warn(
'UserController: Could not fetch OBP user ID, using token sub:',
oauth2User.sub
)
console.warn('UserController: Error details:', error.message)
}
} else {
console.warn(
'UserController: No valid clientConfig or access token, using token sub:',
oauth2User.sub
)
}
// Return user info in format compatible with frontend
return response.json({
user_id: obpUserId,
username: oauth2User.username,
email: oauth2User.email,
email_verified: oauth2User.email_verified,
name: oauth2User.name,
given_name: oauth2User.given_name,
family_name: oauth2User.family_name,
provider: oauth2User.provider || 'oauth2'
})
}
// No authentication session found
console.log('UserController: No authentication session found')
return response.json({})
}
}

View File

@ -1,158 +0,0 @@
/*
* 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 { ExpressMiddlewareInterface } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { PKCEUtils } from '../utils/pkce.js'
/**
* OAuth2 Authorization Middleware
*
* Initiates the OAuth2/OIDC authorization code flow with PKCE.
* This middleware:
* 1. Generates PKCE code verifier and challenge
* 2. Generates state parameter for CSRF protection
* 3. Stores these values in the session
* 4. Redirects the user to the OIDC provider's authorization endpoint
*
* Flow:
* User /oauth2/connect This Middleware OIDC Authorization Endpoint
*
* @see OAuth2CallbackMiddleware for the callback handling
*
* @example
* // Usage in controller:
* @UseBefore(OAuth2AuthorizationMiddleware)
* export class OAuth2ConnectController {
* @Get('/oauth2/connect')
* connect(@Req() request: Request, @Res() response: Response): Response {
* return response
* }
* }
*/
@Service()
export default class OAuth2AuthorizationMiddleware implements ExpressMiddlewareInterface {
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get OAuth2Service from the container to avoid injection issues
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Handle the authorization request
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object
*/
async use(request: Request, response: Response): Promise<void> {
console.log('OAuth2AuthorizationMiddleware: Starting OAuth2 authorization flow')
// Check if OAuth2 service exists and is initialized
if (!this.oauth2Service) {
console.error('OAuth2AuthorizationMiddleware: OAuth2 service is null/undefined')
return response
.status(500)
.send('OAuth2 service not available. Please check server configuration.')
}
if (!this.oauth2Service.isInitialized()) {
console.error('OAuth2AuthorizationMiddleware: OAuth2 service not initialized')
return response
.status(500)
.send(
'OAuth2 service not initialized. Please check server configuration and OIDC provider availability.'
)
}
const session = request.session
const redirectPage = request.query.redirect
// Store redirect page in session for post-authentication redirect
if (redirectPage && typeof redirectPage === 'string') {
session['oauth2_redirect_page'] = redirectPage
console.log('OAuth2AuthorizationMiddleware: Will redirect to:', redirectPage)
} else {
// Default redirect to explorer home
session['oauth2_redirect_page'] = process.env.VITE_OBP_API_EXPLORER_HOST || '/'
}
try {
// Generate PKCE parameters
const codeVerifier = PKCEUtils.generateCodeVerifier()
const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier)
const state = PKCEUtils.generateState()
// Validate generated values
if (!PKCEUtils.isValidCodeVerifier(codeVerifier)) {
throw new Error('Generated code verifier is invalid')
}
if (!PKCEUtils.isValidState(state)) {
throw new Error('Generated state parameter is invalid')
}
// Store PKCE and state parameters in session for callback validation
session['oauth2_state'] = state
session['oauth2_code_verifier'] = codeVerifier
session['oauth2_flow_timestamp'] = Date.now()
console.log('OAuth2AuthorizationMiddleware: PKCE parameters generated')
console.log(' Code verifier length:', codeVerifier.length)
console.log(' Code challenge length:', codeChallenge.length)
console.log(' State:', state.substring(0, 10) + '...')
// Create authorization URL with OIDC scopes
const scopes = ['openid', 'profile', 'email']
const authUrl = this.oauth2Service.createAuthorizationURL(state, scopes)
// Add PKCE challenge to authorization URL
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
console.log('OAuth2AuthorizationMiddleware: Authorization URL created')
console.log(' URL:', authUrl.toString())
console.log(' Scopes:', scopes.join(' '))
console.log(' PKCE method: S256')
// Redirect user to OIDC provider
console.log('OAuth2AuthorizationMiddleware: Redirecting to OIDC provider...')
response.redirect(authUrl.toString())
} catch (error: any) {
console.error('OAuth2AuthorizationMiddleware: Error creating authorization URL:', error)
// Clean up session data on error
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
delete session['oauth2_redirect_page']
return response.status(500).send(`Failed to initiate OAuth2 flow: ${error.message}`)
}
}
}

View File

@ -1,425 +0,0 @@
/*
* 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 { ExpressMiddlewareInterface } from 'routing-controllers'
import type { Request, Response } from 'express'
import { Service, Container } from 'typedi'
import { OAuth2Service } from '../services/OAuth2Service.js'
import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js'
import jwt from 'jsonwebtoken'
/**
* OAuth2 Callback Middleware
*
* Handles the OAuth2/OIDC callback after user authentication.
* This middleware:
* 1. Validates the state parameter (CSRF protection)
* 2. Retrieves the PKCE code verifier from session
* 3. Exchanges the authorization code for tokens
* 4. Fetches user information from the UserInfo endpoint
* 5. Stores tokens and user info in the session
* 6. Redirects the user back to the original page
*
* Flow:
* OIDC Provider /oauth2/callback?code=XXX&state=YYY This Middleware Original Page
*
* @see OAuth2AuthorizationMiddleware for the authorization initiation
*
* @example
* // Usage in controller:
* @UseBefore(OAuth2CallbackMiddleware)
* export class OAuth2CallbackController {
* @Get('/oauth2/callback')
* callback(@Req() request: Request, @Res() response: Response): Response {
* return response
* }
* }
*/
@Service()
export default class OAuth2CallbackMiddleware implements ExpressMiddlewareInterface {
private oauth2Service: OAuth2Service
constructor() {
// Explicitly get OAuth2Service from the container to avoid injection issues
this.oauth2Service = Container.get(OAuth2Service)
}
/**
* Handle the OAuth2 callback
*
* @param {Request} request - Express request object
* @param {Response} response - Express response object
*/
async use(request: Request, response: Response): Promise<void> {
console.log('OAuth2CallbackMiddleware: Processing OAuth2 callback')
const session = request.session
const code = request.query.code as string
const state = request.query.state as string
const error = request.query.error as string
const errorDescription = request.query.error_description as string
// Check for OAuth2 errors from provider
if (error) {
console.error('OAuth2CallbackMiddleware: OAuth2 error from provider:', error)
console.error(' Description:', errorDescription || 'No description provided')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Authentication Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Authentication Error</h1>
<p><strong>Error:</strong> ${this.escapeHtml(error)}</p>
${errorDescription ? `<p><strong>Description:</strong> ${this.escapeHtml(errorDescription)}</p>` : ''}
<p>Authentication failed. Please try again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Validate required parameters
if (!code || !state) {
console.error('OAuth2CallbackMiddleware: Missing code or state parameter')
console.error(' Code present:', !!code)
console.error(' State present:', !!state)
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Invalid Request</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Invalid Callback Request</h1>
<p>The authorization callback is missing required parameters.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Validate state parameter (CSRF protection)
const storedState = session['oauth2_state']
if (!state || state !== storedState) {
console.error('OAuth2CallbackMiddleware: State validation failed')
console.error(' Received state:', state?.substring(0, 10) + '...')
console.error(' Expected state:', storedState?.substring(0, 10) + '...')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Security Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Security Validation Failed</h1>
<p>The state parameter validation failed. This could indicate a CSRF attack.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Get code verifier from session
const codeVerifier = session['oauth2_code_verifier']
if (!codeVerifier) {
console.error('OAuth2CallbackMiddleware: Code verifier not found in session')
console.error(' This could indicate session timeout or invalid session state')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Session Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Session Error</h1>
<p>Your session has expired or is invalid.</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
// Check flow timestamp (prevent replay attacks)
const flowTimestamp = session['oauth2_flow_timestamp']
if (flowTimestamp) {
const flowAge = Date.now() - flowTimestamp
const maxFlowAge = 10 * 60 * 1000 // 10 minutes
if (flowAge > maxFlowAge) {
console.error('OAuth2CallbackMiddleware: Authorization flow expired')
console.error(' Flow age:', Math.floor(flowAge / 1000), 'seconds')
console.error(' Max age:', Math.floor(maxFlowAge / 1000), 'seconds')
this.cleanupSession(session)
return response.status(400).send(`
<html>
<head>
<title>Flow Expired</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="error">
<h1>Authorization Flow Expired</h1>
<p>The authorization flow has expired (timeout: 10 minutes).</p>
<p>Please try logging in again.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
}
try {
console.log('OAuth2CallbackMiddleware: Exchanging authorization code for tokens')
// Exchange authorization code for tokens
const tokens = await this.oauth2Service.exchangeCodeForTokens(code, codeVerifier)
console.log('OAuth2CallbackMiddleware: Tokens received successfully')
console.log(' Access token present:', !!tokens.accessToken)
console.log(' Refresh token present:', !!tokens.refreshToken)
console.log(' ID token present:', !!tokens.idToken)
// Get user info from UserInfo endpoint
console.log('OAuth2CallbackMiddleware: Fetching user info')
const userInfo = await this.oauth2Service.getUserInfo(tokens.accessToken)
// Debug: Decode access token to see what user ID OBP-API will see
try {
const accessTokenDecoded: any = jwt.decode(tokens.accessToken)
console.log('\n\n========================================')
console.log('🔍 ACCESS TOKEN DECODED - THIS IS WHAT OBP-API SEES')
console.log('========================================')
console.log(' sub (user ID):', accessTokenDecoded?.sub)
console.log(' email:', accessTokenDecoded?.email)
console.log(' preferred_username:', accessTokenDecoded?.preferred_username)
console.log(' Full payload:', JSON.stringify(accessTokenDecoded, null, 2))
console.log('========================================\n\n')
} catch (error) {
console.warn('OAuth2CallbackMiddleware: Failed to decode access token:', error)
}
// Store tokens in session
session['oauth2_access_token'] = tokens.accessToken
session['oauth2_refresh_token'] = tokens.refreshToken || null
session['oauth2_id_token'] = tokens.idToken || null
session['oauth2_token_type'] = tokens.tokenType
session['oauth2_expires_in'] = tokens.expiresIn
session['oauth2_token_timestamp'] = Date.now()
// Store user info
session['oauth2_user_info'] = userInfo
// Decode ID token for additional user data
let idTokenPayload: any = null
if (tokens.idToken) {
try {
idTokenPayload = this.oauth2Service.decodeIdToken(tokens.idToken)
} catch (error) {
console.warn('OAuth2CallbackMiddleware: Failed to decode ID token:', error)
}
}
// Create unified user object combining UserInfo and ID token data
const user = {
sub: userInfo.sub,
email: userInfo.email || idTokenPayload?.email,
email_verified: userInfo.email_verified || idTokenPayload?.email_verified,
name: userInfo.name || idTokenPayload?.name,
given_name: userInfo.given_name || idTokenPayload?.given_name,
family_name: userInfo.family_name || idTokenPayload?.family_name,
preferred_username: userInfo.preferred_username || idTokenPayload?.preferred_username,
username: userInfo.preferred_username || userInfo.email || userInfo.sub,
picture: userInfo.picture || idTokenPayload?.picture,
provider: 'oauth2'
}
session['oauth2_user'] = user
// Create clientConfig for OBP API calls with OAuth2 Bearer token
// This allows OBPClientService to work with OAuth2 authentication
// Store session data for authenticated requests
// Always use v5.1.0 for application infrastructure - stable and debuggable
session['clientConfig'] = {
baseUri: process.env.VITE_OBP_API_HOST || 'http://localhost:8080',
version: DEFAULT_OBP_API_VERSION,
oauth2: {
accessToken: tokens.accessToken,
tokenType: tokens.tokenType || 'Bearer'
}
}
console.log('OAuth2CallbackMiddleware: User authenticated successfully')
console.log(' User ID (sub):', user.sub)
console.log(' Username:', user.username)
console.log(' Email:', user.email)
console.log(' Name:', user.name)
console.log('OAuth2CallbackMiddleware: Created clientConfig for OBP API calls')
// Clear OAuth2 flow parameters (keep tokens and user data)
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
// Get redirect page and clean up
const redirectPage =
(session['oauth2_redirect_page'] as string) || process.env.VITE_OBP_API_EXPLORER_HOST || '/'
delete session['oauth2_redirect_page']
console.log('OAuth2CallbackMiddleware: Redirecting to:', redirectPage)
console.log('OAuth2CallbackMiddleware: Authentication flow complete')
// Redirect to original page
response.redirect(redirectPage)
} catch (error: any) {
console.error('OAuth2CallbackMiddleware: Token exchange or user info failed:', error)
console.error(' Error message:', error.message)
console.error(' Error stack:', error.stack)
this.cleanupSession(session)
return response.status(500).send(`
<html>
<head>
<title>Authentication Failed</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background-color: #fee; border: 1px solid #fcc; padding: 15px; border-radius: 5px; }
h1 { color: #c00; }
p { margin: 10px 0; }
a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background-color: #0056b3; }
code { background-color: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<div class="error">
<h1>Authentication Failed</h1>
<p>Failed to complete authentication with the identity provider.</p>
<p><strong>Error:</strong> <code>${this.escapeHtml(error.message)}</code></p>
<p>Please try logging in again. If the problem persists, contact support.</p>
</div>
<a href="/">Return to Home</a>
</body>
</html>
`)
}
}
/**
* Clean up OAuth2 session data
*
* @param {any} session - Express session object
*/
private cleanupSession(session: any): void {
delete session['oauth2_state']
delete session['oauth2_code_verifier']
delete session['oauth2_flow_timestamp']
delete session['oauth2_redirect_page']
delete session['oauth2_access_token']
delete session['oauth2_refresh_token']
delete session['oauth2_id_token']
delete session['oauth2_token_type']
delete session['oauth2_expires_in']
delete session['oauth2_token_timestamp']
delete session['oauth2_user_info']
delete session['oauth2_user']
}
/**
* Escape HTML to prevent XSS
*
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
private escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (m) => map[m])
}
}

View File

@ -1,234 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { OpeyController } from "../controllers/OpeyIIController.js";
import OpeyClientService from '../services/OpeyClientService.js';
import OBPClientService from '../services/OBPClientService.js';
import OBPConsentsService from '../services/OBPConsentsService.js';
import Stream, { Readable } from 'stream';
import { Request, Response } from 'express';
import httpMocks from 'node-mocks-http'
import { EventEmitter } from 'events';
import { InlineResponse2017 } from 'obp-api-typescript';
vi.mock("../../server/services/OpeyClientService", () => {
return {
default: vi.fn().mockImplementation(() => {
return {
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
const readableStream = new Stream.Readable();
for (let i=0; i<10; i++) {
readableStream.push(`Chunk ${i}`);
}
return readableStream as NodeJS.ReadableStream;
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
}
}),
};
});
describe('OpeyController', () => {
let MockOpeyClientService: OpeyClientService
let opeyController: OpeyController
// Mock the OpeyClientService class
const { mockClear } = getMockRes()
beforeEach(() => {
mockClear()
})
beforeAll(() => {
vi.clearAllMocks();
MockOpeyClientService = {
authConfig: {},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
const mockAsisstantMessage = "Hi I'm Opey, your personal banking assistant. I'll certainly not take over the world, no, not at all!"
// Split the message into chunks, but reappend the whitespace (this is to simulate llm tokens)
const mockMessageChunks = mockAsisstantMessage.split(" ")
for (let i = 0; i < mockMessageChunks.length; i++) {
// Don't add whitespace to the last chunk
if (i === mockMessageChunks.length - 1 ) {
mockMessageChunks[i] = `${mockMessageChunks[i]}`
break
}
mockMessageChunks[i] = `${mockMessageChunks[i]} `
}
// Return the fake the token stream
return new ReadableStream<Uint8Array>({
start(controller) {
for (let i = 0; i < mockMessageChunks.length; i++) {
controller.enqueue(new TextEncoder().encode(`data: {"type":"token","content":"${mockMessageChunks[i]}"}\n`));
}
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n`));
controller.close();
},
});
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
} as unknown as OpeyClientService
// Instantiate OpeyController with the mocked OpeyClientService
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService)
})
it('getStatus', async () => {
const res = httpMocks.createResponse();
await opeyController.getStatus(res)
expect(MockOpeyClientService.getOpeyStatus).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
})
it('streamOpey', async () => {
const _eventEmitter = new EventEmitter();
_eventEmitter.addListener('data', () => {
console.log('Data received')
})
// The default event emitter does nothing, so replace
const res = await httpMocks.createResponse({
eventEmitter: EventEmitter,
writableStream: Stream.Writable
});
// Mock request and response objects to pass to express controller
const req = {
body: {
message: 'Hello Opey',
thread_id: '123',
is_tool_call_approval: false
}
} as unknown as Request;
const response = await opeyController.streamOpey({}, req, res)
// Get the stream from the response
const stream = response.body
let chunks: any[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream complete');
context.status = 'ready';
break;
}
}
} catch (error) {
console.error(error)
}
await expect(chunks.length).toBe(10);
await expect(MockOpeyClientService.stream).toHaveBeenCalled();
await expect(res).toBeDefined();
})
})
describe('OpeyController consents', () => {
let mockOBPClientService: OBPClientService
let opeyController: OpeyController
beforeAll(() => {
mockOBPClientService = {
get: vi.fn(async () => {
Promise.resolve({})
})
} as unknown as OBPClientService
const MockOpeyClientService = {
authConfig: {},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
async function * generator() {
for (let i=0; i<10; i++) {
yield `Chunk ${i}`;
}
}
const readableStream = Stream.Readable.from(generator());
return readableStream as NodeJS.ReadableStream;
}),
invoke: vi.fn(async () => {
return {
content: 'Hi this is Opey',
}
})
} as unknown as OpeyClientService
const MockOBPConsentsService = {
createConsent: vi.fn(async () => {
return {
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"status": "INITIATED",
} as InlineResponse2017
})
} as unknown as OBPConsentsService
// Instantiate OpeyController with the mocked OpeyClientService
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return 200 and consent ID when consent is created at OBP', async () => {
const req = getMockReq()
const session = {}
const { res } = getMockRes()
await opeyController.getConsent(session, req, res)
expect(res.status).toHaveBeenCalledWith(200)
// Obviously if you change the MockOBPConsentsService.createConsent mock implementation, you will need to change this test
expect(res.json).toHaveBeenCalledWith({
"consent_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
})
// Expect that the consent object was saved in the session
expect(session).toHaveProperty('obpConsent')
expect(session['obpConsent']).toHaveProperty('consent_id', "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")
expect(session['obpConsent']).toHaveProperty('jwt', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9CUCBDb25zZW50IFRva2VuIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
expect(session['obpConsent']).toHaveProperty('status', "INITIATED")
})
})