Skip to content

fix: mint a Berlin Group consent challenge for the PSU, not for the caller - #65

Merged
hongwei1 merged 1 commit into
develop-obpfrom
fix/bg-sca-challenge-ownership
Aug 5, 2026
Merged

fix: mint a Berlin Group consent challenge for the PSU, not for the caller#65
hongwei1 merged 1 commit into
develop-obpfrom
fix/bg-sca-challenge-ownership

Conversation

@hongwei1

@hongwei1 hongwei1 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

The two Berlin Group consent authorisation endpoints took the PSU from the session. The TPP is the caller on both, so they were naming the TPP — and since the SCA challenge answer is delivered to whoever the challenge names, the OTP was being mailed to the TPP instead of to the PSU. The PSU is now resolved from the request, per the standard's PSU-ID header.

Why the session is the wrong source

In Berlin Group the PSU does not call the API. Under Redirect it authenticates at the ASPSP; under Embedded it hands its factors to the TPP, which relays them — the Implementation Guidelines show step 3 of the Embedded AIS flow as a TPP request carrying the customer's OTP (V1.3.12, §6.1.1.4, p.123).

createChallengeInternal sends the challenge answer to getEmailsByUserId / getPhoneNumbersByUserId of the user the challenge was minted for, and a client_credentials token resolves cc.user to the caller's own auto-vivified pseudo-user. So createChallengesC2(List(u.userId), …) mailed the OTP to the TPP, and the PUT then wrote that pseudo-user onto the consent — updateConsentUser overwrites mUserId unconditionally.

It was also on a clock: once getOrCreateResourceUser stops minting a pseudo-user for client_credentials, both handlers' opening cc.user.openOrThrowException(...) throws outright. #60 named this exact question and deferred it, saying these two "need an answer to 'which PSU is this challenge for' before their auth mode means anything." This is that answer.

Where the PSU actually comes from

Not the body — psuData carries password, encryptedPassword, additionalPassword, additionalEncryptedPassword and no identifier at all, so an Embedded call cannot name its PSU that way. The standard's channel is the PSU-ID header, which OBP had never read anywhere.

Consent.resolveBerlinGroupPsu answers in the order the standard's conditionality implies. PSU-ID is asked for when the ASPSP does not already know (§7.1 p.195 "…and this field has not yet been transmitted before"; §7.2.1 p.206 "contained if not yet contained in a pre-ceeding request"), so what it already knows wins:

  1. the consent's own PSU, once SCA has bound one;
  2. a genuine PSU in the session — the Redirect approach, where the PSU really is the caller;
  3. the PSU-ID header — Embedded, the TPP naming the PSU.

None of the three → 401 PSU_CREDENTIALS_INVALID, the code the standard defines for precisely this. A header contradicting 1 or 2 is refused rather than resolved by precedence; the standard sanctions the check where it defines the header ("the ASPSP might check whether PSU-ID and token match", §6.3.1 p.134), and what it closes is specific — otherwise a lodging TPP could name a third party on a bound consent and have that person's OTP mailed to itself.

By file

  • Http4sBGv13AIS.scala — both cc.user.openOrThrowException lines removed. POST mints the challenge for the resolved PSU. PUT stops discarding the getChallenge result one line above and takes the owner from challenge.expectedUserId, using it for getUserAuthContextsBox and updateConsentUser. authMode = UserOrApplication on all 7 ResourceDocs.
  • ConsentUtil.scalaresolveBerlinGroupPsu (pure, so the rule is testable without a request, following checkBerlinGroupConsentAccess) and findPsuByPsuId.
  • constant.scalaPSU-ID added to RequestHeader. Deliberately not added to BerlinGroupCheck.defaultMandatoryHeaders: the standard makes it conditional.
  • ErrorMessages.scala / BerlinGroupError.scalaOBP-35039, mapped along with OBP-20027 to PSU_CREDENTIALS_INVALID at 401.
  • Users.scala / LiftUsers.scalagetUsersByUsername for the cross-provider fallback.

Two consequences of reading ownership off the challenge:

  • Its consentId is now checked against the path. The connector's validateChallengeAnswerC4 matches on challengeId alone and ignores the consentId it is handed — confirmed to return 200 on unmodified source — so a challenge minted on one consent could be answered on another's path and bind the first consent's PSU to the second. Since ownership is now read off that challenge, it has to be this consent's.
  • The OTP is validated as the challenge's PSU, via a derived CallContext, because under Embedded the TPP is only relaying it. The caller's own right to be there was already settled by checkBerlinGroupConsentAccess. Keeping it on the Connector path leaves CBS-backed deployments unaffected.

