consents flow WIP

This commit is contained in:
Nemo Godebski-Pedersen 2025-03-11 13:42:39 +00:00
parent 01090f85b4
commit d3e44a154d
9 changed files with 302 additions and 106 deletions

View File

@ -28,6 +28,7 @@ VITE_OBP_REDIS_URL = redis://127.0.0.1:6379
# To do this:
VITE_CHATBOT_ENABLED=false
VITE_CHATBOT_URL=http://localhost:5000
VITE_OPEY_CONSUMER_ID=opey_consumer_id # For granting a consent to Opey
# Product styling setting
#VITE_OBP_LINKS_COLOR="#52b165"

View File

@ -92,6 +92,7 @@
"vite-plugin-node-polyfills": "^0.10.0",
"vite-plugin-rewrite-all": "^1.0.2",
"vitest": "^0.34.6",
"vitest-mock-express": "^2.2.0",
"vue-tsc": "^2.0.0"
},
"overrides": {

View File

@ -1,10 +1,11 @@
import { Controller, Session, Req, Res, Post, Get } from 'routing-controllers'
import { Request, Response } from 'express'
import { Transform, pipeline, Readable } from "node:stream"
import { Readable } from "node:stream"
import { ReadableStream as WebReadableStream } from "stream/web"
import { Service } from 'typedi'
import OBPClientService from '../services/OBPClientService'
import OpeyClientService from '../services/OpeyClientService'
import OBPConsentsService from '../services/OBPConsentsService'
import { UserInput } from '../schema/OpeySchema'
import { APIApi, Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151 } from 'obp-api-typescript'
@ -16,6 +17,7 @@ export class OpeyController {
constructor(
public obpClientService: OBPClientService,
public opeyClientService: OpeyClientService,
public obpConsentsService: OBPConsentsService
) {}
@Get('/')
@ -42,7 +44,7 @@ export class OpeyController {
@Session() session: any,
@Req() request: Request,
@Res() response: Response,
) {
): Promise<Response> {
// Read user input from request body
let user_input: UserInput
@ -106,14 +108,49 @@ export class OpeyController {
// }
// 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');
const nodeStream = Readable.fromWeb(frontendStream as WebReadableStream<any>)
// 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('x-vercel-ai-data-stream', 'v1')
response.setHeader('Content-Type', 'text/event-stream');
response.setHeader('Cache-Control', 'no-cache');
response.setHeader('Connection', 'keep-alive');
nodeStream.pipe(response);
@ -125,7 +162,16 @@ export class OpeyController {
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));
})
@ -232,44 +278,17 @@ export class OpeyController {
@Res() response: Response
): Promise<Response | any> {
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 ID")
const obpConsent = session['obpConsent']
// 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({consent_id: obpConsent.consent_id});
}
// create consent as logged in user
const obpConsent = await this.obpConsentsService.createConsent()
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 consentRequestBody = {
"everything": false,
"views": [],
"entitlements": [],
"consumer_id": "33e0a1bd-9f1d-4128-911b-8936110f802f"
}
console.log("Consent: ", obpConsent)
// 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' });
}
session['obpConsent'] = obpConsent
return response.status(200).json({consent_id: obpConsent?.consent_id});
// 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`, consentRequestBody, oauthConfig)
console.log("Consent: ", consent)
// store consent in session, return consent 200 OK
session['obpConsent'] = consent
return response.status(200).json({consent_id: consent.consent_id});
} catch (error) {
console.error("Error in consent endpoint: ", error);
return response.status(500).json({ error: 'Internal Server Error '});
console.error("Error in consent endpoint: ", error);
return response.status(500).json({ error: 'Internal Server Error '});
}
}

View File

