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