fix: declare the auth mode UK consent lodging actually uses - #56
Merged
Conversation
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.
|
This was referenced Aug 3, 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 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 noauthModethey take theUserOnlydefault, which sendsResourceDocMiddlewaredownanonymousAccess, and that returns 401 for any request carrying no user.Why nothing breaks today. OAuth2 token parsing resolves
subto aResourceUserwithout asking what kind of token it came from. For a client-credentials grantsubis theclient_id(RFC 9068), so the caller is handed an auto-vivified user that is not a person —cc.useris neverEmpty, and theUserOnlypath never fires. The two hand-written guards inside these handlers (the anti-anonymous check, and thefilterNotthat 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 in6060f4252. Step three is the token-parsing fix itself.Changes by file
code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scalacreateAccountAccessConsentsResourceDoc:authMode = UserOrApplicationcode/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scalacode/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scalacode/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scalacode/users/DoobieUserQueries.scalagetUsers: exclude consent principals in the WHERE clausecode/users/LiftUsers.scalaThe auth-mode value is not new:
Http4sBGv13AIS.createConsent, the Berlin Group twin of this endpoint, has always beenUserOrApplication. UK now matches it.The
DoobieUserQuerieschange closes a gap from0e43179f3: the filter that keeps a consent's own principal out ofGET /userswas added toLiftUsers.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.
isAppModeonly choosesapplicationAccessoveranonymousAccess; sincec471741f3both shareaccessPipeline(which callsgetUserAndSessionContextFuture, so the consent-principal hook at the end of authentication still runs), andapplicationAccessnow has an explicitcase Full(_) => resultpassing 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 thanAuthenticatedUserIsRequired(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
./run_tests_parallel.sh): 3228 tests, 0 failures, 0 errors, 0 aborted, 4/4 shards green.develop-obpat6060f4252and re-verifiedtest-compileafter the rebase.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 matchingprovideridagainstconsumer.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 /usersis role-gated and often read precisely to audit who holds access, and these are ordinaryResourceUserrows 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) areUserOnlytoo, but for themauthModealone is not enough: they usewithUser/withUserDelete, which require aFulluser, 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 areUserOnlywith authentication declared, but only 6 of those are real implementations — the 4 above plus Berlin Group's two/consents/CONSENTID/authorisationsendpoints. The remaining 50 are stubs (26 static-example, 21NotImplemented, 3 no-op), so the surface step three has to deal with is small.