Skip to content

fix: declare the auth mode UK consent lodging actually uses - #56

Merged
hongwei1 merged 2 commits into
develop-obpfrom
fix/consent-endpoint-authmode
Aug 3, 2026
Merged

fix: declare the auth mode UK consent lodging actually uses#56
hongwei1 merged 2 commits into
develop-obpfrom
fix/consent-endpoint-authmode

Conversation

@hongwei1

@hongwei1 hongwei1 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What and why

Two UK consent-lodging endpoints declare an authentication contract they do not actually want, and only keep working because of an unrelated token-parsing defect. This states the contract they need, so the endpoints survive that defect being fixed. It also closes a filtering gap in the v6.0.0 user search.

The contract problem. Lodging an account-access consent is a client-credentials call: the TPP is authenticated as an application and no PSU exists yet — the PSU is bound later, during the authorise ceremony. Both UK handlers already behave that way, rejecting only a fully anonymous request rather than demanding a user. Their ResourceDocs did not say so: with no authMode they take the UserOnly default, which sends ResourceDocMiddleware down anonymousAccess, and that returns 401 for any request carrying no user.

Why nothing breaks today. OAuth2 token parsing resolves sub to a ResourceUser without asking what kind of token it came from. For a client-credentials grant sub is the client_id (RFC 9068), so the caller is handed an auto-vivified user that is not a person — cc.user is never Empty, and the UserOnly path never fires. The two hand-written guards inside these handlers (the anti-anonymous check, and the filterNot that stops that pseudo-user becoming the consent's owner) exist for the same reason and are deliberately left in place.

Sequencing. This is step one of three, and it has to land first. Fixing the token defect before the contract is stated would make these endpoints 401 the very flow the standard requires them to serve. Step two (Berlin Group's equivalent filterNot) landed separately in 6060f4252. Step three is the token-parsing fix itself.

Changes by file

File Change
code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala createAccountAccessConsents ResourceDoc: authMode = UserOrApplication
code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala same, for the v3.1 twin
code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala scenario pinning the auth mode
code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala same
code/users/DoobieUserQueries.scala getUsers: exclude consent principals in the WHERE clause
code/users/LiftUsers.scala comment only — cross-reference to the above

The auth-mode value is not new: Http4sBGv13AIS.createConsent, the Berlin Group twin of this endpoint, has always been UserOrApplication. UK now matches it.

The DoobieUserQueries change closes a gap from 0e43179f3: the filter that keeps a consent's own principal out of GET /users was added to LiftUsers.getUsersCommon, which backs only the v2.1.0 and v3.0.0 list endpoints. The v6.0.0 search builds its own query and never went through it, so it still returned one row per consent ever granted, each with no username and no email. Placed in the WHERE clause rather than over the result, so it composes with LIMIT/OFFSET instead of returning short pages.

Behaviour

Unchanged on every path that reaches these endpoints today. isAppMode only chooses applicationAccess over anonymousAccess; since c471741f3 both share accessPipeline (which calls getUserAndSessionContextFuture, so the consent-principal hook at the end of authentication still runs), and applicationAccess now has an explicit case Full(_) => result passing an authenticated user through untouched.

The case that changes is one that cannot occur yet: a valid consumer with no user now reaches the handler instead of being rejected. That is the point — it is the safety net for step three.

One difference is real. A fully anonymous request still gets 401, but reports ApplicationNotIdentified (OBP-20200) rather than AuthenticatedUserIsRequired (OBP-20001), and the ResourceDoc gains that error plus a note describing the auth mode. This matches the Berlin Group twin's long-standing behaviour, and OBP-20200 is the more accurate description of "the application did not identify itself". Existing UK tests assert the status code, not the message, so they are unaffected.

Testing

  • Local full suite on JDK 25 (./run_tests_parallel.sh): 3228 tests, 0 failures, 0 errors, 0 aborted, 4/4 shards green.
  • Both new scenarios execute (shard 2) and assert the ResourceDoc auth mode directly — nothing else would catch a silent revert to the default, because the endpoints would keep working right up until the day the token defect is fixed.
  • Rebased onto develop-obp at 6060f4252 and re-verified test-compile after the rebase.
  • CI on this branch: see the checks below.

Trade-offs and known limits

Users auto-vivified for an application are deliberately left visible in GET /users. The original brief asked for them to be hidden as well, by matching providerid against consumer.key_c. They were not, on reflection: the justification for hiding consent principals is volume — one per consent granted, outnumbering real users by orders of magnitude — and that does not carry over, since there is at most one per application (2 rows out of 216 on the local instance). Against that, GET /users is role-gated and often read precisely to audit who holds access, and these are ordinary ResourceUser rows that can carry entitlements. Hiding them would trade a little noise for an audit blind spot, and would erase the only readily visible trace of the defect that mints them. They should stop being created, not stop being shown — which is what step three does.

The GET and DELETE twins of these endpoints are not fixed here and will need work in step three. GET/DELETE /aisp/account-access-consents/CONSENT_ID (and the v3.1 equivalents) are UserOnly too, but for them authMode alone is not enough: they use withUser/withUserDelete, which require a Full user, while their bodies already contain a "consent not yet bound to a PSU may only be read by the Consumer that created it" branch. Those two facts contradict each other, and the pseudo-user is currently what papers over the gap. An audit of the whole consent-lifecycle surface is recorded in .hub-status.md: of 62 ResourceDocs, 56 are UserOnly with authentication declared, but only 6 of those are real implementations — the 4 above plus Berlin Group's two /consents/CONSENTID/authorisations endpoints. The remaining 50 are stubs (26 static-example, 21 NotImplemented, 3 no-op), so the surface step three has to deal with is small.

Lodging an account-access consent is a client-credentials call: the TPP is
authenticated as an application and no PSU exists yet -- the PSU is bound
later, during the authorise ceremony. Both UK handlers already say so,
rejecting only a fully anonymous request rather than demanding a user. Their
ResourceDocs did not. With no authMode they take the UserOnly default, which
sends ResourceDocMiddleware down anonymousAccess, and that returns 401 for any
request carrying no user.

Nothing breaks today only because of a separate defect: OAuth2 token parsing
resolves `sub` to a ResourceUser without asking what kind of token it came
from, and for a client-credentials grant `sub` is the client_id (RFC 9068).
The caller is handed an auto-vivified user that is not a person, cc.user is
never Empty, and the UserOnly path never fires. The two hand-written guards
inside these handlers -- the anti-anonymous check, and the filterNot that stops
that pseudo-user becoming the consent's owner -- exist for the same reason and
stay until the token defect is fixed.

This is the first of three steps and deliberately the one that lands first:
fixing the token defect before the contract is stated would make these
endpoints 401 the very flow the standard requires them to serve.

Behaviour is unchanged on every path that reaches them today. isAppMode only
chooses applicationAccess over anonymousAccess; both call
getUserAndSessionContextFuture first, so the consent principal hook at the end
of authentication still runs, and both pass a Full user through untouched. The
case that changes is one that cannot occur yet: a valid consumer with no user
now reaches the handler instead of being rejected. That is the whole point --
it is the safety net for step three.

One difference is real and worth stating plainly: a fully anonymous request
still gets 401, but reports ApplicationNotIdentified rather than
AuthenticatedUserIsRequired, and the ResourceDoc gains that error plus a note
describing the auth mode. The Berlin Group twin, Http4sBGv13AIS.createConsent,
has always behaved this way; UK now matches it.

Both docs get a scenario pinning the auth mode, because nothing else would
catch a silent revert to the default -- the endpoints would keep working right
up until the day the token defect is fixed.
The filter that keeps a consent's own principal out of GET /users was added to
LiftUsers.getUsersCommon, which backs the v2.1.0 and v3.0.0 list endpoints. The
v6.0.0 search never goes through it -- getUsersV600F builds its own query in
DoobieUserQueries -- so it still returned one row per consent ever granted,
each with no username and no email.

Same predicate, and in the WHERE clause rather than over the result, so it
composes with LIMIT/OFFSET instead of returning short pages.

Users auto-vivified for an application are deliberately left visible. They look
similar -- no real name, one row that stands for something other than a person
-- but the reason for hiding consent principals was volume, one per consent
granted, and that does not carry over: there is at most one per application.
Against that, GET /users is role-gated and often read precisely to audit who
holds access, and these rows can carry entitlements like any other. Hiding them
would trade a little noise for an audit blind spot, and would also erase the
only readily visible trace of the token-parsing defect that mints them. They
should stop being created, not stop being shown.
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@hongwei1
hongwei1 merged commit 5323d5b into develop-obp Aug 3, 2026
25 checks passed
@hongwei1
hongwei1 deleted the fix/consent-endpoint-authmode branch August 4, 2026 10:42
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