diff --git a/AGENTS.md b/AGENTS.md index 853d5425..25a642c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ ## Build, Test, and Dev Commands +- `java`/`mvn` are not on PATH and `JAVA_HOME` is unset here. Set it from the Homebrew prefix (compiles the release-11 target fine, no version lookup needed): + `export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"` + Other brew JDKs available: `openjdk` (latest), `openjdk@21`, `openjdk@25`, `openjdk@26`. - Build everything (runs codegen): `mvn clean install` - Fast local build (skip tests): `mvn clean install -DskipTests` - SDK unit tests: `mvn -pl sdk test` diff --git a/cmdline/pom.xml b/cmdline/pom.xml index 61fc7d9a..f832da24 100644 --- a/cmdline/pom.xml +++ b/cmdline/pom.xml @@ -77,6 +77,18 @@ sdk ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + 3.25.3 + test + diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index 3d5bcce1..995c4b63 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -1,26 +1,14 @@ package io.opentdf.platform; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import com.nimbusds.jose.jwk.JWK; -import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; - -import java.text.ParseException; import com.google.gson.JsonSyntaxException; -import io.opentdf.platform.sdk.AssertionConfig; -import io.opentdf.platform.sdk.AutoConfigureException; -import io.opentdf.platform.sdk.Config; -import io.opentdf.platform.sdk.KeyType; -import io.opentdf.platform.sdk.SDK; -import io.opentdf.platform.sdk.SDKBuilder; -import picocli.CommandLine; -import picocli.CommandLine.HelpCommand; -import picocli.CommandLine.Option; +import com.google.gson.reflect.TypeToken; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; @@ -39,13 +27,27 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; +import java.text.ParseException; import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.Callable; import java.util.function.Consumer; +import io.opentdf.platform.sdk.AssertionConfig; +import io.opentdf.platform.sdk.AutoConfigureException; +import io.opentdf.platform.sdk.Config; +import io.opentdf.platform.sdk.KeyType; +import io.opentdf.platform.sdk.SDK; +import io.opentdf.platform.sdk.SDKBuilder; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.config.Configurator; +import picocli.CommandLine; +import picocli.CommandLine.HelpCommand; +import picocli.CommandLine.Option; /** * Constants for the TDF command line tool. * These must be compile-time constants to appear in annotations. @@ -58,17 +60,67 @@ class Versions { public static final String TDF_SPEC = "4.3.0"; } -@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class }, version = "{\"version\":\"" + Versions.SDK - + "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}") +@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class, + Command.Supports.class }, version = "{\"version\":\"" + Versions.SDK + + "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}") class Command { @Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info") boolean versionInfoRequested; + // Picocli injects the parsed command spec here so buildSDK() can raise + // ParameterException with the right help context when required options + // are missing for encrypt/decrypt/metadata (which all call buildSDK()). + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + @CommandLine.Command(name = "supports", description = "Check if a feature is supported, or list all supported features") + static class Supports implements Callable { + // Static, always-on capabilities of this build. There is no "known but + // unsupported" feature here: anything not in this list is simply unrecognized. + private static final List FEATURES = List.of("dpop", "dpop_nonce_challenge"); + + @CommandLine.Parameters(index = "0", arity = "0..1", description = "Feature to check (e.g., dpop). Omit to list all supported features.") + private String feature; + + @Option(names = "--json", description = "Output as a JSON object mapping feature name to supported (boolean)") + private boolean json; + + @Override + public Integer call() { + if (feature == null) { + printFeatures(FEATURES); + return 0; + } + + Optional canonical = FEATURES.stream().filter(f -> f.equalsIgnoreCase(feature)).findFirst(); + if (json) { + Map result = new LinkedHashMap<>(); + // Emit the canonical feature name for recognized features so casing is stable + // for automation; echo the raw input only for unknown features. + result.put(canonical.orElse(feature), canonical.isPresent()); + System.out.println(new Gson().toJson(result)); + } + return canonical.isPresent() ? 0 : 1; + } + + private void printFeatures(List features) { + if (json) { + Map result = new LinkedHashMap<>(); + features.forEach(f -> result.put(f, true)); + System.out.println(new Gson().toJson(result)); + } else { + features.forEach(System.out::println); + } + } + } + private static class AssertionKeyDeserializer implements JsonDeserializer { @Override - public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { JsonObject jsonObject = json.getAsJsonObject(); - AssertionConfig.AssertionKey assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null); + AssertionConfig.AssertionKey assertionKey = new AssertionConfig.AssertionKey( + AssertionConfig.AssertionKeyAlg.NotDefined, null); if (jsonObject.has("alg")) { assertionKey.alg = context.deserialize(jsonObject.get("alg"), AssertionConfig.AssertionKeyAlg.class); @@ -78,13 +130,15 @@ public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.refl } if (jsonObject.has("jwk")) { try { - assertionKey.jwk = JWK.parse(jsonObject.get("jwk").toString()); + assertionKey.jwk = com.nimbusds.jose.jwk.JWK.parse(jsonObject.get("jwk").toString()); } catch (ParseException e) { throw new JsonParseException("Failed to parse jwk", e); } } if (jsonObject.has("x5c")) { - assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), new TypeToken>() {}.getType()); + assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), + new TypeToken>() { + }.getType()); } return assertionKey; @@ -102,7 +156,19 @@ private Gson buildGson() { private static final String PEM_HEADER = "-----BEGIN (.*)-----"; private static final String PEM_FOOTER = "-----END (.*)-----"; - @Option(names = { "--client-secret" }, required = true) + @Option(names = { "-v", "--verbose" }, scope = CommandLine.ScopeType.INHERIT, defaultValue = "false", description = "Enable verbose output including stack traces on error") + void setVerbose(boolean verbose) { + this.verbose = verbose; + if (verbose) { + var root = org.apache.logging.log4j.LogManager.getRootLogger(); + if (!root.getLevel().isLessSpecificThan(Level.DEBUG)) { + Configurator.setRootLevel(Level.DEBUG); + } + } + } + boolean verbose; + + @Option(names = { "--client-secret" }) private String clientSecret; @Option(names = { "-h", "--plaintext" }, defaultValue = "false") @@ -111,10 +177,10 @@ private Gson buildGson() { @Option(names = { "-i", "--insecure" }, defaultValue = "false") private boolean insecure; - @Option(names = { "--client-id" }, required = true) + @Option(names = { "--client-id" }) private String clientId; - @Option(names = { "-p", "--platform-endpoint" }, required = true) + @Option(names = { "-p", "--platform-endpoint" }) private String platformEndpoint; private Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey) @@ -222,7 +288,7 @@ void encrypt( } catch (JsonSyntaxException e) { // try it as a file path try { - String fileJson = new String(Files.readAllBytes(Paths.get(assertionConfig))); + String fileJson = Files.readString(Paths.get(assertionConfig)); assertionConfigs = gson.fromJson(fileJson, AssertionConfig[].class); } catch (JsonSyntaxException e2) { throw new RuntimeException("Failed to parse assertion from file, expects an list of assertions", @@ -258,6 +324,24 @@ void encrypt( } private SDK buildSDK() { + // The picocli @Option annotations on platformEndpoint/clientId/clientSecret are + // intentionally NOT marked required = true so that `tdf supports ` can + // run without credentials. Subcommands that actually build an SDK enforce them + // here so the failure surfaces as a normal picocli ParameterException (exit 2) + // rather than a deep SDK error. + if (platformEndpoint == null || platformEndpoint.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Missing required option: '--platform-endpoint='"); + } + if (clientId == null || clientId.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Missing required option: '--client-id='"); + } + if (clientSecret == null || clientSecret.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Missing required option: '--client-secret='"); + } + SDKBuilder builder = new SDKBuilder(); if (insecure) { builder.insecureSslFactory(); @@ -296,8 +380,9 @@ void decrypt( } catch (JsonSyntaxException e) { // try it as a file path try { - String fileJson = new String(Files.readAllBytes(Paths.get(assertionVerificationInput))); - assertionVerificationKeys = gson.fromJson(fileJson, Config.AssertionVerificationKeys.class); + String fileJson = Files.readString(Paths.get(assertionVerificationInput)); + assertionVerificationKeys = gson.fromJson(fileJson, + Config.AssertionVerificationKeys.class); } catch (JsonSyntaxException e2) { throw new RuntimeException("Failed to parse assertion verification keys from file", e2); } catch (Exception e3) { diff --git a/cmdline/src/main/java/io/opentdf/platform/TDF.java b/cmdline/src/main/java/io/opentdf/platform/TDF.java index 5d9a27c1..8ff9e88b 100644 --- a/cmdline/src/main/java/io/opentdf/platform/TDF.java +++ b/cmdline/src/main/java/io/opentdf/platform/TDF.java @@ -4,7 +4,16 @@ public class TDF { public static void main(String[] args) { - var result = new CommandLine(new Command()).execute(args); - System.exit(result); + var command = new Command(); + var cmd = new CommandLine(command); + cmd.setExecutionExceptionHandler((ex, commandLine, parseResult) -> { + if (command.verbose) { + ex.printStackTrace(System.err); + } else { + System.err.println(ex.getMessage() != null ? ex.getMessage() : ex.toString()); + } + return 1; + }); + System.exit(cmd.execute(args)); } } \ No newline at end of file diff --git a/cmdline/src/main/resources/log4j2.xml b/cmdline/src/main/resources/log4j2.xml index 51a5a102..a025e82a 100644 --- a/cmdline/src/main/resources/log4j2.xml +++ b/cmdline/src/main/resources/log4j2.xml @@ -6,9 +6,9 @@ - - - + + + diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java new file mode 100644 index 00000000..8995271d --- /dev/null +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -0,0 +1,140 @@ +package io.opentdf.platform; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandTest { + + private final PrintStream originalOut = System.out; + + @AfterEach + void restoreStdout() { + System.setOut(originalOut); + } + + private String captureStdout(Runnable action) { + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8)); + try { + action.run(); + } finally { + System.setOut(originalOut); + } + return captured.toString(StandardCharsets.UTF_8); + } + + @Test + void supports_dpop_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_dpop_nonce_challenge_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "dpop_nonce_challenge"); + assertThat(code).isEqualTo(0); + } + + @Test + void supports_unknown_feature_exits_1() { + int code = new CommandLine(new Command()).execute("supports", "unknown_feature"); + assertThat(code).isEqualTo(1); + } + + @Test + void encrypt_withoutCredentials_failsWithMissingPlatformEndpoint() { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null"); + + // Picocli exit code for ParameterException is USAGE (2). + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Missing required option: '--platform-endpoint='"); + } + + @Test + void supports_withoutCredentials_stillExits0() { + // Regression sentinel: tdf supports must not require --client-id/--client-secret/--platform-endpoint. + int code = new CommandLine(new Command()).execute("supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void verbose_flag_accepted_by_supports() { + int code = new CommandLine(new Command()).execute("--verbose", "supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void verbose_short_flag_accepted_by_supports() { + int code = new CommandLine(new Command()).execute("-v", "supports", "dpop"); + assertThat(code).isEqualTo(0); + } + + @Test + void verbose_flag_sets_verbose_field() { + var command = new Command(); + new CommandLine(command).parseArgs("--verbose", "supports", "dpop"); + assertThat(command.verbose).isTrue(); + } + + @Test + void supports_noArgs_listsFeatures_exits0() { + int[] code = new int[1]; + String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports")); + + assertThat(code[0]).isEqualTo(0); + assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge"); + } + + @Test + void supports_noArgs_json_listsFeatures() { + int[] code = new int[1]; + String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "--json")); + + assertThat(code[0]).isEqualTo(0); + assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true}"); + } + + @Test + void supports_feature_json_true() { + int[] code = new int[1]; + String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "dpop", "--json")); + + assertThat(code[0]).isEqualTo(0); + assertThat(out.trim()).isEqualTo("{\"dpop\":true}"); + } + + @Test + void supports_feature_json_emitsCanonicalName() { + // A recognized feature given with non-canonical casing must serialize under the + // canonical key so JSON output is stable for automation. + int[] code = new int[1]; + String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "DPoP", "--json")); + + assertThat(code[0]).isEqualTo(0); + assertThat(out.trim()).isEqualTo("{\"dpop\":true}"); + } + + @Test + void supports_unknownFeature_json_false() { + int[] code = new int[1]; + String out = captureStdout( + () -> code[0] = new CommandLine(new Command()).execute("supports", "unknown_feature", "--json")); + + assertThat(code[0]).isEqualTo(1); + assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}"); + } + +} diff --git a/sdk/pom.xml b/sdk/pom.xml index 0df312b5..aa69ed7d 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -397,6 +397,11 @@ test-compile + + + src/test/kotlin + + diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java new file mode 100644 index 00000000..ad83fc27 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java @@ -0,0 +1,61 @@ +package io.opentdf.platform.sdk; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; + +public final class DpopKeyValidation { + private DpopKeyValidation() { + } + + public static void validate(JWK jwk, JWSAlgorithm alg) { + if (jwk == null) { + throw new IllegalArgumentException("DPoP JWK cannot be null"); + } + if (alg == null) { + throw new IllegalArgumentException("DPoP algorithm cannot be null"); + } + // DPoP proofs are signed with the private key. A public-only JWK would only fail + // later, opaquely, at proof-signing time — reject it up front so every entry + // point (SDK and CLI) gives the same clear error. + if (!jwk.isPrivate()) { + throw new IllegalArgumentException("DPoP JWK must contain private key material for proof signing"); + } + if (jwk instanceof RSAKey) { + if (!isRsaAlgorithm(alg)) { + throw new IllegalArgumentException("DPoP algorithm " + alg + + " is not compatible with an RSA key; expected one of RS256/RS384/RS512 or PS256/PS384/PS512"); + } + } else if (jwk instanceof ECKey) { + JWSAlgorithm expected = inferEcAlgorithm(((ECKey) jwk).getCurve()); + if (!alg.equals(expected)) { + throw new IllegalArgumentException("DPoP algorithm " + alg + + " is not compatible with EC key on curve " + ((ECKey) jwk).getCurve() + + "; expected " + expected); + } + } else { + throw new IllegalArgumentException("Unsupported JWK type for DPoP: " + jwk.getKeyType() + + "; expected RSA or EC"); + } + } + + public static JWSAlgorithm inferEcAlgorithm(Curve curve) { + if (Curve.P_256.equals(curve)) { + return JWSAlgorithm.ES256; + } + if (Curve.P_384.equals(curve)) { + return JWSAlgorithm.ES384; + } + if (Curve.P_521.equals(curve)) { + return JWSAlgorithm.ES512; + } + throw new IllegalArgumentException("Unsupported EC curve for DPoP: " + curve); + } + + private static boolean isRsaAlgorithm(JWSAlgorithm alg) { + return JWSAlgorithm.RS256.equals(alg) || JWSAlgorithm.RS384.equals(alg) || JWSAlgorithm.RS512.equals(alg) + || JWSAlgorithm.PS256.equals(alg) || JWSAlgorithm.PS384.equals(alg) || JWSAlgorithm.PS512.equals(alg); + } +} 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 5b7bbcc7..dda13258 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java @@ -1,13 +1,15 @@ package io.opentdf.platform.sdk; import com.connectrpc.ConnectException; -import com.connectrpc.Interceptor; import com.connectrpc.ProtocolClientConfig; import com.connectrpc.extensions.GoogleJavaProtobufStrategy; import com.connectrpc.impl.ProtocolClient; import com.connectrpc.okhttp.ConnectOkHttpClient; import com.connectrpc.protocols.GETConfiguration; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; @@ -64,6 +66,8 @@ public class SDKBuilder { private AuthorizationGrant authzGrant; private ProtocolType protocolType = ProtocolType.CONNECT; private SrtSigner srtSigner; + private JWK dpopKey; + private JWSAlgorithm dpopAlg; private static final Logger logger = LoggerFactory.getLogger(SDKBuilder.class); @@ -194,7 +198,7 @@ public SDKBuilder useInsecurePlaintextConnection(Boolean usePlainText) { /** * Set the network protocol to use for communication with platform services. - * + * * @param protocolType the protocol type to use (CONNECT, GRPC, or GRPC_WEB) * @return this builder instance for method chaining * @throws IllegalArgumentException if protocolType is null @@ -213,7 +217,37 @@ public SDKBuilder srtSigner(SrtSigner signer) { return this; } - private Interceptor getAuthInterceptor(RSAKey rsaKey) { + /** + * Configure a custom JWK (RSA or EC) for DPoP (RFC 9449) proof generation, letting the SDK + * infer the JWS algorithm from the key (RS256 for RSA, the curve-appropriate ES* for EC). + * If not provided, the SDK will auto-generate an ephemeral RSA-2048 key. + * RSA keys also serve as the SRT signing key; EC keys use a separate auto-generated RSA key for SRT. + * + * @param dpopKey JWK (RSA or EC) to use for DPoP proofs + * @return this builder instance for method chaining + */ + public SDKBuilder dpopKey(JWK dpopKey) { + this.dpopKey = dpopKey; + this.dpopAlg = null; + return this; + } + + /** + * Configure a custom JWK (RSA or EC) for DPoP (RFC 9449) proof generation together with the + * JWS algorithm to sign the proofs with. The key and algorithm are set as a pair — the SDK + * needs both, so there is no separate algorithm setter. + * + * @param dpopKey JWK (RSA or EC) to use for DPoP proofs + * @param dpopAlg JWS algorithm matching the key type (e.g. RS256, ES256) + * @return this builder instance for method chaining + */ + public SDKBuilder dpopKey(JWK dpopKey, JWSAlgorithm dpopAlg) { + this.dpopKey = dpopKey; + this.dpopAlg = dpopAlg; + return this; + } + + private AuthInterceptor getAuthInterceptor(JWK dpopJwk, JWSAlgorithm dpopAlgorithm) { if (platformEndpoint == null) { throw new SDKException("cannot build an SDK without specifying the platform endpoint"); } @@ -243,6 +277,12 @@ private Interceptor getAuthInterceptor(RSAKey rsaKey) { .getFieldsOrThrow(PLATFORM_ISSUER) .getStringValue(); } catch (IllegalArgumentException e) { + if (this.dpopKey != null) { + throw new SDKException( + "DPoP was requested but the platform_issuer is missing from the well-known " + + "configuration at " + platformEndpoint + + "; the SDK cannot configure DPoP without a token endpoint", e); + } logger.warn( "no `platform_issuer` found in well known configuration. requests from the SDK will be unauthenticated", e); @@ -264,7 +304,7 @@ private Interceptor getAuthInterceptor(RSAKey rsaKey) { if (this.authzGrant == null) { this.authzGrant = new ClientCredentialsGrant(); } - var ts = new TokenSource(clientAuth, rsaKey, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory); + var ts = new TokenSource(clientAuth, dpopJwk, dpopAlgorithm, providerMetadata.getTokenEndpointURI(), this.authzGrant, sslSocketFactory); return new AuthInterceptor(ts); } @@ -282,14 +322,14 @@ public SDKBuilder insecureSslFactory() { } static class ServicesAndInternals { - final Interceptor interceptor; + final AuthInterceptor interceptor; final TrustManager trustManager; final ProtocolClient protocolClient; final SrtSigner srtSigner; final SDK.Services services; - ServicesAndInternals(Interceptor interceptor, TrustManager trustManager, SDK.Services services, ProtocolClient protocolClient, SrtSigner srtSigner) { + ServicesAndInternals(AuthInterceptor interceptor, TrustManager trustManager, SDK.Services services, ProtocolClient protocolClient, SrtSigner srtSigner) { this.interceptor = interceptor; this.trustManager = trustManager; this.services = services; @@ -298,29 +338,71 @@ static class ServicesAndInternals { } } - ServicesAndInternals buildServices() { - // Validate configuration compatibility - if (Boolean.TRUE.equals(usePlainText) && protocolType == ProtocolType.GRPC_WEB) { - throw new SDKException("gRPC-Web protocol is not compatible with useInsecurePlaintextConnection(true). " + - "gRPC-Web is designed for web browsers and typically operates over HTTP/1.1, " + - "while plaintext connections force HTTP/2 prior knowledge."); + // Bundles the resolved DPoP proof key/algorithm with the RSA key used for SRT + // signing. SRT signing always uses RSA, so an EC DPoP key requires a separate + // generated RSA key here. + private static final class DpopMaterial { + final JWK dpopJwk; + final JWSAlgorithm dpopAlg; + final RSAKey srtKey; + + DpopMaterial(JWK dpopJwk, JWSAlgorithm dpopAlg, RSAKey srtKey) { + this.dpopJwk = dpopJwk; + this.dpopAlg = dpopAlg; + this.srtKey = srtKey; + } + } + + private DpopMaterial resolveDpopMaterial() { + if (this.dpopKey == null) { + // Auto-generate a single RSA-2048 key used for both DPoP and SRT. + RSAKey generated = generateSrtRsaKey("Error generating DPoP key"); + return new DpopMaterial(generated, resolveDpopAlgorithm(generated), generated); } - - RSAKey dpopKey; + + RSAKey srtKey = (this.dpopKey instanceof RSAKey) + ? (RSAKey) this.dpopKey + // EC DPoP key: generate a separate RSA key for SRT signing. + : generateSrtRsaKey("Error generating SRT RSA key"); + return new DpopMaterial(this.dpopKey, resolveDpopAlgorithm(this.dpopKey), srtKey); + } + + private JWSAlgorithm resolveDpopAlgorithm(JWK key) { + if (this.dpopAlg != null) { + return this.dpopAlg; + } + return key instanceof ECKey ? inferEcAlgorithm((ECKey) key) : JWSAlgorithm.RS256; + } + + private static RSAKey generateSrtRsaKey(String errorMessage) { try { - dpopKey = new RSAKeyGenerator(2048) + return new RSAKeyGenerator(2048) .keyUse(KeyUse.SIGNATURE) .keyID(UUID.randomUUID().toString()) .generate(); } catch (JOSEException e) { - throw new SDKException("Error generating DPoP key", e); + throw new SDKException(errorMessage, e); } + } + + ServicesAndInternals buildServices() { + // Validate configuration compatibility + if (Boolean.TRUE.equals(usePlainText) && protocolType == ProtocolType.GRPC_WEB) { + throw new SDKException("gRPC-Web protocol is not compatible with useInsecurePlaintextConnection(true). " + + "gRPC-Web is designed for web browsers and typically operates over HTTP/1.1, " + + "while plaintext connections force HTTP/2 prior knowledge."); + } + + // Resolve the DPoP JWK/algorithm and the (always-RSA) SRT signing key. + DpopMaterial dpop = resolveDpopMaterial(); this.platformEndpoint = AddressNormalizer.normalizeAddress(this.platformEndpoint, this.usePlainText); - var authInterceptor = getAuthInterceptor(dpopKey); - var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(dpopKey) : this.srtSigner; - var kasClient = getKASClient(srtSignerToUse, authInterceptor); - var httpClient = getHttpClient(); + var authInterceptor = getAuthInterceptor(dpop.dpopJwk, dpop.dpopAlg); + var srtSignerToUse = this.srtSigner == null ? new DefaultSrtSigner(dpop.srtKey) : this.srtSigner; + + okhttp3.Interceptor dpopRetry = authInterceptor != null ? authInterceptor.dpopRetryInterceptor() : null; + var kasClient = getKASClient(srtSignerToUse, authInterceptor, dpopRetry); + var httpClient = getHttpClient(dpopRetry); var client = getProtocolClient(platformEndpoint, httpClient, authInterceptor); var attributeService = new AttributesServiceClient(client); var namespaceService = new NamespaceServiceClient(client); @@ -394,9 +476,9 @@ public SDK.KAS kas() { } @Nonnull - private KASClient getKASClient(SrtSigner srtSigner, Interceptor interceptor) { + private KASClient getKASClient(SrtSigner srtSigner, AuthInterceptor interceptor, okhttp3.Interceptor dpopRetry) { BiFunction protocolClientFactory = (OkHttpClient client, String address) -> getProtocolClient(address, client, interceptor); - return new KASClient(getHttpClient(), protocolClientFactory, srtSigner, usePlainText); + return new KASClient(getHttpClient(dpopRetry), protocolClientFactory, srtSigner, usePlainText); } public SDK build() { @@ -408,14 +490,24 @@ private ProtocolClient getUnauthenticatedProtocolClient(String endpoint, OkHttpC return getProtocolClient(endpoint, httpClient, null); } - private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClient, Interceptor authInterceptor) { + private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClient, AuthInterceptor authInterceptor) { + // Connect-GET would rewrite idempotent POST RPCs to GET on the wire, which invalidates + // the DPoP proof's htm claim (stamped before the rewrite). Keep it enabled only on the + // unauthenticated bootstrap path where no DPoP proof is attached. + GETConfiguration getConfig = authInterceptor != null + ? GETConfiguration.Disabled.INSTANCE + : GETConfiguration.Enabled.INSTANCE; var protocolClientConfig = new ProtocolClientConfig( endpoint, new GoogleJavaProtobufStrategy(), protocolType.getNetworkProtocol(), null, - GETConfiguration.Enabled.INSTANCE, - authInterceptor == null ? Collections.emptyList() : List.of(ignoredConfig -> authInterceptor) + getConfig, + // connect-kotlin builds the interceptor chain fresh per call, so hand out a new + // AuthInterceptor per invocation (it is documented as "instantiated once per + // request/stream"); the shared instance is retained only for GET gating above and + // the okhttp dpopRetryInterceptor(). + authInterceptor == null ? Collections.emptyList() : List.of(ignoredConfig -> authInterceptor.forNewCall()) ); return new ProtocolClient(new ConnectOkHttpClient(httpClient), protocolClientConfig); @@ -423,9 +515,14 @@ private ProtocolClient getProtocolClient(String endpoint, OkHttpClient httpClien @SuppressWarnings("deprecation") private OkHttpClient getHttpClient() { - // using a single http client is apparently the best practice, subject to everyone wanting to - // have the same protocols + return getHttpClient((okhttp3.Interceptor) null); + } + + private OkHttpClient getHttpClient(okhttp3.Interceptor additionalInterceptor) { var httpClient = new OkHttpClient.Builder(); + if (additionalInterceptor != null) { + httpClient.addInterceptor(additionalInterceptor); + } if (usePlainText) { // For plaintext connections, we need HTTP/2 prior knowledge because gRPC servers // expect HTTP/2, and Connect protocol can communicate with gRPC servers over HTTP/2 @@ -451,4 +548,12 @@ SSLSocketFactory getSslFactory() { X509TrustManager getTrustManager() { return this.trustManager; } + + private static JWSAlgorithm inferEcAlgorithm(ECKey ecKey) { + try { + return DpopKeyValidation.inferEcAlgorithm(ecKey.getCurve()); + } catch (IllegalArgumentException e) { + throw new SDKException(e.getMessage(), e); + } + } } 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 01089452..52496f06 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java @@ -2,7 +2,7 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.oauth2.sdk.AuthorizationGrant; import com.nimbusds.oauth2.sdk.ErrorObject; @@ -14,51 +14,77 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest; import com.nimbusds.oauth2.sdk.http.HTTPResponse; import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.openid.connect.sdk.Nonce; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * The TokenSource class is responsible for providing authorization tokens. It handles * timeouts and creating OIDC calls. It is thread-safe. */ class TokenSource { + /** The authorization scheme the AS bound to the access token, and its header prefix. */ + enum TokenScheme { + DPOP("DPoP"), + BEARER("Bearer"); + + final String headerPrefix; + + TokenScheme(String headerPrefix) { + this.headerPrefix = headerPrefix; + } + } + private Instant tokenExpiryTime; private AccessToken token; + private TokenScheme tokenScheme; private final ClientAuthentication clientAuth; - private final RSAKey rsaKey; + private final JWK dpopJwk; + private final JWSAlgorithm dpopAlg; private final URI tokenEndpointURI; private final AuthorizationGrant authzGrant; private final SSLSocketFactory sslSocketFactory; + // 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); /** - * Constructs a new TokenSource with the specified client authentication and RSA key. + * Constructs a new TokenSource with the specified client authentication and DPoP key. * * @param clientAuth the client authentication to be used by the interceptor - * @param rsaKey the RSA key to be used by the interceptor + * @param dpopJwk the JWK (RSA or EC) to use for DPoP proof generation + * @param dpopAlg the JWS algorithm matching the key type * @param sslSocketFactory Optional SSLSocketFactory for token endpoint requests */ - public TokenSource(ClientAuthentication clientAuth, RSAKey rsaKey, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { + public TokenSource(ClientAuthentication clientAuth, JWK dpopJwk, JWSAlgorithm dpopAlg, URI tokenEndpointURI, AuthorizationGrant authzGrant, SSLSocketFactory sslSocketFactory) { + DpopKeyValidation.validate(dpopJwk, dpopAlg); this.clientAuth = clientAuth; - this.rsaKey = rsaKey; + this.dpopJwk = dpopJwk; + this.dpopAlg = dpopAlg; this.tokenEndpointURI = tokenEndpointURI; this.sslSocketFactory = sslSocketFactory; this.authzGrant = authzGrant; this.tokenExpiryTime = null; } - class AuthHeaders { + static final class AuthHeaders { private final String authHeader; + @Nullable private final String dpopHeader; - public AuthHeaders(String authHeader, String dpopHeader) { + public AuthHeaders(String authHeader, @Nullable String dpopHeader) { this.authHeader = authHeader; this.dpopHeader = dpopHeader; } @@ -67,20 +93,75 @@ public String getAuthHeader() { return authHeader; } + @Nullable public String getDpopHeader() { return dpopHeader; } } + /** + * Immutable pairing of an access token with the auth scheme (DPoP or Bearer) the AS + * assigned it. Returned by {@link #getToken()} so callers read both values atomically: + * the mutable token/tokenScheme fields are only ever written and read under + * getToken()'s monitor, so a caller can never observe a token from one generation + * alongside a scheme from another. + */ + private static final class TokenSnapshot { + final AccessToken accessToken; + final TokenScheme scheme; + + TokenSnapshot(AccessToken accessToken, TokenScheme scheme) { + this.accessToken = accessToken; + this.scheme = scheme; + } + } + public AuthHeaders getAuthHeaders(URL url, String method) { - // Get the access token - AccessToken t = getToken(); + return getAuthHeaders(url, method, null); + } + + /** + * 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). + * + * @param url The URL being accessed + * @param method The HTTP method + * @param nonce Optional server-issued nonce to include in the proof + * @return AuthHeaders with the Authorization header, and a DPoP proof header when DPoP-bound + */ + public AuthHeaders getAuthHeaders(URL url, String method, String nonce) { + // 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. + TokenSnapshot snapshot = getToken(); + AccessToken t = snapshot.accessToken; + + // If the AS returned a plain bearer token, send it as a bearer credential + // without a DPoP proof. Sending "Authorization: DPoP " is a misuse + // of the scheme and resource servers that enforce DPoP will reject it. + if (snapshot.scheme == TokenScheme.BEARER) { + return new AuthHeaders(TokenScheme.BEARER.headerPrefix + " " + t.getValue(), null); + } // Build the DPoP proof for each request String dpopProof; try { - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256); - SignedJWT proof = dpopFactory.createDPoPJWT(method, url.toURI(), t); + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); + + // Get cached nonce if not explicitly provided + if (nonce == null) { + String origin = getOrigin(url); + nonce = nonceCache.get(origin); + } + + SignedJWT proof; + URI htu = htuOf(url.toURI()); + if (nonce != null) { + proof = dpopFactory.createDPoPJWT(method, htu, t, new Nonce(nonce)); + } else { + proof = dpopFactory.createDPoPJWT(method, htu, t); + } dpopProof = proof.serialize(); } catch (URISyntaxException e) { throw new SDKException("Invalid URI syntax for DPoP proof creation", e); @@ -89,70 +170,186 @@ public AuthHeaders getAuthHeaders(URL url, String method) { } return new AuthHeaders( - "DPoP " + t.getValue(), + TokenScheme.DPOP.headerPrefix + " " + t.getValue(), dpopProof); } + /** Cache a server-issued nonce for the given URL's origin. */ + public void cacheNonce(URL url, String nonce) { + if (nonce != null && !nonce.isEmpty()) { + String origin = getOrigin(url); + nonceCache.put(origin, nonce); + logger.trace("Cached DPoP nonce for origin: {}", origin); + } + } + + // RFC 9449 §4.2: the htu claim is the request URI with query and fragment removed + // (and Nimbus additionally rejects a URI that still carries a query). + private static URI htuOf(URI uri) { + if (uri.getRawQuery() == null && uri.getRawFragment() == null) { + return uri; + } + try { + return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), null, null); + } catch (URISyntaxException e) { + throw new SDKException("failed to normalize URI for DPoP htu claim: " + uri, e); + } + } + + /** Get the origin (scheme://host:port) from a URL, used as the nonce cache key. */ + private String getOrigin(URL url) { + int port = url.getPort(); + if (port == -1) { + port = url.getDefaultPort(); + } + return url.getProtocol() + "://" + url.getHost() + ":" + port; + } + /** * Either fetches a new access token or returns the cached access token if it is still valid. * - * @return The access token. + * @return A snapshot of the access token and its assigned auth scheme. */ - private synchronized AccessToken getToken() { + private synchronized TokenSnapshot getToken() { + // If the token is still valid, return the cached snapshot. + if (token != null && !isTokenExpired()) { + return new TokenSnapshot(this.token, this.tokenScheme); + } + + logger.trace("The current access token is expired or empty, getting a new one"); try { - // If the token is expired or initially null, get a new token - if (token == null || isTokenExpired()) { + DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg); + URL tokenEndpointUrl = tokenEndpointURI.toURL(); - logger.trace("The current access token is expired or empty, getting a new one"); + // Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8.2) + String cachedNonce = nonceCache.get(getOrigin(tokenEndpointUrl)); + HTTPResponse httpResponse = sendTokenRequest(dpopFactory, cachedNonce); + TokenResponse tokenResponse = TokenResponse.parse(httpResponse); - // Make the token request - TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, - clientAuth, authzGrant, null); - HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); - if (sslSocketFactory != null) { - httpRequest.setSSLSocketFactory(sslSocketFactory); + // RFC 9449 §8.2: if the AS requires a nonce, cache it and retry once. + if (!tokenResponse.indicatesSuccess()) { + String dpopNonce = nonceForRetry(tokenResponse, httpResponse); + if (dpopNonce != null) { + cacheNonce(tokenEndpointUrl, dpopNonce); + httpResponse = retryWithNonce(dpopFactory, dpopNonce, tokenEndpointUrl); + tokenResponse = TokenResponse.parse(httpResponse); } + if (!tokenResponse.indicatesSuccess()) { + ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject(); + throw new SDKException("failure to get token. description = [" + finalError.getDescription() + + "] error code = [" + finalError.getCode() + + "] error uri = [" + finalError.getURI() + "]"); + } + } - DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256); - - SignedJWT proof = dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI()); + // RFC 9449 §8.2: the AS may supply/rotate a DPoP-Nonce on any response, including a + // successful one. Cache the nonce from the final settled response so the next request + // uses the freshest value (cacheNonce ignores null/empty). + cacheNonce(tokenEndpointUrl, httpResponse.getHeaderValue("DPoP-Nonce")); - httpRequest.setDPoP(proof); - TokenResponse tokenResponse; + applyTokenResponse(tokenResponse); + return new TokenSnapshot(this.token, this.tokenScheme); - HTTPResponse httpResponse = httpRequest.send(); + } catch (SDKException e) { + // Already shaped for the caller — don't double-wrap. + throw e; + } catch (MalformedURLException e) { + throw new SDKException("invalid token endpoint URL: " + tokenEndpointURI, e); + } catch (IOException e) { + throw new SDKException("network error contacting token endpoint " + tokenEndpointURI, e); + } catch (JOSEException e) { + throw new SDKException("DPoP proof generation failed for token endpoint " + tokenEndpointURI, e); + } catch (com.nimbusds.oauth2.sdk.ParseException e) { + throw new SDKException("malformed token response from " + tokenEndpointURI, e); + } catch (RuntimeException e) { + throw new SDKException("unexpected error fetching token from " + tokenEndpointURI, e); + } + } - tokenResponse = TokenResponse.parse(httpResponse); - if (!tokenResponse.indicatesSuccess()) { - ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); - throw new SDKException("failure to get token. description = [" + error.getDescription() + "] error code = [" + error.getCode() + "] error uri = [" + error.getURI() + "]"); - } + /** + * If the error response is an RFC 9449 §8.2 {@code use_dpop_nonce} challenge, return the + * server-supplied nonce to retry with. Returns null when it is a different error, or when the + * challenge is missing its {@code DPoP-Nonce} header (logged as a server protocol violation). + */ + @Nullable + private String nonceForRetry(TokenResponse tokenResponse, HTTPResponse httpResponse) { + ErrorObject error = tokenResponse.toErrorResponse().getErrorObject(); + if (!"use_dpop_nonce".equals(error.getCode())) { + return null; + } + String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce"); + if (dpopNonce == null) { + logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header", + tokenEndpointURI); + } + return dpopNonce; + } - var tokens = tokenResponse.toSuccessResponse().getTokens(); - if (tokens.getDPoPAccessToken() != null) { - logger.trace("retrieved a new DPoP access token"); - } else if (tokens.getAccessToken() != null) { - logger.trace("retrieved a new access token"); - } else { - logger.trace("got an access token of unknown type"); - } + /** Build and send the initial token request, stamping a DPoP proof (with the cached nonce if any). */ + private HTTPResponse sendTokenRequest(DPoPProofFactory dpopFactory, @Nullable String cachedNonce) + throws JOSEException, IOException { + TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null); + HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); + if (sslSocketFactory != null) { + httpRequest.setSSLSocketFactory(sslSocketFactory); + } + URI tokenHtu = htuOf(httpRequest.getURI()); + SignedJWT proof = (cachedNonce != null) + ? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu, new Nonce(cachedNonce)) + : dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu); + httpRequest.setDPoP(proof); + return httpRequest.send(); + } - this.token = tokens.getAccessToken(); + /** Retry the token request once with the AS-provided nonce (RFC 9449 §8.2). */ + private HTTPResponse retryWithNonce(DPoPProofFactory dpopFactory, String dpopNonce, URL tokenEndpointUrl) + throws JOSEException, IOException { + TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null); + HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest(); + if (sslSocketFactory != null) { + retryHttpRequest.setSSLSocketFactory(sslSocketFactory); + } + SignedJWT retryProof = dpopFactory.createDPoPJWT( + retryHttpRequest.getMethod().name(), + htuOf(retryHttpRequest.getURI()), + new Nonce(dpopNonce)); + retryHttpRequest.setDPoP(retryProof); + HTTPResponse retryResponse = retryHttpRequest.send(); + // Cache a nonce the AS rotates on the retry response too, even if the retry also failed, + // so a double-rotation self-heals on the next getToken() call rather than reusing the + // stale, already-rejected nonce (RFC 9449 §8.2). + cacheNonce(tokenEndpointUrl, retryResponse.getHeaderValue("DPoP-Nonce")); + return retryResponse; + } - if (token.getLifetime() != 0) { - // Need some type of leeway but not sure whats best - this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); - } + /** Assign token, scheme, and expiry from a settled successful token response. */ + private void applyTokenResponse(TokenResponse tokenResponse) { + var tokens = tokenResponse.toSuccessResponse().getTokens(); + boolean asAssertsDpop = tokens.getDPoPAccessToken() != null; + if (asAssertsDpop) { + logger.trace("retrieved a new DPoP access token"); + } else if (tokens.getAccessToken() != null) { + logger.trace("retrieved a new access token"); + } else { + logger.warn("token endpoint {} returned a success response with an unknown access token type", + tokenEndpointURI); + } - } else { - // If the token is still valid or not initially null, return the cached token - return this.token; - } + this.token = tokens.getAccessToken(); + if (this.token == null) { + throw new SDKException("token endpoint " + tokenEndpointURI + + " returned a success response with no access token"); + } + this.tokenScheme = asAssertsDpop ? TokenScheme.DPOP : TokenScheme.BEARER; + if (!asAssertsDpop) { + logger.warn("token endpoint {} returned a non-DPoP-bound access token (token_type=Bearer) despite" + + " DPoP proof — falling back to Bearer scheme. Check the IdP DPoP configuration.", + tokenEndpointURI); + } - } catch (Exception e) { - throw new SDKException("failed to get token", e); + if (token.getLifetime() != 0) { + this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3); } - return this.token; } /** 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 a3babe2b..bcaac44b 100644 --- a/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt +++ b/sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt @@ -5,15 +5,34 @@ import com.connectrpc.StreamFunction import com.connectrpc.UnaryFunction import com.connectrpc.http.UnaryHTTPRequest import com.connectrpc.http.clone +import com.nimbusds.jwt.SignedJWT +import org.slf4j.LoggerFactory +import java.net.URL + +internal class AuthInterceptor(private val ts: TokenSource) : Interceptor { + private val logger = LoggerFactory.getLogger(AuthInterceptor::class.java) + + /** + * connect-kotlin builds a fresh interceptor chain per call (ProtocolClient.unary/stream call + * config.createInterceptorChain()), and its Interceptor is documented as "instantiated once + * per request/stream." The SDK honors that by handing out a new AuthInterceptor per call via + * this factory, and unaryFunction() threads the request URL into responseFunction through a + * closure-local variable — no ThreadLocal or cross-call shared mutable state. + */ + fun forNewCall(): AuthInterceptor = AuthInterceptor(ts) -private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ override fun streamFunction(): StreamFunction { return StreamFunction( requestFunction = { request -> val requestHeaders = mutableMapOf>() val authHeaders = ts.getAuthHeaders(request.url, "POST") requestHeaders["Authorization"] = listOf(authHeaders.authHeader) - requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) + authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } + + if (logger.isDebugEnabled) { + logger.debug("DPoP path=stream url={} method=POST authScheme={} {}", + request.url, authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) + } return@StreamFunction request.clone( url = request.url, @@ -29,14 +48,25 @@ private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ } override fun unaryFunction(): UnaryFunction { + // connect-kotlin invokes unaryFunction() once per call, so this local is scoped to a single + // request/response pair: requestFunction stashes the URL here for responseFunction to cache + // any server-issued nonce against, with no shared mutable state across calls. + var requestUrl: URL? = null return UnaryFunction( requestFunction = { request -> + requestUrl = request.url val requestHeaders = mutableMapOf>() val authHeaders = ts.getAuthHeaders(request.url, request.httpMethod.name) requestHeaders["Authorization"] = listOf(authHeaders.authHeader) - requestHeaders["DPoP"] = listOf(authHeaders.dpopHeader) + authHeaders.dpopHeader?.let { requestHeaders["DPoP"] = listOf(it) } - return@UnaryFunction UnaryHTTPRequest( + if (logger.isDebugEnabled) { + logger.debug("DPoP path=unary url={} method={} authScheme={} {}", + request.url, request.httpMethod.name, + authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) + } + + UnaryHTTPRequest( url = request.url, contentType = request.contentType, headers = requestHeaders, @@ -47,8 +77,119 @@ private class AuthInterceptor(private val ts: TokenSource) : Interceptor{ ) }, responseFunction = { resp -> + val url = requestUrl + + // Cache any server-issued DPoP nonce for future requests to the same origin + val dpopNonce = resp.headers["dpop-nonce"]?.firstOrNull() + ?: resp.headers["DPoP-Nonce"]?.firstOrNull() + if (dpopNonce != null && url != null) { + ts.cacheNonce(url, dpopNonce) + } + if (logger.isDebugEnabled) { + logger.debug("DPoP path=unary-response url={} nonceCached={} status={}", + url, dpopNonce != null && url != null, resp.status) + } resp }, ) } -} \ No newline at end of file + + /** + * Returns an OkHttp interceptor that retries on RFC 9449 §9 DPoP nonce challenges + * from resource servers (KAS and the platform-services Connect client). + * A 401 is retried only when WWW-Authenticate carries scheme=DPoP and error=use_dpop_nonce; + * any other 401 (or any 401 with only a stray DPoP-Nonce header) is passed through unchanged. + * The challenge is retried at most once: if the retried request is itself challenged, that + * response is returned as-is (logged at WARN) rather than looping. + * Rotated nonces are cached after every successful proceed so the next request picks them up. + */ + fun dpopRetryInterceptor(): okhttp3.Interceptor = okhttp3.Interceptor { chain -> + val url = chain.request().url.toUrl() + val outgoingMethod = chain.request().method + var response = chain.proceed(chain.request()) + + // RFC 9449 §9: cache any rotated nonce from the response, regardless of status. + cacheNonceIfPresent(url, response) + + if (logger.isDebugEnabled) { + logger.debug("DPoP path=okhttp url={} method={} status={} authScheme={} {}", + url, outgoingMethod, response.code, + authScheme(chain.request().header("Authorization")), + dpopSummary(chain.request().header("DPoP"))) + } + + if (response.code == 401 && isDpopNonceChallenge(response)) { + val dpopNonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") + if (dpopNonce != null) { + response.close() + ts.cacheNonce(url, dpopNonce) + val authHeaders = ts.getAuthHeaders(url, chain.request().method) + val newRequestBuilder = chain.request().newBuilder() + .header("Authorization", authHeaders.authHeader) + .removeHeader("DPoP") + // Re-add only when the refreshed token is still DPoP-bound. Without the + // remove above, a Bearer downgrade would leave the original request's + // stale DPoP proof paired with a Bearer Authorization header. + authHeaders.dpopHeader?.let { newRequestBuilder.header("DPoP", it) } + val newRequest = newRequestBuilder.build() + if (logger.isDebugEnabled) { + logger.debug("DPoP path=okhttp-retry url={} method={} nonce={} authScheme={} {}", + url, chain.request().method, dpopNonce, + authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader)) + } + response = try { + chain.proceed(newRequest) + } catch (e: Exception) { + logger.debug("DPoP retry request to {} failed", url, e) + throw e + } + cacheNonceIfPresent(url, response) + logger.debug("DPoP path=okhttp-retry-response url={} status={}", url, response.code) + // A second nonce challenge after the retry means the handshake failed twice + // (e.g. aggressive nonce rotation or clock skew). We do not loop; surface it + // at WARN so the double failure is visible rather than returning a bare 401. + if (response.code == 401 && isDpopNonceChallenge(response)) { + logger.warn("DPoP nonce challenge from {} persisted after retry; returning 401", url) + } + } else { + // RFC 9449 §9 requires the nonce alongside use_dpop_nonce. A challenge + // without it is a server protocol violation; surface it rather than + // passing a bare, unexplained 401 back to the caller (mirrors the + // token-endpoint handling in TokenSource.getToken). + logger.warn("DPoP nonce challenge from {} lacked a DPoP-Nonce header; passing 401 through", url) + } + } + response + } + + private fun cacheNonceIfPresent(url: URL, response: okhttp3.Response) { + val nonce = response.header("dpop-nonce") ?: response.header("DPoP-Nonce") + if (nonce != null) { + ts.cacheNonce(url, nonce) + } + } + + private fun isDpopNonceChallenge(response: okhttp3.Response): Boolean { + return response.challenges().any { challenge -> + challenge.scheme.equals("DPoP", ignoreCase = true) && + challenge.authParams["error"].equals("use_dpop_nonce", ignoreCase = true) + } + } + + private fun authScheme(authHeader: String?): String { + if (authHeader == null) return "" + val idx = authHeader.indexOf(' ') + return if (idx > 0) authHeader.substring(0, idx) else "?" + } + + private fun dpopSummary(dpopProof: String?): String { + if (dpopProof == null) return "dpop=" + return try { + val claims = SignedJWT.parse(dpopProof).jwtClaimsSet + "dpop[htm=${claims.getStringClaim("htm")} htu=${claims.getStringClaim("htu")}" + + " jti=${claims.getStringClaim("jti")} nonce=${claims.getStringClaim("nonce")}]" + } catch (e: Exception) { + "dpop=" + } + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java new file mode 100644 index 00000000..e4ab2f4f --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java @@ -0,0 +1,575 @@ +package io.opentdf.platform.sdk; + +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.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.Response; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.apache.logging.log4j.core.config.Property; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class DPoPRetryInterceptorTest { + + private static final String FAKE_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 AuthInterceptor buildAuthInterceptor(MockWebServer tokenServer, RSAKey rsaKey) { + return new AuthInterceptor(buildTokenSource(tokenServer, rsaKey)); + } + + private TokenSource buildTokenSource(MockWebServer tokenServer, RSAKey rsaKey) { + return new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + } + + @Test + void retryOn401WithDPoPNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + // Queue multiple token responses (one for each getAuthHeaders call during retry) + for (int i = 0; i < 5; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + // First request returns 401 + DPoP-Nonce + DPoP nonce challenge; second returns 200 + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "server-issued-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.enqueue(new MockResponse().setResponseCode(200)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(2); + assertThat(response.code()).isEqualTo(200); + + // Verify second request carries a DPoP proof with the nonce + kasServer.takeRequest(); // consume first request + RecordedRequest retryRequest = kasServer.takeRequest(); + String dpopHeader = retryRequest.getHeader("DPoP"); + assertThat(dpopHeader).isNotNull(); + + SignedJWT dpopJwt = SignedJWT.parse(dpopHeader); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("server-issued-nonce"); + } + } + + @Test + void retryDowngradesToBearerAndDropsStaleDpopProof() throws Exception { + // Regression guard for AuthInterceptor.dpopRetryInterceptor's removeHeader("DPoP"). + // When the token refreshed on retry comes back as a plain Bearer token (the AS did + // not bind it to the DPoP key), the retried request must send Authorization: Bearer + // with NO DPoP header. Without the removeHeader, the original request's stale DPoP + // proof would ride along next to a Bearer credential -- an RFC 9449 scheme misuse + // that DPoP-enforcing resource servers reject. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + // The single token fetched during the retry is a plain Bearer token. + tokenServer.enqueue(new MockResponse() + .setBody(BEARER_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + // First request returns 401 + DPoP-Nonce + DPoP nonce challenge; second returns 200 + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "server-issued-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.enqueue(new MockResponse().setResponseCode(200)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + // Seed the initial request with a stale DPoP proof + DPoP Authorization so the + // downgrade genuinely has a proof to strip (guards against a vacuously-null check). + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .header("Authorization", "DPoP stale-token") + .header("DPoP", "stale-proof") + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(response.code()).isEqualTo(200); + assertThat(kasServer.getRequestCount()).isEqualTo(2); + // Only one credential refresh happens on retry. + assertThat(tokenServer.getRequestCount()).isEqualTo(1); + + kasServer.takeRequest(); // consume first request + RecordedRequest retryRequest = kasServer.takeRequest(); + assertThat(retryRequest.getHeader("Authorization")).startsWith("Bearer "); + // The stale proof must be gone: no DPoP header paired with a Bearer credential. + assertThat(retryRequest.getHeader("DPoP")).isNull(); + } + } + + @Test + void noRetryOn401WithoutDPoPNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse().setResponseCode(401)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void onlyRetriesOnceWhenSecondResponseAlsoChallengesWithNonce() throws Exception { + // Pins the single-retry guarantee: even if the retry response is also a + // 401 + DPoP-Nonce + use_dpop_nonce challenge, no further retry is attempted. + // This protects against an infinite-retry loop if an AS misbehaves or rotates + // nonces faster than the client can spend them. The persistent challenge must + // also be logged at WARN so the double failure is visible rather than a silent 401. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer(); + LogCapture logs = LogCapture.attach(AuthInterceptor.class)) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "first-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "second-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(2); + assertThat(response.code()).isEqualTo(401); + assertThat(logs.warnings()).anyMatch(m -> m.contains("persisted after retry")); + } + } + + @Test + void concurrentRequestsAllRetrySuccessfully() throws Exception { + // Smoke test: drive 10 parallel requests through the retry interceptor, each of + // which sees a 401+nonce followed by a 200. All 10 must eventually return 200 + // and each retry must carry a DPoP-Nonce claim. Regressions in the cross-thread + // safety of the nonce cache or interceptor state should surface here. + final int parallelism = 10; + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + // One token response per request — the cache keeps us to one in practice, + // but enqueue enough that any per-thread re-fetch doesn't deadlock the test. + for (int i = 0; i < parallelism * 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + // A FIFO queue can't deliver alternating 401/200 reliably under concurrent + // load — by the time request N's retry arrives, request N+1's first attempt + // may have already consumed N's 200. Use a stateful dispatcher that decides + // based on whether the request already carries a nonce. + kasServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String dpop = request.getHeader("DPoP"); + boolean hasNonce = false; + if (dpop != null) { + try { + hasNonce = SignedJWT.parse(dpop) + .getJWTClaimsSet().getStringClaim("nonce") != null; + } catch (Exception e) { + // Malformed/unparseable proof simply counts as "no nonce present". + } + } + if (hasNonce) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "concurrent-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""); + } + }); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + ExecutorService pool = Executors.newFixedThreadPool(parallelism); + try { + List> tasks = new ArrayList<>(); + for (int i = 0; i < parallelism; i++) { + tasks.add(() -> { + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + try (Response response = client.newCall(request).execute()) { + return response.code(); + } + }); + } + List> results = pool.invokeAll(tasks, 30, TimeUnit.SECONDS); + for (Future f : results) { + assertThat(f.get()).isEqualTo(200); + } + } finally { + pool.shutdownNow(); + } + + // Each request produced a 401 + a retry: 2 * parallelism total. + assertThat(kasServer.getRequestCount()).isEqualTo(parallelism * 2); + + // Every retry must carry a nonce — pin that the cross-thread URL/nonce + // bookkeeping never produced a retry without one. + int retriesWithNonce = 0; + int totalRetries = 0; + for (int i = 0; i < parallelism * 2; i++) { + RecordedRequest recorded = kasServer.takeRequest(); + String dpop = recorded.getHeader("DPoP"); + if (dpop == null) { + continue; + } + String nonce = SignedJWT.parse(dpop).getJWTClaimsSet().getStringClaim("nonce"); + if (nonce != null) { + retriesWithNonce++; + } + if (nonce != null) { + totalRetries++; + } + } + assertThat(totalRetries).isEqualTo(parallelism); + assertThat(retriesWithNonce).isEqualTo(parallelism); + } + } + + @Test + void noRetryOn401WithDPoPNonceButNoChallenge() throws Exception { + // A bare DPoP-Nonce header on a 401 (no WWW-Authenticate) must not trigger a retry — + // otherwise any rogue origin can poison the nonce cache and burn a token round-trip. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "spurious-nonce")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void noRetryOn401WithNonDpopChallenge() throws Exception { + // WWW-Authenticate: Basic must not trigger a DPoP retry even if DPoP-Nonce is present. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "spurious-nonce") + .addHeader("WWW-Authenticate", "Basic realm=\"x\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void noRetryOn401WithDpopErrorOtherThanUseDpopNonce() throws Exception { + // RFC 9449 §9 only signals retry on error=use_dpop_nonce. Other DPoP errors + // (invalid_token, insufficient_scope, etc.) must surface to the caller. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(401) + .addHeader("DPoP-Nonce", "fresh-nonce") + .addHeader("WWW-Authenticate", "DPoP error=\"invalid_token\"")); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Response response = client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(401); + } + } + + @Test + void rotatedNonceFromSuccessfulResponseIsCachedForNextRequest() throws Exception { + // RFC 9449 §9: any response (including 200) may rotate the nonce. The retry + // interceptor must pick that up so the *next* request picks it from the cache. + // Note: the retry interceptor itself does not stamp DPoP headers on the initial + // request — those come from the auth path that builds the request — so we + // verify cache population by querying the TokenSource directly afterward. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse() + .setResponseCode(200) + .addHeader("DPoP-Nonce", "rotated-nonce")); + kasServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new AuthInterceptor(ts).dpopRetryInterceptor()) + .build(); + + client.newCall(new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build()).execute().close(); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders( + kasServer.url("/kas/rewrap").url(), "POST"); + String nonceClaim = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("rotated-nonce"); + } + } + + @Test + void noRetryOnSuccessResponse() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer(); + MockWebServer kasServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + kasServer.enqueue(new MockResponse().setResponseCode(200)); + kasServer.start(); + + AuthInterceptor authInterceptor = buildAuthInterceptor(tokenServer, rsaKey); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(authInterceptor.dpopRetryInterceptor()) + .build(); + + Request request = new Request.Builder() + .url(kasServer.url("/kas/rewrap")) + .post(okhttp3.RequestBody.create(new byte[0])) + .build(); + Response response = client.newCall(request).execute(); + response.close(); + + assertThat(kasServer.getRequestCount()).isEqualTo(1); + assertThat(response.code()).isEqualTo(200); + } + } + + /** + * Captures WARN-and-above log messages from a target logger for the duration of a + * test, then detaches on close. Used to assert that a failure is surfaced in the + * logs rather than swallowed silently. + */ + private static final class LogCapture extends AbstractAppender implements AutoCloseable { + private final List messages = new CopyOnWriteArrayList<>(); + private final LoggerConfig loggerConfig; + + private LogCapture(LoggerConfig loggerConfig) { + super("test-capture", null, null, false, Property.EMPTY_ARRAY); + this.loggerConfig = loggerConfig; + } + + static LogCapture attach(Class target) { + LoggerContext ctx = (LoggerContext) LogManager.getContext(false); + LoggerConfig config = ctx.getConfiguration().getLoggerConfig(target.getName()); + LogCapture appender = new LogCapture(config); + appender.start(); + config.addAppender(appender, Level.WARN, null); + return appender; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + List warnings() { + return messages; + } + + @Override + public void close() { + loggerConfig.removeAppender(getName()); + stop(); + } + } +} 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 b24dcbd8..3437cecd 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java @@ -198,6 +198,108 @@ public String alg() { } } + @Test + void ecDpopKeyAutoGeneratesRsaSrtSigner() throws Exception { + // When the caller supplies an EC DPoP key, the SDK must auto-generate a separate + // RSA-2048 key for SRT signing because DefaultSrtSigner uses RSASSASigner which + // rejects non-RSA keys. Without this separation, build() would throw inside + // DefaultSrtSigner's constructor. + try (MockWebServer oidcServer = startMockOidcServer()) { + String issuer = oidcServer.url("my_realm").toString(); + Server platformServices = startWellKnownGrpcServer(issuer); + try { + com.nimbusds.jose.jwk.ECKey ecDpopKey = new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(ecDpopKey) + .build(); + + assertThat(sdk.getSrtSigner()).isPresent(); + // This test only covers the SRT-signer split (SRT always signs with RSA). + // That an EC DPoP key produces ES256 DPoP *proofs* is covered at the minting + // point in TokenSourceTest.ecKeyGeneratesDPoPProof. + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + // Sanity-check: the SRT signer can actually sign, which would fail if it was + // mistakenly handed the EC key (RSASSASigner constructor would have thrown). + byte[] signed = sdk.getSrtSigner().get().sign(new byte[]{1, 2, 3}); + assertThat(signed).isNotEmpty(); + } finally { + platformServices.shutdownNow(); + } + } + } + + @Test + void rsaDpopKeyReusesSameKeyForSrt() throws Exception { + // When the caller supplies an RSA DPoP key, SDKBuilder reuses it for SRT signing + // (no second RSA key is generated). This test pins that behavior so a regression + // that splits the keys (and burns a key-generation per build) is caught. + try (MockWebServer oidcServer = startMockOidcServer()) { + String issuer = oidcServer.url("my_realm").toString(); + Server platformServices = startWellKnownGrpcServer(issuer); + try { + com.nimbusds.jose.jwk.RSAKey rsaDpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + // Exercise the two-arg dpopKey(key, alg) overload (explicit algorithm). + var sdk = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(rsaDpopKey, com.nimbusds.jose.JWSAlgorithm.RS256) + .build(); + + assertThat(sdk.getSrtSigner()).isPresent(); + assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256"); + } finally { + platformServices.shutdownNow(); + } + } + } + + private MockWebServer startMockOidcServer() throws IOException { + MockWebServer httpServer = new MockWebServer(); + httpServer.start(); + String issuer = httpServer.url("my_realm").toString(); + String tokenEndpoint = httpServer.url("tokens").toString(); + String oidcConfig; + try (var in = SDKBuilderTest.class.getResourceAsStream("/oidc-config.json")) { + oidcConfig = new String(in.readAllBytes(), StandardCharsets.UTF_8) + .replace("", issuer) + .replace("", tokenEndpoint); + } + httpServer.enqueue(new MockResponse().setBody(oidcConfig).setHeader("Content-type", "application/json")); + return httpServer; + } + + private Server startWellKnownGrpcServer(String issuer) throws IOException { + WellKnownServiceGrpc.WellKnownServiceImplBase wellKnownService = new WellKnownServiceGrpc.WellKnownServiceImplBase() { + @Override + public void getWellKnownConfiguration(GetWellKnownConfigurationRequest request, + StreamObserver responseObserver) { + var val = Value.newBuilder().setStringValue(issuer).build(); + var config = Struct.newBuilder().putFields("platform_issuer", val).build(); + responseObserver.onNext(GetWellKnownConfigurationResponse.newBuilder().setConfiguration(config).build()); + responseObserver.onCompleted(); + } + }; + return ServerBuilder.forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build() + .start(); + } + void sdkServicesSetup(boolean useSSLPlatform, boolean useSSLIDP) throws Exception { HeldCertificate rootCertificate = new HeldCertificate.Builder() @@ -367,7 +469,7 @@ public ServerCall.Listener interceptCall(ServerCall responseObserver) { + responseObserver.onNext(GetWellKnownConfigurationResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + }; + + Server platformServices = ServerBuilder + .forPort(getRandomPort()) + .directExecutor() + .addService(wellKnownService) + .build(); + try { + platformServices.start(); + + com.nimbusds.jose.jwk.RSAKey dpopKey = new com.nimbusds.jose.jwk.gen.RSAKeyGenerator(2048) + .keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE) + .keyID(java.util.UUID.randomUUID().toString()) + .generate(); + + SDKBuilder builder = SDKBuilder.newBuilder() + .clientSecret("user", "password") + .platformEndpoint("http://localhost:" + platformServices.getPort()) + .useInsecurePlaintextConnection(true) + .protocol(ProtocolType.GRPC) + .dpopKey(dpopKey); + + SDKException ex = assertThrows(SDKException.class, builder::build); + assertThat(ex.getMessage()).contains("DPoP").contains("platform_issuer"); + } finally { + platformServices.shutdownNow(); + } + } + @Test void testProtocolConfiguration() { // Test protocol setter and getter functionality diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java new file mode 100644 index 00000000..e22d20ae --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java @@ -0,0 +1,661 @@ +package io.opentdf.platform.sdk; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +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.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.URL; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TokenSourceTest { + + private static final String FAKE_TOKEN_RESPONSE = + "{\"access_token\":\"test-access-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}"; + + private static final String BEARER_TOKEN_RESPONSE = + "{\"access_token\":\"plain-bearer-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + + private TokenSource buildTokenSource(MockWebServer tokenServer, RSAKey rsaKey) { + return new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + } + + @Test + void cachedNonceIsIncludedInNextDPoPProof() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // Token endpoint queues two responses: one for initial fetch, one in case of re-fetch + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, "server-nonce-abc"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isEqualTo("server-nonce-abc"); + } + } + + @Test + void explicitNonceOverridesCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, "cached-nonce"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST", "explicit-nonce"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isEqualTo("explicit-nonce"); + } + } + + @Test + void noNonceClaimWhenNoCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String nonceClaim = dpopJwt.getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isNull(); + } + } + + @Test + void htuClaimStripsQueryAndFragment() throws Exception { + // RFC 9449 §4.2: htu must omit query and fragment, and Nimbus rejects + // anything else with IllegalArgumentException. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL urlWithQuery = new URL("https://kas.example.com/kas?foo=bar&baz=qux#frag"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(urlWithQuery, "POST"); + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + String htu = dpopJwt.getJWTClaimsSet().getStringClaim("htu"); + + assertThat(htu).isEqualTo("https://kas.example.com/kas"); + } + } + + @Test + void ecKeyGeneratesDPoPProof() throws Exception { + ECKey ecKey = new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + ecKey, + JWSAlgorithm.ES256, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + URL testUrl = new URL("https://kas.example.com/kas"); + ts.cacheNonce(testUrl, "ec-nonce"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + assertThat(headers.getAuthHeader()).startsWith("DPoP "); + + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + assertThat(dpopJwt.getHeader().getAlgorithm()).isEqualTo(JWSAlgorithm.ES256); + assertThat(dpopJwt.getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("ec-nonce"); + } + } + + @Test + void explicitDpopAlgorithmIsUsedForProof() throws Exception { + // A caller may override the default RS256 with an explicit algorithm (via + // SDKBuilder.dpopAlgorithm). Verify the override actually reaches the proof + // factory rather than being silently replaced by the RS256 default. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = new TokenSource( + new ClientSecretBasic(new ClientID("test-client"), new Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS512, + tokenServer.url("/token").uri(), + new ClientCredentialsGrant(), + null + ); + URL testUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + + SignedJWT dpopJwt = SignedJWT.parse(headers.getDpopHeader()); + assertThat(dpopJwt.getHeader().getAlgorithm()).isEqualTo(JWSAlgorithm.RS512); + } + } + + @Test + void noncesAreIsolatedByOrigin() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 4; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL kasUrl = new URL("https://kas.example.com/kas"); + URL otherUrl = new URL("https://other.example.com/kas"); + + ts.cacheNonce(kasUrl, "kas-nonce"); + + TokenSource.AuthHeaders headersForKas = ts.getAuthHeaders(kasUrl, "POST"); + TokenSource.AuthHeaders headersForOther = ts.getAuthHeaders(otherUrl, "POST"); + + String kasNonce = SignedJWT.parse(headersForKas.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + String otherNonce = SignedJWT.parse(headersForOther.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(kasNonce).isEqualTo("kas-nonce"); + assertThat(otherNonce).isNull(); + } + } + + @Test + void noncesAreIsolatedByPort() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL port8080 = new URL("https://kas.example.com:8080/kas"); + URL port9090 = new URL("https://kas.example.com:9090/kas"); + + ts.cacheNonce(port8080, "nonce-8080"); + TokenSource.AuthHeaders headers8080 = ts.getAuthHeaders(port8080, "POST"); + TokenSource.AuthHeaders headers9090 = ts.getAuthHeaders(port9090, "POST"); + + assertThat(SignedJWT.parse(headers8080.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("nonce-8080"); + assertThat(SignedJWT.parse(headers9090.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isNull(); + } + } + + @Test + void noncesAreIsolatedByScheme() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + for (int i = 0; i < 2; i++) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + } + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL httpsUrl = new URL("https://kas.example.com/kas"); + URL httpUrl = new URL("http://kas.example.com/kas"); + + ts.cacheNonce(httpsUrl, "https-nonce"); + TokenSource.AuthHeaders headersHttps = ts.getAuthHeaders(httpsUrl, "POST"); + TokenSource.AuthHeaders headersHttp = ts.getAuthHeaders(httpUrl, "POST"); + + assertThat(SignedJWT.parse(headersHttps.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("https-nonce"); + assertThat(SignedJWT.parse(headersHttp.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isNull(); + } + } + + @Test + void noncesShareCacheForImplicitAndExplicitDefaultPort() throws Exception { + // Pins the getDefaultPort() normalization in TokenSource.getOrigin — + // https://host and https://host:443 must share a cache entry. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL implicitPort = new URL("https://kas.example.com/kas"); + URL explicitPort = new URL("https://kas.example.com:443/kas"); + + ts.cacheNonce(explicitPort, "shared-nonce"); + TokenSource.AuthHeaders headers = ts.getAuthHeaders(implicitPort, "POST"); + + assertThat(SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce")).isEqualTo("shared-nonce"); + } + } + + @Test + void emptyNonceIsNotCached() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + ts.cacheNonce(testUrl, ""); + ts.cacheNonce(testUrl, null); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + String nonceClaim = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + + assertThat(nonceClaim).isNull(); + } + } + + @Test + void getToken_retriesWithNonceOnUseDpopNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // First: 401 use_dpop_nonce + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "retry-nonce-abc") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + // Second: success + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"real-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL resourceUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceUrl, "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("DPoP real-token"); + assertThat(tokenServer.getRequestCount()).isEqualTo(2); + + RecordedRequest first = tokenServer.takeRequest(); + String firstNonce = SignedJWT.parse(first.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(firstNonce).isNull(); + + RecordedRequest second = tokenServer.takeRequest(); + String secondNonce = SignedJWT.parse(second.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(secondNonce).isEqualTo("retry-nonce-abc"); + } + } + + @Test + void getToken_cachesNonceFromSuccessfulResponse() throws Exception { + // RFC 9449 §8.2: the AS may supply/rotate a DPoP-Nonce on any response, including a + // successful 200 with no prior use_dpop_nonce challenge. That nonce must be cached so the + // next proof for the same origin carries it. The resource URL shares the token endpoint's + // origin, so the cached nonce surfaces in the very next proof built for it. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "rotated-nonce-xyz") + .setBody(FAKE_TOKEN_RESPONSE)); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + // Same origin (scheme://host:port) as the token endpoint, so it reads the cached nonce. + URL sameOriginResource = tokenServer.url("/kas").url(); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(sameOriginResource, "POST"); + + String nonceClaim = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("rotated-nonce-xyz"); + } + } + + @Test + void getToken_cachesRotatedNonceFromFailedRetryResponse() throws Exception { + // RFC 9449 §8.2: if the AS rotates the nonce again on the *retried* response (a second + // use_dpop_nonce), that fresh nonce must still be cached so the next getToken() call retries + // with it rather than reusing the first, already-rejected nonce. The first call throws (the + // handshake failed twice), but the second call — issued with the rotated nonce cached from + // the failed retry — succeeds against a token endpoint that now accepts it. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // Call #1, request #1: 401 use_dpop_nonce with nonce-A. + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "nonce-A") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + // Call #1, request #2 (retry with nonce-A): 401 use_dpop_nonce rotating to nonce-B. + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .addHeader("DPoP-Nonce", "nonce-B") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce rotated\"}")); + // Call #2, request #3 (initial request now carries the cached nonce-B): success. + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(FAKE_TOKEN_RESPONSE)); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL resourceUrl = new URL("https://kas.example.com/kas"); + + // First call fails after the double challenge, but caches nonce-B from the failed retry. + assertThatThrownBy(() -> ts.getAuthHeaders(resourceUrl, "POST")) + .isInstanceOf(SDKException.class); + assertThat(tokenServer.getRequestCount()).isEqualTo(2); + + // Second call succeeds; its initial request must carry the rotated nonce-B. + TokenSource.AuthHeaders headers = ts.getAuthHeaders(resourceUrl, "POST"); + assertThat(tokenServer.getRequestCount()).isEqualTo(3); + + tokenServer.takeRequest(); // request #1 + tokenServer.takeRequest(); // request #2 + RecordedRequest third = tokenServer.takeRequest(); + String thirdNonce = SignedJWT.parse(third.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(thirdNonce).isEqualTo("nonce-B"); + } + } + + @Test + void getToken_throwsDescriptiveErrorWhenUseDpopNonceLacksHeader() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // 401 use_dpop_nonce but NO DPoP-Nonce header + tokenServer.enqueue(new MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\":\"use_dpop_nonce\",\"error_description\":\"nonce required\"}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL kasUrl = new URL("https://kas.example.com/kas"); + + assertThatThrownBy(() -> ts.getAuthHeaders(kasUrl, "POST")) + .isInstanceOf(SDKException.class) + .satisfies(e -> assertThat(e.getMessage() + (e.getCause() != null ? e.getCause().getMessage() : "")) + .contains("use_dpop_nonce")); + } + } + + @Test + void getToken_surfacesMalformedTokenResponseDistinctly() throws Exception { + // Pre-fix every token-fetch failure surfaced as "failed to get token" with the + // cause buried. After C3 the parse failure is attributed to the token endpoint. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + // 200 with non-JSON body trips ParseException inside nimbus. + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("this is not json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL kasUrl = new URL("https://kas.example.com/kas"); + + assertThatThrownBy(() -> ts.getAuthHeaders(kasUrl, "POST")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed token response") + .hasMessageContaining(tokenServer.url("/token").toString()); + } + } + + @Test + void constructor_rejectsRsaKeyWithEcAlgorithm() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048).keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()).generate(); + URI tokenUri = new URL("https://idp.example.com/token").toURI(); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + rsaKey, JWSAlgorithm.ES256, + tokenUri, + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("RSA") + .hasMessageContaining("ES256"); + } + + @Test + void constructor_rejectsEcKeyWithMismatchedCurveAlgorithm() throws Exception { + ECKey ecKey = new ECKeyGenerator(Curve.P_256).keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()).generate(); + URI tokenUri = new URL("https://idp.example.com/token").toURI(); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + ecKey, JWSAlgorithm.ES384, + tokenUri, + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("P-256") + .hasMessageContaining("ES384"); + } + + @Test + void constructor_rejectsUnsupportedJwkType() throws Exception { + // OKP type — parsed from a static JWK to avoid pulling in the Tink dependency + // that OctetKeyPairGenerator needs at runtime. + JWK okp = JWK.parse("{\"kty\":\"OKP\",\"crv\":\"Ed25519\"," + + "\"x\":\"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\"," + + "\"d\":\"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A\"}"); + URI tokenUri = new URL("https://idp.example.com/token").toURI(); + assertThatThrownBy(() -> new TokenSource( + new ClientSecretBasic(new ClientID("c"), new Secret("s")), + okp, JWSAlgorithm.EdDSA, + tokenUri, + new ClientCredentialsGrant(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported JWK type"); + } + + @Test + void getToken_usesProactivelyCachedNonce() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"access_token\":\"proactive-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + // Pre-seed the cache for the token endpoint origin + ts.cacheNonce(tokenServer.url("/token").url(), "proactive-nonce"); + + ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); + + assertThat(tokenServer.getRequestCount()).isEqualTo(1); + + RecordedRequest request = tokenServer.takeRequest(); + String nonceClaim = SignedJWT.parse(request.getHeader("DPoP")) + .getJWTClaimsSet().getStringClaim("nonce"); + assertThat(nonceClaim).isEqualTo("proactive-nonce"); + } + } + + @Test + void htmClaimReflectsRequestMethod() throws Exception { + // RFC 9449 §4.2: the htm claim binds the proof to the HTTP method. Pins that the + // method passed to getAuthHeaders is stamped verbatim into the proof — the reason + // SDKBuilder disables Connect-GET so authenticated requests stay POST and the + // pre-minted proof's htm can't drift from the method actually sent. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setBody(FAKE_TOKEN_RESPONSE) + .setHeader("Content-Type", "application/json")); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + URL testUrl = new URL("https://kas.example.com/kas"); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(testUrl, "POST"); + String htm = SignedJWT.parse(headers.getDpopHeader()) + .getJWTClaimsSet().getStringClaim("htm"); + + assertThat(htm).isEqualTo("POST"); + } + } + + @Test + void getAuthHeaders_downgradesToBearerWhenTokenEndpointReturnsBearer() throws Exception { + // Keycloak realms with DPoP disabled return token_type=Bearer even when the client + // sent a DPoP proof. The SDK must not emit "Authorization: DPoP " — + // that scheme is reserved for DPoP-bound tokens (RFC 9449 §7.1) and a DPoP-enforcing + // resource server will reject it. Downgrade to Bearer and omit the proof. + RSAKey rsaKey = new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate(); + try (MockWebServer tokenServer = new MockWebServer()) { + tokenServer.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(BEARER_TOKEN_RESPONSE)); + tokenServer.start(); + + TokenSource ts = buildTokenSource(tokenServer, rsaKey); + + TokenSource.AuthHeaders headers = ts.getAuthHeaders(new URL("https://kas.example.com/kas"), "POST"); + + assertThat(headers.getAuthHeader()).isEqualTo("Bearer plain-bearer-token"); + assertThat(headers.getDpopHeader()).isNull(); + } + } +} diff --git a/sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt b/sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt new file mode 100644 index 00000000..483037a1 --- /dev/null +++ b/sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt @@ -0,0 +1,137 @@ +package io.opentdf.platform.sdk + +import com.connectrpc.Idempotency +import com.connectrpc.MethodSpec +import com.connectrpc.StreamType +import com.connectrpc.http.HTTPMethod +import com.connectrpc.http.HTTPResponse +import com.connectrpc.http.UnaryHTTPRequest +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.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.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URL +import java.util.UUID + +/** + * Covers the connect-kotlin request/response path of [AuthInterceptor] — the one that threads the + * request URL from requestFunction to responseFunction (via a per-call closure-local) so nonces are + * cached against the correct origin. connect-kotlin builds a fresh interceptor + function pair per + * call, so each pair's URL is isolated. The okhttp-level retry interceptor is covered separately in + * DPoPRetryInterceptorTest. + */ +class AuthInterceptorConnectPathTest { + + private val fakeTokenResponse = + "{\"access_token\":\"test-token\",\"token_type\":\"DPoP\",\"expires_in\":3600}" + + private fun rsaKey(): RSAKey = RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate() + + private fun buildTokenSource(tokenServer: MockWebServer, rsaKey: RSAKey) = TokenSource( + ClientSecretBasic(ClientID("test-client"), Secret("test-secret")), + rsaKey, + JWSAlgorithm.RS256, + tokenServer.url("/token").toUri(), + ClientCredentialsGrant(), + null, + ) + + private fun unaryRequest(url: URL) = UnaryHTTPRequest( + url = url, + contentType = "application/grpc", + timeout = null, + headers = emptyMap(), + methodSpec = MethodSpec("test.Service/Method", Any::class, Any::class, StreamType.UNARY, Idempotency.UNKNOWN), + message = Buffer(), + httpMethod = HTTPMethod.POST, + ) + + private fun httpResponse(headers: Map>) = HTTPResponse( + status = 200, + headers = headers, + message = Buffer(), + trailers = emptyMap(), + cause = null, + ) + + @Test + fun requestFunctionAddsDPoPHeadersAndResponseFunctionCachesNonceForOrigin() { + val rsaKey = rsaKey() + MockWebServer().use { tokenServer -> + repeat(2) { + tokenServer.enqueue( + MockResponse().setBody(fakeTokenResponse).setHeader("Content-Type", "application/json"), + ) + } + tokenServer.start() + + val ts = buildTokenSource(tokenServer, rsaKey) + val unary = AuthInterceptor(ts).unaryFunction() + val kasUrl = URL("https://kas.example.com/kas") + + val outgoing = unary.requestFunction.invoke(unaryRequest(kasUrl)) + assertThat(outgoing.headers["Authorization"]!!.first()).startsWith("DPoP ") + assertThat(outgoing.headers["DPoP"]!!.first()).isNotEmpty() + + // responseFunction reads the URL stashed by requestFunction and caches the server + // nonce against the request origin. + unary.responseFunction.invoke(httpResponse(mapOf("DPoP-Nonce" to listOf("conn-nonce")))) + + val nonce = SignedJWT.parse(ts.getAuthHeaders(kasUrl, "POST").dpopHeader) + .jwtClaimsSet.getStringClaim("nonce") + assertThat(nonce).isEqualTo("conn-nonce") + } + } + + @Test + fun eachUnaryFunctionPairIsolatesItsUrlSoNoncesCacheAgainstTheirOwnOrigin() { + // connect-kotlin builds a fresh interceptor + function pair per call. Each unaryFunction() + // invocation therefore holds its own request URL in a closure-local, so interleaved calls + // to two different origins never cross-contaminate their cached nonces (the failure mode the + // previous ThreadLocal handoff had to guard against by hand). + val rsaKey = rsaKey() + MockWebServer().use { tokenServer -> + repeat(2) { + tokenServer.enqueue( + MockResponse().setBody(fakeTokenResponse).setHeader("Content-Type", "application/json"), + ) + } + tokenServer.start() + + val ts = buildTokenSource(tokenServer, rsaKey) + val interceptor = AuthInterceptor(ts) + val urlA = URL("https://kas-a.example.com/kas") + val urlB = URL("https://kas-b.example.com/kas") + + // Two independent call pairs, requests issued before either response settles. + val pairA = interceptor.unaryFunction() + val pairB = interceptor.unaryFunction() + pairA.requestFunction.invoke(unaryRequest(urlA)) + pairB.requestFunction.invoke(unaryRequest(urlB)) + + // Responses settle out of order; each must cache against its own pair's origin. + pairB.responseFunction.invoke(httpResponse(mapOf("DPoP-Nonce" to listOf("nonce-B")))) + pairA.responseFunction.invoke(httpResponse(mapOf("DPoP-Nonce" to listOf("nonce-A")))) + + val nonceA = SignedJWT.parse(ts.getAuthHeaders(urlA, "POST").dpopHeader) + .jwtClaimsSet.getStringClaim("nonce") + val nonceB = SignedJWT.parse(ts.getAuthHeaders(urlB, "POST").dpopHeader) + .jwtClaimsSet.getStringClaim("nonce") + assertThat(nonceA).isEqualTo("nonce-A") + assertThat(nonceB).isEqualTo("nonce-B") + } + } +}