Skip to content

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

Merged
hongwei1 merged 1 commit into
develop-obpfrom
fix/uk-consent-read-access
Aug 7, 2026
Merged

fix: resolve the UK consent access check against the PSU, not the session principal#68
hongwei1 merged 1 commit into
develop-obpfrom
fix/uk-consent-read-access

Conversation

@hongwei1

@hongwei1 hongwei1 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

What was broken

GET and DELETE on account-access-consents refused every caller the standard describes. Once a consent
was authorised, an AISP polling it with a client-credentials token got 403 OBP-35023, and so did a
request 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:

Auth mode v3.1 v4.0.1
Consent-Id + Consumer-Key 403 OBP-35023 403 OBP-35023
client-credentials Bearer 403 OBP-35023 403 OBP-35023
PSU DirectLogin 200 200

The 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.checkUKConsentAccess is not at fault. It already skips the PSU comparison for a caller with no
PSU and judges it on the lodging Consumer, and UKOpenBankingV401ConsentAccessTests covers every one of
those 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 is
never Empty on a request reaching these handlers:

  • a client-credentials token auto-vivifies a pseudo-user keyed on the consumer's own client key
    (OAuth2.getOrCreateResourceUser maps the JWT sub onto idGivenByProvider, and in that grant sub
    is the client id);
  • consent-header authentication has applyUKRules swap cc.user to 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.actingPsu supplies the missing step — the PSU applyUKRules set aside on consenter if there
is one, otherwise whatever genuine PSU the session carries:

def actingPsu(callContext: CallContext): Option[User] =
  callContext.consenter.toOption.orElse(genuinePsu(callContext))

genuinePsu alone is not enough: it filters the pseudo-user by comparing idGivenByProvider against the
consumer key, but a shadow user's providerid is a random UUID and survives that filter. This is exactly
why checkUKConsent already reads consenter at its own PSU comparison, and says so there. The
OBP-native read path had already been through the same fix — getConsentByConsentId carries 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.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 across Http4sUKOBv310AccountAccess.scala and
Http4sUKOBv401AccountInfo.scala, and that is how they came to agree on the wrong argument; each site is
now a single call.

Berlin Group was checked for the same defect and does not have it. Its two checkBerlinGroupConsentAccess
sites pass genuinePsu without consenter, which would be the same bug — except
BerlinGroupCheck.hasUnwantedConsentIdHeaderForBGEndpoint rejects a Consent-ID header on exactly the
/consents/{id}, /consents/{id}/authorisations and /consents/{id}/authorisations/{authId} paths
(400 OBP-20256, verified against a running instance), so the shadow-user swap can never happen there and
genuinePsu is sufficient. That is a load-bearing coupling rather than an independent guarantee, and worth
knowing about if those paths ever start accepting the header.

Resolution per caller:

Caller consenter genuinePsu Outcome
consent header the PSU the shadow user matches consent.userId → allowed
client credentials None None (pseudo-user filtered) None → Consumer rule decides → allowed
the PSU's own token None the PSU matches → allowed
a different PSU's token None that other PSU mismatch → still refused

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

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:

Probe Result
a different PSU, same Consumer 403 OBP-35023
a different TPP's client credentials 403 OBP-35015
DELETE by a different PSU 403 OBP-35023
the consent's own PSU 200

Downstream: OBP-Hola's UK v4.0.1 dashboard rendered Could not read consent status: 403 Forbidden … OBP-35023 in place of the consent status on every consent. Re-run end to end through OIDC → Portal → SCA
against this build, it now shows Status: AUTH, Permissions: [ReadAccountsDetail, ReadBalances],
Expires: 2026-08-14 10:50:54.0.

Tests

UKOpenBankingV401ConsentAccessTests gains a Consent.actingPsu feature pinning the four shapes a caller
can 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 consenter lookup reds 3 scenarios, dropping the
pseudo-user filter reds 2.

Regression: code.api.UKOpenBanking 393/393, code.api.berlin.group 176/176.

Notes

  • Based on 2f43f2e9a, so this sits on top of the Berlin Group challenge-ownership change.
  • The rule function and its unit tests are untouched — they were already correct.

…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
hongwei1 force-pushed the fix/uk-consent-read-access branch from 4494b15 to cb62bd4 Compare August 7, 2026 09:20
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

@hongwei1
hongwei1 merged commit 5267b17 into develop-obp Aug 7, 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