Skip to content

Commit 5b46545

Browse files
committed
Reusing Consents. Delete all ACCEPTED or INITIATED consents button.
1 parent ae2157d commit 5b46545

7 files changed

Lines changed: 334 additions & 2 deletions

File tree

apps/api-manager/src/routes/backend/opey/consent/+server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { obp_requests } from '$lib/obp/requests';
66
import { obpErrorResponse } from '$lib/obp/errors';
77
import { env } from '$env/dynamic/private';
88
import { deduplicateRoles, pickConsentRole } from '@obp/shared/opey';
9-
import { capConsentTtlSeconds } from '@obp/shared/server/obp';
9+
import { capConsentTtlSeconds, findReusableConsent } from '@obp/shared/server/obp';
1010

1111
/**
1212
* POST /api/opey/consent
@@ -119,6 +119,27 @@ export async function POST(event: RequestEvent) {
119119
bank_id: bank_id || ''
120120
}));
121121

122+
// Try to reuse an existing consent that already covers this scope. Without
123+
// this, every consent_request from Opey mints a new consent at OBP — even
124+
// when an identical one already exists for the user, leading to consent
125+
// proliferation and "asking the same view again and again" UX.
126+
const reusable = await findReusableConsent({
127+
obpGet: (p, t) => obp_requests.get(p, t),
128+
accessToken,
129+
opeyConsumerId,
130+
requiredEntitlements: entitlements,
131+
requiredViews: normalizedViews
132+
});
133+
if (reusable) {
134+
return json({
135+
consent_jwt: reusable.jwt,
136+
consent_id: reusable.consent_id,
137+
status: reusable.status,
138+
roles: normalizedRequiredRoles,
139+
reused: true
140+
});
141+
}
142+
122143
const now = new Date().toISOString().split('.')[0] + 'Z';
123144

124145
// Cap against OBP's `consents.max_time_to_live` (via /obp/v7.0.0/consents/config)

apps/portal/src/routes/(protected)/user/consents/+page.server.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,85 @@ export const actions = {
152152
};
153153
},
154154

155+
deleteAll: async ({ locals }) => {
156+
const token = locals.session.data.oauth?.access_token;
157+
if (!token) {
158+
return { message: 'No access token found in session.' };
159+
}
160+
161+
// Page through /my/consents so we delete every record, not just the
162+
// 10 the load function fetches for display.
163+
const pageLimit = 50;
164+
const all: OBPConsent[] = [];
165+
try {
166+
let offset = 0;
167+
while (true) {
168+
const resp: { consents?: OBPConsent[] } = await obp_requests.get(
169+
`/obp/v5.1.0/my/consents?limit=${pageLimit}&offset=${offset}`,
170+
token
171+
);
172+
const batch = resp?.consents ?? [];
173+
all.push(...batch);
174+
if (batch.length < pageLimit) break;
175+
offset += pageLimit;
176+
}
177+
} catch (err) {
178+
logger.error('deleteAll: failed to list consents:', err);
179+
const msg =
180+
err instanceof OBPRequestError
181+
? err.message
182+
: err instanceof Error
183+
? err.message
184+
: 'Failed to list consents for deletion.';
185+
return { message: msg };
186+
}
187+
188+
// Only ACCEPTED or INITIATED consents are meaningfully deletable via
189+
// DELETE /my/consents/{ID} — REVOKED/REJECTED/EXPIRED rows can't be
190+
// revoked again. Skip them so the response stays clean instead of
191+
// surfacing one error per skipped row.
192+
const DELETABLE_STATUSES = new Set(['ACCEPTED', 'INITIATED']);
193+
const deletable = all.filter((c) => DELETABLE_STATUSES.has(c.status));
194+
const skipped = all.length - deletable.length;
195+
196+
if (deletable.length === 0) {
197+
return {
198+
success: true,
199+
message:
200+
skipped > 0
201+
? `No ACCEPTED or INITIATED consents to delete (${skipped} skipped).`
202+
: 'No consents to delete.'
203+
};
204+
}
205+
206+
let deleted = 0;
207+
const errors: string[] = [];
208+
for (const c of deletable) {
209+
try {
210+
await obp_requests.delete(`/obp/v5.1.0/my/consents/${c.consent_id}`, token);
211+
deleted++;
212+
} catch (err) {
213+
const msg = err instanceof Error ? err.message : String(err);
214+
errors.push(`${String(c.consent_id ?? 'unknown').slice(0, 8)}: ${msg}`);
215+
}
216+
}
217+
218+
const skippedNote = skipped > 0 ? ` (${skipped} skipped — not ACCEPTED or INITIATED)` : '';
219+
220+
if (errors.length > 0) {
221+
const sample = errors.slice(0, 3).join('; ');
222+
const more = errors.length > 3 ? ` (and ${errors.length - 3} more)` : '';
223+
return {
224+
message: `Deleted ${deleted} consent${deleted === 1 ? '' : 's'}${skippedNote}. ${errors.length} failed: ${sample}${more}`
225+
};
226+
}
227+
228+
return {
229+
success: true,
230+
message: `Deleted ${deleted} consent${deleted === 1 ? '' : 's'}${skippedNote}.`
231+
};
232+
},
233+
155234
setConsentTtl: async ({ request, locals }) => {
156235
const data = await request.formData();
157236
const rawSeconds = data.get('seconds');

apps/portal/src/routes/(protected)/user/consents/+page.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,33 @@
1717
);
1818
let selectedTtl = $state<number>(data.currentConsentTtlSeconds ?? 604800);
1919
let savingTtl = $state(false);
20+
let deletingAll = $state(false);
2021
</script>
2122

23+
<div class="mb-6 flex items-center justify-between gap-4">
24+
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Consents</h1>
25+
<form
26+
method="post"
27+
action="?/deleteAll"
28+
use:enhance={() => {
29+
deletingAll = true;
30+
return async ({ update }) => {
31+
await update({ reset: false });
32+
deletingAll = false;
33+
};
34+
}}
35+
>
36+
<button
37+
type="submit"
38+
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60 dark:bg-red-700 dark:hover:bg-red-800"
39+
disabled={deletingAll}
40+
data-testid="delete-all-consents"
41+
>
42+
{deletingAll ? 'Deleting all consents…' : 'Delete all consents'}
43+
</button>
44+
</form>
45+
</div>
46+
2247
{#if form?.success}
2348
<div class="mb-8 rounded-lg border border-green-200 bg-green-50 p-4 text-center dark:border-green-800 dark:bg-green-900/20">
2449
<p class="font-semibold text-green-600 dark:text-green-400">{form.message}</p>

apps/portal/src/routes/backend/opey/consent/+server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { obpErrorResponse } from '@obp/shared/obp';
77
import { env } from '$env/dynamic/private';
88
import { deduplicateRoles, pickConsentRole } from '@obp/shared/opey';
99
import { getOpeyConsentTtlSeconds } from '$lib/server/userPreferences';
10-
import { capConsentTtlSeconds } from '@obp/shared/server/obp';
10+
import { capConsentTtlSeconds, findReusableConsent } from '@obp/shared/server/obp';
1111

1212
/**
1313
* POST /backend/opey/consent
@@ -156,6 +156,27 @@ export async function POST(event: RequestEvent) {
156156
entitlements.push({ role_name: preferred.role_name, bank_id: preferred.bank_id });
157157
}
158158

159+
// Try to reuse an existing consent that already covers this scope. Without
160+
// this, every consent_request from Opey mints a new consent at OBP — even
161+
// when an identical one already exists for the user, leading to consent
162+
// proliferation and "asking the same view again and again" UX.
163+
const reusable = await findReusableConsent({
164+
obpGet: (p, t) => obp_requests.get(p, t),
165+
accessToken,
166+
opeyConsumerId,
167+
requiredEntitlements: entitlements,
168+
requiredViews: normalizedViews
169+
});
170+
if (reusable) {
171+
return json({
172+
consent_jwt: reusable.jwt,
173+
consent_id: reusable.consent_id,
174+
status: reusable.status,
175+
roles: normalizedRequiredRoles,
176+
reused: true
177+
});
178+
}
179+
159180
const now = new Date().toISOString().split('.')[0] + 'Z';
160181

161182
// Per-user TTL preference (OBP personal data field) → env default → built-in 7 days,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Reuse logic for per-tool-call consents.
3+
*
4+
* Without this, every consent_request from Opey mints a fresh consent at OBP
5+
* even when an existing one already covers the required scope. That's what
6+
* was causing "asking for the same views again and again" in the chat: every
7+
* tool call resulted in a new consent record with identical claims.
8+
*
9+
* "Covers" is a subset check:
10+
* - consumer_id matches the Opey consumer.
11+
* - status === 'ACCEPTED'.
12+
* - `jwt_expires_at` is in the future. (Note: OBP returns `jwt_payload` as a
13+
* JSON STRING in this listing, so we don't trust `consent.jwt_payload.exp`
14+
* here either — same trap as the baseline session-consent reuse fix. See
15+
* [[reference-obp-consents-jwt-payload-is-string]].)
16+
* - Every required `(role_name, bank_id)` is present in the consent's
17+
* entitlements (subset, not equality — broader consents still cover).
18+
* - Every required `(bank_id, account_id, view_id)` is present in the
19+
* consent's views (likewise subset).
20+
*
21+
* OBP returns consents sorted by `created_date:desc` by default, so the first
22+
* match is the most recent — gives a stable, predictable reuse target.
23+
*/
24+
25+
import { createLogger } from '$shared/utils/logger';
26+
import type { ObpGet } from './consentsConfig.js';
27+
28+
const logger = createLogger('ConsentReuse');
29+
30+
export interface RequiredEntitlement {
31+
role_name: string;
32+
/** Empty string for system-wide roles. */
33+
bank_id: string;
34+
}
35+
36+
export interface RequiredView {
37+
bank_id: string;
38+
account_id: string;
39+
view_id: string;
40+
}
41+
42+
export interface ReusableConsent {
43+
consent_id: string;
44+
jwt: string;
45+
status: string;
46+
}
47+
48+
export async function findReusableConsent(opts: {
49+
obpGet: ObpGet;
50+
accessToken: string;
51+
opeyConsumerId: string;
52+
requiredEntitlements: RequiredEntitlement[];
53+
requiredViews: RequiredView[];
54+
}): Promise<ReusableConsent | null> {
55+
const {
56+
obpGet,
57+
accessToken,
58+
opeyConsumerId,
59+
requiredEntitlements,
60+
requiredViews
61+
} = opts;
62+
63+
logger.info(
64+
`findReusableConsent: scanning for reuse — consumer=${opeyConsumerId}, required entitlements=${JSON.stringify(requiredEntitlements)}, required views=${JSON.stringify(requiredViews)}`
65+
);
66+
67+
let list: any;
68+
try {
69+
list = await obpGet('/obp/v5.1.0/my/consents', accessToken);
70+
} catch (err) {
71+
logger.info('findReusableConsent: failed to fetch /my/consents — skipping reuse', err);
72+
return null;
73+
}
74+
75+
const consents: any[] = Array.isArray(list?.consents) ? list.consents : [];
76+
if (consents.length === 0) {
77+
logger.info('findReusableConsent: /my/consents returned no consents');
78+
return null;
79+
}
80+
81+
const now = new Date();
82+
const reasons: string[] = [];
83+
84+
for (const c of consents) {
85+
const cid = String(c.consent_id ?? 'unknown').slice(0, 8);
86+
87+
if (c.consumer_id !== opeyConsumerId) {
88+
reasons.push(`${cid}: wrong consumer_id (${String(c.consumer_id ?? '').slice(0, 8)})`);
89+
continue;
90+
}
91+
if (c.status !== 'ACCEPTED') {
92+
reasons.push(`${cid}: status=${c.status}`);
93+
continue;
94+
}
95+
if (!c.jwt_expires_at) {
96+
reasons.push(`${cid}: no jwt_expires_at field`);
97+
continue;
98+
}
99+
if (new Date(c.jwt_expires_at) < now) {
100+
reasons.push(`${cid}: expired at ${c.jwt_expires_at}`);
101+
continue;
102+
}
103+
104+
// jwt_payload is a JSON string in the listing — parse it.
105+
let payload: any;
106+
try {
107+
payload =
108+
typeof c.jwt_payload === 'string' ? JSON.parse(c.jwt_payload) : c.jwt_payload;
109+
} catch {
110+
reasons.push(`${cid}: jwt_payload JSON.parse failed`);
111+
continue;
112+
}
113+
114+
const existingEntitlements: any[] = Array.isArray(payload?.entitlements)
115+
? payload.entitlements
116+
: [];
117+
const existingViews: any[] = Array.isArray(payload?.views) ? payload.views : [];
118+
119+
const entitlementsOk = requiredEntitlements.every((req) =>
120+
existingEntitlements.some(
121+
(e: any) =>
122+
typeof e === 'object' &&
123+
e?.role_name === req.role_name &&
124+
(e?.bank_id ?? '') === (req.bank_id ?? '')
125+
)
126+
);
127+
if (!entitlementsOk) {
128+
reasons.push(
129+
`${cid}: entitlements mismatch — existing=${JSON.stringify(existingEntitlements)}`
130+
);
131+
continue;
132+
}
133+
134+
const viewsOk = requiredViews.every((req) =>
135+
existingViews.some(
136+
(v: any) =>
137+
typeof v === 'object' &&
138+
v?.bank_id === req.bank_id &&
139+
v?.account_id === req.account_id &&
140+
v?.view_id === req.view_id
141+
)
142+
);
143+
if (!viewsOk) {
144+
reasons.push(`${cid}: views mismatch — existing=${JSON.stringify(existingViews)}`);
145+
continue;
146+
}
147+
148+
logger.info(
149+
`findReusableConsent: ✓ reusing consent ${c.consent_id} — covers ${requiredEntitlements.length} role(s) and ${requiredViews.length} view(s).`
150+
);
151+
return { consent_id: c.consent_id, jwt: c.jwt, status: c.status };
152+
}
153+
154+
logger.info(
155+
`findReusableConsent: ✗ no reusable consent found among ${consents.length} candidates. Rejection reasons:\n ${reasons.join('\n ')}`
156+
);
157+
return null;
158+
}