Tests

Red-first, against unmodified source: 106 passed, 6 failed — the 6 new scenarios, no pre-existing test disturbed. The two expectedUserId assertions failed with the pseudo-user's id where resourceUser1's belonged, i.e. the OTP misdelivery caught directly rather than argued.

After the fix, the same seven BG v1.3 suites: 125 passed, 0 failed (106 originals + 6 HTTP + 6 resolveBerlinGroupPsu unit + 7 authMode).

Full local suite on JDK 25: 3333 tests, 0 failures, 0 errors, 0 skipped, all 4 shards green, 10m 20s.

New coverage: the card's required end-to-end (client-credentials TPP + PSU-ID completes POST → PUT and the consent binds to the named PSU); the challenge is minted for that PSU; no PSU identifiable → 401; unresolvable PSU-ID → 401; PSU-ID contradicting a bound consent → 403; a bound consent needing no header; cross-consent challenge reuse → 400.

Trade-offs and behaviour changes

Not done, deliberately: the first factor is still not verified. psuData.password remains unchecked and the updatePsuAuthentication branch stays mocked, so PSU-ID is an assertion by the TPP rather than proof. It is the OTP — delivered out of band to the PSU this resolves to — that binds the consent, which is why resolving it correctly is what makes the unverified assertion safe. Making the first factor real means unmocking that branch and is its own change.

PSU-ID resolution goes to the local identity provider first, then across providers accepting only a unique username match. A federated PSU resolves; an ambiguous one is refused rather than guessed.

Behaviour changes:

  • The OTP now reaches the PSU rather than a client-credentials caller.
  • POST /consents/CONSENTID/authorisations returns 401 PSU_CREDENTIALS_INVALID on an unclaimed consent with no PSU in the session and no PSU-ID header; it previously returned 201 having minted a challenge for the caller.
  • The consent binds to the challenge's PSU, not the session principal — so a client-credentials PUT can no longer take a consent off its PSU.
  • A challenge answered on a different consent's path returns 400; it previously succeeded and bound the wrong consent.
  • PSU-ID is read on these two endpoints for the first time.

A TPP that authorises with the PSU's own token — the Redirect journey the end-to-end suite exercises — is unaffected.

Downstream: OBP-Hola drives this flow end to end. If it lodges and authorises under client_credentials it must now send PSU-ID; if it authorises with the PSU's own token, nothing changes for it.

…aller

POST /consents/CONSENTID/authorisations minted its SCA challenge against
cc.user, and the PUT twin bound the consent to that same principal. In
Berlin Group that principal is the wrong one. The TPP makes these calls,
not the PSU: under Redirect the PSU authenticates at the ASPSP, under
Embedded it hands its factors to the TPP, which relays them -- the
Implementation Guidelines show it as a TPP request carrying the customer's
OTP (V1.3.12, section 6.1.1.4, p.123).

What that cost is concrete rather than formal. createChallengeInternal
delivers the challenge answer to getEmailsByUserId / getPhoneNumbersByUserId
of the user the challenge names, and a client-credentials token resolves to
the caller's own auto-vivified pseudo-user. So the OTP was mailed to the TPP
and never reached the PSU, and the PUT then wrote that pseudo-user onto the
consent, updateConsentUser overwriting mUserId unconditionally. The regression
test catches it directly, asserting the challenge's expectedUserId rather than
only the consent it produces.

Where the standard puts the PSU's identity is the PSU-ID header, which OBP had
never read. It is not in the body: psuData carries four password fields and no
identifier at all, so an Embedded call cannot name its PSU any other way.
Consent.resolveBerlinGroupPsu takes the header, the consent's own PSU and a
genuine PSU in the session, and answers in the order the standard's
conditionality implies -- PSU-ID is asked for when the ASPSP does not already
know (sections 7.1 p.195 and 7.2.1 p.206), so what it already knows wins:

  1. the consent's PSU, once SCA has bound one;
  2. a genuine PSU in the session, which is the Redirect approach;
  3. the PSU-ID header, which is Embedded.

None of the three is refused with the code the standard defines for exactly
that, PSU_CREDENTIALS_INVALID. A header contradicting 1 or 2 is refused rather
than resolved by precedence, which the standard sanctions where it defines the
header -- "the ASPSP might check whether PSU-ID and token match" (section 6.3.1,
p.134) -- and what it closes is specific: otherwise a lodging TPP could name a
third party on a bound consent and have that person's OTP mailed to itself.

