From 5cb5cfc229acda3b032e3af17a6d6234ae5a066a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 29 Dec 2025 09:02:37 +0100 Subject: [PATCH] use plain express 4 with cleanup --- server/app.ts | 25 +- .../controllers/OAuth2CallbackController.ts | 98 ---- server/controllers/OAuth2ConnectController.ts | 77 ---- .../controllers/OAuth2ProvidersController.ts | 108 ----- server/controllers/OpeyIIController.ts | 360 --------------- server/controllers/RequestController.ts | 238 ---------- server/controllers/StatusController.ts | 214 --------- server/controllers/UserController.ts | 190 -------- .../OAuth2AuthorizationMiddleware.ts | 158 ------- .../middlewares/OAuth2CallbackMiddleware.ts | 425 ------------------ server/test/opey-controller.test.ts | 234 ---------- 11 files changed, 7 insertions(+), 2120 deletions(-) delete mode 100644 server/controllers/OAuth2CallbackController.ts delete mode 100644 server/controllers/OAuth2ConnectController.ts delete mode 100644 server/controllers/OAuth2ProvidersController.ts delete mode 100644 server/controllers/OpeyIIController.ts delete mode 100644 server/controllers/RequestController.ts delete mode 100644 server/controllers/StatusController.ts delete mode 100644 server/controllers/UserController.ts delete mode 100644 server/middlewares/OAuth2AuthorizationMiddleware.ts delete mode 100644 server/middlewares/OAuth2CallbackMiddleware.ts delete mode 100644 server/test/opey-controller.test.ts diff --git a/server/app.ts b/server/app.ts index 0161cf7..9757e23 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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` diff --git a/server/controllers/OAuth2CallbackController.ts b/server/controllers/OAuth2CallbackController.ts deleted file mode 100644 index 44bc529..0000000 --- a/server/controllers/OAuth2CallbackController.ts +++ /dev/null @@ -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 . - * - * 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 - } -} diff --git a/server/controllers/OAuth2ConnectController.ts b/server/controllers/OAuth2ConnectController.ts deleted file mode 100644 index dc7f8aa..0000000 --- a/server/controllers/OAuth2ConnectController.ts +++ /dev/null @@ -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 . - * - * 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 - * Login - * - * // 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 - } -} diff --git a/server/controllers/OAuth2ProvidersController.ts b/server/controllers/OAuth2ProvidersController.ts deleted file mode 100644 index e295b2d..0000000 --- a/server/controllers/OAuth2ProvidersController.ts +++ /dev/null @@ -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 . - * - * 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 { - 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 - } - } -} diff --git a/server/controllers/OpeyIIController.ts b/server/controllers/OpeyIIController.ts deleted file mode 100644 index 788d065..0000000 --- a/server/controllers/OpeyIIController.ts +++ /dev/null @@ -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 { - 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 { - 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): 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) - - response.setHeader('Content-Type', 'text/event-stream') - response.setHeader('Cache-Control', 'no-cache') - response.setHeader('Connection', 'keep-alive') - - nodeStream.pipe(response) - - return new Promise((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 { - // 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 { - // 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 { - 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 { - // 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' }); - // } - - // } -} diff --git a/server/controllers/RequestController.ts b/server/controllers/RequestController.ts deleted file mode 100644 index 4fbd772..0000000 --- a/server/controllers/RequestController.ts +++ /dev/null @@ -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 . - * - * 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 { - 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' - }) - } - } -} diff --git a/server/controllers/StatusController.ts b/server/controllers/StatusController.ts deleted file mode 100644 index adf6369..0000000 --- a/server/controllers/StatusController.ts +++ /dev/null @@ -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 . - * - * 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 { - 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 { - 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 { - 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 { - 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' - }) - } - } -} diff --git a/server/controllers/UserController.ts b/server/controllers/UserController.ts deleted file mode 100644 index 4aea5f0..0000000 --- a/server/controllers/UserController.ts +++ /dev/null @@ -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 . - * - * 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({}) - } -} diff --git a/server/middlewares/OAuth2AuthorizationMiddleware.ts b/server/middlewares/OAuth2AuthorizationMiddleware.ts deleted file mode 100644 index 8b52a62..0000000 --- a/server/middlewares/OAuth2AuthorizationMiddleware.ts +++ /dev/null @@ -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 . - * - * 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 { - 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}`) - } - } -} diff --git a/server/middlewares/OAuth2CallbackMiddleware.ts b/server/middlewares/OAuth2CallbackMiddleware.ts deleted file mode 100644 index 88dabf4..0000000 --- a/server/middlewares/OAuth2CallbackMiddleware.ts +++ /dev/null @@ -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 . - * - * 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 { - 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(` - - - Authentication Error - - - -
-

Authentication Error

-

Error: ${this.escapeHtml(error)}

- ${errorDescription ? `

Description: ${this.escapeHtml(errorDescription)}

` : ''} -

Authentication failed. Please try again.

-
- Return to Home - - - `) - } - - // 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(` - - - Invalid Request - - - -
-

Invalid Callback Request

-

The authorization callback is missing required parameters.

-

Please try logging in again.

-
- Return to Home - - - `) - } - - // 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(` - - - Security Error - - - -
-

Security Validation Failed

-

The state parameter validation failed. This could indicate a CSRF attack.

-

Please try logging in again.

-
- Return to Home - - - `) - } - - // 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(` - - - Session Error - - - -
-

Session Error

-

Your session has expired or is invalid.

-

Please try logging in again.

-
- Return to Home - - - `) - } - - // 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(` - - - Flow Expired - - - -
-

Authorization Flow Expired

-

The authorization flow has expired (timeout: 10 minutes).

-

Please try logging in again.

-
- Return to Home - - - `) - } - } - - 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(` - - - Authentication Failed - - - -
-

Authentication Failed

-

Failed to complete authentication with the identity provider.

-

Error: ${this.escapeHtml(error.message)}

-

Please try logging in again. If the problem persists, contact support.

-
- Return to Home - - - `) - } - } - - /** - * 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 } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return text.replace(/[&<>"']/g, (m) => map[m]) - } -} diff --git a/server/test/opey-controller.test.ts b/server/test/opey-controller.test.ts deleted file mode 100644 index f3b316c..0000000 --- a/server/test/opey-controller.test.ts +++ /dev/null @@ -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({ - 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") - }) -}) \ No newline at end of file