From bf4b74c746f082bf75f265de81ddfa8891b91a19 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 29 Dec 2025 09:07:21 +0100 Subject: [PATCH] use plain express 5 with new files --- server/routes/obp.ts | 244 +++++++++++++++++++ server/routes/opey.ts | 280 ++++++++++++++++++++++ server/routes/status.ts | 228 ++++++++++++++++++ server/routes/user.ts | 173 +++++++++++++ server/services/OAuth2ClientWithConfig.ts | 11 +- 5 files changed, 932 insertions(+), 4 deletions(-) create mode 100644 server/routes/obp.ts create mode 100644 server/routes/opey.ts create mode 100644 server/routes/status.ts create mode 100644 server/routes/user.ts diff --git a/server/routes/obp.ts b/server/routes/obp.ts new file mode 100644 index 0000000..bf55513 --- /dev/null +++ b/server/routes/obp.ts @@ -0,0 +1,244 @@ +/* + * Open Bank Project - API Explorer II + * Copyright (C) 2023-2025, TESOBE GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Email: contact@tesobe.com + * TESOBE GmbH + * Osloerstrasse 16/17 + * Berlin 13359, Germany + * + * This product includes software developed at + * TESOBE (http://www.tesobe.com/) + * + */ + +import { Router } from 'express' +import type { Request, Response } from 'express' +import { Container } from 'typedi' +import OBPClientService from '../services/OBPClientService.js' +import { OAuth2Service } from '../services/OAuth2Service.js' + +const router = Router() + +// Get services from container +const obpClientService = Container.get(OBPClientService) +const oauth2Service = Container.get(OAuth2Service) + +/** + * Check if access token is expired and refresh it if needed + * This ensures API calls always use a valid token + */ +async function 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 (oauth2Service.isTokenExpired(accessToken)) { + console.log('OBP: Access token expired, attempting refresh') + + if (!refreshToken) { + console.log('OBP: No refresh token available') + return false + } + + try { + const newTokens = await 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 + + // Update clientConfig with new access token + if (session.clientConfig && session.clientConfig.oauth2) { + session.clientConfig.oauth2.accessToken = newTokens.accessToken + console.log('OBP: Updated clientConfig with refreshed token') + } + + console.log('OBP: Token refresh successful') + return true + } catch (error) { + console.error('OBP: Token refresh failed:', error) + return false + } + } + + // Token is still valid + return true +} + +/** + * GET /get + * Proxy GET requests to OBP API + * Query params: + * - path: OBP API path to call (e.g., /obp/v5.1.0/banks) + */ +router.get('/get', async (req: Request, res: Response) => { + try { + const path = req.query.path as string + const session = req.session as any + + // Ensure token is valid before making the request + const tokenValid = await ensureValidToken(session) + if (!tokenValid && session.oauth2_user) { + console.log('OBP: Token expired and refresh failed') + return res.status(401).json({ + code: 401, + message: 'Session expired. Please log in again.' + }) + } + + const oauthConfig = session.clientConfig + + const result = await obpClientService.get(path, oauthConfig) + res.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(`OBP: 401 Unauthorized for path: ${req.query.path} (user not authenticated)`) + } else { + console.error('OBP: GET request error:', error) + } + res.status(error.status || 500).json({ + code: error.status || 500, + message: error.message || 'Internal server error' + }) + } +}) + +/** + * POST /create + * Proxy POST requests to OBP API + * Query params: + * - path: OBP API path to call + * Body: JSON data to send to OBP API + */ +router.post('/create', async (req: Request, res: Response) => { + try { + const path = req.query.path as string + const data = req.body + const session = req.session as any + + // Ensure token is valid before making the request + const tokenValid = await ensureValidToken(session) + if (!tokenValid && session.oauth2_user) { + console.log('OBP: Token expired and refresh failed') + return res.status(401).json({ + code: 401, + message: 'Session expired. Please log in again.' + }) + } + + const oauthConfig = session.clientConfig + + // Debug logging to diagnose authentication issues + console.log('OBP.create - Debug Info:') + console.log(' Path:', path) + console.log(' Session exists:', !!session) + 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') + + const result = await obpClientService.create(path, data, oauthConfig) + res.json(result) + } catch (error: any) { + console.error('OBP.create error:', error) + res.status(error.status || 500).json({ + code: error.status || 500, + message: error.message || 'Internal server error' + }) + } +}) + +/** + * PUT /update + * Proxy PUT requests to OBP API + * Query params: + * - path: OBP API path to call + * Body: JSON data to send to OBP API + */ +router.put('/update', async (req: Request, res: Response) => { + try { + const path = req.query.path as string + const data = req.body + const session = req.session as any + + // Ensure token is valid before making the request + const tokenValid = await ensureValidToken(session) + if (!tokenValid && session.oauth2_user) { + console.log('OBP: Token expired and refresh failed') + return res.status(401).json({ + code: 401, + message: 'Session expired. Please log in again.' + }) + } + + const oauthConfig = session.clientConfig + + const result = await obpClientService.update(path, data, oauthConfig) + res.json(result) + } catch (error: any) { + console.error('OBP.update error:', error) + res.status(error.status || 500).json({ + code: error.status || 500, + message: error.message || 'Internal server error' + }) + } +}) + +/** + * DELETE /delete + * Proxy DELETE requests to OBP API + * Query params: + * - path: OBP API path to call + */ +router.delete('/delete', async (req: Request, res: Response) => { + try { + const path = req.query.path as string + const session = req.session as any + + // Ensure token is valid before making the request + const tokenValid = await ensureValidToken(session) + if (!tokenValid && session.oauth2_user) { + console.log('OBP: Token expired and refresh failed') + return res.status(401).json({ + code: 401, + message: 'Session expired. Please log in again.' + }) + } + + const oauthConfig = session.clientConfig + + const result = await obpClientService.discard(path, oauthConfig) + res.json(result) + } catch (error: any) { + console.error('OBP.delete error:', error) + res.status(error.status || 500).json({ + code: error.status || 500, + message: error.message || 'Internal server error' + }) + } +}) + +export default router diff --git a/server/routes/opey.ts b/server/routes/opey.ts new file mode 100644 index 0000000..58bca65 --- /dev/null +++ b/server/routes/opey.ts @@ -0,0 +1,280 @@ +/* + * Open Bank Project - API Explorer II + * Copyright (C) 2023-2025, TESOBE GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Email: contact@tesobe.com + * TESOBE GmbH + * Osloerstrasse 16/17 + * Berlin 13359, Germany + * + * This product includes software developed at + * TESOBE (http://www.tesobe.com/) + * + */ + +import { Router } from 'express' +import type { Request, Response } from 'express' +import { Readable } from 'node:stream' +import { ReadableStream as WebReadableStream } from 'stream/web' +import { Container } from 'typedi' +import OBPClientService from '../services/OBPClientService.js' +import OpeyClientService from '../services/OpeyClientService.js' +import OBPConsentsService from '../services/OBPConsentsService.js' +import { UserInput } from '../schema/OpeySchema.js' + +const router = Router() + +// Get services from container +const obpClientService = Container.get(OBPClientService) +const opeyClientService = Container.get(OpeyClientService) +const obpConsentsService = Container.get(OBPConsentsService) + +/** + * Helper function to convert web stream to Node.js stream + */ +function 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(String(error))) + } + })() + + return nodeReadable + } +} + +/** + * GET /opey + * Check Opey chatbot status + */ +router.get('/opey', async (req: Request, res: Response) => { + try { + const opeyStatus = await opeyClientService.getOpeyStatus() + console.log('Opey status: ', opeyStatus) + res.status(200).json({ status: 'Opey is running' }) + } catch (error) { + console.error('Error in /opey endpoint: ', error) + res.status(500).json({ error: 'Internal Server Error' }) + } +}) + +/** + * POST /opey/stream + * Stream chatbot responses + * Body: { message, thread_id, is_tool_call_approval } + */ +router.post('/opey/stream', async (req: Request, res: Response) => { + try { + const session = req.session as any + + if (!session) { + console.error('Session not found') + return res.status(401).json({ error: 'Session Time Out' }) + } + + // Check if the consent is in the session + const opeyConfig = session.opeyConfig + if (!opeyConfig) { + console.error('Opey config not found in session') + return res.status(500).json({ error: 'Internal Server Error' }) + } + + // Read user input from request body + let user_input: UserInput + try { + console.log('Request body: ', req.body) + user_input = { + message: req.body.message, + thread_id: req.body.thread_id, + is_tool_call_approval: req.body.is_tool_call_approval + } + } catch (error) { + console.error('Error in stream endpoint, could not parse into UserInput: ', error) + return res.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 opeyClientService.stream(user_input, opeyConfig) + } catch (error) { + console.error('Error reading stream: ', error) + return res.status(500).json({ error: 'Internal Server Error' }) + } + + if (!stream) { + console.error('Stream is not received or not readable') + return res.status(500).json({ error: 'Internal Server Error' }) + } + + // Transform our stream + const frontendStream: ReadableStream = stream.pipeThrough(frontendTransformer) + + const nodeStream = safeFromWeb(frontendStream as WebReadableStream) + + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + + nodeStream.pipe(res) + + // Handle stream completion + nodeStream.on('end', () => { + console.log('Stream ended successfully') + }) + + nodeStream.on('error', (error) => { + console.error('Stream error:', error) + }) + + // Add a timeout to prevent hanging + const timeout = setTimeout(() => { + console.warn('Stream timeout reached') + nodeStream.destroy() + }, 30000) + + // Clear the timeout when stream ends + nodeStream.on('end', () => clearTimeout(timeout)) + nodeStream.on('error', () => clearTimeout(timeout)) + } catch (error) { + console.error('Error in /opey/stream:', error) + if (!res.headersSent) { + res.status(500).json({ error: 'Internal Server Error' }) + } + } +}) + +/** + * POST /opey/invoke + * Invoke chatbot without streaming + * Body: { message, thread_id, is_tool_call_approval } + */ +router.post('/opey/invoke', async (req: Request, res: Response) => { + try { + const session = req.session as any + + // Check if the consent is in the session + const opeyConfig = session.opeyConfig + if (!opeyConfig) { + console.error('Opey config not found in session') + return res.status(500).json({ error: 'Internal Server Error' }) + } + + let user_input: UserInput + try { + user_input = { + message: req.body.message, + thread_id: req.body.thread_id, + is_tool_call_approval: req.body.is_tool_call_approval + } + } catch (error) { + console.error('Error in invoke endpoint, could not parse into UserInput: ', error) + return res.status(500).json({ error: 'Internal Server Error' }) + } + + const opey_response = await opeyClientService.invoke(user_input, opeyConfig) + res.status(200).json(opey_response) + } catch (error) { + console.error('Error in /opey/invoke:', error) + res.status(500).json({ error: 'Internal Server Error' }) + } +}) + +/** + * POST /opey/consent + * Retrieve or create a consent for Opey to access OBP on user's behalf + */ +router.post('/opey/consent', async (req: Request, res: Response) => { + try { + const session = req.session as any + + // Create consent as logged in user + const opeyConfig = await opeyClientService.getOpeyConfig() + session.opeyConfig = opeyConfig + + // Check if user already has a consent for opey + const consentId = await 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 obpConsentsService.getConsentByConsentId(session, consentId) + + return res.status(200).json({ consent_id: consent.consent_id, jwt: consent.jwt }) + } else { + console.log('No existing consent ID found') + } + + await obpConsentsService.createConsent(session) + + console.log('Consent at controller: ', session.opeyConfig) + + const authConfig = session.opeyConfig?.authConfig + + res.status(200).json({ + consent_id: authConfig?.obpConsent.consent_id, + jwt: authConfig?.obpConsent.jwt + }) + } catch (error) { + console.error('Error in /opey/consent endpoint: ', error) + res.status(500).json({ error: 'Internal Server Error' }) + } +}) + +export default router diff --git a/server/routes/status.ts b/server/routes/status.ts new file mode 100644 index 0000000..9c8c7b2 --- /dev/null +++ b/server/routes/status.ts @@ -0,0 +1,228 @@ +/* + * Open Bank Project - API Explorer II + * Copyright (C) 2023-2025, TESOBE GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Email: contact@tesobe.com + * TESOBE GmbH + * Osloerstrasse 16/17 + * Berlin 13359, Germany + * + * This product includes software developed at + * TESOBE (http://www.tesobe.com/) + * + */ + +import { Router } from 'express' +import type { Request, Response } from 'express' +import { Container } from 'typedi' +import OBPClientService from '../services/OBPClientService.js' +import { OAuth2Service } from '../services/OAuth2Service.js' +import { commitId } from '../app.js' +import { + RESOURCE_DOCS_API_VERSION, + MESSAGE_DOCS_API_VERSION, + API_VERSIONS_LIST_API_VERSION +} from '../../src/shared-constants.js' + +const router = Router() + +// Get services from container +const obpClientService = Container.get(OBPClientService) +const oauth2Service = Container.get(OAuth2Service) + +const connectors = ['akka_vDec2018', 'rest_vMar2019', 'stored_procedure_vDec2019', 'rabbitmq_vOct2024'] + +/** + * Helper function to check if response contains an error + */ +function 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 response + return true + } + } + return false +} + +/** + * Check if resource docs are accessible + */ +async function checkResourceDocs(oauthConfig: any, version: string): Promise { + try { + const path = `/obp/${RESOURCE_DOCS_API_VERSION}/resource-docs/${version}/obp` + const resourceDocs = await obpClientService.get(path, oauthConfig) + return !isCodeError(resourceDocs, path) + } catch (error) { + return false + } +} + +/** + * Check if message docs are accessible + */ +async function checkMessageDocs(oauthConfig: any, version: string): Promise { + try { + const messageDocsCodeResult = await Promise.all( + connectors.map(async (connector) => { + const path = `/obp/${MESSAGE_DOCS_API_VERSION}/message-docs/${connector}` + return !isCodeError(await obpClientService.get(path, oauthConfig), path) + }) + ) + return messageDocsCodeResult.every((isCodeError: boolean) => isCodeError) + } catch (error) { + return false + } +} + +/** + * Check if API versions are accessible + */ +async function checkApiVersions(oauthConfig: any, version: string): Promise { + try { + const path = `/obp/${API_VERSIONS_LIST_API_VERSION}/api/versions` + const versions = await obpClientService.get(path, oauthConfig) + return !isCodeError(versions, path) + } catch (error) { + return false + } +} + +/** + * GET /status + * Get application status and health checks + */ +router.get('/status', async (req: Request, res: Response) => { + try { + const session = req.session as any + const oauthConfig = session.clientConfig + const version = 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 obpClientService.get(`/obp/${version}/users/current`, oauthConfig) + apiVersions = await checkApiVersions(oauthConfig, version) + messageDocs = await checkMessageDocs(oauthConfig, version) + resourceDocs = await checkResourceDocs(oauthConfig, version) + } catch (error) { + console.error('Status: Error fetching authenticated data:', error) + } + } + + res.json({ + status: apiVersions && messageDocs && resourceDocs, + apiVersions, + messageDocs, + resourceDocs, + currentUser, + isAuthenticated, + commitId + }) + } catch (error) { + console.error('Status: Error getting status:', error) + res.status(500).json({ + status: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } +}) + +/** + * GET /status/oauth2 + * Get OAuth2/OIDC status + */ +router.get('/status/oauth2', (req: Request, res: Response) => { + try { + const isInitialized = oauth2Service.isInitialized() + const oidcConfig = oauth2Service.getOIDCConfiguration() + const healthCheckActive = oauth2Service.isHealthCheckActive() + const healthCheckAttempts = oauth2Service.getHealthCheckAttempts() + + res.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) { + res.status(500).json({ + available: false, + message: 'Error checking OAuth2 status', + error: error instanceof Error ? error.message : 'Unknown error' + }) + } +}) + +/** + * GET /status/oauth2/reconnect + * Attempt to reconnect OAuth2/OIDC + */ +router.get('/status/oauth2/reconnect', async (req: Request, res: Response) => { + try { + if (oauth2Service.isInitialized()) { + return res.json({ + success: true, + message: 'OAuth2 is already connected', + alreadyConnected: true + }) + } + + const wellKnownUrl = process.env.VITE_OBP_OAUTH2_WELL_KNOWN_URL + if (!wellKnownUrl) { + return res.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!') + res.json({ + success: true, + message: 'OAuth2 reconnection successful', + issuer: oauth2Service.getOIDCConfiguration()?.issuer || null + }) + } catch (error) { + console.error('Manual OAuth2 reconnection failed:', error) + res.status(500).json({ + success: false, + message: 'OAuth2 reconnection failed', + error: error instanceof Error ? error.message : 'Unknown error' + }) + } +}) + +export default router diff --git a/server/routes/user.ts b/server/routes/user.ts new file mode 100644 index 0000000..fd445d1 --- /dev/null +++ b/server/routes/user.ts @@ -0,0 +1,173 @@ +/* + * Open Bank Project - API Explorer II + * Copyright (C) 2023-2025, TESOBE GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Email: contact@tesobe.com + * TESOBE GmbH + * Osloerstrasse 16/17 + * Berlin 13359, Germany + * + * This product includes software developed at + * TESOBE (http://www.tesobe.com/) + * + */ + +import { Router } from 'express' +import type { Request, Response } from 'express' +import { Container } from 'typedi' +import OBPClientService from '../services/OBPClientService.js' +import { OAuth2Service } from '../services/OAuth2Service.js' +import { DEFAULT_OBP_API_VERSION } from '../../src/shared-constants.js' + +const router = Router() + +// Get services from container +const obpClientService = Container.get(OBPClientService) +const oauth2Service = Container.get(OAuth2Service) + +const obpExplorerHome = process.env.VITE_OBP_API_EXPLORER_HOST + +/** + * GET /user/current + * Get current logged in user information + */ +router.get('/user/current', async (req: Request, res: Response) => { + try { + console.log('User: Getting current user') + const session = req.session as any + + // Check OAuth2 session + if (!session.oauth2_user) { + console.log('User: No authentication session found') + return res.json({}) + } + + console.log('User: Returning OAuth2 user info') + const oauth2User = session.oauth2_user + const accessToken = session.oauth2_access_token + const refreshToken = session.oauth2_refresh_token + + // Check if access token is expired and needs refresh + if (accessToken && oauth2Service.isTokenExpired(accessToken)) { + console.log('User: Access token expired') + + if (refreshToken) { + console.log('User: Attempting token refresh') + try { + const newTokens = await 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 + + // Update clientConfig with new access token + if (session.clientConfig && session.clientConfig.oauth2) { + session.clientConfig.oauth2.accessToken = newTokens.accessToken + console.log('User: Updated clientConfig with new access token') + } + + console.log('User: Token refresh successful') + } catch (error) { + console.error('User: Token refresh failed:', error) + return res.json({}) + } + } else { + console.log('User: No refresh token available, user needs to re-authenticate') + return res.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 { + const version = DEFAULT_OBP_API_VERSION + console.log('User: Fetching OBP user from /obp/' + version + '/users/current') + const obpUser = await obpClientService.get(`/obp/${version}/users/current`, clientConfig) + if (obpUser && obpUser.user_id) { + obpUserId = obpUser.user_id + console.log('User: Got OBP user ID:', obpUserId, '(was:', oauth2User.sub, ')') + } else { + console.warn('User: OBP user response has no user_id:', obpUser) + } + } catch (error: any) { + console.warn('User: Could not fetch OBP user ID, using token sub:', oauth2User.sub) + console.warn('User: Error details:', error.message) + } + } else { + console.warn('User: No valid clientConfig or access token, using token sub:', oauth2User.sub) + } + + // Return user info in format compatible with frontend + res.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' + }) + } catch (error) { + console.error('User: Error getting current user:', error) + res.json({}) + } +}) + +/** + * GET /user/logoff + * Logout user and clear session + * Query params: + * - redirect: URL to redirect to after logout (optional) + */ +router.get('/user/logoff', (req: Request, res: Response) => { + console.log('User: Logging out user') + const session = req.session as any + + // 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.oauth2_provider + delete session.clientConfig + delete session.opeyConfig + + // Destroy the session completely + session.destroy((err: any) => { + if (err) { + console.error('User: Error destroying session:', err) + } else { + console.log('User: Session destroyed successfully') + } + + const redirectPage = (req.query.redirect as string) || obpExplorerHome || '/' + console.log('User: Redirecting to:', redirectPage) + res.redirect(redirectPage) + }) +}) + +export default router diff --git a/server/services/OAuth2ClientWithConfig.ts b/server/services/OAuth2ClientWithConfig.ts index 670bc08..5e19f91 100644 --- a/server/services/OAuth2ClientWithConfig.ts +++ b/server/services/OAuth2ClientWithConfig.ts @@ -48,6 +48,7 @@ import type { OIDCConfiguration, TokenResponse } from '../types/oauth2.js' export class OAuth2ClientWithConfig extends OAuth2Client { public OIDCConfig?: OIDCConfiguration public provider: string + public wellKnownUri?: string private _clientSecret: string private _redirectUri: string @@ -67,14 +68,16 @@ export class OAuth2ClientWithConfig extends OAuth2Client { * @example * await client.initOIDCConfig('http://localhost:9000/obp-oidc/.well-known/openid-configuration') */ - async initOIDCConfig(oidcConfigUrl: string): Promise { + async initOIDCConfig(wellKnownUrl: string): Promise { console.log( - `OAuth2ClientWithConfig: Fetching OIDC config for ${this.provider} from:`, - oidcConfigUrl + `OAuth2ClientWithConfig: Fetching OIDC config for ${this.provider} from: ${wellKnownUrl}` ) + // Store the well-known URL for health checks + this.wellKnownUri = wellKnownUrl + try { - const response = await fetch(oidcConfigUrl) + const response = await fetch(wellKnownUrl) if (!response.ok) { throw new Error(