fix: mint a Berlin Group consent challenge for the PSU, not for the caller - #65
Merged
Merged
Conversation
…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.
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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).
createChallengeInternalsends the challenge answer togetEmailsByUserId/getPhoneNumbersByUserIdof the user the challenge was minted for, and aclient_credentialstoken resolvescc.userto the caller's own auto-vivified pseudo-user. SocreateChallengesC2(List(u.userId), …)mailed the OTP to the TPP, and the PUT then wrote that pseudo-user onto the consent —updateConsentUseroverwritesmUserIdunconditionally.It was also on a clock: once
getOrCreateResourceUserstops minting a pseudo-user forclient_credentials, both handlers' openingcc.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 —
psuDatacarriespassword,encryptedPassword,additionalPassword,additionalEncryptedPasswordand no identifier at all, so an Embedded call cannot name its PSU that way. The standard's channel is thePSU-IDheader, which OBP had never read anywhere.Consent.resolveBerlinGroupPsuanswers 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:PSU-IDheader — 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— bothcc.user.openOrThrowExceptionlines removed. POST mints the challenge for the resolved PSU. PUT stops discarding thegetChallengeresult one line above and takes the owner fromchallenge.expectedUserId, using it forgetUserAuthContextsBoxandupdateConsentUser.authMode = UserOrApplicationon all 7 ResourceDocs.ConsentUtil.scala—resolveBerlinGroupPsu(pure, so the rule is testable without a request, followingcheckBerlinGroupConsentAccess) andfindPsuByPsuId.constant.scala—PSU-IDadded toRequestHeader. Deliberately not added toBerlinGroupCheck.defaultMandatoryHeaders: the standard makes it conditional.ErrorMessages.scala/BerlinGroupError.scala—OBP-35039, mapped along withOBP-20027toPSU_CREDENTIALS_INVALIDat 401.Users.scala/LiftUsers.scala—getUsersByUsernamefor the cross-provider fallback.Two consequences of reading ownership off the challenge:
consentIdis now checked against the path. The connector'svalidateChallengeAnswerC4matches onchallengeIdalone and ignores theconsentIdit 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.CallContext, because under Embedded the TPP is only relaying it. The caller's own right to be there was already settled bycheckBerlinGroupConsentAccess. 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
expectedUserIdassertions failed with the pseudo-user's id whereresourceUser1'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
resolveBerlinGroupPsuunit + 7authMode).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-IDcompletes POST → PUT and the consent binds to the named PSU); the challenge is minted for that PSU; no PSU identifiable → 401; unresolvablePSU-ID→ 401;PSU-IDcontradicting 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.passwordremains unchecked and theupdatePsuAuthenticationbranch stays mocked, soPSU-IDis 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:
POST /consents/CONSENTID/authorisationsreturns 401PSU_CREDENTIALS_INVALIDon an unclaimed consent with no PSU in the session and noPSU-IDheader; it previously returned 201 having minted a challenge for the caller.PSU-IDis 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_credentialsit must now sendPSU-ID; if it authorises with the PSU's own token, nothing changes for it.