Skip to content

Commit caf3175

Browse files
committed
More targetted consents. everything: false. Consents component clearer
in case of REVOKED consent. Search by user_id . Longer Consent TTL.
1 parent 6e2428d commit caf3175

11 files changed

Lines changed: 477 additions & 159 deletions

File tree

apps/api-manager/src/lib/components/metrics/MetricsQueryForm.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,11 @@
146146
<label class="qf-inline"><span>Apps</span>
147147
<input type="text" bind:value={queryForm.include_app_names} onblur={handleFieldChange} onchange={handleFieldChange} placeholder="csv" name="include_app_names" />
148148
</label>
149-
<label class="qf-inline"><span>User</span>
150-
<input type="text" bind:value={queryForm.username} onblur={handleFieldChange} onchange={handleFieldChange} placeholder="ID" name="username" />
149+
<label class="qf-inline"><span>User ID</span>
150+
<input type="text" bind:value={queryForm.user_id} onblur={handleFieldChange} onchange={handleFieldChange} placeholder="user_id" name="user_id" />
151+
</label>
152+
<label class="qf-inline"><span>Username</span>
153+
<input type="text" bind:value={queryForm.username} onblur={handleFieldChange} onchange={handleFieldChange} placeholder="username" name="username" />
151154
</label>
152155
<label class="qf-inline qf-sm"><span>Ver</span>
153156
<input type="text" bind:value={queryForm.implemented_in_version} onblur={handleFieldChange} onchange={handleFieldChange} placeholder="ver" name="implemented_in_version" />

apps/api-manager/src/lib/server/opey/OBPIntegrationService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,15 @@ export class DefaultOBPIntegrationService implements OBPIntegrationService {
108108
const now = new Date().toISOString().split('.')[0] + 'Z';
109109

110110
const body = {
111-
everything: true,
111+
// Baseline session consent: authenticates the user with Opey but grants
112+
// no elevated access. Specific roles are granted on demand by the
113+
// per-tool-call flow (/backend/opey/consent).
114+
everything: false,
112115
entitlements: [],
113116
consumer_id: this.opeyConsumerId,
114117
views: [],
115118
valid_from: now,
116-
time_to_live: 3600
119+
time_to_live: 18000 // 5 hours
117120
};
118121

119122
const consent = await obp_requests.post('/obp/v5.1.0/my/consents/IMPLICIT', body, accessToken);

apps/api-manager/src/routes/(protected)/users/[user_id]/+page.svelte

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,37 @@
6868
<!-- User Info Panel -->
6969
<div class="panel mb-6">
7070
<div class="panel-header">
71-
<h1 class="text-2xl font-bold">{user.username || "Unknown User"}</h1>
72-
<div class="text-sm text-gray-500 mt-1">
73-
{user.provider || "Unknown"} Provider
71+
<div class="flex items-center justify-between">
72+
<div>
73+
<h1 class="text-2xl font-bold">{user.username || "Unknown User"}</h1>
74+
<div class="text-sm text-gray-500 mt-1">
75+
{user.provider || "Unknown"} Provider
76+
</div>
77+
</div>
78+
{#if user.user_id}
79+
<a
80+
href="/metrics?user_id={encodeURIComponent(
81+
user.user_id,
82+
)}&from_date={encodeURIComponent(metricsFromDate)}"
83+
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
84+
data-testid="user-metrics-link"
85+
>
86+
<svg
87+
class="h-4 w-4"
88+
fill="none"
89+
stroke="currentColor"
90+
viewBox="0 0 24 24"
91+
>
92+
<path
93+
stroke-linecap="round"
94+
stroke-linejoin="round"
95+
stroke-width="2"
96+
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
97+
/>
98+
</svg>
99+
View API Metrics
100+
</a>
101+
{/if}
74102
</div>
75103
</div>
76104
<div class="panel-content">
@@ -79,17 +107,6 @@
79107
<div class="info-label">User ID</div>
80108
<div class="info-value">
81109
<span class="font-mono">{user.user_id || "N/A"}</span>
82-
{#if user.user_id}
83-
<a
84-
href="/metrics?user_id={encodeURIComponent(
85-
user.user_id,
86-
)}&from_date={encodeURIComponent(metricsFromDate)}"
87-
class="action-link"
88-
data-testid="user-metrics-link"
89-
>
90-
View API Metrics
91-
</a>
92-
{/if}
93110
</div>
94111
</div>
95112
<div class="info-item">

