Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/DPoPMode.java
Original file line number Diff line number Diff line change
@@ -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
}
194 changes: 194 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java
Original file line number Diff line number Diff line change
@@ -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<String, DPoPMode> 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()

Check failure on line 125 in sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "configuration" 3 times.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaHRwJNnw9RFyM_sMwQI&open=AaHRwJNnw9RFyM_sMwQI&pullRequest=441
? 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);

Check warning on line 150 in sdk/src/main/java/io/opentdf/platform/sdk/DPoPModeResolver.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Invoke method(s) only conditionally.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaHRwJNnw9RFyM_sMwQH&open=AaHRwJNnw9RFyM_sMwQH&pullRequest=441
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;
}
}
34 changes: 33 additions & 1 deletion sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

Expand Down
45 changes: 40 additions & 5 deletions sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
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<String, String> nonceCache = new ConcurrentHashMap<>();
private static final Logger logger = LoggerFactory.getLogger(TokenSource.class);
Expand All @@ -69,7 +70,18 @@
* @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;
Expand Down Expand Up @@ -120,17 +132,24 @@
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.
Expand All @@ -144,6 +163,8 @@
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 {
Expand All @@ -156,7 +177,7 @@
}

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 {
Expand All @@ -169,6 +190,7 @@
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);
Expand Down Expand Up @@ -196,8 +218,21 @@
}
}

/** 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("/");

Check warning on line 227 in sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Group parts of the regex together to make the intended operator precedence explicit.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaHRwJQjw9RFyM_sMwQJ&open=AaHRwJQjw9RFyM_sMwQJ&pullRequest=441

Check warning on line 227 in sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaHRwJQjw9RFyM_sMwQK&open=AaHRwJQjw9RFyM_sMwQK&pullRequest=441
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();
Expand Down
Loading
Loading