diff --git a/components.d.ts b/components.d.ts index 62a87ee..06a6e91 100644 --- a/components.d.ts +++ b/components.d.ts @@ -8,6 +8,7 @@ export {} declare module 'vue' { export interface GlobalComponents { ChatWidget: typeof import('./src/components/ChatWidget.vue')['default'] + ChatWidgetII: typeof import('./src/components/ChatWidgetII.vue')['default'] Collections: typeof import('./src/components/Collections.vue')['default'] Content: typeof import('./src/components/Content.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] diff --git a/server/controllers/OpeyController.ts b/server/controllers/OpeyController.ts index 45676f0..a181318 100644 --- a/server/controllers/OpeyController.ts +++ b/server/controllers/OpeyController.ts @@ -45,6 +45,94 @@ export class OpeyController { private obpClientService: OBPClientService, ) {} + @Post('/consent') + /** + * Retrieves a consent from OBP for the current user + */ + async getConsent( + @Session() session: any, + @Req() request: Request, + @Res() response: Response + ): Response { + try { + console.log("Getting consent from OBP") + // Check if consent is already in session + if (session['obpConsent']) { + console.log("Consent found in session, returning cached consent") + // NOTE: Arguably we should not return the consent to the frontend as it could be hijacked, + // we can keep everything in the backend and only return the JWT token + return response.status(200).json(true); + } + + const oauthConfig = session['clientConfig'] + const version = this.obpClientService.getOBPVersion() + // Obbiously this should not be hard-coded, especially the consumer_id, but for now it is + const consentBody = { + "everything": false, + "views": [], + "entitlements": [], + "consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f" + } + // 33e0a1bd-9f1d-4128-911b-8936110f802f + + // Get current user, only proceed if user is logged in + const currentUser = await this.obpClientService.get(`/obp/${version}/users/current`, oauthConfig) + const currentResponseKeys = Object.keys(currentUser) + if (!currentResponseKeys.includes('user_id')) { + return response.status(400).json({ message: 'User not logged in, Authentication required' }); + } + + // url needs to be changed once we get the 'bankless' consent endpoint + // this creates a consent for the current logged in user, and starts SCA flow i.e. sends SMS or email OTP to user + const consent = await this.obpClientService.create(`/obp/${version}/banks/gh.29.uk/my/consents/IMPLICIT`, consentBody, oauthConfig) + console.log("Consent: ", consent) + + // store consent in session, return consent 200 OK + session['obpConsent'] = consent + return response.status(200).json(true); + } 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 + ): Response { + 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' }); + } + + } + + @Post('/token') /** * Retrieves a JWT token for the current user. diff --git a/src/components/ChatWidget.vue b/src/components/ChatWidget.vue index d746e3e..d0784fb 100644 --- a/src/components/ChatWidget.vue +++ b/src/components/ChatWidget.vue @@ -33,7 +33,7 @@ import { inject } from 'vue'; import { obpApiHostKey } from '@/obp/keys'; import { getCurrentUser } from '../obp'; - import { getOpeyJWT } from '@/obp/common-functions' + import { getOpeyJWT, getOpeyConsent, answerOpeyConsentChallenge } from '@/obp/common-functions' import { storeToRefs } from "pinia"; import { socket } from '@/socket'; import { useConnectionStore } from '@/stores/connection'; @@ -80,6 +80,8 @@ userInput: '', sessionId: uuidv4(), awaitingConnection: !this.isConnected, + awaitingConsentChallengeAnswer: false, + consentChallengeAnswer: '', isLoading: false, obpApiHost: null, isLoggedIn: null, @@ -128,6 +130,20 @@ }); } + + // try to get a consent token + try { + token = await getOpeyConsent() + this.awaitingConsentChallengeAnswer = true + } catch (error) { + console.log('Error getting consent for opey from OBP: ', error) + this.errorState = true + ElMessage({ + message: 'Error getting consent for opey from OBP', + type: 'error' + }); + + } // Establish the WebSocket connection console.log('Establishing WebSocket connection'); @@ -143,6 +159,33 @@ } }, + async answerConsentChallenge() { + const challengeAnswer = this.consentChallengeAnswer + if (!challengeAnswer) { + console.error("empty challenge answer") + return + } + + try { + const answerBody = { + answer: challengeAnswer + } + const response = await answerOpeyConsentChallenge(answerBody) + if (response.status === 200) { + console.log('Consent challenge answered successfully, Consent approved') + this.awaitingConsentChallengeAnswer = false + } + } catch (error) { + + console.log('Error answering consent challenge: ', error) + this.errorState = true + ElMessage({ + message: 'Error answering consent challenge', + type: 'error' + }); + } + }, + async sendMessage() { if (this.userInput.trim()) { // Message in OpenAI standard format for user message @@ -269,7 +312,15 @@ Chat with Opey Powered by OpenAI -
+
+ + + Submit +
+
diff --git a/src/components/ChatWidgetII.vue b/src/components/ChatWidgetII.vue new file mode 100644 index 0000000..d86feda --- /dev/null +++ b/src/components/ChatWidgetII.vue @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/obp/common-functions.ts b/src/obp/common-functions.ts index c4bc512..8dfc454 100644 --- a/src/obp/common-functions.ts +++ b/src/obp/common-functions.ts @@ -85,6 +85,29 @@ export async function getOpeyJWT() { return token } +export async function getOpeyConsent() { + const response = await axios.post('/api/opey/consent').catch((error) => { + if (error.response) { + throw new Error(`getOpeyConsent returned an error: ${error.toJSON()}`); + } else { + throw new Error(`getOpeyConsent returned an error: ${error.message}`); + } + }); + const consent = String(response?.data?.consent) + return consent +} + +export async function answerOpeyConsentChallenge(answerBody: any) { + const response = await axios.post('/api/opey/consent/answer-challenge', answerBody).catch((error) => { + if (error.response) { + throw new Error(`answerOpeyConsentChallenge returned an error: ${error.toJSON()}`); + } else { + throw new Error(`answerOpeyConsentChallenge returned an error: ${error.message}`); + } + }); + return response +} + export function clearCacheByName(cacheName: string) { if ('caches' in window) { caches.delete(cacheName).then(function(success) {