apps/portal/src/lib/server/opey/OBPIntegrationService.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Session } from 'svelte-kit-sessions';
55
import { obp_requests } from '$lib/obp/requests';
66
import type { OBPConsent, OBPConsentInfo } from '$lib/obp/types';
77
import { env } from '$env/dynamic/private';
8+
import { getOpeyConsentTtlSeconds } from '$lib/server/userPreferences';
89

910
export interface OBPIntegrationService {
1011
getOrCreateOpeyConsent(session: Session): Promise<OBPConsent>;
@@ -107,13 +108,21 @@ export class DefaultOBPIntegrationService implements OBPIntegrationService {
107108
private async createImplicitConsent(accessToken: string): Promise<OBPConsent> {
108109
const now = new Date().toISOString().split('.')[0] + 'Z';
109110

111+
// Per-user TTL preference (OBP personal data field) overrides the env default
112+
// which overrides the built-in 7-day fallback. OBP rejects values above its
113+
// `consents.max_time_to_live` prop with OBP-35020, so the value must be ≤ that.
114+
const ttl = await getOpeyConsentTtlSeconds(accessToken, Number(env.OPEY_CONSENT_TTL_SECONDS));
115+
110116
const body = {
111-
everything: true,
117+
// Baseline session consent: authenticates the user with Opey but grants
118+
// no elevated access. Specific roles are granted on demand by the
119+
// per-tool-call flow (/backend/opey/consent).
120+
everything: false,
112121
entitlements: [],
113122
consumer_id: this.opeyConsumerId,
114123
views: [],
115124
valid_from: now,
116-
time_to_live: 3600
125+
time_to_live: ttl
117126
};
118127

119128
const consent = await obp_requests.post('/obp/v5.1.0/my/consents/IMPLICIT', body, accessToken);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Server-side helpers for reading and writing per-user preferences stored as OBP
3+
* "personal data fields" (the `/obp/.../my/personal-data-fields` endpoint). Mirrors
4+
* the client-side pattern in `apps/portal/src/lib/stores/currentBank.svelte.ts` but
5+
* for code that runs on the server (consent-creation routes etc).
6+
*
7+
* Note: OBP's `user_attributes` is a *different* endpoint with different auth
8+
* requirements — don't conflate the two. The OBP response payload for
9+
* personal-data-fields happens to nest the records under a `user_attributes` key,
10+
* but the endpoint and concept this module talks to is personal data fields.
11+
*/
12+
import { createLogger } from '@obp/shared/utils';
13+
import { obp_requests } from '$lib/obp/requests';
14+
15+
const logger = createLogger('UserPreferences');
16+
17+
interface PersonalDataField {
18+
user_attribute_id: string;
19+
name: string;
20+
value: string;
21+
type?: string;
22+
}
23+
24+
interface PersonalDataFieldRecord {
25+
id: string;
26+
value: string;
27+
}
28+
29+
/** OBP personal-data-field name for the user's preferred Consent-JWT lifetime, in seconds. */
30+
export const OPEY_CONSENT_TTL_FIELD = 'OPEY_CONSENT_TTL_SECONDS';
31+
32+
/** Built-in fallback when nothing else is configured. Currently 7 days. */
33+
export const DEFAULT_OPEY_CONSENT_TTL_SECONDS = 604800;
34+
35+
/** Read a single OBP personal-data-field by name. Returns null if missing or unreadable. */
36+
export async function getPersonalDataField(
37+
accessToken: string,
38+
name: string
39+
): Promise<PersonalDataFieldRecord | null> {
40+
try {
41+
const res = await obp_requests.get('/obp/v6.0.0/my/personal-data-fields', accessToken);
42+
const fields: PersonalDataField[] = res?.user_attributes ?? [];
43+
const found = fields.find((f) => f.name === name);
44+
if (!found) return null;
45+
return { id: found.user_attribute_id, value: found.value };
46+
} catch (err) {
47+
logger.warn(`Failed to read OBP personal data field '${name}':`, err);
48+
return null;
49+
}
50+
}
51+
52+
/** Create or update an OBP personal-data-field by name. */
53+
export async function setPersonalDataField(
54+
accessToken: string,
55+
name: string,
56+
value: string
57+
): Promise<void> {
58+
const existing = await getPersonalDataField(accessToken, name);
59+
if (existing) {
60+
await obp_requests.put(
61+
`/obp/v6.0.0/my/personal-data-fields/${encodeURIComponent(existing.id)}`,
62+
{ name, value, type: 'STRING' },
63+
accessToken
64+
);
65+
} else {
66+
await obp_requests.post(
67+
'/obp/v6.0.0/my/personal-data-fields',
68+
{ name, value, type: 'STRING' },
69+
accessToken
70+
);
71+
}
72+
}
73+
74+
/**
75+
* Resolve the consent TTL (seconds) to use when creating a consent on this user's behalf.
76+
* Order of precedence:
77+
* 1. The user's `OPEY_CONSENT_TTL_SECONDS` personal data field, if set to a positive number.
78+
* 2. The `envDefault` argument (typically `Number(env.OPEY_CONSENT_TTL_SECONDS)`).
79+
* 3. The built-in default (7 days).
80+
*/
81+
export async function getOpeyConsentTtlSeconds(
82+
accessToken: string | undefined,
83+
envDefault?: number
84+
): Promise<number> {
85+
const fallback =
86+
Number.isFinite(envDefault) && (envDefault as number) > 0
87+
? (envDefault as number)
88+
: DEFAULT_OPEY_CONSENT_TTL_SECONDS;
89+
if (!accessToken) return fallback;
90+
const found = await getPersonalDataField(accessToken, OPEY_CONSENT_TTL_FIELD);
91+
if (!found) return fallback;
92+
const n = Number(found.value);
93+
return Number.isFinite(n) && n > 0 ? n : fallback;
94+
}
95+
96+
/** Persist the user's chosen consent TTL (seconds) as a personal data field. */
97+
export async function setOpeyConsentTtlSeconds(accessToken: string, seconds: number): Promise<void> {
98+
await setPersonalDataField(accessToken, OPEY_CONSENT_TTL_FIELD, String(Math.floor(seconds)));
99+
}

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

Lines changed: 58 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import type { OBPConsent } from '$lib/obp/types';
66
import { obp_requests } from '$lib/obp/requests';
77
import { OBPRequestError } from '@obp/shared/obp';
88
import { env } from '$env/dynamic/private';
9+
import {
10+
getPersonalDataField,
11+
setPersonalDataField,
12+
OPEY_CONSENT_TTL_FIELD,
13+
DEFAULT_OPEY_CONSENT_TTL_SECONDS
14+
} from '$lib/server/userPreferences';
915

1016
const displayConsent = (consent: OBPConsent): boolean => {
1117
// We want to display the consent if it is revoked and not more than a day old
@@ -27,43 +33,6 @@ const displayConsent = (consent: OBPConsent): boolean => {
2733

2834
const VALID_STATUSES = ['INITIATED', 'ACCEPTED', 'REJECTED', 'REVOKED'] as const;
2935

30-
/** Decode a JWT's payload segment (no signature check — display only). */
31-
function decodeJwtPayload(jwt: string | undefined): Record<string, any> | null {
32-
const segment = jwt?.split('.')[1];
33-
if (!segment) return null;
34-
try {
35-
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
36-
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
37-
} catch {
38-
return null;
39-
}
40-
}
41-
42-
/**
43-
* OBP returns `jwt_payload` as a JSON *string* (alongside the raw `jwt`). Normalise it
44-
* to a parsed object so the page can show which roles/views the consent actually grants.
45-
* Falls back to decoding the `jwt` itself for API versions that omit `jwt_payload`.
46-
*/
47-
function normalizeConsentPayload(consent: OBPConsent): OBPConsent {
48-
const raw = consent.jwt_payload as unknown;
49-
let payload: Record<string, any> | null = null;
50-
if (typeof raw === 'string') {
51-
try {
52-
payload = JSON.parse(raw);
53-
} catch {
54-
payload = null;
55-
}
56-
} else if (raw && typeof raw === 'object') {
57-
payload = raw as Record<string, any>;
58-
}
59-
if (!payload) {
60-
payload = decodeJwtPayload(consent.jwt) ?? {};
61-
}
62-
payload.entitlements = Array.isArray(payload.entitlements) ? payload.entitlements : [];
63-
payload.views = Array.isArray(payload.views) ? payload.views : [];
64-
return { ...consent, jwt_payload: payload as OBPConsent['jwt_payload'] };
65-
}
66-
6736
export async function load(event: RequestEvent) {
6837
const token = event.locals.session.data.oauth?.access_token;
6938
if (!token) {
@@ -97,18 +66,7 @@ export async function load(event: RequestEvent) {
9766
});
9867
}
9968

100-
let consents = consentResponse.consents;
101-
102-
for (let consent of consents) {
103-
// Filter out consents that should not be displayed
104-
if (!displayConsent(consent)) {
105-
// If the consent should not be displayed, set it to null
106-
consents = consents.filter((c) => c.consent_id !== consent.consent_id);
107-
}
108-
}
109-
110-
// Parse each consent's jwt_payload so the page can render its roles/views.
111-
consents = consents.map(normalizeConsentPayload);
69+
const consents = consentResponse.consents.filter(displayConsent);
11270

11371
// Split consents into Opey and Other consents
11472
const opeyConsents = consents.filter((consent: OBPConsent) =>
@@ -129,10 +87,26 @@ export async function load(event: RequestEvent) {
12987
opeyConsents.sort(sortByCreatedDate);
13088
otherConsents.sort(sortByCreatedDate);
13189

90+
// Load the user's preferred consent TTL (OBP personal data field), falling back
91+
// to env / built-in default. Surface seconds + an env-configurable max so the UI
92+
// can render only options that won't be rejected by OBP-35020.
93+
const ttlField = await getPersonalDataField(token, OPEY_CONSENT_TTL_FIELD);
94+
const envDefault = Number(env.OPEY_CONSENT_TTL_SECONDS);
95+
const parsed = ttlField ? Number(ttlField.value) : NaN;
96+
const currentConsentTtlSeconds: number =
97+
Number.isFinite(parsed) && parsed > 0
98+
? parsed
99+
: Number.isFinite(envDefault) && envDefault > 0
100+
? envDefault
101+
: DEFAULT_OPEY_CONSENT_TTL_SECONDS;
102+
const maxConsentTtlSeconds: number = Number(env.OPEY_CONSENT_TTL_MAX_SECONDS) || 7776000; // 90 days
103+
132104
return {
133105
opeyConsents,
134106
otherConsents,
135-
activeStatus: status
107+
activeStatus: status,
108+
currentConsentTtlSeconds,
109+
maxConsentTtlSeconds
136110
};
137111
}
138112

@@ -176,5 +150,39 @@ export const actions = {
176150
success: true,
177151
message: 'Consent deleted successfully.'
178152
};
153+
},
154+
155+
setConsentTtl: async ({ request, locals }) => {
156+
const data = await request.formData();
157+
const rawSeconds = data.get('seconds');
158+
const seconds = Number(rawSeconds);
159+
if (!Number.isFinite(seconds) || seconds <= 0) {
160+
return { message: 'Invalid consent duration.' };
161+
}
162+
163+
const max = Number(env.OPEY_CONSENT_TTL_MAX_SECONDS) || 7776000;
164+
if (seconds > max) {
165+
return { message: `Duration exceeds the configured max (${max}s).` };
166+
}
167+
168+
const token = locals.session.data.oauth?.access_token;
169+
if (!token) {
170+
return { message: 'No access token found in session.' };
171+
}
172+
173+
try {
174+
await setPersonalDataField(token, OPEY_CONSENT_TTL_FIELD, String(Math.floor(seconds)));
175+
} catch (err) {
176+
logger.error('Error saving consent TTL preference:', err);
177+
const errorMessage =
178+
err instanceof OBPRequestError
179+
? err.message
180+
: err instanceof Error
181+
? err.message
182+
: 'Failed to save consent duration.';
183+
return { message: errorMessage };
184+
}
185+
186+
return { success: true, message: 'Consent duration saved.' };
179187
}
180188
} satisfies Actions;

0 commit comments

Comments
 (0)