packages/shared/src/lib/server/obp/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ export {
88
_resetConsentsConfigCache
99
} from './consentsConfig.js';
1010
export type { ObpGet } from './consentsConfig.js';
11+
export { findReusableConsent } from './consentReuse.js';
12+
export type {
13+
RequiredEntitlement,
14+
RequiredView,
15+
ReusableConsent
16+
} from './consentReuse.js';

packages/shared/src/routes/api/opey/consent/+server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { obpRequests } from '../../../../hooks.server';
66
import { env } from '$env/dynamic/private';
77
import { deduplicateRoles, pickConsentRole } from '$lib/opey/utils/roles';
88
import { capConsentTtlSeconds } from '$lib/server/obp/consentsConfig';
9+
import { findReusableConsent } from '$lib/server/obp/consentReuse';
910

1011
/**
1112
* POST /api/opey/consent
@@ -116,6 +117,27 @@ export async function POST(event: RequestEvent) {
116117
bank_id: bank_id || ''
117118
}));
118119

120+
// Try to reuse an existing consent that already covers this scope. Without
121+
// this, every consent_request from Opey mints a new consent at OBP — even
122+
// when an identical one already exists for the user, leading to consent
123+
// proliferation and "asking the same view again and again" UX.
124+
const reusable = await findReusableConsent({
125+
obpGet: (p, t) => obpRequests.get(p, t),
126+
accessToken,
127+
opeyConsumerId,
128+
requiredEntitlements: entitlements,
129+
requiredViews: normalizedViews
130+
});
131+
if (reusable) {
132+
return json({
133+
consent_jwt: reusable.jwt,
134+
consent_id: reusable.consent_id,
135+
status: reusable.status,
136+
roles: normalizedRequiredRoles,
137+
reused: true
138+
});
139+
}
140+
119141
const now = new Date().toISOString().split('.')[0] + 'Z';
120142

121143
// Cap against OBP's `consents.max_time_to_live` (via /obp/v7.0.0/consents/config)

0 commit comments

Comments
 (0)