fix: Let an audience provider refuse a token whatever its order, and sort providers without overflow - EXO-90439 - #28
Conversation
…sort providers without overflow EXO-90439 OAuthAccessTokenCustomizerService sorted its providers with (p1, p2) -> p2.getOrder() - p1.getOrder(), which overflows between HIGHEST_PRECEDENCE and LOWEST_PRECEDENCE, and resolved the aud claim with a short-circuiting stream. A provider refusing a token by throwing, as the MCP server's does for a user outside its audience, was therefore heard only while it happened to sort before every provider able to answer. Providers now sort by ascending getOrder() through Integer.compare, the Spring Ordered convention, which keeps the order every shipped provider runs in today. Every audience provider is consulted for every access token, the first non-empty answer in that order becomes the claim, and a refusal raised by any of them refuses the token wherever it sorts. The two SPIs document both contracts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…efused resource at debug EXO-90439 addProvider runs from another webapp's startup while token requests may iterate the lists; it now builds a new sorted list and assigns it to a volatile field, so a request never iterates a list being sorted and sees a provider once it is added. OAuthAccessTokenAudienceTokenRequestProvider logs a refused resource parameter at debug: the value is client-supplied and the provider now runs on every access token. The authority SPI says refusing a token belongs to the audience SPI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ace in the order EXO-90439 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Self-review close-outTwo AI review rounds by independent
Verified conform:
Classification: N1, computed at 🤖 Generated with Claude Code |
…than volatile fields, and drop an always-true audience check EXO-90439 The provider lists stay copy-on-write; token requests now read them through synchronized accessors, the same monitor init() and addProvider(...) write under. computeJwtAudiences never answers null, so the aud claim is set unconditionally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Self-review close-out — addendum at
|
| Round | Finding | Status |
|---|---|---|
| 3 | Sonar S3077 ×2 | ✅ Fields are plain Lists, still copy-on-write, read and written under the service's monitor through synchronized accessors. The in-place mutant still fails addingAProviderLeavesTheHeldListUntouched. |
| 3 | Sonar S2589 | ✅ aud set unconditionally: computeJwtAudiences answers a non-empty list or throws |
| 3 | 🟢 Chained-call indent | ✅ Whitespace only |
| 3 | 🟡 PR body still said volatile |
✅ Body updated to the change as it stands |
| 3 | 🟠 eng-standards#128 still said volatile |
✅ Rewritten there and restamped at 6612c4f |
Verified conform:
- Publication. Every write and every read of both lists holds the same monitor, so the writer's unlock happens-before the reader's lock. No list is modified after it is published.
- Contention. Each token request takes two uncontended monitor acquisitions, each held for one field load.
- Tests. The full auth-server build passes, 126 tests. mcp-server#36's reflection-based provider test, which injects these fields, passes against this head.
- Gates. Sonar's quality gate and PR Build both pass at
6612c4f.
Nothing outstanding from the AI review side.
Classification: N1, computed at 6612c4f. Its approver must be an Architect or Senior Developer who knows it is N1, not an approval on AI review alone. Author ≠ approver.
🤖 Generated with Claude Code



Symptom: the MCP server refuses an access token to a user outside its audience by throwing from its
OAuthAccessTokenAudienceProvider. That refusal was reached only while the MCP provider sorted beforeOAuthAccessTokenAudienceTokenRequestProvider, which answers with the RFC 8707resourceparameter that MCP clients send. With the order reversed, an excluded user would get a token and be refused only at the MCP server's door.Cause:
OAuthAccessTokenCustomizerServicesorted its providers with(p1, p2) -> p2.getOrder() - p1.getOrder(). BetweenHIGHEST_PRECEDENCEandLOWEST_PRECEDENCEthat subtraction overflows, and the overflow alone put the MCP provider first.computeJwtAudiencesthen stopped at the first non-empty answer, so a provider sorted after it was never consulted and its refusal never heard.Fix:
getOrder()throughComparator.comparingInt, the SpringOrderedconvention. This keeps the order every shipped provider runs in today.aud, and a thrownOAuth2AuthenticationExceptionrefuses the token wherever its provider sorts. Authority providers keep stopping at the first answer.synchronizedinit(),addProvider(...)and the private accessors token requests read them through. A token request therefore never iterates a list being sorted while another webapp registers its provider.OAuthAccessTokenAudienceTokenRequestProviderlogs a refusedresourceat debug. The value is client-supplied, and the provider now runs on every access token.audclaim is set unconditionally, sincecomputeJwtAudiencesanswers a non-empty list or throws.OAuthAccessTokenCustomizerServiceTestcovers the order, including theHIGHEST/LOWEST_PRECEDENCEpair, a refusal before and after an answering provider, providers added after startup, and a held list left untouched. Each pin fails against its mutant: the subtraction comparator, a descendingInteger.compare, the short-circuit, in-place mutation, and a dropped authority provider.Same task as Meeds-io/mcp-server#36, whose provider relies on this. That PR does not wait on this one.
Knowledge: Meeds-io/eng-standards#128, #129
This change is classified N1 (token issuance is an authorization decision on the trust boundary), computed at
6612c4f. Its approver must be an Architect or Senior Developer who knows it is N1, not an approval on AI review alone; author ≠ approver.🤖 Generated with Claude Code