-
Notifications
You must be signed in to change notification settings - Fork 3
feat(sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397) #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dmihalcik-virtru
wants to merge
43
commits into
main
Choose a base branch
from
DSPX-3397-java-sdk
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
27acfab
feat(java-sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397)
dmihalcik-virtru 227c685
fix(java-sdk): fix DPoP compile errors, nonce caching, and add 401-retry
dmihalcik-virtru f014f37
feat(java-sdk): add --dpop and --dpop-key CLI flags to encrypt/decrypt
dmihalcik-virtru 88f8363
fix(java-sdk): propagate --dpop flags to subcommand help via ScopeTyp…
dmihalcik-virtru ba8df35
fix(cmdline): replace System.exit in Supports with Callable<Integer>
dmihalcik-virtru 565e518
test(java-sdk): add TokenSource unit tests and remove unused altindag…
dmihalcik-virtru dea0db5
docs: DPoP nonce challenge design spec
dmihalcik-virtru 6959e50
docs: DPoP nonce challenge implementation plan
dmihalcik-virtru e21f387
feat(sdk): retry token request with nonce on use_dpop_nonce (RFC 9449…
dmihalcik-virtru 2a60cae
fix(sdk): address DPoP nonce retry quality issues
dmihalcik-virtru f832f40
feat(cmdline): declare dpop_nonce_challenge support
dmihalcik-virtru 82c31cc
fix(sdk): harden DPoP retry interceptor
dmihalcik-virtru a4f8911
fix(sdk): tighten TokenSource error handling and JWK validation
dmihalcik-virtru 61e8708
fix(cmdline): restore fast-fail validation for credential options
dmihalcik-virtru 6955c61
fix(cmdline): print stack trace for failures that escape picocli
dmihalcik-virtru 30f9ebf
Add verbose logging and DPoP retry exception logging
dmihalcik-virtru c854ca8
Fall back to Bearer scheme when AS returns non-DPoP-bound token
dmihalcik-virtru bd52761
fix(sdk): strip query and fragment from DPoP htu claim
dmihalcik-virtru 69ba817
test(sdk): advertise DPoP token_type in SDKBuilder mock IdP
dmihalcik-virtru 47343c5
docs(sdk): correct RFC 9449 section citations and remove stale plan/spec
dmihalcik-virtru 3d2ba84
fix(sdk): fail loudly when DPoP is requested but well-known omits pla…
dmihalcik-virtru 9a2d5fa
fix(cmdline): validate DPoP key options at parse time + add coverage
dmihalcik-virtru 07692f4
debug(sdk): add DPoP path/method/claims logging to AuthInterceptor
dmihalcik-virtru 4b99dcf
fix(sdk): disable Connect-GET on authenticated client (DPoP htm drift)
dmihalcik-virtru f4b1328
fix(sdk): log malformed DPoP nonce challenge instead of swallowing 401
dmihalcik-virtru eea6f27
fix(sdk): drop stale DPoP proof header on Bearer-downgrade retry
dmihalcik-virtru 61a4b8c
fix(sdk): read token and scheme atomically to close a data race
dmihalcik-virtru 475a42a
fix(sdk): reject public-only DPoP JWK in central validation
dmihalcik-virtru 6fb0c6a
refactor(cmdline): validate DpopMaterial in its constructor
dmihalcik-virtru 953d35a
docs(sdk): correct getAuthHeaders Javadoc for Bearer fallback
dmihalcik-virtru 083f4e6
test(sdk): assert DPoP proof htm claim reflects request method
dmihalcik-virtru b3649b4
test(sdk): cover AuthInterceptor connect-kotlin request/response path
dmihalcik-virtru e7fc2cc
refactor(sdk): model token scheme as an enum, make AuthHeaders static
dmihalcik-virtru 5eade00
docs(sdk): clarify retry-once semantics and trim redundant helper Jav…
dmihalcik-virtru ca4c1a5
fix(sdk): surface persistent DPoP nonce challenge after retry
dmihalcik-virtru 723b17b
docs: note JAVA_HOME setup via brew for local builds
dmihalcik-virtru 6e63f6d
Merge branch 'main' into DSPX-3397-java-sdk
dmihalcik-virtru 15b009c
test(sdk): cover DPoP Bearer-downgrade retry and explicit proof algor…
dmihalcik-virtru 6e13d27
feat(cmdline): remove DPoP algorithm/key CLI flags
dmihalcik-virtru 2f1d2e4
chore(sdk): remove unused imports flagged by SonarCloud
dmihalcik-virtru 131b82c
feat(cmdline): list features and add --json to `supports`
dmihalcik-virtru 7c24672
refactor(sdk): address SonarCloud findings
dmihalcik-virtru a47fc9f
fix(sdk,cmdline): address PR review comments
dmihalcik-virtru File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Integer> { | ||
| // 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<String> 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<String> canonical = FEATURES.stream().filter(f -> f.equalsIgnoreCase(feature)).findFirst(); | ||
| if (json) { | ||
| Map<String, Boolean> 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<String> features) { | ||
| if (json) { | ||
| Map<String, Boolean> 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<AssertionConfig.AssertionKey> { | ||
| @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<List<com.nimbusds.jose.util.Base64>>() {}.getType()); | ||
| assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), | ||
| new TypeToken<List<com.nimbusds.jose.util.Base64>>() { | ||
| }.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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is really useful |
||
| // intentionally NOT marked required = true so that `tdf supports <feature>` 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=<platformEndpoint>'"); | ||
| } | ||
| if (clientId == null || clientId.isEmpty()) { | ||
| throw new CommandLine.ParameterException(spec.commandLine(), | ||
| "Missing required option: '--client-id=<clientId>'"); | ||
| } | ||
| if (clientSecret == null || clientSecret.isEmpty()) { | ||
| throw new CommandLine.ParameterException(spec.commandLine(), | ||
| "Missing required option: '--client-secret=<clientSecret>'"); | ||
| } | ||
|
|
||
| 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) { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.