Skip to content

fix: correct Berlin Group SCA authorisation URL segment order - #5

Open
hongwei1 wants to merge 15 commits into
OpenBankProject:mainfrom
hongwei1:main
Open

fix: correct Berlin Group SCA authorisation URL segment order#5
hongwei1 wants to merge 15 commits into
OpenBankProject:mainfrom
hongwei1:main

Conversation

@hongwei1

Copy link
Copy Markdown
Contributor

Summary

  • The payment SCA flow in otp/+page.server.ts called /obp/v1.3/berlin-group/..., but OBP-API registers these Berlin Group v1.3 payment routes at /berlin-group/v1.3/... with no /obp/ prefix (see Http4sBGv13AIS.scala/Http4sBGv13PIS.scala), so both the create-authorisation POST and the OTP-submit PUT were hitting a non-existent path.
  • Fixed both calls to use the correct /berlin-group/v1.3/${paymentService}/${paymentProduct}/${paymentId}/authorisations[/${authorisationId}] path.

Test plan

  • Exercise the Portal "payment" OTP flow end-to-end against a running OBP-API instance and confirm the authorisation POST/PUT succeed (200/201) instead of 404.

hongwei1 added 15 commits July 18, 2026 08:23
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.
…t 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.
The UK consent approval page only showed Confirm/Deny with no account
selection, and the SCA page's POST .../authorise call omitted account_ids
entirely. OBP-API's authorise endpoint now requires account_ids and rejects
any account the PSU doesn't hold, so this call was failing with 400.

Fetch the PSU's own accounts at the consent's bank (GET /my/accounts,
filtered by bank_id) and let them check which ones the requested
permissions apply to. Carry the selection through the SCA redirect as a
comma-joined account_ids query param, then a hidden form field, and
include it in the final POST .../authorise body.
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.
…ookie

When exactly one provider is available, +page.server.ts's load() normally
auto-redirects straight to /login/{provider}, which mints a fresh
obp_oauth_state cookie. This page only renders that single-provider case
when an error/success message is suppressing the redirect (e.g. a failed
login retry) -- and it still ran a 60-second invalidateAll() interval in
that state. If invalidateAll() raced a real click on the provider link,
the interval's response could overwrite the state cookie the click's own
request had just set, causing an intermittent "Security validation
failed" state mismatch on the callback.

Skip the refresh interval whenever only one provider is available, and
disable preload on the provider link so hovering/tapping it can't mint an
extra state cookie ahead of the real navigation.
/health required every monitored dependency to be healthy and answered 503
otherwise. That is readiness logic in a liveness endpoint: an orchestrator
polling it would restart a perfectly functional app because Opey or a secondary
OAuth2 provider was down, even though pages keep being served either way, so the
restart only widens an unrelated outage.

It also flattened the nuance /status already encodes. summarizeHealth groups the
OAuth2 providers, so one dead provider alongside a working one is 'partial'; the
`every()` here turned any single unhealthy check into a hard 503 — including
'OAuth2: google', which is reported unhealthy simply because it was never
configured. Locally that left /health returning 503 while the app was entirely
usable. And "no checks registered" counted as healthy, the opposite of the rule
summarizeHealth states for that case.

Both apps carried the same copy; both now answer 200 unconditionally, matching
what OBP-API and Hola serve at /health. Dependency health remains available in
full detail at /status, which is unchanged.

No probe was relying on the old behaviour: the portal and api-manager
deployments in obp-local-k8s use `path: /` for readiness.
…P mandate

The VRP approval screen rendered a validity window and a bank, and nothing else. The PSU
was asked to authorise a standing payment authority without being shown the account it
would be paid from, who could be paid, or any of the limits it would be good for.

Not a missing feature so much as an unhandled shape: a VRP consent-request payload carries
from_account, a to_account naming the counterparty, and a limit block, where an
account-access one carries account_access and entitlements. None of the existing blocks
matched, so all of it fell through silently.

The page now shows the debtor account, the payee, and the four limits that bound the
mandate -- per payment, per month, per year and in total, each with its transaction count
where the payload sets one.

counterparty_name is the TPP's own label and can be blank, so the payee row falls back to
the account being paid rather than rendering an empty field on the one screen whose
purpose is to say who gets the money. (Hola was in fact dropping that name; fixed
separately in OBP-Hola.)

Verified in a browser against a running stack: the screen shows the from-account, the
payee account, and GBP 100 per payment / GBP 250 at most 1 payment per month / GBP 1200 at
most 12 per year / GBP 12000 at most 120 in total.
…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.
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.
… 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.
/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.
…hout

Running the endpoint showed it answering 503 on a working instance. Opey was
down -- as it usually is locally -- and summarizeHealth counts every
non-OAuth2 check as core, so its overall verdict was `unhealthy`. Reading
readiness off that verdict reproduced exactly the judgement /health was
changed to stop making: pull a Portal that renders every page and serves
every call out of rotation because an optional assistant is unavailable.

Readiness is now decided here instead: every required dependency healthy, and
at least one OAuth2 provider healthy. Optional services are named in a short
exclusion list, so a service added later gates readiness by default and
excusing it has to be written down. summarizeHealth's verdict is still
reported in the body -- it is the right answer for the human view on /status,
which is a different question.
setData writes to the store only when saveUninitialized is set, which this
app does not set -- otherwise it just replaces the in-memory object and the
change dies with the request. Every other call site in this codebase already
pairs it with save(); the three added here did not.

The consequence was immediate. The UK flow stored the PSU's account selection
and redirected to the SCA step, which then read the session, found nothing,
and answered "No accounts were selected for this consent. Please start over."
to someone who had just selected them. The Berlin Group SCA page stored the
authorisation it had started and forgot it before the next render, so it
began minting a fresh challenge each time -- reinstating the defect it was
written to fix.
SvelteKit's "?/name" shorthand replaces the page's query string rather than
appending to it. Naming the action -- which the resend button made necessary,
since a default action cannot coexist with named ones -- turned the form's
bare "?/confirm" into a URL with no CONSENT_ID. The form had no action at all
before, so it posted to the current URL and the parameter survived.

A wrong OTP does not redirect, so load() re-ran against the stripped URL and
the page answered "Missing required parameter: CONSENT_ID." instead of saying
why the code was rejected. The sibling UK consent page already preserves its
query string this way, for the same reason.
fix: show the PSU what they are granting, and why a Berlin Group call failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant