fix: resolve the UK consent access check against the PSU, not the session principal - #68
Merged
Merged
Conversation
…sion principal 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.
hongwei1
force-pushed
the
fix/uk-consent-read-access
branch
from
August 7, 2026 09:20
4494b15 to
cb62bd4
Compare
|
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 was referenced Aug 7, 2026
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.



What was broken
GETandDELETEonaccount-access-consentsrefused every caller the standard describes. Once a consentwas authorised, an AISP polling it with a client-credentials token got 403
OBP-35023, and so did arequest authenticated by the consent itself. The only credential that worked was a PSU-signed token, which
a TPP never holds — so the self-service poll and revoke these endpoints exist for were unreachable. Both
versions behave identically; v3.1 only looks healthy because no client in this ecosystem calls its
read-back endpoint.
Measured on
d9cb47ea4, one authorised consent per version:Consent-Id+Consumer-KeyOBP-35023OBP-35023OBP-35023OBP-35023The endpoints' own comments state the opposite intent: "the standard has the AISP revoke its own consent
with a client-credentials token, which carries no PSU."
Why it survived a well-tested rule
Consent.checkUKConsentAccessis not at fault. It already skips the PSU comparison for a caller with noPSU and judges it on the lodging Consumer, and
UKOpenBankingV401ConsentAccessTestscovers every one ofthose combinations, including the one whose outcome the previous change deliberately flipped.
The problem is that no caller could produce that input. The four call sites passed
cc.user, which isnever
Emptyon a request reaching these handlers:(
OAuth2.getOrCreateResourceUsermaps the JWTsubontoidGivenByProvider, and in that grantsubis the client id);
applyUKRulesswapcc.userto the consent's shadow user.So the rule was asked about the wrong person and the comparison could never match. A well-tested function
kept being handed an identity the tests never covered, because no caller could construct one. The defect
was in the composition, not in either half.
The change
Consent.actingPsusupplies the missing step — the PSUapplyUKRulesset aside onconsenterif thereis one, otherwise whatever genuine PSU the session carries:
genuinePsualone is not enough: it filters the pseudo-user by comparingidGivenByProvideragainst theconsumer key, but a shadow user's
provideridis a random UUID and survives that filter. This is exactlywhy
checkUKConsentalready readsconsenterat its own PSU comparison, and says so there. TheOBP-native read path had already been through the same fix —
getConsentByConsentIdcarries the comment"under consent authentication the principal is the per-consent shadow user, so comparing it against the
consent's PSU never matched and the PSU got a 404 for their own consent" — so this is the UK path
catching up with a lesson the codebase had already learned elsewhere.
Consent.assertUKConsentAccessthen keeps the rule and the identity it is asked about in one place. Theguard was four verbatim copies of the same six lines across
Http4sUKOBv310AccountAccess.scalaandHttp4sUKOBv401AccountInfo.scala, and that is how they came to agree on the wrong argument; each site isnow a single call.
Berlin Group was checked for the same defect and does not have it. Its two
checkBerlinGroupConsentAccesssites pass
genuinePsuwithoutconsenter, which would be the same bug — exceptBerlinGroupCheck.hasUnwantedConsentIdHeaderForBGEndpointrejects aConsent-IDheader on exactly the/consents/{id},/consents/{id}/authorisationsand/consents/{id}/authorisations/{authId}paths(400
OBP-20256, verified against a running instance), so the shadow-user swap can never happen there andgenuinePsuis sufficient. That is a load-bearing coupling rather than an independent guarantee, and worthknowing about if those paths ever start accepting the header.
Resolution per caller:
consentergenuinePsuconsent.userId→ allowedNarrowing is unaffected: a session acting as a different PSU is still refused with
ConsentDoesNotMatchUser, and a second TPP withConsentDoesNotMatchConsumer.Verification
Rebuilt and run against a live instance. The six calls that returned 403 now return 200, on both versions
and all three credentials. Still refused, on a consent bound to one PSU and lodged by one Consumer:
OBP-35023OBP-35015DELETEby a different PSUOBP-35023Downstream: OBP-Hola's UK v4.0.1 dashboard rendered
Could not read consent status: 403 Forbidden … OBP-35023in place of the consent status on every consent. Re-run end to end through OIDC → Portal → SCAagainst this build, it now shows
Status: AUTH,Permissions: [ReadAccountsDetail, ReadBalances],Expires: 2026-08-14 10:50:54.0.Tests
UKOpenBankingV401ConsentAccessTestsgains aConsent.actingPsufeature pinning the four shapes a callercan arrive in, plus the composition the endpoints perform — which is the part that regressed rather than
either half. Suite is 20/20.
Both halves are mutation-checked: dropping the
consenterlookup reds 3 scenarios, dropping thepseudo-user filter reds 2.
Regression:
code.api.UKOpenBanking393/393,code.api.berlin.group176/176.Notes
2f43f2e9a, so this sits on top of the Berlin Group challenge-ownership change.