Skip to content

Pattern matching for federated client JWT sub claim - #4081

Draft
marcohelmerich wants to merge 15 commits into
cloudfoundry:developfrom
marcohelmerich:3507-client-jwt-sub-pattern
Draft

marcohelmerich wants to merge 15 commits into
cloudfoundry:developfrom
marcohelmerich:3507-client-jwt-sub-pattern

Conversation

@marcohelmerich

@marcohelmerich marcohelmerich commented Sep 22, 2026

Copy link
Copy Markdown

Fixes #3507

Problem

Federated private_key_jwt credentials (RFC 7523) match the sub claim verbatim. GitLab and
GitHub derive that claim from the execution context:

project_path:myteam/deploy:ref_type:branch:ref:main
repo:octo-org/octo-repo:environment:Production

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's
ci_id_token_sub_claim_components so sub no longer carries the ref, removes the branch
restriction altogether.

Change

An optional sub_pattern alongside sub in a jwt_creds entry:

{"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 a
component that carries a path. A pattern has a wildcard for each.

Wildcard Matches Use for
* one component, crossing neither : nor / a group, a project, an environment
** across /, never across : a git ref, which may contain /
pattern asserted sub result
…:ref:** …:ref:main accepted
…:ref:** …:ref:feature/nested accepted
…:ref:** …:ref:main:evil rejected — neither wildcard crosses :
repo:*:ref:refs/heads/main repo:otherorg/otherrepo:ref:… rejected — * does not cross /
project_path:myteam/*:… project_path:myteam/sub/nested:… rejected
project_path:myteam*:… project_path:myteamOTHER/r:… rejected
any of the above same subject, different issuer rejected
any of the above same subject, signing key not in the issuer's key set rejected

The distinction matters where a component identifies the caller.
repo:*:ref:refs/heads/main admits one repository; repo:**:ref:refs/heads/main would admit
every repository of every organisation on that issuer. Prefer *, and reach for ** only where
the component genuinely holds a path.

Per-environment separation stays expressible: a production client pins sub: …:ref:main
exactly, 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/token path, and java.util.regex has no match timeout, so a pathological pattern
would stall the process. The generated expression is literals joined by [^:/]* or [^:]* — no
nesting, alternation or backreferences — so catastrophic backtracking is structurally impossible.
This follows the existing UaaStringUtils convention of escaping every metacharacter before
substituting the wildcards.

A separate field rather than detecting * inside sub. Reinterpreting already-persisted
subjects as patterns on upgrade would silently widen live credentials, and would collide with the
sub:"*", iss:"*" delete-all sentinel in ClientJwtConfiguration.delete. The two fields are
mutually exclusive. A pattern is mirrored into subject, which keeps it non-null for credKey,
equals and delete, and makes a node that does not yet know the field compare the pattern text
literally and so fail closed during a rolling upgrade.

Binding the asserted subject into the claims verifier. validateFederatedClientJWT passed the
configured subject to DefaultJWTClaimsVerifier, which cannot work for a pattern, so it now
passes the asserted subject when the credential is a pattern. This is not a bypass:
DefaultJWTProcessor verifies the signature against the issuer's key set before the claims
verifier runs, the issuer is still matched verbatim and is what selects that key set, sub
remains required via JWT_RFC7523_CLAIMS, and the operator's pattern has already authorised the
subject. 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 also a*, which pin no structure and would authorise most
of 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_pattern is normalised to
none, so isSubjectPattern, the deduplication key and equals cannot disagree about whether a
credential 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_pattern removes the credential
stored 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 an
allowlist.

Backward compatibility

Exact credentials take an equals fast path, serialize byte-identically (pinned by a test), and
require no migration, since the field lives inside the existing client_jwt_config column. Every
pre-existing federated test in JwtClientAuthenticationTest passes unmodified.

One fix beyond the feature

ClientAdminEndpointsValidator persisted a directly supplied client_jwt_config without parsing
it. 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 in
syncWithExisting, falls back to the raw input and stores it, leaving the client unreadable;
PUT /oauth/clients/tx fails in syncWithExisting before validation runs and rolls back. The
field 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 iss or aud; the pre-existing credKey collision where
credentials differing only by aud deduplicate against each other (fixing it changes
deduplication for existing clients); changeMode=UPDATE ignoring federated credentials.

Tests

Full model and server suites pass: 5590 tests, 0 failures. New coverage in
UaaStringUtilsTest, ClientJwtCredentialTest, ClientJwtChangeRequestTest,
ClientJwtConfigurationTest, JwtClientAuthenticationTest, ClientAdminBootstrapTests and
ClientJwtCredentialsEndpointMockMvcTests, covering every rejection above, a pattern that
matches while the signing key is not trusted, a pattern scoped to the wrong issuer, and a
malformed client_jwt_config on create and on each update endpoint.

PrivateKeyJwtClientAuthIT is not extended: it requires a running server I could not exercise
locally, 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.md
and the REST Docs field descriptors. jwt_creds was previously absent from the configuration
reference.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 22, 2026

Copy link
Copy Markdown

CLA Not Signed

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity · 2 Low severity

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.

Comment thread docs/UAA-APIs.rst Outdated
@strehle

strehle commented Sep 22, 2026

Copy link
Copy Markdown
Member

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.
Once done and tests are green I will start with local review on it.

@strehle strehle added in_review The PR is currently in review waiting-cla labels Sep 22, 2026
@marcohelmerich

Copy link
Copy Markdown
Author

@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.

@strehle

strehle commented Sep 23, 2026

Copy link
Copy Markdown
Member

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in_review The PR is currently in review waiting-cla

Projects

Development

Successfully merging this pull request may close these issues.

[Feature] pattern matching for Client JWT sub

3 participants