From 88d1a730b9733b89dbcc6e5ecfc736ebb29b2c1c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 08:23:20 +0200 Subject: [PATCH 01/15] fix: correct Berlin Group SCA authorisation URL segment order The payment SCA endpoints called /obp/v1.3/berlin-group/... but OBP-API registers these routes at /berlin-group/v1.3/... with no /obp/ prefix, so both the create-authorisation POST and OTP-submit PUT calls were hitting a non-existent path. --- apps/portal/src/routes/(protected)/otp/+page.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/routes/(protected)/otp/+page.server.ts b/apps/portal/src/routes/(protected)/otp/+page.server.ts index 4b303bac..80df6529 100644 --- a/apps/portal/src/routes/(protected)/otp/+page.server.ts +++ b/apps/portal/src/routes/(protected)/otp/+page.server.ts @@ -81,7 +81,7 @@ export const actions = { // Step 1: Create authorisation const authResponse = await obp_requests.post( - `/obp/v1.3/berlin-group/${paymentService}/${paymentProduct}/${paymentId}/authorisations`, + `/berlin-group/v1.3/${paymentService}/${paymentProduct}/${paymentId}/authorisations`, {}, token ); @@ -90,7 +90,7 @@ export const actions = { // Step 2: Submit OTP to authorisation await obp_requests.put( - `/obp/v1.3/berlin-group/${paymentService}/${paymentProduct}/${paymentId}/authorisations/${authorisationId}`, + `/berlin-group/v1.3/${paymentService}/${paymentProduct}/${paymentId}/authorisations/${authorisationId}`, { scaAuthenticationData: otp }, token ); From a80a60a6e42e9691e96a511b749d92bd7908d271 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:45:14 +0200 Subject: [PATCH 02/15] fix(portal): call the Berlin Group native SCA endpoints for BG consent approval The SCA confirmation page submitted the OTP to /obp/v3.1.0/banks/{bank}/consents/{id}/challenge, which only transitions consents in status INITIATED -- Berlin Group consents are created in status received, so this endpoint could never succeed regardless of OTP correctness. Call the real BG-native pair instead: start the authorisation in load() (POST .../consents/{id}/authorisations) and submit the answer in the form action (PUT .../consents/{id}/authorisations/{authorisationId}), threading the authorisationId through a hidden form field. --- apps/portal/src/lib/obp/types.ts | 12 ++++ .../+page.server.ts | 56 ++++++++++++++----- .../+page.svelte | 1 + 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/portal/src/lib/obp/types.ts b/apps/portal/src/lib/obp/types.ts index 62a9e5eb..641c4bba 100644 --- a/apps/portal/src/lib/obp/types.ts +++ b/apps/portal/src/lib/obp/types.ts @@ -312,6 +312,18 @@ export interface OBPBGPaymentAuthorisation { sca_status: string; } +// Berlin Group Consent Authorisation (SCA) types +export interface OBPBGStartConsentAuthorisation { + scaStatus: string; + authorisationId: string; + pushMessage: string; + _links: { scaStatus: string }; +} +export interface OBPBGConsentAuthorisationResult { + scaStatus: string; + _links?: { scaStatus?: { href?: string } }; +} + // Personal Data Field (User Attribute) export interface OBPPersonalDataField { user_attribute_id: string; diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts index 5e74f7b2..f3edb51b 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts @@ -4,20 +4,47 @@ import type { RequestEvent, Actions } from '@sveltejs/kit'; import { redirect, isRedirect } from '@sveltejs/kit'; import { obp_requests } from '$lib/obp/requests'; import { OBPRequestError } from '@obp/shared/obp'; -import { env } from '$env/dynamic/private'; +import type { + OBPBGStartConsentAuthorisation, + OBPBGConsentAuthorisationResult +} from '$lib/obp/types'; export async function load(event: RequestEvent) { const consentId = event.url.searchParams.get('CONSENT_ID'); - if (!consentId) { return { loadError: 'Missing required parameter: CONSENT_ID.', consentId: '', - }; + authorisationId: '' + }; + } + + const token = event.locals.session.data.oauth?.access_token; + if (!token) { + return { + loadError: 'No access token found in session.', + consentId, + authorisationId: '' + }; } - return { consentId }; + try { + const startResponse: OBPBGStartConsentAuthorisation = await obp_requests.post( + `/berlin-group/v1.3/consents/${consentId}/authorisations`, + { scaAuthenticationData: '' }, + token + ); + + return { consentId, authorisationId: startResponse.authorisationId }; + } catch (e) { + logger.error('Error starting BG consent authorisation:', e); + let errorMessage = 'Failed to start consent authorisation.'; + if (e instanceof OBPRequestError) { + errorMessage = e.message; + } + return { loadError: errorMessage, consentId, authorisationId: '' }; + } } export const actions = { @@ -25,35 +52,34 @@ export const actions = { const formData = await request.formData(); const otp = formData.get('otp') as string; const consentId = formData.get('consentId') as string; + const authorisationId = formData.get('authorisationId') as string; if (!otp) { return { message: 'Please enter the OTP code.' }; } + if (!authorisationId) { + return { message: 'Missing authorisation id. Please reload the page.' }; + } + const token = locals.session.data.oauth?.access_token; if (!token) { return { message: 'No access token found in session.' }; } - const defaultBankId = env.DEFAULT_BANK_ID; - if (!defaultBankId) { - logger.error('DEFAULT_BANK_ID environment variable is not set'); - return { message: 'Server configuration error: DEFAULT_BANK_ID is not set.' }; - } - try { - const response = await obp_requests.post( - `/obp/v3.1.0/banks/${defaultBankId}/consents/${consentId}/challenge`, - { answer: otp }, + const response: OBPBGConsentAuthorisationResult = await obp_requests.put( + `/berlin-group/v1.3/consents/${consentId}/authorisations/${authorisationId}`, + { scaAuthenticationData: otp }, token ); - if (response.status === 'ACCEPTED' || response.status === 'VALID') { + if (response.scaStatus === 'valid') { redirect(303, `/confirm-bg-consent-request-redirect-uri?CONSENT_ID=${consentId}`); } return { - message: `Challenge was not accepted. Status: ${response.status}` + message: `Challenge was not accepted. Status: ${response.scaStatus}` }; } catch (e) { if (isRedirect(e)) throw e; diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte index 41d65eb9..2f999ee4 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte @@ -25,6 +25,7 @@
+
+ +
+

+ Select Accounts +

+ {#if data.userAccounts?.length} +

+ Choose which of your accounts the requested permissions apply to: +

+
+ {#each data.userAccounts as account} + + {/each} +
+ {:else} +

+ You have no accounts at this bank, so this consent cannot be authorised. +

+ {/if} +
+
- + - From 9f989a54994b608b907d1ff891dad8eecd7b5615 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 22 Jul 2026 09:03:28 +0200 Subject: [PATCH 04/15] fix(portal): preserve query params in the UK consent form action URLs SvelteKit's "?/actionName" shorthand replaces the page's entire query string rather than appending to it. load() requires CONSENT_ID/bank_id from the URL, so submitting a bare "?/confirm" or "?/deny" stripped them -- on any non-redirecting action response (e.g. a validation failure) load() re-ran against the stripped URL and threw its own "Missing required parameter" error, masking the action's real message. Preserve the existing query string per SvelteKit's documented convention for named actions on a page with search params. --- .../uk-consent-request/+page.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte index a4354d64..c22cfaf3 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte @@ -1,5 +1,20 @@
@@ -114,7 +129,7 @@
-
+ @@ -126,7 +141,7 @@ Confirm Consent
-
+
+ + {#if data.payload.to_account} +
+

+ Payments you are authorising +

+
+ {#if data.payload.from_account?.account_routing?.address} +
+ Paid from: + {data.payload.from_account.account_routing.address} + ({data.payload.from_account.account_routing.scheme}) +
+ {/if} + + {#if data.payload.to_account.counterparty_name} +
+ Paid to: + {data.payload.to_account.counterparty_name} +
+ {/if} + {#if data.payload.to_account.account_routing?.address} +
+ + {data.payload.to_account.counterparty_name + ? 'Their account:' + : 'Paid to:'} + + {data.payload.to_account.account_routing.address} + ({data.payload.to_account.account_routing.scheme}) +
+ {/if} +
+ + {#if data.payload.to_account.limit} + {@const limit = data.payload.to_account.limit} + {@const currency = limit.currency ?? ''} +

+ Limits on those payments +

+
    + {#each [['Per payment', limit.max_single_amount, null], ['Per month', limit.max_monthly_amount, limit.max_number_of_monthly_transactions], ['Per year', limit.max_yearly_amount, limit.max_number_of_yearly_transactions], ['In total', limit.max_total_amount, limit.max_number_of_transactions]] as [label, amount, count]} + {#if amount !== undefined && amount !== null} +
  • + {label}: + + {currency} + {amount} + {#if count !== null && count !== undefined} + · at most {count} payment{count === 1 ? '' : 's'} + {/if} + +
  • + {/if} + {/each} +
+ {/if} +
+ {/if} + {#if !data.payload.everything && data.payload.account_access?.length}

From 61a81562bfb1ad0f19a90ce51fe37c7b0cbb041f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 7 Aug 2026 19:03:06 +0200 Subject: [PATCH 08/15] fix: show the reason a Berlin Group call failed instead of a generic message Two error shapes reach this client. OBP's own endpoints answer {code, message}; Berlin Group answers {tppMessages: [{code, text}]}, which is what NextGenPSD2 specifies. Only the first was recognised, so every Berlin Group failure fell past it into `new OBPErrorBase("Error posting OBP data to ...")` -- an OBPErrorBase, not an OBPRequestError, which the pages then fail to match and replace with their own generic text. The effect: an account-ownership refusal reads exactly like a transport fault. A PSU who lodged a consent naming an account they do not hold saw only "Failed to start consent authorisation." with nothing to act on, while the API had answered OBP-35037: One or more of the specified account_ids is not held by the current user. A consent may only be authorised for accounts the authorising user holds. The SCA page compounded it: with no authorisation to answer, pressing Verify said "Missing authorisation id. Please reload the page." Reloading re-runs the same failing call, so that sends the PSU round the loop with the real reason still hidden. It now says the authorisation could not be started and points at the reason above it. All five verbs share one extractor rather than repeating the shape check. Verified in a browser: the same consent that produced the generic message now shows the OBP-35037 text naming the account, and a consent for an account the PSU does hold still authorises and reads data. --- apps/portal/src/lib/obp/requests.ts | 45 ++++++++++++++----- .../+page.server.ts | 7 ++- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/portal/src/lib/obp/requests.ts b/apps/portal/src/lib/obp/requests.ts index ccea06f8..c116e151 100644 --- a/apps/portal/src/lib/obp/requests.ts +++ b/apps/portal/src/lib/obp/requests.ts @@ -19,6 +19,26 @@ class OBPRequests { logger.info('Initialized.'); } + /** + * The error the API actually reported, or null when the body carries none. + * + * Two shapes reach here. OBP's own endpoints answer `{code, message}`; Berlin Group answers + * `{tppMessages: [{code, text}]}`, which is what NextGenPSD2 specifies. Only the first was + * understood, so every Berlin Group failure fell through to a generic "Error posting OBP data" + * and the reason never reached the PSU -- an account-ownership refusal reads exactly like a + * transport fault. + */ + private static apiError(data: any): { code: string | number; message: string } | null { + if (data?.code && data?.message) { + return { code: data.code, message: data.message }; + } + const tppMessage = data?.tppMessages?.[0]; + if (tppMessage?.text) { + return { code: tppMessage.code ?? 'ERROR', message: tppMessage.text }; + } + return null; + } + private logRateLimitInfo(response: Response, url: string): void { const remaining = response.headers.get('X-Rate-Limit-Remaining'); if (remaining !== null && parseInt(remaining) < 0) { @@ -70,8 +90,9 @@ class OBPRequests { ); } - if (data && data.code && data.message) { - throw new OBPRequestError(data.code, data.message); + const apiError = OBPRequests.apiError(data); + if (apiError) { + throw new OBPRequestError(apiError.code as any, apiError.message); } else { throw new OBPErrorBase(`Error fetching OBP data from ${url}: ${response.statusText}`); } @@ -109,8 +130,9 @@ class OBPRequests { if (!response.ok) { logger.error('Failed to post OBP data:', { statusText: response.statusText, data }); - if (data && data.code && data.message) { - throw new OBPRequestError(data.code, data.message); + const apiError = OBPRequests.apiError(data); + if (apiError) { + throw new OBPRequestError(apiError.code as any, apiError.message); } else { throw new OBPErrorBase(`Error posting OBP data to ${url}: ${response.statusText}`); } @@ -157,8 +179,9 @@ class OBPRequests { if (!response.ok) { logger.error('Failed to delete OBP data:', response.statusText, data); - if (data && data.code && data.message) { - throw new OBPRequestError(data.code, data.message); + const apiError = OBPRequests.apiError(data); + if (apiError) { + throw new OBPRequestError(apiError.code as any, apiError.message); } else { throw new OBPErrorBase(`Error deleting OBP data from ${url}: ${response.statusText}`); } @@ -195,8 +218,9 @@ class OBPRequests { if (!response.ok) { logger.error('Failed to put OBP data:', { statusText: response.statusText, data }); - if (data && data.code && data.message) { - throw new OBPRequestError(data.code, data.message); + const apiError = OBPRequests.apiError(data); + if (apiError) { + throw new OBPRequestError(apiError.code as any, apiError.message); } else { throw new OBPErrorBase(`Error putting OBP data to ${url}: ${response.statusText}`); } @@ -233,8 +257,9 @@ class OBPRequests { if (!response.ok) { logger.error('Failed to patch OBP data:', { statusText: response.statusText, data }); - if (data && data.code && data.message) { - throw new OBPRequestError(data.code, data.message); + const apiError = OBPRequests.apiError(data); + if (apiError) { + throw new OBPRequestError(apiError.code as any, apiError.message); } else { throw new OBPErrorBase(`Error patching OBP data to ${url}: ${response.statusText}`); } diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts index f3edb51b..337a0759 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts @@ -59,7 +59,12 @@ export const actions = { } if (!authorisationId) { - return { message: 'Missing authorisation id. Please reload the page.' }; + // There is no authorisation to answer because starting one failed, and the reason is + // already on the page above. Telling the PSU to reload sends them round the same loop. + return { + message: + 'This consent has no authorisation to confirm — starting one did not succeed. See the reason above.' + }; } const token = locals.session.data.oauth?.access_token; From 9b16749293e1d8da849e63b6ac96a7e2c16277cb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 7 Aug 2026 19:30:42 +0200 Subject: [PATCH 09/15] fix: return the PSU to the TPP after a Berlin Group authorisation The consent JWT stores request headers as [{name, values}] -- the shape OBP's HTTPParam serialises to. This read them as {"TPP-Redirect-URI": "..."}, so the lookup found nothing, every time. tppRedirectUri stayed empty, the redirect never fired, and the PSU was left sitting on a "you may now close this window" page after a successful authorisation. Under the Redirect approach that return is the last step of the ceremony, not a courtesy: the TPP is waiting for the PSU to come back before it can use the consent. Verified against a running stack: the page now sends the browser to the TPP's registered redirect URI, which for the demo app lands back on its accounts page with the consent in session. --- .../+page.server.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-redirect-uri/+page.server.ts b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-redirect-uri/+page.server.ts index 58d939d1..eeea0292 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-redirect-uri/+page.server.ts +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-redirect-uri/+page.server.ts @@ -51,16 +51,17 @@ export async function load(event: RequestEvent) { const jwtPayload = decodeJwtPayload(consent.jwt); const requestHeaders = jwtPayload.request_headers || []; - let tppRedirectUri = ''; - let tppNokRedirectUri = ''; - for (const header of requestHeaders) { - if (header['TPP-Redirect-URI']) { - tppRedirectUri = header['TPP-Redirect-URI']; - } - if (header['TPP-Nok-Redirect-URI']) { - tppNokRedirectUri = header['TPP-Nok-Redirect-URI']; - } - } + // The consent JWT stores these as [{name, values}], the shape OBP's HTTPParam serialises to + // -- not as {"TPP-Redirect-URI": "..."}. Reading them the second way found nothing, ever, so + // the PSU was left on this page after a successful Berlin Group authorisation instead of + // being returned to the TPP that sent them. Under the Redirect approach that return is the + // last step of the ceremony, not a nicety. + const headerValue = (wanted: string): string => { + const match = requestHeaders.find((h: any) => h?.name === wanted); + return match?.values?.[0] ?? ''; + }; + const tppRedirectUri = headerValue('TPP-Redirect-URI'); + const tppNokRedirectUri = headerValue('TPP-Nok-Redirect-URI'); return { consentId, From 621dd2a6a3c42f5c7ab46d292a9c52044d19cf61 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 8 Aug 2026 11:21:01 +0200 Subject: [PATCH 10/15] fix: stop re-sending the SCA code, and stop guessing why accounts are missing Starting a Berlin Group consent authorisation mints a fresh challenge and delivers a new OTP. It was being started from load(), and SvelteKit re-runs load() after any action that does not redirect -- so a mistyped code replaced the challenge the PSU was answering, their retry answered one whose code they had never seen, and that failure minted another. The PSU could not catch up, and every attempt sent another message. The authorisation is now started once and kept in the session for the whole ceremony, with an explicit "send a new one" button as the only path that supersedes it -- which a code that expired or never arrived now needs, since a wrong answer no longer does it by accident. The UK consent page told the PSU "you have no accounts at this bank" whenever the account list failed to load, because a failed call and an empty bank both ended as an empty array. That is a claim about the PSU with no evidence behind it: someone who does hold accounts there was told they do not, given no reason, and left with the Confirm button disabled. The two states are now distinct and the failure carries its reason. --- apps/portal/src/hooks.server.ts | 10 +++ .../+page.server.ts | 76 +++++++++++++++++-- .../+page.svelte | 14 +++- .../uk-consent-request/+page.server.ts | 11 ++- .../uk-consent-request/+page.svelte | 9 +++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/apps/portal/src/hooks.server.ts b/apps/portal/src/hooks.server.ts index 6b848e5e..4d0cdeea 100644 --- a/apps/portal/src/hooks.server.ts +++ b/apps/portal/src/hooks.server.ts @@ -363,5 +363,15 @@ declare module 'svelte-kit-sessions' { consentRequestId: string; bankId: string; }; + /** + * The Berlin Group consent authorisation this PSU is currently answering. + * + * Held across renders because starting one mints a new challenge and delivers a new OTP, so + * re-deriving it per render would invalidate the code the PSU is looking at. + */ + bgConsentAuthorisation?: { + consentId: string; + authorisationId: string; + }; } } diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts index 337a0759..8ce3951d 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.server.ts @@ -9,6 +9,37 @@ import type { OBPBGConsentAuthorisationResult } from '$lib/obp/types'; +/** + * Start a Berlin Group consent authorisation and remember which one this PSU is answering. + * + * OBP mints a fresh challenge per POST — a new authorisation id and a newly delivered OTP — so this + * must not run on every render. It is called from load(), and SvelteKit re-runs load() after any + * action that does not redirect: a mistyped OTP would therefore replace the challenge the PSU is + * holding, their retry would answer a challenge whose code they have never seen, and that failure + * would mint another. The PSU can never catch up, and each attempt sends another OTP. + * + * So the id is kept in the session and reused for the whole ceremony. `resend` is the one deliberate + * way to get a new one, for when the code really did expire or never arrived. + */ +async function startAuthorisation(event: RequestEvent, consentId: string, token: string) { + const startResponse: OBPBGStartConsentAuthorisation = await obp_requests.post( + `/berlin-group/v1.3/consents/${consentId}/authorisations`, + { scaAuthenticationData: '' }, + token + ); + await event.locals.session.setData({ + ...event.locals.session.data, + bgConsentAuthorisation: { consentId, authorisationId: startResponse.authorisationId } + }); + return startResponse.authorisationId; +} + +/** Forget the remembered authorisation, so the next consent starts its own. */ +async function forgetAuthorisation(event: { locals: App.Locals }) { + const { bgConsentAuthorisation, ...rest } = event.locals.session.data; + await event.locals.session.setData(rest); +} + export async function load(event: RequestEvent) { const consentId = event.url.searchParams.get('CONSENT_ID'); @@ -29,14 +60,15 @@ export async function load(event: RequestEvent) { }; } - try { - const startResponse: OBPBGStartConsentAuthorisation = await obp_requests.post( - `/berlin-group/v1.3/consents/${consentId}/authorisations`, - { scaAuthenticationData: '' }, - token - ); + // Already answering one for this consent: reuse it rather than mint a second and invalidate the + // code the PSU is looking at. See startAuthorisation. + const started = event.locals.session.data.bgConsentAuthorisation; + if (started?.consentId === consentId && started.authorisationId) { + return { consentId, authorisationId: started.authorisationId }; + } - return { consentId, authorisationId: startResponse.authorisationId }; + try { + return { consentId, authorisationId: await startAuthorisation(event, consentId, token) }; } catch (e) { logger.error('Error starting BG consent authorisation:', e); let errorMessage = 'Failed to start consent authorisation.'; @@ -48,7 +80,33 @@ export async function load(event: RequestEvent) { } export const actions = { - default: async ({ request, locals }) => { + /** + * Deliberately mint a new challenge, for a code that expired or never arrived. + * + * This is the only path that is supposed to invalidate the previous one, which is why it is a + * button the PSU presses rather than something that happens to them on every re-render. + */ + resend: async (event) => { + const consentId = (await event.request.formData()).get('consentId') as string; + const token = event.locals.session.data.oauth?.access_token; + if (!consentId || !token) { + return { message: 'Cannot request a new code: the session or the consent id is missing.' }; + } + try { + await startAuthorisation(event as RequestEvent, consentId, token); + return { message: 'A new code has been sent. Use the most recent one.' }; + } catch (e) { + event.locals && logger.error('Error restarting BG consent authorisation:', e); + return { + message: e instanceof OBPRequestError ? e.message : 'Failed to request a new code.' + }; + } + }, + + // Named rather than `default` because `resend` exists: SvelteKit refuses a default action + // alongside named ones. + confirm: async (event) => { + const { request, locals } = event; const formData = await request.formData(); const otp = formData.get('otp') as string; const consentId = formData.get('consentId') as string; @@ -80,6 +138,8 @@ export const actions = { ); if (response.scaStatus === 'valid') { + // Done with this one; a later consent must not reuse a spent authorisation id. + await forgetAuthorisation(event); redirect(303, `/confirm-bg-consent-request-redirect-uri?CONSENT_ID=${consentId}`); } diff --git a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte index 2f999ee4..27f3c335 100644 --- a/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte +++ b/apps/portal/src/routes/(protected)/confirm-bg-consent-request-sca/+page.svelte @@ -23,7 +23,7 @@

{/if} - + @@ -46,4 +46,16 @@ + + +
+ + +
diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts index 2620e03c..2bac818a 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.server.ts @@ -91,6 +91,10 @@ export async function load(event: RequestEvent) { // OBP-API only binds/grants access for accounts the authorising user actually holds // (POST .../authorise requires account_ids, and rejects any account the PSU doesn't hold). let userAccounts: { accountId: string; label: string }[] = []; + // "The list could not be loaded" and "there are no accounts" are different facts, and the second + // is a statement about the PSU that the page should not make on the strength of a failed call. + // An empty list with no error reads as "you hold nothing here"; this keeps the reason instead. + let accountsError = ''; try { const accountsResponse = await obp_requests.get('/obp/v6.0.0/my/accounts', token); userAccounts = (accountsResponse.accounts || []) @@ -101,6 +105,10 @@ export async function load(event: RequestEvent) { })); } catch (e) { logger.warn('Could not fetch user accounts:', e); + accountsError = + e instanceof OBPRequestError + ? `Your accounts could not be loaded: ${e.message}` + : 'Your accounts could not be loaded. Please try again.'; } return { @@ -111,7 +119,8 @@ export async function load(event: RequestEvent) { status, permissions, expirationDateTime, - userAccounts + userAccounts, + accountsError }; } diff --git a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte index c22cfaf3..542bd117 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte +++ b/apps/portal/src/routes/(protected)/uk-consent-request/+page.svelte @@ -120,6 +120,15 @@ {/each}
+ {:else if data.accountsError} + +

+ {data.accountsError} +

{:else}

You have no accounts at this bank, so this consent cannot be authorised. From 6509c8992780f26d611e2c5565ddab6af95d149f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 8 Aug 2026 11:25:07 +0200 Subject: [PATCH 11/15] fix: add a readiness probe, and bind the consented accounts server-side /health became an unconditional 200 so an orchestrator would stop restarting a working instance over a dependency it does not need to serve pages. That left nothing a machine could ask about dependency health: /status renders the same verdict for a human but is a page and answers 200 whatever it finds. /ready now reports summarizeHealth's verdict as a status code. `partial` stays ready on purpose -- it means a group still has a working member, and pulling the instance out of rotation for that turns a degraded login into no login -- while `unknown` does not, because no check has reported yet and readiness on no evidence is how a starting instance takes traffic it cannot serve. The accounts the PSU ticked travelled to the SCA step in the query string, where they were editable between the screen that showed them and the answer that committed them. OBP-API refuses accounts the PSU does not hold, so the exposure was bounded to their own -- but the consent record is what an audit reads, and it could name accounts that never appeared on the screen they consented from. The selection is now held against the challenge it was minted for and read from the session. Also: the provider handed back to OBP-OIDC was read off the session's `user`, which has no such field, so it was always undefined and the parameter its own comment says OBP-OIDC needs to resolve the PSU was never sent. It lives on the oauth entry. --- apps/api-manager/src/routes/health/+server.ts | 6 ++-- apps/api-manager/src/routes/ready/+server.ts | 27 +++++++++++++++++ apps/portal/src/hooks.server.ts | 10 +++++++ .../uk-consent-request-sca/+page.server.ts | 30 +++++++++++-------- .../uk-consent-request-sca/+page.svelte | 1 - .../uk-consent-request/+page.server.ts | 14 +++++++-- apps/portal/src/routes/health/+server.ts | 6 ++-- apps/portal/src/routes/ready/+server.ts | 27 +++++++++++++++++ 8 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 apps/api-manager/src/routes/ready/+server.ts create mode 100644 apps/portal/src/routes/ready/+server.ts diff --git a/apps/api-manager/src/routes/health/+server.ts b/apps/api-manager/src/routes/health/+server.ts index fee9f917..f7788a61 100644 --- a/apps/api-manager/src/routes/health/+server.ts +++ b/apps/api-manager/src/routes/health/+server.ts @@ -14,8 +14,10 @@ import type { RequestHandler } from './$types'; * 503. And it counted "no checks registered" as healthy, the opposite of the rule summarizeHealth * states for that case ('unknown', never 'healthy'). * - * Ask /status for dependency health: it reports per-service detail and its own overall verdict. - * This matches the /health that OBP-API and Hola serve. + * Ask /ready for dependency health -- it is the readiness half of this split and answers 503 when + * the dependencies are not there, so a load balancer can route on it. /status renders the same + * verdict for a human but is a page and always answers 200, so it cannot serve as a probe. + * This /health matches the one OBP-API and Hola serve. */ export const GET: RequestHandler = async () => { return new Response(JSON.stringify({ status: 'ok' }), { diff --git a/apps/api-manager/src/routes/ready/+server.ts b/apps/api-manager/src/routes/ready/+server.ts new file mode 100644 index 00000000..3bbc6848 --- /dev/null +++ b/apps/api-manager/src/routes/ready/+server.ts @@ -0,0 +1,27 @@ +import type { RequestHandler } from './$types'; +import { healthCheckRegistry, summarizeHealth } from '@obp/shared/health-check'; + +/** + * Readiness probe: this instance can do useful work, i.e. its dependencies answer. + * + * The other half of the split /health introduced. /health is liveness and says only "the process is + * serving HTTP", which is what an orchestrator should restart on. Readiness is what a load balancer + * should route on, and it needs a status code, not a rendered page: /status shows the same verdict + * to a human but is a +page and answers 200 whatever it finds, so nothing machine-readable reported + * dependency health at all. + * + * `partial` stays ready on purpose. summarizeHealth reports it when a group still has a working + * member -- one OAuth2 provider down while another serves -- and pulling the instance out of + * rotation for that would turn a degraded login into no login. `unknown` is not ready: it means no + * check has reported yet, and claiming readiness on no evidence is how a starting instance takes + * traffic it cannot serve. + */ +export const GET: RequestHandler = async () => { + const summary = summarizeHealth(healthCheckRegistry.getSnapshots()); + const ready = summary.overallStatus === 'healthy' || summary.overallStatus === 'partial'; + + return new Response(JSON.stringify({ status: summary.overallStatus, ready, services: summary.services }), { + status: ready ? 200 : 503, + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/apps/portal/src/hooks.server.ts b/apps/portal/src/hooks.server.ts index 4d0cdeea..46a6a88b 100644 --- a/apps/portal/src/hooks.server.ts +++ b/apps/portal/src/hooks.server.ts @@ -373,5 +373,15 @@ declare module 'svelte-kit-sessions' { consentId: string; authorisationId: string; }; + /** + * The accounts the PSU ticked on the UK consent screen, bound to the challenge minted for + * that selection. Held here rather than round-tripped through the URL so the consent cannot + * be authorised for accounts that never appeared on the screen they consented from. + */ + ukConsentFlow?: { + consentId: string; + challengeId: string; + accountIds: string[]; + }; } } diff --git a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts index 090f4fbe..53881415 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts +++ b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.server.ts @@ -18,9 +18,6 @@ export async function load(event: RequestEvent) { const bankId = event.url.searchParams.get('bank_id'); const challengeId = event.url.searchParams.get('challenge_id'); const requestedOidcReturnUrl = event.url.searchParams.get('oidc_return_url'); - // The accounts the PSU selected on /uk-consent-request, carried through as a comma-joined - // query param -- OBP-API's authorise endpoint requires account_ids in the final POST body. - const accountIds = event.url.searchParams.get('account_ids') || ''; if (!consentId || !bankId || !challengeId) { return { @@ -28,8 +25,7 @@ export async function load(event: RequestEvent) { consentId: consentId || '', bankId: bankId || '', challengeId: challengeId || '', - oidcReturnUrl: '', - accountIds: '' + oidcReturnUrl: '' }; } @@ -40,8 +36,7 @@ export async function load(event: RequestEvent) { consentId, bankId, challengeId, - oidcReturnUrl: '', - accountIds: '' + oidcReturnUrl: '' }; } @@ -53,7 +48,7 @@ export async function load(event: RequestEvent) { logger.warn(`Rejected untrusted oidc_return_url: ${requestedOidcReturnUrl}`); } - return { consentId, bankId, challengeId, oidcReturnUrl, accountIds }; + return { consentId, bankId, challengeId, oidcReturnUrl }; } export const actions = { @@ -64,14 +59,20 @@ export const actions = { const bankId = formData.get('bankId') as string; const challengeId = formData.get('challengeId') as string; const oidcReturnUrlRaw = formData.get('oidcReturnUrl') as string; - const accountIds = ((formData.get('accountIds') as string) || '') - .split(',') - .map((id) => id.trim()) - .filter(Boolean); if (!otp) { return { message: 'Please enter the OTP code.' }; } + + // The selection the PSU made on the previous screen, held server-side against the challenge + // that was minted for it. Read from the session rather than the request so what gets + // authorised is what was on the screen they consented from -- see the note where it is + // stored in uk-consent-request/+page.server.ts. + const ukConsentFlow = locals.session.data.ukConsentFlow; + const accountIds = + ukConsentFlow?.consentId === consentId && ukConsentFlow?.challengeId === challengeId + ? ukConsentFlow.accountIds + : []; if (accountIds.length === 0) { return { message: 'No accounts were selected for this consent. Please start over.' }; } @@ -112,7 +113,10 @@ export const actions = { returnUrl.searchParams.set('consent_id', consentId); returnUrl.searchParams.set('consent_status', 'ACCEPTED'); const username = locals.session.data.user?.username; - const provider = locals.session.data.user?.provider; + // oauth, not user: the session records the IdP that authenticated this PSU on the oauth + // entry, and `user` has no `provider` at all. This read was always undefined, so the param + // the comment above says OBP-OIDC needs to resolve the PSU was never actually sent. + const provider = locals.session.data.oauth?.provider; if (username) returnUrl.searchParams.set('username', username); if (provider) returnUrl.searchParams.set('provider', provider); redirect(303, returnUrl.toString()); diff --git a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte index c14f472b..9b7df79f 100644 --- a/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte +++ b/apps/portal/src/routes/(protected)/uk-consent-request-sca/+page.svelte @@ -40,7 +40,6 @@ -