Pattern matching for federated client JWT sub claim - #4081
marcohelmerich wants to merge 15 commits into
Conversation
|
Existing wildcard helpers are a poor fit for JWT claims that are structured as colon delimited key/value pairs, such as the GitLab and GitHub OIDC 'sub' claim. constructSimpleWildcardPattern treats '.' as the separator, so a branch name containing a dot fails to match, while constructSimpleWildcardPatternWithAnyCharDelimiter lets a wildcard swallow the remaining claim components. Add a variant where '*' matches any sequence of characters except ':', so a wildcard stays inside the segment it was written in.
A caller that evaluates a wildcard on a request path generally holds a configuration object that is rebuilt per request, so a compiled pattern cannot be cached on the caller itself. Cache by the generated expression instead, bounded so that a large number of distinct patterns cannot grow the cache without limit.
A federated credential matches the 'sub' claim of a client assertion verbatim. GitLab and GitHub derive that claim from the execution context, so a pipeline that deploys from more than one branch or environment needs one credential per value, against a limit of ten. Add an optional sub_pattern alongside sub, where '*' matches any characters other than the ':' claim separator, so a wildcard cannot swallow the remaining claim components. The two are mutually exclusive, and a pattern is mirrored into the subject so that subject stays non-null and a node that does not know the field compares the pattern text literally and fails closed. A pattern must contain a wildcard and some literal context of its own, so that '*' alone, which would trust any subject the issuer asserts, is rejected. Existing credentials keep exact matching and serialize unchanged.
A subject pattern and an exact subject of the same text are different trust statements. Include the distinction in the deduplication key, so that adding one does not silently discard the other, and in the delete filter, so that removing one leaves the other in place. Deletion keeps comparing the subject text verbatim: a pattern selects the credential stored under that pattern and does not remove every credential it would match.
Select the federated credential by issuer first, always verbatim, because the issuer determines the key set the assertion is verified against, and only then match the subject, which may now be a pattern. The claims verifier was bound to the configured subject, which cannot work for a pattern, so bind it to the subject actually asserted once a pattern has authorised it. The assertion is still only accepted if its signature verifies against the key set of the configured issuer, the issuer still has to match exactly, and 'sub' remains a required claim, so a pattern widens which subjects that issuer may assert and nothing else.
Carry sub_pattern through the change request, so a federated credential can be added and removed by pattern. A request that carries a pattern but no subject still identifies a federated credential.
A client_jwt_config supplied directly on the client was persisted without being parsed, so a malformed value was only rejected when it was read back, surfacing as a server error rather than a bad request. Parse it while validating, alongside the values folded out of additional information.
Describe the fields of a jwt_creds entry and the semantics of a subject pattern, and add a federated example to the client jwt endpoint. jwt_creds itself was missing from the configuration reference, so document it there as well.
The limit that bounds a pattern also bounds the subject it is matched against, so a pattern does not match an asserted subject longer than 256 characters. That is observable at authentication time and was not written down.
A wildcard stopped only at ':', so it also spanned '/'. That made a natural looking pattern far broader than it reads: "repo:*:ref:refs/heads/main" admitted every repository of every organisation on the issuer, because one wildcard covered both, and "project_path:myteam*:ref:*" admitted a group whose name merely starts with the configured one. Bind '*' to a single component, crossing neither ':' nor '/', and add '**' for the components that legitimately carry a path, such as a git ref. Require a pattern to contain at least one entirely literal component as well, so that "a*" and similar, which pin no structure at all, are rejected. Also normalise a blank sub_pattern to none, so isSubjectPattern, credKey and equals cannot disagree about whether a credential carries one, and prefer an exact credential over a pattern that matches the same subject, since the stored order of credentials is not stable.
c1bd8d2 to
1a9a8e5
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Endpoint validation and error handling still have unresolved issues that can produce 500 responses.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
What changed in this PR
Adds structured sub_pattern glob matching for federated client JWT subjects, including issuer scoping, validation, caching, endpoint support, tests, and documentation.
Changes:
- Adds
*and**subject-pattern matching. - Integrates patterns with authentication, configuration, deletion, and bootstrap flows.
- Expands test coverage and updates API/configuration documentation.
| File | Reviewed changes |
|---|---|
uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientJwtCredentialsEndpointMockMvcTests.java |
Tests endpoint persistence and pattern validation. |
uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java |
Documents the new request field. |
server/src/test/java/org/cloudfoundry/identity/uaa/oauth/jwt/JwtClientAuthenticationTest.java |
Tests pattern authentication, precedence, issuer, and signature behavior. |
server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java |
Tests configuration lifecycle, deduplication, and deletion. |
server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java |
Tests bootstrap pattern support. |
server/src/main/java/org/cloudfoundry/identity/uaa/oauth/jwt/JwtClientAuthentication.java |
Implements issuer-scoped pattern authentication. |
server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java |
Supports pattern storage, deduplication, and deletion. |
server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java |
Validates direct JWT configuration input. |
model/src/test/java/org/cloudfoundry/identity/uaa/util/UaaStringUtilsTest.java |
Tests wildcard expression generation. |
model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtCredentialTest.java |
Tests pattern credential validation and matching. |
model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequestTest.java |
Tests pattern request serialization. |
model/src/main/java/org/cloudfoundry/identity/uaa/util/WildcardPatternCache.java |
Provides bounded compiled-pattern caching. |
model/src/main/java/org/cloudfoundry/identity/uaa/util/UaaStringUtils.java |
Generates structured wildcard expressions. |
model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtCredential.java |
Models and validates pattern credentials. |
model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequest.java |
Supports pattern-based client JWT requests. |
docs/UAA-Configuration-Reference.md |
Documents jwt_creds configuration. |
docs/UAA-Client-Authentication.md |
Describes subject-pattern usage and semantics. |
docs/UAA-APIs.rst |
Documents pattern registration and deletion. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
thanks for your PR. seems you did it with AI and thus you can as first step AI review back. In addition to accept the PR we need CLA. |
|
@strehle Yes, CLA is in the making right now. And yes, Java is not in my range of expertise (it's go and ruby...) so we are kind of relying on AI there. Hope that will work out quality wise and I am happy to do the review runs. |
|
You can use AI to follow the copilot comment then publish your PR and finally I can try to finalize your requirement because I know what you want |
The parse check only ran when a client was created. On update, syncWithExisting fails to read the malformed value, updateClientDetails catches that and falls back to the raw input, and the value was persisted, so the request itself answered 500 and the client stayed unreadable. Validate the field in both modes.
The clientjwt endpoint documentation still described a single wildcard that crosses '/'. Describe '*' and '**' as implemented, and parameterize the credential order test instead of looping over both orders.
PUT /oauth/clients/tx parses the supplied client_jwt_config in syncWithExisting before the validator runs, and without the fallback the single update has, so a malformed value still answered 500. Reject it there as invalid client details, sharing the parse check with the validator, and cover all three update endpoints.


Fixes #3507
Problem
Federated
private_key_jwtcredentials (RFC 7523) match thesubclaim verbatim. GitLab andGitHub derive that claim from the execution context:
A repository that deploys from several branches therefore needs one credential per branch,
against a cap of ten (
MAX_KEY_SIZE). The usual workaround, shortening GitLab'sci_id_token_sub_claim_componentssosubno longer carries the ref, removes the branchrestriction altogether.
Change
An optional
sub_patternalongsidesubin ajwt_credsentry:{"iss": "https://gitlab.example.com", "sub_pattern": "project_path:myteam/deploy:ref_type:branch:ref:**"}Subjects are structured by two separators:
:between claim components, and/inside acomponent that carries a path. A pattern has a wildcard for each.
*:nor/**/, never across:/sub…:ref:**…:ref:main…:ref:**…:ref:feature/nested…:ref:**…:ref:main:evil:repo:*:ref:refs/heads/mainrepo:otherorg/otherrepo:ref:…*does not cross/project_path:myteam/*:…project_path:myteam/sub/nested:…project_path:myteam*:…project_path:myteamOTHER/r:…The distinction matters where a component identifies the caller.
repo:*:ref:refs/heads/mainadmits one repository;repo:**:ref:refs/heads/mainwould admitevery repository of every organisation on that issuer. Prefer
*, and reach for**only wherethe component genuinely holds a path.
Per-environment separation stays expressible: a production client pins
sub: …:ref:mainexactly, a non-production client uses
sub_pattern: …:ref:feature-**.Design
Glob rather than regex. A user-supplied regular expression would be the first one evaluated on
the
/oauth/tokenpath, andjava.util.regexhas no match timeout, so a pathological patternwould stall the process. The generated expression is literals joined by
[^:/]*or[^:]*— nonesting, alternation or backreferences — so catastrophic backtracking is structurally impossible.
This follows the existing
UaaStringUtilsconvention of escaping every metacharacter beforesubstituting the wildcards.
A separate field rather than detecting
*insidesub. Reinterpreting already-persistedsubjects as patterns on upgrade would silently widen live credentials, and would collide with the
sub:"*", iss:"*"delete-all sentinel inClientJwtConfiguration.delete. The two fields aremutually exclusive. A pattern is mirrored into
subject, which keeps it non-null forcredKey,equalsanddelete, and makes a node that does not yet know the field compare the pattern textliterally and so fail closed during a rolling upgrade.
Binding the asserted subject into the claims verifier.
validateFederatedClientJWTpassed theconfigured subject to
DefaultJWTClaimsVerifier, which cannot work for a pattern, so it nowpasses the asserted subject when the credential is a pattern. This is not a bypass:
DefaultJWTProcessorverifies the signature against the issuer's key set before the claimsverifier runs, the issuer is still matched verbatim and is what selects that key set,
subremains required via
JWT_RFC7523_CLAIMS, and the operator's pattern has already authorised thesubject. A pattern widens which subjects one trusted issuer may assert, and nothing else.
The issuer is never pattern matched, because it selects the trust anchor. It is checked first,
which also bounds pattern evaluation to that issuer's credentials.
Validation. A pattern must contain a wildcard and at least one component that is entirely
literal. That rejects
*,*:*and alsoa*, which pin no structure and would authorise mostof what the issuer can assert. Patterns are limited to 256 characters and five wildcards; the
length limit also applies to the subject being matched. A blank
sub_patternis normalised tonone, so
isSubjectPattern, the deduplication key andequalscannot disagree about whether acredential carries one. Compiled patterns are held in a bounded LRU keyed by the generated
expression.
Selection is deterministic. Credentials are not stored in a stable order, and a pattern may
overlap another credential's subject, so an exact credential is preferred over a pattern that
matches the same subject.
Deletion compares subject text verbatim. Deleting a
sub_patternremoves the credentialstored under that pattern, not the subjects it would match.
Known limitation: glob has no negation, so a pattern cannot express "any branch except one".
Where the branches to admit share a prefix,
…:ref:feature-**expresses the same intent as anallowlist.
Backward compatibility
Exact credentials take an
equalsfast path, serialize byte-identically (pinned by a test), andrequire no migration, since the field lives inside the existing
client_jwt_configcolumn. Everypre-existing federated test in
JwtClientAuthenticationTestpasses unmodified.One fix beyond the feature
ClientAdminEndpointsValidatorpersisted a directly suppliedclient_jwt_configwithout parsingit. On create, a malformed value was only rejected when read back, as a 500 rather than a 400. On
update it answered 500 immediately.
PUT /oauth/clients/{id}catches the parse failure insyncWithExisting, falls back to the raw input and stores it, leaving the client unreadable;PUT /oauth/clients/txfails insyncWithExistingbefore validation runs and rolls back. Thefield is now rejected with a 400 on create and on all four update endpoints of the client admin
API. The new validation exposed this; it is in
three separate commits and can be dropped.
Non-goals
Follow-ups on request: patterns for
issoraud; the pre-existingcredKeycollision wherecredentials differing only by
auddeduplicate against each other (fixing it changesdeduplication for existing clients);
changeMode=UPDATEignoring federated credentials.Tests
Full
modelandserversuites pass: 5590 tests, 0 failures. New coverage inUaaStringUtilsTest,ClientJwtCredentialTest,ClientJwtChangeRequestTest,ClientJwtConfigurationTest,JwtClientAuthenticationTest,ClientAdminBootstrapTestsandClientJwtCredentialsEndpointMockMvcTests, covering every rejection above, a pattern thatmatches while the signing key is not trusted, a pattern scoped to the wrong issuer, and a
malformed
client_jwt_configon create and on each update endpoint.PrivateKeyJwtClientAuthITis not extended: it requires a running server I could not exerciselocally, and I did not want to add a test I had not run. The MockMvc test covers the endpoint
through the full application context.
Docs updated in
UAA-Client-Authentication.md,UAA-APIs.rst,UAA-Configuration-Reference.mdand the REST Docs field descriptors.
jwt_credswas previously absent from the configurationreference.