The PUT no longer needs a session user at all. The challenge already records
whose authorisation it is, and getChallenge was being called and discarded one
line above, so the consent now binds to the challenge's PSU. That also closes
the reverse hole: a client-credentials PUT could previously take a consent off
its PSU and onto the caller.

Two consequences of reading ownership off the challenge. Its consentId is now
checked against the path, because the connector's validateChallengeAnswerC4
matches on challengeId alone and ignores the consentId it is handed -- without
it, a challenge minted on one consent could be answered on another's and bind
the first consent's PSU to the second. And the OTP is validated as the
challenge's PSU rather than as the token's principal, since under Embedded the
TPP is only relaying it; the caller's own right to be there was already settled
by checkBerlinGroupConsentAccess. Passing a derived CallContext keeps this on
the Connector path, so CBS-backed deployments are unaffected.

PSU-ID resolves against the local identity provider first, then across
providers when exactly one user answers to the username, so a federated PSU
still resolves and an ambiguous one is refused rather than guessed.

What this deliberately does not do is verify a first factor. psuData.password
is still unchecked and the updatePsuAuthentication branch stays mocked, so
PSU-ID is an assertion by the TPP. It is the OTP, delivered out of band to the
PSU this resolves to, that binds the consent -- which is why resolving it
correctly is what makes the unverified assertion safe.

All seven ResourceDocs now declare UserOrApplication. b5d556d brought the two
GET siblings across and held these back on the grounds that a doc's auth mode
says nothing until the handler has an answer to which PSU an authorisation is
for. It now has one, and it does not come from the session.

Behaviour changes. The OTP goes to the PSU rather than to a client-credentials
caller. POST authorisations returns 401 PSU_CREDENTIALS_INVALID where an
unclaimed consent has no PSU in the session and no PSU-ID header, having
previously minted a challenge for the caller. The consent binds to the
challenge's PSU rather than to the session principal. A challenge answered on a
different consent's path is refused with 400. A TPP that authorises with the
PSU's own token, which is the Redirect journey the end-to-end suite exercises,
is unaffected.
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@hongwei1
hongwei1 merged commit 2f43f2e into develop-obp Aug 5, 2026
25 checks passed
hongwei1 added a commit that referenced this pull request Aug 7, 2026
#67)