@ -1,5 +1,5 @@
import { Service } from 'typedi'
import { Configuration, ConsentApi, ConsumerConsentrequestsBody, InlineResponse20151} from 'obp-api-typescript'
import { Configuration, ConsentApi, ConsentsIMPLICITBody, ConsumerConsentrequestsBody, InlineResponse20151, InlineResponse2017} from 'obp-api-typescript'
import OBPClientService from './OBPClientService'
import { AxiosResponse } from 'axios'
@ -66,7 +66,41 @@ export default class OBPConsentsService {
throw new Error("Invalid client type, must be 'logged_in_user' or 'API_Explorer'")
}
}
async createConsent(): Promise<InlineResponse2017 | undefined> {
// Create a consent as the logged in user, using Opey's consumerID
// I.e. give permission to Opey to do anything on behalf of the logged in user
const client = await this.createConsentClient('logged_in_user', '/obp/v5.1.0/banks/BANK_ID/my/consents/IMPLICIT', 'POST')
if (!client) {
throw new Error('Could not create Consents API client')
}
const opeyConsumerID = process.env.VITE_OPEY_CONSUMER_ID
if (!opeyConsumerID) {
throw new Error('Opey Consumer ID is missing, please set VITE_OPEY_CONSUMER_ID')
}
const body: ConsentsIMPLICITBody = {
everything: false,
entitlements: [],
consumer_id: opeyConsumerID,
views: [],
valid_from: new Date().toISOString(),
time_to_live: 3600,
}
try {
const consentResponse = await client.oBPv310CreateConsentImplicit(body, 'test', {headers: {'Content-Type': 'application/json'}})
console.log("Consent Response: ", consentResponse)
return consentResponse.data
} catch (error) {
console.error(error)
throw new Error(`Could not create consent, ${error}`)
}
}
async createConsentRequest(): Promise<InlineResponse20151 | undefined> {
// this should be done as API Explorer II, so set client on instance for that

View File

@ -1,5 +1,5 @@
import {describe, beforeAll, it, vi, Mock, MockInstance } from 'vitest'
import { ConsentApi, InlineResponse20151 } from 'obp-api-typescript'
import { ConsentApi, InlineResponse20151, InlineResponse2017 } from 'obp-api-typescript'
import { AxiosResponse } from 'axios'
const mockGetOAuthHeader = vi.fn(async () => (`OAuth oauth_consumer_key="jgaawf2fnj4yixqdsfaq4gipt4v1wvgsxgre",oauth_nonce="JiGDBWA3MAyKtsd9qkfWCxfju36bMjsA",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1741364123",oauth_version="1.0",oauth_signature="sa%2FRylnsdfLK8VPZI%2F2WkGFlTKs%3D"`));
@ -128,4 +128,44 @@ describe('OBPConsentsService.createConsentRequest', () => {
expect(mockOBPv500CreateConsentRequest).toHaveBeenCalled();
});
});
});
describe('OBPConsentsService.createConsent', () => {
let obpConsentsService: OBPConsentsService;
let mockOBPv310CreateConsentImplicit: Mock
let mockConsentApi: ConsentApi;
beforeEach(() => {
// reset mocks
vi.clearAllMocks();
// Create service instance
obpConsentsService = new OBPConsentsService();
})
it('with mocked', async () => {
// Create mock response function for consent IMPLICIT
mockOBPv310CreateConsentImplicit = vi.fn().mockResolvedValue({
data: {
consent_id: '12345678',
jwt: "asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0",
status: 'INITIATED',
},
} as AxiosResponse<InlineResponse2017>);
mockConsentApi = {
oBPv310CreateConsentImplicit: mockOBPv310CreateConsentImplicit,
} as unknown as ConsentApi;
// Mock the createConsentClient method
vi.spyOn(obpConsentsService, 'createConsentClient').mockResolvedValue(mockConsentApi);
const consentRequest = await obpConsentsService.createConsent();
expect(consentRequest).toBeDefined();
expect(consentRequest).toHaveProperty('consent_id', '12345678');
expect(consentRequest).toHaveProperty('jwt', 'asdjfawieofaowbfaowhh2084h02pefhh0.20fh02h0h29eyf09q3h09h.2-hf4-8h284hf0h0h0284h0');
expect(consentRequest).toHaveProperty('status', 'INITIATED');
expect(mockOBPv310CreateConsentImplicit).toHaveBeenCalled();
})
})

View File

