From a312805b45eb831615360228a9c07db69070b4d5 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 12 Nov 2026 16:27:14 -0500 Subject: [PATCH 1/2] feat(sdk): DSPX-5640 negotiate DPoP htu style from server advertisement The SDK always signed DPoP proofs with the full request URL as htu. The first DPoP servers (service v0.11.x, DSP 2.7.x) compare an RPC's htu against its procedure and reject a full URL, so a new Java client could not talk to an old server. Pick the htu style per server origin, mirroring the Go SDK (sdk/auth/dpop_mode.go): 1. unauthenticated GET /.well-known/opentdf-configuration; a dpop block's rfc9449 is authoritative, else a non-empty top-level dpop_signing_alg_values_supported means RFC 9449 (service v0.18.0+), 2. else RFC 9728 /.well-known/oauth-protected-resource with that key, 3. else legacy. Only definitive answers are cached: a non-5xx answer from the opentdf well-known (a 4xx or an unparseable 200 means not advertised) or an RFC 9728 advertisement. A network error or 5xx on the well-known step uses legacy for that request only, logs a warning, and is retried on the next request. The platform origin is seeded from the bootstrap well-known response when it advertises, so the common case makes no extra requests. Legacy keeps the DPoP Authorization scheme, ath and nonce handling; only the htu changes, to the procedure (/pkg.Service/Method, the last two path segments, dropping any proxy prefix). Non-RPC requests and token endpoint proofs keep the full URI, and non-DPoP Bearer tokens are unaffected. SDKBuilder.dpopMode(AUTO | LEGACY | RFC9449; default AUTO) overrides discovery, matching Go's WithDPoPMode. Verified with mvn -pl sdk -am test (459 tests, 0 failures). DPoPModeTest covers each advertisement source, both, neither, transient failures not being cached, definitive 404 and unparseable answers being cached, bootstrap seeding, the override, and a nonce challenge in each mode. SDKBuilderTest now checks a seeded platform signs the full URL while a KAS with no well-known documents gets the procedure htu. Signed-off-by: Dave Mihalcik --- .../io/opentdf/platform/sdk/DPoPMode.java | 28 ++ .../platform/sdk/DPoPModeResolver.java | 194 +++++++++ .../io/opentdf/platform/sdk/SDKBuilder.java | 34 +- .../io/opentdf/platform/sdk/TokenSource.java | 45 ++- .../opentdf/platform/sdk/AuthInterceptor.kt | 9 +- .../io/opentdf/platform/sdk/DPoPModeTest.java | 380 ++++++++++++++++++ .../opentdf/platform/sdk/SDKBuilderTest.java | 18 +- 7 files changed, 699 insertions(+), 9 deletions(-) create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/DPoPMode.java create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DPoPMode.java b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPMode.java new file mode 100644 index 00000000..c9c29fdc --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPMode.java @@ -0,0 +1,28 @@ +package io.opentdf.platform.sdk; + +/** + * Selects the {@code htu} form of DPoP proofs sent to platform services and KAS. Both forms use + * the {@code DPoP} Authorization scheme, and token endpoint proofs always use the full URL. + * Plain Bearer tokens (an AS that did not bind the token to the DPoP key) are unaffected + * and are always sent as {@code Authorization: Bearer} without a proof. + */ +public enum DPoPMode { + /** + * Discover the style once per server origin from what the server advertises: the + * {@code dpop} block or {@code dpop_signing_alg_values_supported} in + * {@code /.well-known/opentdf-configuration}, then RFC 9728 + * {@code /.well-known/oauth-protected-resource}. Servers that advertise neither get + * {@link #LEGACY}; a discovery that gets no answer uses legacy for that request and is retried. + * This is the default. + */ + AUTO, + /** + * Pre-RFC 9449 form the first DPoP platforms (service v0.11.x) require: an RPC's {@code htu} + * is its procedure ({@code /package.Service/Method}); other requests keep the full URL. + */ + LEGACY, + /** + * RFC 9449 form: the {@code htu} is the full request URL without query or fragment. + */ + RFC9449 +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java new file mode 100644 index 00000000..d76a1fe2 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java @@ -0,0 +1,194 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.net.URL; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Decides, per server origin, whether DPoP proofs for resource requests use the RFC 9449 htu or + * the legacy htu older platforms expect (see {@link DPoPMode}). In {@link DPoPMode#AUTO} the + * server's advertisement is fetched once per origin and cached; a discovery that got no answer + * from the server falls back to legacy for that request only and is retried next time. Mirrors + * the Go SDK's {@code sdk/auth/dpop_mode.go}. It is thread-safe. + */ +class DPoPModeResolver { + static final String OPENTDF_CONFIGURATION_PATH = "/.well-known/opentdf-configuration"; + static final String PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; + private static final String DPOP_ALGS_KEY = "dpop_signing_alg_values_supported"; + private static final long MAX_DOCUMENT_BYTES = 1 << 20; + + private static final Logger logger = LoggerFactory.getLogger(DPoPModeResolver.class); + + private final DPoPMode configured; + @Nullable + private final OkHttpClient httpClient; + // Definitive answers only (never AUTO), keyed by origin (scheme://host:port) + private final Map modeByOrigin = new ConcurrentHashMap<>(); + + /** + * @param configured the requested mode; anything other than AUTO is used as-is + * @param httpClient client used for discovery. It must not carry the SDK's auth + * interceptors: discovery requests are sent without Authorization or DPoP. + * May be null when {@code configured} is not AUTO. + */ + DPoPModeResolver(DPoPMode configured, @Nullable OkHttpClient httpClient) { + this.configured = Objects.requireNonNull(configured, "configured"); + if (configured == DPoPMode.AUTO && httpClient == null) { + throw new IllegalArgumentException("DPoP mode discovery requires an HTTP client"); + } + this.httpClient = httpClient; + } + + static DPoPModeResolver fixed(DPoPMode mode) { + if (mode == DPoPMode.AUTO) { + throw new IllegalArgumentException("a fixed DPoP mode cannot be AUTO"); + } + return new DPoPModeResolver(mode, null); + } + + /** + * Whether an opentdf well-known configuration advertises RFC 9449. A {@code dpop} block, when + * present, is authoritative; otherwise a non-empty {@code dpop_signing_alg_values_supported} + * means support, since every release that publishes it (service v0.18.0+) accepts a full-URL htu. + */ + static boolean advertisesRfc9449(JsonObject config) { + JsonElement dpop = config.get("dpop"); + if (dpop != null && dpop.isJsonObject()) { + JsonElement rfc9449 = dpop.getAsJsonObject().get("rfc9449"); + return rfc9449 != null && rfc9449.isJsonPrimitive() && rfc9449.getAsJsonPrimitive().isBoolean() + && rfc9449.getAsBoolean(); + } + return hasNonEmptyArray(config, DPOP_ALGS_KEY); + } + + /** As {@link #advertisesRfc9449(JsonObject)}, for the configuration the SDK bootstraps from. */ + static boolean advertisesRfc9449(Struct config) { + Value dpop = config.getFieldsOrDefault("dpop", null); + if (dpop != null && dpop.hasStructValue()) { + Value rfc9449 = dpop.getStructValue().getFieldsOrDefault("rfc9449", null); + return rfc9449 != null && rfc9449.hasBoolValue() && rfc9449.getBoolValue(); + } + Value algs = config.getFieldsOrDefault(DPOP_ALGS_KEY, null); + return algs != null && algs.hasListValue() && algs.getListValue().getValuesCount() > 0; + } + + /** + * Record that the server at {@code url} accepts RFC 9449, e.g. from the well-known + * configuration the SDK already fetched. Only positives are seeded: a server that omits the + * well-known advertisement may still publish RFC 9728 metadata. + */ + void recordRfc9449(URL url) { + if (configured == DPoPMode.AUTO) { + modeByOrigin.put(TokenSource.getOrigin(url), DPoPMode.RFC9449); + } + } + + /** @return {@link DPoPMode#RFC9449} or {@link DPoPMode#LEGACY} for the URL's origin. */ + DPoPMode modeFor(URL url) { + if (configured != DPoPMode.AUTO) { + return configured; + } + String origin = TokenSource.getOrigin(url); + DPoPMode known = modeByOrigin.get(origin); + if (known != null) { + return known; + } + // Discovery runs outside any lock; concurrent first requests may each discover, which is + // harmless since they reach the same answer. + DPoPMode discovered = discover(origin); + return discovered == null ? DPoPMode.LEGACY : modeByOrigin.computeIfAbsent(origin, o -> discovered); + } + + /** @return the definitive mode, or null when the server gave no answer (don't cache). */ + @Nullable + private DPoPMode discover(String origin) { + // 1 + 2: the opentdf configuration. The HTTP form wraps it in "configuration". + Exception wellKnownError = null; + try { + JsonObject wellKnown = fetchJson(origin + OPENTDF_CONFIGURATION_PATH); + if (wellKnown != null) { + JsonObject config = wellKnown.has("configuration") && wellKnown.get("configuration").isJsonObject() + ? wellKnown.getAsJsonObject("configuration") + : wellKnown; + if (advertisesRfc9449(config)) { + return logResolved(origin, DPoPMode.RFC9449, "opentdf-configuration"); + } + } + } catch (IOException e) { + wellKnownError = e; + } + + // 3: RFC 9728 protected resource metadata. It is new, so its absence says little. + Exception resourceError = null; + try { + JsonObject resourceMetadata = fetchJson(origin + PROTECTED_RESOURCE_PATH); + if (resourceMetadata != null && hasNonEmptyArray(resourceMetadata, DPOP_ALGS_KEY)) { + return logResolved(origin, DPoPMode.RFC9449, "oauth-protected-resource"); + } + } catch (IOException e) { + resourceError = e; + } + + if (wellKnownError != null) { + logger.warn("DPoP server discovery failed for {}; using legacy proofs for this request" + + " (well-known error: {}, protected-resource error: {})", + origin, wellKnownError.toString(), resourceError); + return null; + } + return logResolved(origin, DPoPMode.LEGACY, "no RFC 9449 advertisement"); + } + + /** + * GET a well-known document without credentials. + * + * @return the JSON object, or null for a definitive "not published" (a non-5xx error status + * or a 200 whose body is not a JSON object) + * @throws IOException on a network error or a 5xx, which are transient + */ + @Nullable + private JsonObject fetchJson(String url) throws IOException { + Request request = new Request.Builder().url(url).get().header("Accept", "application/json").build(); + try (Response response = httpClient.newCall(request).execute()) { + if (response.code() >= 500) { + throw new IOException("GET " + url + " returned HTTP " + response.code()); + } + if (response.code() != 200) { + logger.debug("GET {} returned HTTP {}; treating as not advertised", url, response.code()); + return null; + } + // A truncated document fails to parse below, which is treated as not advertised. + String text = response.peekBody(MAX_DOCUMENT_BYTES).string(); + try { + return JsonParser.parseString(text).getAsJsonObject(); + } catch (JsonParseException | IllegalStateException e) { + logger.debug("GET {} did not return a JSON object; treating as not advertised", url, e); + return null; + } + } + } + + private static boolean hasNonEmptyArray(JsonObject object, String key) { + JsonElement value = object.get(key); + return value != null && value.isJsonArray() && value.getAsJsonArray().size() > 0; + } + + private static DPoPMode logResolved(String origin, DPoPMode mode, String reason) { + logger.debug("DPoP style for {} is {} ({})", origin, mode, reason); + return mode; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java index dda13258..77be44c7 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -45,6 +45,8 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.cert.X509Certificate; @@ -68,6 +70,7 @@ public class SDKBuilder { private SrtSigner srtSigner; private JWK dpopKey; private JWSAlgorithm dpopAlg; + private DPoPMode dpopMode = DPoPMode.AUTO; private static final Logger logger = LoggerFactory.getLogger(SDKBuilder.class); @@ -247,6 +250,24 @@ public SDKBuilder dpopKey(JWK dpopKey, JWSAlgorithm dpopAlg) { return this; } + /** + * Choose how DPoP-bound tokens are presented to platform services and KAS. The default, + * {@link DPoPMode#AUTO}, discovers per server whether it accepts RFC 9449 or only the legacy + * style of older platforms; set {@link DPoPMode#RFC9449} or {@link DPoPMode#LEGACY} to skip + * discovery. + * + * @param dpopMode the DPoP presentation mode + * @return this builder instance for method chaining + * @throws IllegalArgumentException if dpopMode is null + */ + public SDKBuilder dpopMode(DPoPMode dpopMode) { + if (dpopMode == null) { + throw new IllegalArgumentException("DPoPMode cannot be null"); + } + this.dpopMode = dpopMode; + return this; + } + private AuthInterceptor getAuthInterceptor(JWK dpopJwk, JWSAlgorithm dpopAlgorithm) { if (platformEndpoint == null) { throw new SDKException("cannot build an SDK without specifying the platform endpoint"); @@ -304,7 +325,18 @@ private AuthInterceptor getAuthInterceptor(JWK dpopJwk, JWSAlgorithm dpopAlgorit if (this.authzGrant == null) { this.authzGrant = new ClientCredentialsGrant(); } - var ts = new TokenSource(clientAuth, dpopJwk, dpopAlgorithm, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory); + // Discovery reuses the unauthenticated bootstrap client so it never sends credentials. + var dpopModeResolver = new DPoPModeResolver(dpopMode, httpClient); + // Reuse the configuration fetched above rather than discovering the platform again. Only + // a positive is seeded: RFC 9728 metadata may still advertise. + if (DPoPModeResolver.advertisesRfc9449(config.getConfiguration())) { + try { + dpopModeResolver.recordRfc9449(new URL(platformEndpoint)); + } catch (MalformedURLException e) { + throw new SDKException("invalid platform endpoint: " + platformEndpoint, e); + } + } + var ts = new TokenSource(clientAuth, dpopJwk, dpopAlgorithm, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory, dpopModeResolver); return new AuthInterceptor(ts); } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 52496f06..9cbc2d51 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -55,6 +55,7 @@ enum TokenScheme { private final URI tokenEndpointURI; private final AuthorizationGrant authzGrant; private final SSLSocketFactory sslSocketFactory; + private final DPoPModeResolver dpopModeResolver; // Cache for server-issued nonces, keyed by origin (scheme://host:port) private final Map nonceCache = new ConcurrentHashMap<>(); private static final Logger logger = LoggerFactory.getLogger(TokenSource.class); @@ -69,7 +70,18 @@ enum TokenScheme { * @param sslSocketFactory Optional SSLSocketFactory for token endpoint requests */ public TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dpopAlg, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { + this(clientAuth, dpopJwk, dpopAlg, tokenEndpointURI, authzGrant, sslSocketFactory, + DPoPModeResolver.fixed(DPoPMode.RFC9449)); + } + + /** + * As above, choosing per resource-server origin between the RFC 9449 and legacy DPoP htu. + * + * @param dpopModeResolver decides the DPoP style for each request URL's origin + */ + TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dpopAlg, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory, DPoPModeResolver dpopModeResolver) { DpopKeyValidation.validate(dpopJwk, dpopAlg); + this.dpopModeResolver = dpopModeResolver; this.clientAuth = clientAuth; this.dpopJwk = dpopJwk; this.dpopAlg = dpopAlg; @@ -120,17 +132,24 @@ public AuthHeaders getAuthHeaders(URL url, String method) { return getAuthHeaders(url, method, null); } + public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { + // Every authenticated SDK request is a Connect/gRPC call. + return getAuthHeaders(url, method, nonce, true); + } + /** * Get authorization headers for a request. When the token is DPoP-bound this includes * a freshly minted DPoP proof; if the AS issued a plain Bearer token the DPoP header is - * null (see the Bearer fallback below). + * null (see the Bearer fallback below). The proof's htu follows the {@link DPoPMode} resolved + * for the URL's origin: the full URL for RFC 9449, or the legacy form (see legacyHtuOf). * * @param url The URL being accessed * @param method The HTTP method * @param nonce Optional server-issued nonce to include in the proof + * @param rpcRequest whether the request is a Connect/gRPC call, which selects the legacy htu form * @return AuthHeaders with the Authorization header, and a DPoP proof header when DPoP-bound */ - public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { + AuthHeaders getAuthHeaders(URL url, String method, String nonce, boolean rpcRequest) { // Snapshot the token and its scheme together under getToken()'s lock so the // Authorization value and the DPoP/Bearer decision always come from the same // token generation. @@ -144,6 +163,8 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { return new AuthHeaders(TokenScheme.BEARER.headerPrefix + " " + t.getValue(), null); } + boolean legacy = rpcRequest && dpopModeResolver.modeFor(url) == DPoPMode.LEGACY; + // Build the DPoP proof for each request String dpopProof; try { @@ -156,7 +177,7 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { } SignedJWT proof; - URI htu = htuOf(url.toURI()); + URI htu = legacy ? legacyHtuOf(url.toURI()) : htuOf(url.toURI()); if (nonce != null) { proof = dpopFactory.createDPoPJWT(method, htu, t, new Nonce(nonce)); } else { @@ -169,6 +190,7 @@ public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { throw new SDKException("Error creating DPoP proof", e); } + // The DPoP scheme is accepted by legacy servers too; only the htu differs. return new AuthHeaders( TokenScheme.DPOP.headerPrefix + " " + t.getValue(), dpopProof); @@ -196,8 +218,21 @@ private static URI htuOf(URI uri) { } } - /** Get the origin (scheme://host:port) from a URL, used as the nonce cache key. */ - private String getOrigin(URL url) { + // Legacy (service v0.11.x) servers compare an RPC's htu against its procedure + // ("/package.Service/Method"), i.e. the last two path segments, which also drops any + // reverse-proxy prefix; they reject a full URL. Non-RPC requests keep the full URI, as do + // token endpoint proofs. + private static URI legacyHtuOf(URI uri) { + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + String[] segments = path.replaceAll("^/+|/+$", "").split("/"); + if (segments.length < 2) { + return htuOf(uri); + } + return URI.create("/" + segments[segments.length - 2] + "/" + segments[segments.length - 1]); + } + + /** Get the origin (scheme://host:port) from a URL, used as the nonce and DPoP mode cache key. */ + static String getOrigin(URL url) { int port = url.getPort(); if (port == -1) { port = url.getDefaultPort(); diff --git a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt index bcaac44b..5b30672d 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -123,7 +123,7 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { if (dpopNonce != null) { response.close() ts.cacheNonce(url, dpopNonce) - val authHeaders = ts.getAuthHeaders(url, chain.request().method) + val authHeaders = ts.getAuthHeaders(url, chain.request().method, null, isRpc(chain.request())) val newRequestBuilder = chain.request().newBuilder() .header("Authorization", authHeaders.authHeader) .removeHeader("DPoP") @@ -169,6 +169,13 @@ internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { } } + // Connect and gRPC calls get the legacy procedure htu; anything else keeps the full URI. + private fun isRpc(request: okhttp3.Request): Boolean { + if (request.header("Connect-Protocol-Version") != null) return true + val contentType = request.header("Content-Type") ?: request.body?.contentType()?.toString() ?: return false + return contentType.startsWith("application/grpc") || contentType.startsWith("application/connect+") + } + private fun isDpopNonceChallenge(response: okhttp3.Response): Boolean { return response.challenges().any { challenge -> challenge.scheme.equals("DPoP", ignoreCase = true) && diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java new file mode 100644 index 00000000..d41f98dc --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java @@ -0,0 +1,380 @@ +package io.opentdf.platform.sdk; + +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.oauth2.sdk.ClientCredentialsGrant; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +import static io.opentdf.platform.sdk.DPoPModeResolver.OPENTDF_CONFIGURATION_PATH; +import static io.opentdf.platform.sdk.DPoPModeResolver.PROTECTED_RESOURCE_PATH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * DSPX-5640: the SDK picks RFC 9449 or legacy DPoP headers from what each server advertises. + */ +class DPoPModeTest { + private static final String RPC_PATH = "/kas.AccessService/Rewrap"; + private static final String DPOP_TOKEN_RESPONSE = + "{\"access_token\":\"test-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}"; + private static final String BEARER_TOKEN_RESPONSE = + "{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + private static final String ALGS = "\"dpop_signing_alg_values_supported\":[\"ES256\",\"RS256\"]"; + + private static RSAKey rsaKey; + + private MockWebServer tokenServer; + private MockWebServer resourceServer; + // Well-known documents the resource server publishes, keyed by path; absent paths 404. + private final Map wellKnown = new ConcurrentHashMap<>(); + private final List discoveryRequests = new CopyOnWriteArrayList<>(); + private final AtomicBoolean challengeNextCall = new AtomicBoolean(); + + @BeforeAll + static void generateKey() throws Exception { + rsaKey = new RSAKeyGenerator(2048).keyUse(KeyUse.SIGNATURE).keyID(UUID.randomUUID().toString()).generate(); + } + + @BeforeEach + void startServers() throws Exception { + tokenServer = new MockWebServer(); + tokenServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return json(DPOP_TOKEN_RESPONSE); + } + }); + tokenServer.start(); + + resourceServer = new MockWebServer(); + resourceServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String path = request.getPath(); + if (path.startsWith("/.well-known/")) { + discoveryRequests.add(request); + return wellKnown.getOrDefault(path, new MockResponse().setResponseCode(404)); + } + if (challengeNextCall.getAndSet(false)) { + return new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "server-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""); + } + return new MockResponse().setResponseCode(200); + } + }); + resourceServer.start(); + } + + @AfterEach + void stopServers() throws Exception { + tokenServer.shutdown(); + resourceServer.shutdown(); + } + + @Test + void wellKnownDpopBlockOnly() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop\":{\"rfc9449\":true}}}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH); + } + + @Test + void wellKnownDpopBlockOverridesTopLevelAlgs() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, + json("{\"configuration\":{\"dpop\":{\"rfc9449\":false}," + ALGS + "}}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + } + + @Test + void wellKnownTopLevelAlgsOnly() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"platform_issuer\":\"x\"," + ALGS + "}}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH); + } + + @Test + void wellKnownEmptyAlgsIsNotAnAdvertisement() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, + json("{\"configuration\":{\"dpop_signing_alg_values_supported\":[]}}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + + @Test + void protectedResourceMetadataOnly() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"platform_issuer\":\"x\"}}")); + wellKnown.put(PROTECTED_RESOURCE_PATH, json("{\"resource\":\"x\"," + ALGS + "}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + + @Test + void protectedResourceMetadataWithoutOpentdfConfiguration() throws Exception { + wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + } + + @Test + void bothAdvertisedStopsAtWellKnown() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop\":{\"rfc9449\":true}," + ALGS + "}}")); + wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); + assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH); + } + + @Test + void neitherAdvertisedSendsLegacyHeaders() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"platform_issuer\":\"x\"}}")); + TokenSource ts = tokenSource(autoResolver()); + + // A reverse-proxy prefix is dropped: legacy servers compare against the procedure. + TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceServer.url("/proxy" + RPC_PATH).url(), "POST"); + + // Legacy keeps the DPoP scheme; only the htu changes. + assertThat(headers.getAuthHeader()).isEqualTo("DPoP test-token"); + JWTClaimsSet proof = SignedJWT.parse(headers.getDpopHeader()).getJWTClaimsSet(); + assertThat(proof.getStringClaim("htu")).isEqualTo(RPC_PATH); + assertThat(proof.getStringClaim("htm")).isEqualTo("POST"); + assertThat(proof.getStringClaim("ath")).isNotEmpty(); + // Non-RPC requests, and paths too short to be a procedure, keep the full URI. + URL plain = resourceServer.url("/kas/v2/rewrap").url(); + assertThat(htuOf(ts.getAuthHeaders(plain, "POST", null, false))).isEqualTo(plain.toString()); + URL shortPath = resourceServer.url("/kas").url(); + assertThat(htuOf(ts.getAuthHeaders(shortPath, "POST"))).isEqualTo(shortPath.toString()); + // Discovery is unauthenticated and its definitive answer is cached per origin. + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + for (RecordedRequest r : discoveryRequests) { + assertThat(r.getMethod()).isEqualTo("GET"); + assertThat(r.getHeader("Authorization")).isNull(); + assertThat(r.getHeader("DPoP")).isNull(); + } + } + + @Test + void advertisedSendsRfc9449Headers() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{" + ALGS + "}}")); + URL url = resourceServer.url(RPC_PATH + "?q=1").url(); + + TokenSource.AuthHeaders headers = tokenSource(autoResolver()).getAuthHeaders(url, "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("DPoP test-token"); + JWTClaimsSet proof = SignedJWT.parse(headers.getDpopHeader()).getJWTClaimsSet(); + assertThat(proof.getStringClaim("htu")).isEqualTo(resourceServer.url(RPC_PATH).toString()); + assertThat(proof.getStringClaim("ath")).isNotEmpty(); + } + + @Test + void discoveryFailureIsLegacyForThatRequestOnly() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, new MockResponse().setResponseCode(503)); + TokenSource ts = tokenSource(autoResolver()); + + assertThat(htuOf(ts.getAuthHeaders(rpcUrl(), "POST"))).isEqualTo(RPC_PATH); + + // The 5xx was transient, so the next request discovers again and now sees RFC 9449. + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{" + ALGS + "}}")); + assertThat(htuOf(ts.getAuthHeaders(rpcUrl(), "POST"))).isEqualTo(rpcUrl().toString()); + assertThat(discoveredPaths()).containsExactly( + OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH, OPENTDF_CONFIGURATION_PATH); + } + + @Test + void discoveryFailureStillHonorsProtectedResourceMetadata() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, new MockResponse().setResponseCode(503)); + wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); + DPoPModeResolver resolver = autoResolver(); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + + @Test + void unreachableServerIsLegacy() throws Exception { + URL url = resourceServer.url(RPC_PATH).url(); + resourceServer.shutdown(); + assertThat(autoResolver().modeFor(url)).isEqualTo(DPoPMode.LEGACY); + } + + @Test + void unparseableDocumentIsDefinitivelyLegacy() throws Exception { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("not json")); + DPoPModeResolver resolver = autoResolver(); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + + @Test + void notFoundIsDefinitivelyLegacy() throws Exception { + // service v0.11.x: no RFC 9449 advertisement anywhere. + DPoPModeResolver resolver = autoResolver(); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + + @Test + void bootstrapAdvertisementSeedsPlatformWithoutDiscovery() throws Exception { + Struct algs = Struct.newBuilder().putFields("dpop_signing_alg_values_supported", + Value.newBuilder().setListValue(ListValue.newBuilder() + .addValues(Value.newBuilder().setStringValue("ES256"))).build()).build(); + Struct declined = Struct.newBuilder() + .putFields("dpop", Value.newBuilder().setStructValue(Struct.newBuilder() + .putFields("rfc9449", Value.newBuilder().setBoolValue(false).build())).build()) + .putAllFields(algs.getFieldsMap()) + .build(); + assertThat(DPoPModeResolver.advertisesRfc9449(algs)).isTrue(); + assertThat(DPoPModeResolver.advertisesRfc9449(declined)).isFalse(); + assertThat(DPoPModeResolver.advertisesRfc9449(Struct.getDefaultInstance())).isFalse(); + + DPoPModeResolver resolver = autoResolver(); + resolver.recordRfc9449(resourceServer.url("/").url()); + assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); + assertThat(discoveryRequests).isEmpty(); + + DPoPModeResolver legacy = new DPoPModeResolver(DPoPMode.LEGACY, null); + legacy.recordRfc9449(resourceServer.url("/").url()); + assertThat(legacy.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); + } + + @ParameterizedTest + @EnumSource(value = DPoPMode.class, names = {"LEGACY", "RFC9449"}) + void overrideSkipsDiscovery(DPoPMode mode) throws Exception { + // Advertise the opposite of the override to show the override wins. + if (mode == DPoPMode.LEGACY) { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{" + ALGS + "}}")); + } + TokenSource ts = tokenSource(new DPoPModeResolver(mode, new OkHttpClient())); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(rpcUrl(), "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("DPoP test-token"); + assertThat(htuOf(headers)).isEqualTo(mode == DPoPMode.LEGACY ? RPC_PATH : rpcUrl().toString()); + assertThat(discoveryRequests).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = DPoPMode.class, names = {"LEGACY", "RFC9449"}) + void nonceChallengeIsRetriedInEachMode(DPoPMode expected) throws Exception { + if (expected == DPoPMode.RFC9449) { + wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{" + ALGS + "}}")); + } + AuthInterceptor interceptor = new AuthInterceptor(tokenSource(autoResolver())); + OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor.dpopRetryInterceptor()).build(); + challengeNextCall.set(true); + + // Connect-shaped request, so the legacy retry signs the procedure htu. + Request request = new Request.Builder() + .url(resourceServer.url("/proxy" + RPC_PATH)) + .header("Connect-Protocol-Version", "1") + .post(RequestBody.create(new byte[0])) + .build(); + try (Response response = client.newCall(request).execute()) { + assertThat(response.code()).isEqualTo(200); + } + + RecordedRequest retry = lastNonDiscoveryRequest(); + assertThat(retry.getHeader("Authorization")).isEqualTo("DPoP test-token"); + JWTClaimsSet proof = SignedJWT.parse(retry.getHeader("DPoP")).getJWTClaimsSet(); + assertThat(proof.getStringClaim("nonce")).isEqualTo("server-nonce"); + assertThat(proof.getStringClaim("htu")).isEqualTo(expected == DPoPMode.LEGACY + ? RPC_PATH + : resourceServer.url("/proxy" + RPC_PATH).toString()); + } + + @Test + void bearerTokenSkipsDiscoveryAndProof() throws Exception { + tokenServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return json(BEARER_TOKEN_RESPONSE); + } + }); + TokenSource.AuthHeaders headers = tokenSource(autoResolver()).getAuthHeaders(rpcUrl(), "POST"); + assertThat(headers.getAuthHeader()).isEqualTo("Bearer test-token"); + assertThat(headers.getDpopHeader()).isNull(); + assertThat(discoveryRequests).isEmpty(); + } + + @Test + void autoRequiresHttpClientAndBuilderRejectsNull() { + assertThatThrownBy(() -> new DPoPModeResolver(DPoPMode.AUTO, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SDKBuilder.newBuilder().dpopMode(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private DPoPModeResolver autoResolver() { + return new DPoPModeResolver(DPoPMode.AUTO, new OkHttpClient()); + } + + private TokenSource tokenSource(DPoPModeResolver resolver) { + return new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null, + resolver); + } + + private static String htuOf(TokenSource.AuthHeaders headers) throws Exception { + return SignedJWT.parse(headers.getDpopHeader()).getJWTClaimsSet().getStringClaim("htu"); + } + + private URL rpcUrl() { + return resourceServer.url(RPC_PATH).url(); + } + + private List discoveredPaths() { + return discoveryRequests.stream().map(RecordedRequest::getPath).collect(java.util.stream.Collectors.toList()); + } + + private RecordedRequest lastNonDiscoveryRequest() throws InterruptedException { + RecordedRequest last = null; + for (int i = resourceServer.getRequestCount(); i > 0; i--) { + RecordedRequest r = resourceServer.takeRequest(); + if (!r.getPath().startsWith("/.well-known/")) { + last = r; + } + } + return last; + } + + private static MockResponse json(String body) { + return new MockResponse().setBody(body).setHeader("Content-Type", "application/json"); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java index 3437cecd..80e3d91a 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java @@ -3,6 +3,7 @@ import com.connectrpc.ResponseMessageKt; import com.google.protobuf.Struct; import com.google.protobuf.Value; +import com.nimbusds.jwt.SignedJWT; import io.grpc.Metadata; import io.grpc.Server; import io.grpc.ServerBuilder; @@ -353,7 +354,13 @@ void sdkServicesSetup(boolean useSSLPlatform, boolean useSSLIDP) throws Exceptio public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, StreamObserver responseObserver) { var val = Value.newBuilder().setStringValue(issuer).build(); - var config = Struct.newBuilder().putFields("platform_issuer", val).build(); + // advertising RFC 9449 here seeds the platform's DPoP style (DSPX-5640) + var algs = Value.newBuilder().setListValue(com.google.protobuf.ListValue.newBuilder() + .addValues(Value.newBuilder().setStringValue("RS256"))).build(); + var config = Struct.newBuilder() + .putFields("platform_issuer", val) + .putFields("dpop_signing_alg_values_supported", algs) + .build(); var response = GetWellKnownConfigurationResponse .newBuilder() .setConfiguration(config) @@ -440,10 +447,11 @@ public ServerCall.Listener interceptCall(ServerCall ServerCall.Listener interceptCall(ServerCall ServerCall.Listener interceptCall(ServerCall Date: Fri, 13 Nov 2026 17:17:15 -0500 Subject: [PATCH 2/2] fix(sdk): retry transient DPoP metadata discovery and clear quality findings Signed-off-by: Dave Mihalcik --- .../platform/sdk/DPoPModeResolver.java | 15 ++++--- .../io/opentdf/platform/sdk/TokenSource.java | 11 +++-- .../io/opentdf/platform/sdk/DPoPModeTest.java | 44 +++++++++++++------ 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java index d76a1fe2..76ae5bc4 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java @@ -122,8 +122,9 @@ private DPoPMode discover(String origin) { try { JsonObject wellKnown = fetchJson(origin + OPENTDF_CONFIGURATION_PATH); if (wellKnown != null) { - JsonObject config = wellKnown.has("configuration") && wellKnown.get("configuration").isJsonObject() - ? wellKnown.getAsJsonObject("configuration") + JsonElement configuration = wellKnown.get("configuration"); + JsonObject config = configuration != null && configuration.isJsonObject() + ? configuration.getAsJsonObject() : wellKnown; if (advertisesRfc9449(config)) { return logResolved(origin, DPoPMode.RFC9449, "opentdf-configuration"); @@ -144,10 +145,12 @@ private DPoPMode discover(String origin) { resourceError = e; } - if (wellKnownError != null) { - logger.warn("DPoP server discovery failed for {}; using legacy proofs for this request" - + " (well-known error: {}, protected-resource error: {})", - origin, wellKnownError.toString(), resourceError); + if (wellKnownError != null || resourceError != null) { + if (logger.isWarnEnabled()) { + logger.warn("DPoP server discovery failed for {}; using legacy proofs for this request" + + " (well-known error: {}, protected-resource error: {})", + origin, String.valueOf(wellKnownError), String.valueOf(resourceError)); + } return null; } return logResolved(origin, DPoPMode.LEGACY, "no RFC 9449 advertisement"); diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java index 9cbc2d51..bf1bb3bf 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -224,11 +224,16 @@ private static URI htuOf(URI uri) { // token endpoint proofs. private static URI legacyHtuOf(URI uri) { String path = uri.getRawPath() == null ? "" : uri.getRawPath(); - String[] segments = path.replaceAll("^/+|/+$", "").split("/"); - if (segments.length < 2) { + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == '/') { + end--; + } + int methodSeparator = path.lastIndexOf('/', end - 1); + if (methodSeparator <= 0) { return htuOf(uri); } - return URI.create("/" + segments[segments.length - 2] + "/" + segments[segments.length - 1]); + int serviceSeparator = path.lastIndexOf('/', methodSeparator - 1); + return URI.create("/" + path.substring(serviceSeparator + 1, end)); } /** Get the origin (scheme://host:port) from a URL, used as the nonce and DPoP mode cache key. */ diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java index d41f98dc..2f1b69a0 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPModeTest.java @@ -105,28 +105,28 @@ void stopServers() throws Exception { } @Test - void wellKnownDpopBlockOnly() throws Exception { + void wellKnownDpopBlockOnly() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop\":{\"rfc9449\":true}}}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH); } @Test - void wellKnownDpopBlockOverridesTopLevelAlgs() throws Exception { + void wellKnownDpopBlockOverridesTopLevelAlgs() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop\":{\"rfc9449\":false}," + ALGS + "}}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); } @Test - void wellKnownTopLevelAlgsOnly() throws Exception { + void wellKnownTopLevelAlgsOnly() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"platform_issuer\":\"x\"," + ALGS + "}}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH); } @Test - void wellKnownEmptyAlgsIsNotAnAdvertisement() throws Exception { + void wellKnownEmptyAlgsIsNotAnAdvertisement() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop_signing_alg_values_supported\":[]}}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); @@ -134,7 +134,7 @@ void wellKnownEmptyAlgsIsNotAnAdvertisement() throws Exception { } @Test - void protectedResourceMetadataOnly() throws Exception { + void protectedResourceMetadataOnly() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"platform_issuer\":\"x\"}}")); wellKnown.put(PROTECTED_RESOURCE_PATH, json("{\"resource\":\"x\"," + ALGS + "}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); @@ -142,13 +142,13 @@ void protectedResourceMetadataOnly() throws Exception { } @Test - void protectedResourceMetadataWithoutOpentdfConfiguration() throws Exception { + void protectedResourceMetadataWithoutOpentdfConfiguration() { wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); } @Test - void bothAdvertisedStopsAtWellKnown() throws Exception { + void bothAdvertisedStopsAtWellKnown() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("{\"configuration\":{\"dpop\":{\"rfc9449\":true}," + ALGS + "}}")); wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); assertThat(autoResolver().modeFor(rpcUrl())).isEqualTo(DPoPMode.RFC9449); @@ -174,6 +174,8 @@ void neitherAdvertisedSendsLegacyHeaders() throws Exception { assertThat(htuOf(ts.getAuthHeaders(plain, "POST", null, false))).isEqualTo(plain.toString()); URL shortPath = resourceServer.url("/kas").url(); assertThat(htuOf(ts.getAuthHeaders(shortPath, "POST"))).isEqualTo(shortPath.toString()); + URL trailingSlashes = resourceServer.url("/proxy" + RPC_PATH + "///").url(); + assertThat(htuOf(ts.getAuthHeaders(trailingSlashes, "POST"))).isEqualTo(RPC_PATH); // Discovery is unauthenticated and its definitive answer is cached per origin. assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); for (RecordedRequest r : discoveryRequests) { @@ -211,7 +213,7 @@ void discoveryFailureIsLegacyForThatRequestOnly() throws Exception { } @Test - void discoveryFailureStillHonorsProtectedResourceMetadata() throws Exception { + void discoveryFailureStillHonorsProtectedResourceMetadata() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, new MockResponse().setResponseCode(503)); wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); DPoPModeResolver resolver = autoResolver(); @@ -220,6 +222,21 @@ void discoveryFailureStillHonorsProtectedResourceMetadata() throws Exception { assertThat(discoveredPaths()).containsExactly(OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); } + @Test + void protectedResourceFailureIsLegacyForThatRequestOnly() throws Exception { + wellKnown.put(PROTECTED_RESOURCE_PATH, new MockResponse().setResponseCode(503)); + TokenSource ts = tokenSource(autoResolver()); + + assertThat(htuOf(ts.getAuthHeaders(rpcUrl(), "POST"))).isEqualTo(RPC_PATH); + + // A transient metadata error must not pin an RFC 9449 server to legacy. + wellKnown.put(PROTECTED_RESOURCE_PATH, json("{" + ALGS + "}")); + assertThat(htuOf(ts.getAuthHeaders(rpcUrl(), "POST"))).isEqualTo(rpcUrl().toString()); + assertThat(discoveredPaths()).containsExactly( + OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH, + OPENTDF_CONFIGURATION_PATH, PROTECTED_RESOURCE_PATH); + } + @Test void unreachableServerIsLegacy() throws Exception { URL url = resourceServer.url(RPC_PATH).url(); @@ -228,7 +245,7 @@ void unreachableServerIsLegacy() throws Exception { } @Test - void unparseableDocumentIsDefinitivelyLegacy() throws Exception { + void unparseableDocumentIsDefinitivelyLegacy() { wellKnown.put(OPENTDF_CONFIGURATION_PATH, json("not json")); DPoPModeResolver resolver = autoResolver(); assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); @@ -237,7 +254,7 @@ void unparseableDocumentIsDefinitivelyLegacy() throws Exception { } @Test - void notFoundIsDefinitivelyLegacy() throws Exception { + void notFoundIsDefinitivelyLegacy() { // service v0.11.x: no RFC 9449 advertisement anywhere. DPoPModeResolver resolver = autoResolver(); assertThat(resolver.modeFor(rpcUrl())).isEqualTo(DPoPMode.LEGACY); @@ -246,7 +263,7 @@ void notFoundIsDefinitivelyLegacy() throws Exception { } @Test - void bootstrapAdvertisementSeedsPlatformWithoutDiscovery() throws Exception { + void bootstrapAdvertisementSeedsPlatformWithoutDiscovery() { Struct algs = Struct.newBuilder().putFields("dpop_signing_alg_values_supported", Value.newBuilder().setListValue(ListValue.newBuilder() .addValues(Value.newBuilder().setStringValue("ES256"))).build()).build(); @@ -315,7 +332,7 @@ void nonceChallengeIsRetriedInEachMode(DPoPMode expected) throws Exception { } @Test - void bearerTokenSkipsDiscoveryAndProof() throws Exception { + void bearerTokenSkipsDiscoveryAndProof() { tokenServer.setDispatcher(new Dispatcher() { @Override public MockResponse dispatch(RecordedRequest request) { @@ -332,7 +349,8 @@ public MockResponse dispatch(RecordedRequest request) { void autoRequiresHttpClientAndBuilderRejectsNull() { assertThatThrownBy(() -> new DPoPModeResolver(DPoPMode.AUTO, null)) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> SDKBuilder.newBuilder().dpopMode(null)) + SDKBuilder builder = SDKBuilder.newBuilder(); + assertThatThrownBy(() -> builder.dpopMode(null)) .isInstanceOf(IllegalArgumentException.class); }