All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
AccessDeniedException(403) andInvalidTargetException(400), subtypes ofTokenExchangeException, for theaccess_deniedandinvalid_targeterrors authserver 0.2.0 returns on token exchange; neither counts toward the circuit breaker.ResourceOptions.builder().resourceMetadataUrl(...), Spring'sauthplane.resource-metadata-urlandAuthplaneMcpSetup.Builder.resourceMetadataUrl(...)point theresource_metadatachallenge at an AS-hosted RFC 9728 document; validated at construction.- New
WwwAuthenticate.descriptionFor(errorCode)andFALLBACK_ERROR_DESCRIPTION, plusverboseDescriptionoverloads onWwwAuthenticate.of(...)andFailureResponse.of(...)that restore the exception message for local debugging.
- BREAKING
WwwAuthenticate.of(...)andFailureResponse.of(...)now emit a fixederror_descriptionchosen by theerrorcode — on the challenge and in the JSON body alike — instead of the exception message. Migration: loggetMessage()server-side, or passverboseDescription: true. - BREAKING The
ServerTransportSecurityExceptionthemcpandspringadapters raise now carries the fixed per-code sentence, not the exception message, which the MCP SDK's transport renders into the response.extract(...)still throws the typed exception with its own message. - BREAKING
AuthplaneAuthenticationProviderputs the fixed per-code sentence, not the exception message, into theOAuth2AuthenticationExceptionit raises, which Spring'sBearerTokenAuthenticationEntryPointrenders on the wire. Migration: readgetCause()for the original exception. - BREAKING
ASCredentialsnow rejects a blankclientSecretat construction: authserver ≥ 0.1.2 answersactive: falseto unauthenticated introspection, so a public client would silently reject every token as revoked. Register a confidential client and pass its secret. - The built-in introspection checker warns at construction when the client has no
AuthProvider, and once per checker when the AS answersactive: falsefor a token that passed local verification, pointing at the runtime-client requirement (authserver admin resource runtime-client add). - BREAKING A resource identifier must name a host:
urn:example:api,https:///mcpandhttps://:8443/mcpare rejected at construction. Migration: configure the absolute URL clients use, e.g.https://api.example.com/mcp. DocumentCache.forceRefresh()now waits for a refresh already in flight instead of returning the document it holds as 0.2.0 did — its caller reaches it precisely because that document lacks thekid.
VerifiedClaims.mayAct(): authserver 0.2.0 no longer issuesmay_act; removed in the next minor.
JwksCacheandMetadataCachegained public constructors taking ajava.time.Clock, so the refresh intervals can be driven from an injected time source rather than the wall clock. Additive; the existing constructors are unchanged and delegate withClock.systemUTC().DocumentCache.forceRefreshIgnoringFailureBackoff()— public, and the only way to force a fetch while a failed refresh is backing off. It exists for a caller that is asking a question and wants the attempt made rather than the cached answer, and is prepared to wait for a timeout: an administrative refresh, or a test. It is named rather than a boolean onforceRefreshso no request path reaches it by flipping an argument, and no request path should.
-
A server cache directive that is not in the future no longer makes the document permanently expired.
Cache-Control: no-storeandno-cacheparse to an expiry of0,max-age=0to the current second, and a staleExpires:to a past one; the effective TTL was computed by subtracting the cache timestamp from that, so any of them produced a negative TTL — about -1.7e9 forno-store. A negative TTL is expired on every read, so every read took the synchronous re-fetch branch on the caller's thread, and the failure backoff could not help because it only arms when a fetch throws: an endpoint answeringno-storesuccessfully cleared the backoff and re-armed the expiry on the same call. Such an expiry is now treated as no preference and the configured interval governs. This was latent while nothing on a verification path read the metadata cache; the change above puts it there, before signature verification, so an unauthenticated caller would otherwise have set the fetch rate against the authorization server. -
Authorization server metadata is now re-read under ordinary verification traffic, so
metadataRefreshSecondstakes effect on a resource server that only verifies tokens. Such a server never calls the token, introspection or revocation endpoints, so nothing on its request path touched the metadata document after start-up: the SDK kept the copy fetched at build time indefinitely and never followed a rotatedjwks_uri. Verification now reads through the metadata cache before each key lookup; once the configured interval has elapsed the document is re-fetched and a rotatedjwks_urirebinds JWKS fetching before the lookup runs. -
A metadata document is validated when it is fetched rather than when it is read, so a refresh returning an invalid document (wrong issuer, non-HTTPS endpoint, missing
jwks_uri) no longer displaces the good one and cannot repoint key retrieval. The last valid document keeps being served.jwks_uriis now part of that validation rather than being checked at read time. RFC 8414 §2 marks it OPTIONAL — REQUIRED is OpenID Connect Discovery, a different document — but this SDK requires it regardless: every verification path builds a JWT validator, and introspection is layered on top of that rather than offered instead of it. It is also the field the refresh mechanism itself runs on — a document without it left nothing to reconcile the JWKS binding against, so a later rotation would not be followed even after the authorization server fixed its document. A document lacking it already failed atAuthplaneClientbuild time; what changes is that a refresh returning one is now rejected and backed off like any other failed refresh, instead of being published and then raising on every read. -
A failed
jwks_urirebind is retried instead of stranding key retrieval on the withdrawn URI. The rebind used to be driven by the metadata document changing, and the document is published to the cache before anything acts on the change — so one transient failure at the new endpoint left JWKS fetching pinned to a URI the authorization server had already withdrawn, with nothing left to re-trigger it: every later refresh returned that same document, so the change never fired again. A single 503 at the wrong moment meant every token failed to verify until the process restarted. The binding is now reconciled against the document on each key lookup, so a rebind that fails simply retries on the next one. -
A failed document refresh no longer costs a network round trip on every subsequent lookup. The cache timestamp advances only on success, so against an unreachable endpoint the document stayed permanently expired and each read took the synchronous re-fetch branch — a full HTTP timeout per verification once metadata moved onto the verification path, serialized behind the cache's fetch lock. A failed refresh now backs off for up to 30 seconds (never longer than the configured refresh interval), and a read that finds a refresh already in flight serves the document it holds rather than waiting for it. The same backoff governs a failed
jwks_urirebind: the binding is reconciled against the metadata document on every key lookup, so a rotatedjwks_urithat is down would otherwise cost a JWKS fetch per verification — the rebind builds a fresh cache per attempt, which has no backoff of its own to inherit. Tokens whose keys are already cached keep verifying throughout, and the rebind is still retried until it succeeds, on the backoff instead of on every lookup. The backoff also governs the forced refresh akidmiss triggers, which is the path a rotation puts every token on once the keys in hand are the new ones: without it each such verification paid a full fetch against a failing endpoint, and an unauthenticated caller presenting unknownkidvalues set that rate. -
elideSecretsno longer ships the userinfo of a scheme-relative identifier whose path or query contains a later://, such as//svc:pw@api.example.com/mcp?next=https://x, in a message that claims to have elided it — the authority is now located by testing the leading//first.
-
BREAKING
nimbus-jose-jwtmoves from 9.40 to 10.9.1, closing GHSA-xwmg-2g98-w7v9 — a denial of service on deeply nested JSON. 9.40 sits inside the vulnerable range (>= 9.38-rc1, < 10.0.2), and 0.1.0 shipped with it. This library is what parses an access token, on the verification path, before any signature is checked, so the input is attacker-supplied and the caller does not need to be authenticated.Migration: nimbus types are part of this SDK's public API —
DPoPKeyMaterial.fromJwktakes acom.nimbusds.jose.jwk.JWKandpublicJwk()returns one. If you construct DPoP key material yourself, or pinnimbus-jose-jwtin your own build, you need nimbus 10.x. If you only use the SDK's own entrypoints, Maven resolves it transitively and there is nothing to do. Pinning to the minimum fixed version (10.0.2) rather than the current one would not have softened this: both are 10.x and impose the same major. -
CacheHeaderParser.parseExpiresAtreturnsnullforCache-Control: no-storeandno-cacheinstead of0. Those directives say the response should not be reused, which for a document this SDK has to keep serving is not an expiry it can honour — so the honest answer is "no usable preference", and the caller falls back to its configured interval. The0was read as an absolute expiry at the epoch, which is what made the document permanently stale; once the cache started discarding a non-future expiry the sentinel became indistinguishable fromnullwhile the javadoc still claimed it meant "immediately expired". Both sibling SDKs already model this as absent rather than as zero. A caller reading the return value directly should treatnullas "use your own interval"; nothing else in this SDK distinguished the two values. -
DocumentCache.forceRefresh()now respects the failure backoff instead of fetching unconditionally, and returns the currently held document when a refresh is backing off or already in flight. This is a public method, inherited by the publicJwksCacheandMetadataCache, so an embedder calling it during a backoff window now gets a cached document back — and the return type cannot say which happened. It changed because the SDK's own request path reaches it:JwksCache.getKeyByKid(kid, true)is called on everykidthe cached document does not hold, which is the state a rotation to an unreachablejwks_urileaves behind, and fetching unconditionally there cost a full HTTP timeout per verification. UseforceRefreshIgnoringFailureBackoff()if you need the old unconditional behaviour. -
A resource identifier carrying userinfo (
https://svc:pw@api.example.com/mcp) is now rejected at construction by the newProtectedResourceMetadata.requireNoUserinfo(String)gate, called fromAuthplaneClient.resource(...), theAuthplaneResourceconstructor andProtectedResourceMetadata.Builder#build(), because the identifier is published verbatim to unauthenticated callers (RFC 9110 §4.2.4, RFC 3986 §3.2.1); a host with a port and an opaque identifier such asurn:example:apiare unaffected.Migration: If
authplane.resource(or theresourceUripassed toAuthplaneClient.resource(...)) carries userinfo, remove it — otherwise the resource, and a Spring context that builds one, now fails at startup. Present the credential in theAuthorizationheader instead. -
A resource identifier without a scheme is now rejected at construction —
AuthplaneClient.resource(...), theAuthplaneResourceconstructor, andProtectedResourceMetadata.Builder#build()all call the newProtectedResourceMetadata.requireScheme(String)gate. As shipped in 0.1.0 the SDK refused such an identifier at derivation, which covered the PRM URL but not the other sink that splices the scheme: the DPoPhtubinding target readnull://api.example.com/mcp, so every DPoP-bound request against a scheme-relative identifier failed. Rejecting at construction closes both. The derivation-time gate stays as a backstop and now names the requirement the identifier actually fails (no scheme, no authority, or opaque) instead of listing all of them. RFC 8707 §2 requires an absolute URI, which RFC 3986 §4.3 defines as always carrying a scheme, so an identifier that names a resource by scheme is not turned away. Note the gate is scheme-only: an opaque identifier such asurn:example:apistill constructs here and fails later if a PRM URL is derived from it. Whether construction is the right place to refuse an identifier with no host is a separate question, not settled by this change, and tracked in #31.Migration: A scheme-relative or relative resource identifier now fails at startup instead of at the first 401. Prefix the intended scheme.
wellKnownUrlenforces the same four gates (in a different order, so the component named in the message can differ from a constructor's) as the constructors, so a caller reaching it directly with a string no constructor saw is refused there too rather than splicing a malformed identifier into a challenge. -
The derived Protected Resource Metadata URL now preserves the resource identifier's query component. RFC 9728 §3 forms the well-known URI by inserting the well-known string "between the host component and the path and/or query components, if any", and §3.1 removes the terminating slash following the host when a path or query is present:
https://api.example.com/mcp?tenant=a→https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a, and bothhttps://api.example.com?x=1andhttps://api.example.com/?x=1→https://api.example.com/.well-known/oauth-protected-resource?x=1. A query component is legal in a resource indicator — RFC 8707 §2 states the SHOULD NOT and its exception in the same sentence, and RFC 9728 §1.2 carries it forward. The raw (percent-encoded) query is preserved verbatim, as the raw authority already was in 0.1.0. An empty query (a bare trailing?) is treated as absent and derives the query-less URL. An identifier without a query derives exactly the same URL as in 0.1.0.A query component is also now validated at construction, against the RFC 3986 §3.4 grammar (
query = *( pchar / "/" / "?" )), by the same four boundaries that reject a fragment. The query is spliced verbatim into theresource_metadataparameter of theWWW-Authenticatechallenge, andWwwAuthenticate.escapeQuotedStringstrips only control characters,\and"— so a raw non-ASCII octet shipped into a header field that RFC 9110 §5.5 confines to US-ASCII, and a characterjava.net.URIrejects (a space,",\,|,^,{,},<,>, a malformed percent-escape) passed construction and then threw out of the 401 response path — a 500 in place of the challenge. Nothing is escaped on the operator's behalf: rewriting the query would change the resource's identity. Neither the authority nor the path is gated — both were always part of the derived URL and are unchanged here — and closing them is tracked in #29.The DPoP
htuderived byAuthplaneResource.normalizeRequestUrlnow reads the raw authority too. Previously a percent-encoded userinfo was decoded into it, so the server computedhttps://u@b@hostwhere the client provedhttps://u%40b@hostand the proof never matched. The matching correction to the PRM URL shipped in 0.1.0; this is the same defect on the DPoP path.Migration: If your resource identifier contains a query component, the PRM document URL advertised in
WWW-Authenticate: … resource_metadata=now includes that query. Update any hard-coded expectation of the old query-less URL. Your existing PRM route continues to serve the document — routing is unchanged. Serving distinct documents per query value is not supported. If that query falls outside the RFC 3986 §3.4 grammar, the resource — and a Spring context that builds one — now fails at startup rather than at the first 401. The realistic case is unescaped brackets,?filter[a]=b, whichjava.net.URI, browsers and servlet containers all accept; percent-encode the offending octets (?filter%5Ba%5D=b) to keep the identifier. -
A resource identifier carrying a URI fragment is now rejected at construction (
AuthplaneClient.resource(...), theAuthplaneResourceconstructor andProtectedResourceMetadata.builder()), withIllegalArgumentException. RFC 8707 §2 states the resource URI "MUST NOT include a fragment component", and RFC 9728 §1.2 defines the resource identifier as a URL with no fragment. Previously the fragment was dropped when deriving the.well-knownURL but published verbatim in the document'sresourcefield, so the served document named an identifier its own URL disagreed with — which RFC 9728 §3.3 requires a conformant client to discard, with no error raised on the server. Deriving a PRM path or URL from a fragment-bearing identifier is refused as well, and refused before the identifier is checked for derivability, so an identifier that is both fragment-bearing and non-derivable is reported for the fragment — which is also what keeps the fragment out of the message.The
resourceform parameter thatClientCredentialsGrantandTokenExchangesend to the authorization server is not gated: that parameter carries whatever string the caller passes to the grant, not the configured resource identifier, and it is the RFC 8707 §2 indicator in its primary role. Closing it is tracked in #30.Migration: If
authplane.resource(or theresourceUripassed toAuthplaneClient.resource(...)) contains a#, remove the fragment — otherwise the resource, and a Spring context that builds one, now fails at startup. Only an unescaped#is a fragment delimiter; a percent-encoded%23inside the path is unaffected. Resource identifiers without a fragment are unchanged, as is the handling of opaque identifiers such asurn:example:api.
- Initial release.