mirror of
https://github.com/OpenBankProject/API-Explorer-II.git
synced 2026-02-06 10:47:04 +00:00
use plain express 4 with cleanup
This commit is contained in:
parent
52dfe6fb6b
commit
94fc898f5d
@ -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`
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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' });
|
||||
// }
|
||||
|
||||
// }
|
||||
}
|
||||
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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({})
|
||||
}
|
||||
}
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user