@ -2,10 +2,13 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { OpeyController } from "../controllers/OpeyIIController";
import OpeyClientService from '../services/OpeyClientService';
import OBPClientService from '../services/OBPClientService';
import OBPConsentsService from '../services/OBPConsentsService';
import Stream, { Readable } from 'stream';
import { Request, Response } from 'express';
import { getMockReq, getMockRes } from 'vitest-mock-express'
import httpMocks from 'node-mocks-http'
import { EventEmitter } from 'events';
import { InlineResponse2017 } from 'obp-api-typescript';
vi.mock("../../server/services/OpeyClientService", () => {
return {
@ -35,37 +38,60 @@ vi.mock("../../server/services/OpeyClientService", () => {
});
describe('OpeyController', () => {
let MockOpeyClientService: OpeyClientService
let opeyController: OpeyController
// Mock the OpeyClientService class
const MockOpeyClientService = {
authConfig: {},
opeyConfig: {},
getOpeyStatus: vi.fn(async () => {
return {status: 'running'}
}),
stream: vi.fn(async () => {
const { mockClear } = getMockRes()
beforeEach(() => {
mockClear()
})
async function * generator() {
for (let i=0; i<10; i++) {
yield `Chunk ${i}`;
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]} `
}
}
const readableStream = Stream.Readable.from(generator());
// 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
return readableStream as NodeJS.ReadableStream;
}),
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)
})
// Instantiate OpeyController with the mocked OpeyClientService
const opeyController = new OpeyController(new OBPClientService, MockOpeyClientService)
it('getStatus', async () => {
const res = httpMocks.createResponse();
@ -75,8 +101,10 @@ describe('OpeyController', () => {
expect(res.statusCode).toBe(200);
})
it('streamOpey', async () => {
const _eventEmitter = new EventEmitter();
_eventEmitter.addListener('data', () => {
console.log('Data received')
@ -87,6 +115,7 @@ describe('OpeyController', () => {
writableStream: Stream.Writable
});
// Mock request and response objects to pass to express controller
const req = {
body: {
message: 'Hello Opey',
@ -95,25 +124,26 @@ describe('OpeyController', () => {
}
} as unknown as Request;
// Define handelrs for events
const response = await opeyController.streamOpey({}, req, res)
// Get the stream from the response
const stream = response.body
let chunks: any[] = [];
try {
const response = await opeyController.streamOpey({}, req, res)
response.on('end', async () => {
console.log('Stream ended')
console.log(res._getData())
await expect(res.statusCode).toBe(200);
})
response.on('data', async (chunk) => {
console.log(chunk)
await chunks.push(chunk);
await expect(chunk).toBeDefined();
})
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream complete');
context.status = 'ready';
break;
}
}
} catch (error) {
console.error(error)
}
@ -127,7 +157,7 @@ describe('OpeyController', () => {
})
describe('OpeyController consents flow', () => {
describe('OpeyController consents', () => {
let mockOBPClientService: OBPClientService
let opeyController: OpeyController
@ -165,9 +195,18 @@ describe('OpeyController consents flow', () => {
})
} 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)
opeyController = new OpeyController(new OBPClientService, MockOpeyClientService, MockOBPConsentsService)
})
afterEach(() => {
@ -175,25 +214,22 @@ describe('OpeyController consents flow', () => {
})
it('should return 200 and consent ID when consent is created at OBP', async () => {
vi.mock('../services/OBPClientService', () => {
return {
default: vi.fn().mockImplementation(() => {
return {
get: vi.fn(async () => ({ user_id: 'mocked-user-id' })),
create: vi.fn(async () => ({
"consent_request_id": "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
"consumer_id": "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
"payload": "payload"
})),
}
}),
}
})
const req = {}
const res = httpMocks.createResponse()
await opeyController.getConsentRequest({}, req, res)
await expect(res.status).toBe(200)
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")
})
})

View File

@ -8,7 +8,8 @@ import { Close, Top as ElTop } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import ChatMessage from './ChatMessage.vue';
import { v4 as uuidv4 } from 'uuid';
import { OpeyStreamContext, OpeyMessage, UserMessage, sendOpeyMessage } from '@/obp/opey-functions';
import { OpeyStreamContext, OpeyMessage, UserMessage, sendOpeyMessage, getOpeyConsent } from '@/obp/opey-functions';
import { getCurrentUser } from '@/obp';
export default {
setup () {
@ -38,6 +39,12 @@ export default {
components: {
ChatMessage,
},
async created() {
const isLoggedIn = await this.checkLoginStatus()
if (isLoggedIn) {
this.initiateConsentFlow()
}
},
methods: {
async toggleChat() {
this.chatOpen = !this.chatOpen
@ -45,14 +52,22 @@ export default {
await this.initiateConsentFlow()
}
},
async checkLoginStatus(): Promise<boolean> {
const currentUser = await getCurrentUser()
const currentResponseKeys = Object.keys(currentUser)
if (currentResponseKeys.includes('username')) {
return true
} else {
return false
}
},
async initiateConsentFlow() {
const consentResponse = await fetch('/api/opey/consent/request', {
method: 'POST',
})
// get consent for Opey from user
const consentResponse = await getOpeyConsent()
if (consentResponse.ok) {
const consentData = await consentResponse.json()
if (consentData.success) {
if (consentResponse) {
const consentId = consentResponse.consent_id
if (consentId) {
this.userHasConsented = true
ElMessage.success('Consent granted. You can now chat with Opey.')
} else {

View File

@ -19,6 +19,10 @@ export interface OpeyStreamContext {
status: string;
}
export interface OpeyConsentObject {
consent_id: string;
}
async function pushOrUpdateOpeyMessage(currentMessage: OpeyMessage, context: OpeyStreamContext): Promise<void> {
const existingMessage = context.messages.find(m => m.id === currentMessage.id);
if (existingMessage) {
@ -128,4 +132,28 @@ export async function sendOpeyMessage(
throw new Error(`Error sending Opey message: ${error}`);
}
}
export async function getOpeyConsent(): Promise<OpeyConsentObject> {
// Get consent from the Opey API
try {
const consentResponse = await fetch('/api/opey/consent', {
method: 'POST',
})
if (!consentResponse.ok) {
throw new Error(`Failed to get Opey consent: ${consentResponse.statusText}`);
}
const consent = await consentResponse.json();
return consent
} catch (error) {
console.error('Error getting Opey consent:', error);
throw new Error(`${error instanceof Error ? error.message : String(error)}`);
}
}

View File

@ -242,4 +242,26 @@ describe('sendOpeyMessage', () => {
expect(mockContext.status).toBe('ready')
})
})
describe('getOpeyConsent', () => {
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({consent_id: 1234}), {
headers: { 'content-type': 'application/json' },
status: 200,
}))
);
})
it('should call fetch', async () => {
await OpeyModule.getOpeyConsent()
expect(global.fetch).toHaveBeenCalled()
})
it('should return a consent id', async () => {
const consentId = await OpeyModule.getOpeyConsent()
expect(consentId).toStrictEqual({consent_id: 1234})
})
})