opey consents WIP

This commit is contained in:
nemo 2025-01-23 12:36:06 +00:00
parent 6c4676cc87
commit 1ca686b892
5 changed files with 168 additions and 2 deletions

1
components.d.ts vendored
View File

@ -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']

View File

@ -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.

View File

@ -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 @@
<span>Chat with Opey</span>
<img alt="Powered by OpenAI" src="@/assets/powered-by-openai-badge-outlined-on-dark.svg" height="32">
</div>
<div v-if="this.isLoggedIn" v-loading="this.awaitingConnection" element-loading-text="Awaiting Connection..." class="chat-messages" ref="messages">
<div v-show="this.awaitingConsentChallengeAnswer">
<el-input
v-model="consentChallengeAnswer"
placeholder="Enter the challenge answer"
>
</el-input>
<el-button @click="answerConsentChallenge">Submit</el-button>
</div>
<div v-if="this.isLoggedIn" v-loading="this.awaitingConnection && !this.awaitingConsentChallengeAnswer" element-loading-text="Awaiting Connection..." class="chat-messages" ref="messages">
<div v-for="(message, index) in chatMessages" :key="index" :class="['chat-message', message.role]">
<div v-if="(this.isStreaming)&&(index === this.chatMessages.length -1)">
<div v-html="renderMarkdown(this.currentMessageSnapshot)"></div>

View File

@ -0,0 +1,3 @@
<!--
placeholder for Opey II Chat widget
-->

View File

@ -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) {