* fix: mint a Berlin Group consent challenge for the PSU, not for the caller (#65)

POST /consents/CONSENTID/authorisations minted its SCA challenge against
cc.user, and the PUT twin bound the consent to that same principal. In
Berlin Group that principal is the wrong one. The TPP makes these calls,
not the PSU: under Redirect the PSU authenticates at the ASPSP, under
Embedded it hands its factors to the TPP, which relays them -- the
Implementation Guidelines show it as a TPP request carrying the customer's
OTP (V1.3.12, section 6.1.1.4, p.123).

What that cost is concrete rather than formal. createChallengeInternal
delivers the challenge answer to getEmailsByUserId / getPhoneNumbersByUserId
of the user the challenge names, and a client-credentials token resolves to
the caller's own auto-vivified pseudo-user. So the OTP was mailed to the TPP
and never reached the PSU, and the PUT then wrote that pseudo-user onto the
consent, updateConsentUser overwriting mUserId unconditionally. The regression
test catches it directly, asserting the challenge's expectedUserId rather than
only the consent it produces.

Where the standard puts the PSU's identity is the PSU-ID header, which OBP had
never read. It is not in the body: psuData carries four password fields and no
identifier at all, so an Embedded call cannot name its PSU any other way.
Consent.resolveBerlinGroupPsu takes the header, the consent's own PSU and a
genuine PSU in the session, and answers in the order the standard's
conditionality implies -- PSU-ID is asked for when the ASPSP does not already
know (sections 7.1 p.195 and 7.2.1 p.206), so what it already knows wins:

  1. the consent's PSU, once SCA has bound one;
  2. a genuine PSU in the session, which is the Redirect approach;
  3. the PSU-ID header, which is Embedded.

None of the three is refused with the code the standard defines for exactly
that, PSU_CREDENTIALS_INVALID. A header contradicting 1 or 2 is refused rather
than resolved by precedence, which the standard sanctions where it defines the
header -- "the ASPSP might check whether PSU-ID and token match" (section 6.3.1,
p.134) -- and what it closes is specific: otherwise a lodging TPP could name a
third party on a bound consent and have that person's OTP mailed to itself.

The PUT no longer needs a session user at all. The challenge already records
whose authorisation it is, and getChallenge was being called and discarded one
line above, so the consent now binds to the challenge's PSU. That also closes
the reverse hole: a client-credentials PUT could previously take a consent off
its PSU and onto the caller.

Two consequences of reading ownership off the challenge. Its consentId is now
checked against the path, because the connector's validateChallengeAnswerC4
matches on challengeId alone and ignores the consentId it is handed -- without
it, a challenge minted on one consent could be answered on another's and bind
the first consent's PSU to the second. And the OTP is validated as the
challenge's PSU rather than as the token's principal, since under Embedded the
TPP is only relaying it; the caller's own right to be there was already settled
by checkBerlinGroupConsentAccess. Passing a derived CallContext keeps this on
the Connector path, so CBS-backed deployments are unaffected.

PSU-ID resolves against the local identity provider first, then across
providers when exactly one user answers to the username, so a federated PSU
still resolves and an ambiguous one is refused rather than guessed.

What this deliberately does not do is verify a first factor. psuData.password
is still unchecked and the updatePsuAuthentication branch stays mocked, so
PSU-ID is an assertion by the TPP. It is the OTP, delivered out of band to the
PSU this resolves to, that binds the consent -- which is why resolving it
correctly is what makes the unverified assertion safe.

All seven ResourceDocs now declare UserOrApplication. b5d556d brought the two
GET siblings across and held these back on the grounds that a doc's auth mode
says nothing until the handler has an answer to which PSU an authorisation is
for. It now has one, and it does not come from the session.

Behaviour changes. The OTP goes to the PSU rather than to a client-credentials
caller. POST authorisations returns 401 PSU_CREDENTIALS_INVALID where an
unclaimed consent has no PSU in the session and no PSU-ID header, having
previously minted a challenge for the caller. The consent binds to the
challenge's PSU rather than to the session principal. A challenge answered on a
different consent's path is refused with 400. A TPP that authorises with the
PSU's own token, which is the Redirect journey the end-to-end suite exercises,
is unaffected.

* fix: resolve the UK consent access check against the PSU, not the session principal (#68)

GET and DELETE on account-access-consents refused every caller the standard
describes. An authorised consent answered 403 OBP-35023 to the AISP polling it
with a client-credentials token, and to a request authenticated by the consent
itself -- leaving a PSU-signed token, which a TPP never holds, as the only way in.
The self-service poll and revoke the endpoints exist for were unreachable.

checkUKConsentAccess was not the problem: it already skips the PSU comparison for
a caller with no PSU and judges it on the lodging Consumer, and every one of those
combinations is unit-tested. The problem is that no caller could produce that
input. The four call sites passed cc.user, which is never Empty on a request that
reaches these handlers -- a client-credentials token auto-vivifies a pseudo-user
keyed on the consumer's own client key, and applyUKRules swaps in the consent's
shadow user -- so the rule was asked about the wrong person and the comparison
could never match. A well-tested rule kept being handed an identity the tests
never covered because no caller could construct one.

Consent.actingPsu supplies the missing step: the PSU applyUKRules set aside on
consenter if there is one, otherwise whatever genuine PSU the session carries.
genuinePsu alone is not enough, because a shadow user's idGivenByProvider is a
random UUID rather than the consumer key and so survives that filter -- which is
why checkUKConsent already reads consenter at its own PSU comparison, and this
follows it. The OBP-native read path had already been through the same fix, and
says so at getConsentByConsentId.

Consent.assertUKConsentAccess then keeps the rule and the identity it is asked
about in one place. The guard was four verbatim copies of the same six lines, and
that is how they came to agree on the wrong argument; collapsing them to one call
each is what stops the next edit from having to get it right four times.

Narrowing is unaffected: a session acting as a different PSU is still refused with
ConsentDoesNotMatchUser, and a second TPP with ConsentDoesNotMatchConsumer.

Verified against a running instance for both versions and all three credentials:
the six calls that returned 403 now return 200, while a different PSU, a different
TPP, and DELETE by a different PSU stay refused. Hola's v4.0.1 consent panel,
which showed the 403 in place of the consent status, now renders status,
permissions and expiry. Both halves of actingPsu are mutation-checked: dropping
consenter reds 3 scenarios, dropping the pseudo-user filter reds 2.
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