diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index d4551e14..a9264ec2 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -13,6 +13,9 @@ on:
types:
- checks_requested
+permissions:
+ contents: read
+
jobs:
pr:
name: Validate PR title
diff --git a/pom.xml b/pom.xml
index 5f3c3d55..9c380979 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
11
11
2.20.0
- 1.57.2
+ 1.63.0
3.25.3
@@ -25,9 +25,18 @@
- io.opentdf.platform
- platform-client
- 0.1.0-SNAPSHOT
+ io.grpc
+ grpc-bom
+ ${grpc.version}
+ pom
+ import
+
+
+ org.junit
+ junit-bom
+ 5.10.1
+ pom
+ import
org.apache.logging.log4j
@@ -50,12 +59,6 @@
1.18.30
provided
-
- org.junit.jupiter
- junit-jupiter-engine
- 5.9.2
- test
-
org.mockito
mockito-core
@@ -68,13 +71,6 @@
5.2.0
test
-
- org.junit.jupiter
- junit-jupiter-api
- 5.9.2
- test
-
-
org.apache.maven.plugin-tools
maven-plugin-annotations
diff --git a/protocol/pom.xml b/protocol/pom.xml
index 47912106..ae7729d9 100644
--- a/protocol/pom.xml
+++ b/protocol/pom.xml
@@ -32,12 +32,10 @@
io.grpc
grpc-protobuf
- ${grpc.version}
-
+
io.grpc
grpc-stub
- ${grpc.version}
diff --git a/sdk/pom.xml b/sdk/pom.xml
index e8b82672..9f6bec12 100644
--- a/sdk/pom.xml
+++ b/sdk/pom.xml
@@ -3,7 +3,6 @@
4.0.0
- io.opentdf.platform
sdk
sdk
@@ -20,7 +19,43 @@
org.junit.jupiter
- junit-jupiter-engine
+ junit-jupiter
+
+
+ com.nimbusds
+ oauth2-oidc-sdk
+ 11.10.1
+
+
+ io.grpc
+ grpc-netty-shaded
+ runtime
+
+
+ io.grpc
+ grpc-protobuf
+
+
+ io.grpc
+ grpc-stub
+
+
+ org.apache.tomcat
+ annotations-api
+ 6.0.53
+ provided
+
+
+ org.assertj
+ assertj-core
+ 3.8.0
+ test
+
+
+ com.squareup.okhttp3
+ mockwebserver
+ 5.0.0-alpha.14
+ test
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/GRPCAuthInterceptor.java b/sdk/src/main/java/io/opentdf/platform/sdk/GRPCAuthInterceptor.java
new file mode 100644
index 00000000..24fc186e
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/GRPCAuthInterceptor.java
@@ -0,0 +1,156 @@
+package io.opentdf.platform.sdk;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.ClientCredentialsGrant;
+import com.nimbusds.oauth2.sdk.ErrorObject;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.dpop.DPoPProofFactory;
+import com.nimbusds.oauth2.sdk.dpop.DefaultDPoPProofFactory;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import com.nimbusds.oauth2.sdk.tokenexchange.TokenExchangeGrant;
+import io.grpc.CallOptions;
+import io.grpc.Channel;
+import io.grpc.ClientCall;
+import io.grpc.ClientInterceptor;
+import io.grpc.ForwardingClientCall;
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Instant;
+
+/**
+ * The GRPCAuthInterceptor class is responsible for intercepting client calls before they are sent
+ * to the server. It adds authentication headers to the requests by fetching and caching access
+ * tokens.
+ */
+class GRPCAuthInterceptor implements ClientInterceptor {
+ private Instant tokenExpiryTime;
+ private AccessToken token;
+ private final ClientAuthentication clientAuth;
+ private final RSAKey rsaKey;
+ private final URI tokenEndpointURI;
+
+ /**
+ * Constructs a new GRPCAuthInterceptor with the specified client authentication and RSA key.
+ *
+ * @param clientAuth the client authentication to be used by the interceptor
+ * @param rsaKey the RSA key to be used by the interceptor
+ */
+ public GRPCAuthInterceptor(ClientAuthentication clientAuth, RSAKey rsaKey, URI tokenEndpointURI) {
+ this.clientAuth = clientAuth;
+ this.rsaKey = rsaKey;
+ this.tokenEndpointURI = tokenEndpointURI;
+ }
+
+ /**
+ * Intercepts the client call before it is sent to the server.
+ *
+ * @param method The method descriptor for the call.
+ * @param callOptions The call options for the call.
+ * @param next The next channel in the channel pipeline.
+ * @param The type of the request message.
+ * @param The type of the response message.
+ * @return A client call with the intercepted behavior.
+ */
+ @Override
+ public ClientCall interceptCall(MethodDescriptor method,
+ CallOptions callOptions, Channel next) {
+ return new ForwardingClientCall.SimpleForwardingClientCall<>(next.newCall(method, callOptions)) {
+ @Override
+ public void start(Listener responseListener, Metadata headers) {
+ // Get the access token
+ AccessToken t = getToken();
+ headers.put(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER),
+ "DPoP " + t.getValue());
+
+ // Build the DPoP proof for each request
+ try {
+ DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256);
+
+ URI uri = new URI("/" + method.getFullMethodName());
+ SignedJWT proof = dpopFactory.createDPoPJWT("POST", uri, t);
+ headers.put(Metadata.Key.of("DPoP", Metadata.ASCII_STRING_MARSHALLER),
+ proof.serialize());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Invalid URI syntax for DPoP proof creation", e);
+ } catch (JOSEException e) {
+ throw new RuntimeException("Error creating DPoP proof", e);
+ }
+ super.start(responseListener, headers);
+ }
+ };
+ }
+
+ /**
+ * Either fetches a new access token or returns the cached access token if it is still valid.
+ *
+ * @return The access token.
+ */
+ private synchronized AccessToken getToken() {
+ try {
+ // If the token is expired or initially null, get a new token
+ if (token == null || isTokenExpired()) {
+
+ // Construct the client credentials grant
+ AuthorizationGrant clientGrant = new ClientCredentialsGrant();
+
+ // Make the token request
+ TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI,
+ clientAuth, clientGrant, null);
+ HTTPRequest httpRequest = tokenRequest.toHTTPRequest();
+
+ DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256);
+
+ SignedJWT proof = dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI());
+
+ httpRequest.setDPoP(proof);
+ TokenResponse tokenResponse;
+
+ HTTPResponse httpResponse = httpRequest.send();
+
+ tokenResponse = TokenResponse.parse(httpResponse);
+ if (!tokenResponse.indicatesSuccess()) {
+ ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
+ throw new RuntimeException("Token request failed: " + error);
+ }
+
+ this.token = tokenResponse.toSuccessResponse().getTokens().getAccessToken();
+ // DPoPAccessToken dPoPAccessToken = tokens.getDPoPAccessToken();
+
+
+ if (token.getLifetime() != 0) {
+ // Need some type of leeway but not sure whats best
+ this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3);
+ }
+
+ } else {
+ // If the token is still valid or not initially null, return the cached token
+ return this.token;
+ }
+
+ } catch (Exception e) {
+ // TODO Auto-generated catch block
+ throw new RuntimeException("failed to get token", e);
+ }
+ return this.token;
+ }
+
+ /**
+ * Checks if the token has expired.
+ *
+ * @return true if the token has expired, false otherwise.
+ */
+ private boolean isTokenExpired() {
+ return this.tokenExpiryTime != null && this.tokenExpiryTime.isBefore(Instant.now());
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
index f57c3e76..49af620d 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -1,55 +1,60 @@
package io.opentdf.platform.sdk;
+import io.grpc.Channel;
import io.opentdf.platform.policy.attributes.AttributesServiceGrpc;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc.AttributesServiceFutureStub;
+import io.opentdf.platform.policy.namespaces.NamespaceServiceGrpc;
+import io.opentdf.platform.policy.namespaces.NamespaceServiceGrpc.NamespaceServiceFutureStub;
import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceGrpc;
+import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceGrpc.ResourceMappingServiceFutureStub;
+import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceGrpc;
+import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceGrpc.SubjectMappingServiceFutureStub;
/**
- * Interact with OpenTDF platform services and perform TDF data operations with
- * this object.
+ * The SDK class represents a software development kit for interacting with the opentdf platform. It
+ * provides various services and stubs for making API calls to the opentdf platform.
*/
public class SDK {
- private final String platformEndpoint;
- private AttributesServiceGrpc.AttributesServiceFutureStub attributesServiceFutureStub;
- private ResourceMappingServiceGrpc.ResourceMappingServiceFutureStub resourceMappingServiceFutureStub;
-
- public AttributesServiceGrpc.AttributesServiceFutureStub getAttributesServiceFutureStub() {
- return attributesServiceFutureStub;
- }
-
- public ResourceMappingServiceGrpc.ResourceMappingServiceFutureStub getResourceMappingServiceFutureStub() {
- return resourceMappingServiceFutureStub;
- }
-
-
- private SDK(String platformEndpoint) {
- this.platformEndpoint = platformEndpoint;
- }
-
- public String getPlatformEndpoint() {
- return this.platformEndpoint;
- }
-
- /**
- * Builder pattern for SDK objects
- */
- public static class Builder implements Cloneable {
- private String platformEndpoint;
-
- public Builder platformEndpoint(String platformEndpoint) {
- this.platformEndpoint = platformEndpoint;
- return this;
- }
-
- public SDK build() {
- return new SDK(this.platformEndpoint);
+ private final Services services;
+
+ // TODO: add KAS
+ public interface Services {
+ AttributesServiceFutureStub attributes();
+ NamespaceServiceFutureStub namespaces();
+ SubjectMappingServiceFutureStub subjectMappings();
+ ResourceMappingServiceFutureStub resourceMappings();
+
+ static Services newServices(Channel channel) {
+ var attributeService = AttributesServiceGrpc.newFutureStub(channel);
+ var namespaceService = NamespaceServiceGrpc.newFutureStub(channel);
+ var subjectMappingService = SubjectMappingServiceGrpc.newFutureStub(channel);
+ var resourceMappingService = ResourceMappingServiceGrpc.newFutureStub(channel);
+
+ return new Services() {
+ @Override
+ public AttributesServiceFutureStub attributes() {
+ return attributeService;
+ }
+
+ @Override
+ public NamespaceServiceFutureStub namespaces() {
+ return namespaceService;
+ }
+
+ @Override
+ public SubjectMappingServiceFutureStub subjectMappings() {
+ return subjectMappingService;
+ }
+
+ @Override
+ public ResourceMappingServiceFutureStub resourceMappings() {
+ return resourceMappingService;
+ }
+ };
+ }
}
- @Override public Builder clone() {
- try {
- return (Builder) super.clone();
- } catch (CloneNotSupportedException e) {
- throw new RuntimeException(e);
- }
+ public SDK(Services services) {
+ this.services = services;
}
- }
-}
+}
\ No newline at end of file
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
new file mode 100644
index 00000000..20f10dd4
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
@@ -0,0 +1,127 @@
+package io.opentdf.platform.sdk;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.oauth2.sdk.GeneralException;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import io.grpc.ManagedChannel;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.Status;
+import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationRequest;
+import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationResponse;
+import io.opentdf.platform.wellknownconfiguration.WellKnownServiceGrpc;
+
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * A builder class for creating instances of the SDK class.
+ */
+public class SDKBuilder {
+ private static final String PLATFORM_ISSUER = "platform_issuer";
+ private String platformEndpoint = null;
+ private ClientAuthentication clientAuth = null;
+ private Boolean usePlainText;
+
+ public static SDKBuilder newBuilder() {
+ SDKBuilder builder = new SDKBuilder();
+ builder.usePlainText = false;
+
+ return builder;
+ }
+
+ public SDKBuilder platformEndpoint(String platformEndpoint) {
+ this.platformEndpoint = platformEndpoint;
+ return this;
+ }
+
+ public SDKBuilder clientSecret(String clientID, String clientSecret) {
+ ClientID cid = new ClientID(clientID);
+ Secret cs = new Secret(clientSecret);
+ this.clientAuth = new ClientSecretBasic(cid, cs);
+ return this;
+ }
+
+ public SDKBuilder useInsecurePlaintextConnection(Boolean usePlainText) {
+ this.usePlainText = usePlainText;
+ return this;
+ }
+
+ // this is not exposed publicly so that it can be tested
+ ManagedChannel buildChannel() {
+ // we don't add the auth listener to this channel since it is only used to call the
+ // well known endpoint
+ ManagedChannel bootstrapChannel = null;
+ GetWellKnownConfigurationResponse config;
+ try {
+ bootstrapChannel = getManagedChannelBuilder().build();
+ var stub = WellKnownServiceGrpc.newBlockingStub(bootstrapChannel);
+ try {
+ config = stub.getWellKnownConfiguration(GetWellKnownConfigurationRequest.getDefaultInstance());
+ } catch (Exception e) {
+ Status status = Status.fromThrowable(e);
+ throw new SDKException(String.format("Got grpc status [%s] when getting configuration", status), e);
+ }
+ } finally {
+ if (bootstrapChannel != null) {
+ bootstrapChannel.shutdown();
+ }
+ }
+
+ String platformIssuer;
+ try {
+ platformIssuer = config
+ .getConfiguration()
+ .getFieldsOrThrow(PLATFORM_ISSUER)
+ .getStringValue();
+
+ } catch (Exception e) {
+ throw new SDKException("Error getting the issuer from the platform", e);
+ }
+
+ Issuer issuer = new Issuer(platformIssuer);
+ OIDCProviderMetadata providerMetadata;
+ try {
+ providerMetadata = OIDCProviderMetadata.resolve(issuer);
+ } catch (IOException | GeneralException e) {
+ throw new SDKException("Error resolving the OIDC provider metadata", e);
+ }
+
+ RSAKey rsaKey;
+ try {
+ rsaKey = new RSAKeyGenerator(2048)
+ .keyUse(KeyUse.SIGNATURE)
+ .keyID(UUID.randomUUID().toString())
+ .generate();
+ } catch (JOSEException e) {
+ throw new SDKException("Error generating DPoP key", e);
+ }
+
+ GRPCAuthInterceptor interceptor = new GRPCAuthInterceptor(clientAuth, rsaKey, providerMetadata.getTokenEndpointURI());
+
+ return getManagedChannelBuilder()
+ .intercept(interceptor)
+ .build();
+ }
+
+ public SDK build() {
+ return new SDK(SDK.Services.newServices(buildChannel()));
+ }
+
+ private ManagedChannelBuilder> getManagedChannelBuilder() {
+ ManagedChannelBuilder> channelBuilder = ManagedChannelBuilder
+ .forTarget(platformEndpoint);
+
+ if (usePlainText) {
+ channelBuilder = channelBuilder.usePlaintext();
+ }
+ return channelBuilder;
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKException.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKException.java
new file mode 100644
index 00000000..0db5da43
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKException.java
@@ -0,0 +1,7 @@
+package io.opentdf.platform.sdk;
+
+public class SDKException extends RuntimeException {
+ public SDKException(String message, Exception reason) {
+ super(message, reason);
+ }
+}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java
new file mode 100644
index 00000000..d24e28b3
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java
@@ -0,0 +1,139 @@
+package io.opentdf.platform.sdk;
+
+import com.google.protobuf.Struct;
+import com.google.protobuf.Value;
+import io.grpc.ConnectivityState;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.stub.StreamObserver;
+import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationRequest;
+import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationResponse;
+import io.opentdf.platform.wellknownconfiguration.WellKnownServiceGrpc;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class SDKBuilderTest {
+
+ @Test
+ void testCreatingSDKChannel() throws IOException, InterruptedException {
+ Server wellknownServer = null;
+ // we use the HTTP server for two things:
+ // * it returns the OIDC configuration we use at bootstrapping time
+ // * it fakes out being an IDP and returns an access token when need to retrieve an access token
+ try (MockWebServer httpServer = new MockWebServer()) {
+ String oidcConfig;
+ try (var in = SDKBuilderTest.class.getResourceAsStream("/oidc-config.json")) {
+ oidcConfig = new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ String issuer = httpServer.url("my_realm").toString();
+ oidcConfig = oidcConfig
+ // if we don't do this then the library code complains that the issuer is wrong
+ .replace("", issuer)
+ // we want this server to be called when we fetch an access token during a service call
+ .replace("", httpServer.url("tokens").toString());
+ httpServer.enqueue(new MockResponse()
+ .setBody(oidcConfig)
+ .setHeader("Content-type", "application/json")
+ );
+
+ 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();
+ var response = GetWellKnownConfigurationResponse
+ .newBuilder()
+ .setConfiguration(config)
+ .build();
+ responseObserver.onNext(response);
+ responseObserver.onCompleted();
+ }
+ };
+
+ AtomicReference authHeaderFromRequest = new AtomicReference<>(null);
+ AtomicReference dpopHeaderFromRequest = new AtomicReference<>(null);
+
+ // we use the server in two different ways. the first time we use it to actually return
+ // issuer for bootstrapping. the second time we use the interception functionality in order
+ // to make sure that we are including a DPoP proof and an auth header
+ int randomPort;
+ try (ServerSocket socket = new ServerSocket(0)) {
+ randomPort = socket.getLocalPort();
+ }
+ wellknownServer = ServerBuilder
+ .forPort(randomPort)
+ .directExecutor()
+ .addService(wellKnownService)
+ .intercept(new ServerInterceptor() {
+ @Override
+ public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, ServerCallHandler next) {
+ authHeaderFromRequest.set(headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER)));
+ dpopHeaderFromRequest.set(headers.get(Metadata.Key.of("DPoP", Metadata.ASCII_STRING_MARSHALLER)));
+ return next.startCall(call, headers);
+ }
+ })
+ .build()
+ .start();
+
+ ManagedChannel channel = SDKBuilder
+ .newBuilder()
+ .clientSecret("client-id", "client-secret")
+ .platformEndpoint("localhost:" + wellknownServer.getPort())
+ .useInsecurePlaintextConnection(true)
+ .buildChannel();
+
+ assertThat(channel).isNotNull();
+ assertThat(channel.getState(false)).isEqualTo(ConnectivityState.IDLE);
+
+ var wellKnownStub = WellKnownServiceGrpc.newBlockingStub(channel);
+
+ httpServer.enqueue(new MockResponse()
+ .setBody("{\"access_token\": \"hereisthetoken\", \"token_type\": \"Bearer\"}")
+ .setHeader("Content-Type", "application/json"));
+
+ var ignored = wellKnownStub.getWellKnownConfiguration(GetWellKnownConfigurationRequest.getDefaultInstance());
+ channel.shutdownNow();
+
+ // we've now made two requests. one to get the bootstrapping info and one
+ // call that should activate the token fetching logic
+ assertThat(httpServer.getRequestCount()).isEqualTo(2);
+
+ httpServer.takeRequest();
+ var accessTokenRequest = httpServer.takeRequest();
+ assertThat(accessTokenRequest).isNotNull();
+ var authHeader = accessTokenRequest.getHeader("Authorization");
+ assertThat(authHeader).isNotNull();
+ var authHeaderParts = authHeader.split(" ");
+ assertThat(authHeaderParts).hasSize(2);
+ assertThat(authHeaderParts[0]).isEqualTo("Basic");
+ var usernameAndPassword = new String(Base64.getDecoder().decode(authHeaderParts[1]), StandardCharsets.UTF_8);
+ assertThat(usernameAndPassword).isEqualTo("client-id:client-secret");
+
+ assertThat(dpopHeaderFromRequest.get()).isNotNull();
+ assertThat(authHeaderFromRequest.get()).isEqualTo("DPoP hereisthetoken");
+
+ var body = new String(accessTokenRequest.getBody().readByteArray(), StandardCharsets.UTF_8);
+ assertThat(body).contains("grant_type=client_credentials");
+
+ } finally {
+ if (wellknownServer != null) {
+ wellknownServer.shutdownNow();
+ }
+ }
+ }
+}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java
deleted file mode 100644
index 00f0a86e..00000000
--- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package io.opentdf.platform.sdk;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.util.logging.Logger;
-import org.junit.jupiter.api.Test;
-
-class SDKTest {
- private static final Logger logger = Logger.getLogger(SDKTest.class.getName());
-
- @Test
- public void testBuilderPlatformEndpoint() {
- SDK sdk = new SDK.Builder().platformEndpoint("https://kas.dev").build();
- assertEquals("https://kas.dev", sdk.getPlatformEndpoint());
- }
-}
diff --git a/sdk/src/test/resources/oidc-config.json b/sdk/src/test/resources/oidc-config.json
new file mode 100644
index 00000000..4e9827f9
--- /dev/null
+++ b/sdk/src/test/resources/oidc-config.json
@@ -0,0 +1,145 @@
+{
+ "issuer": "",
+ "authorization_endpoint": "https://example.com/oauth2/v1/authorize",
+ "token_endpoint": "",
+ "userinfo_endpoint": "https://example.com/oauth2/v1/userinfo",
+ "registration_endpoint": "https://example.com/oauth2/v1/clients",
+ "jwks_uri": "https://example.com/oauth2/v1/keys",
+ "response_types_supported": [
+ "code",
+ "id_token",
+ "code id_token",
+ "code token",
+ "id_token token",
+ "code id_token token"
+ ],
+ "response_modes_supported": [
+ "query",
+ "fragment",
+ "form_post",
+ "okta_post_message"
+ ],
+ "grant_types_supported": [
+ "authorization_code",
+ "implicit",
+ "refresh_token",
+ "password",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "urn:openid:params:grant-type:ciba",
+ "urn:okta:params:oauth:grant-type:otp",
+ "http://auth0.com/oauth/grant-type/mfa-otp",
+ "urn:okta:params:oauth:grant-type:oob",
+ "http://auth0.com/oauth/grant-type/mfa-oob"
+ ],
+ "subject_types_supported": [
+ "public"
+ ],
+ "id_token_signing_alg_values_supported": [
+ "RS256"
+ ],
+ "scopes_supported": [
+ "openid",
+ "email",
+ "profile",
+ "address",
+ "phone",
+ "offline_access",
+ "groups"
+ ],
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "none"
+ ],
+ "claims_supported": [
+ "iss",
+ "ver",
+ "sub",
+ "aud",
+ "iat",
+ "exp",
+ "jti",
+ "auth_time",
+ "amr",
+ "idp",
+ "nonce",
+ "name",
+ "nickname",
+ "preferred_username",
+ "given_name",
+ "middle_name",
+ "family_name",
+ "email",
+ "email_verified",
+ "profile",
+ "zoneinfo",
+ "locale",
+ "address",
+ "phone_number",
+ "picture",
+ "website",
+ "gender",
+ "birthdate",
+ "updated_at",
+ "at_hash",
+ "c_hash"
+ ],
+ "code_challenge_methods_supported": [
+ "S256"
+ ],
+ "introspection_endpoint": "https://example.com/oauth2/v1/introspect",
+ "introspection_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "none"
+ ],
+ "revocation_endpoint": "https://example.com/oauth2/v1/revoke",
+ "revocation_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "none"
+ ],
+ "end_session_endpoint": "https://example.com/oauth2/v1/logout",
+ "request_parameter_supported": true,
+ "request_object_signing_alg_values_supported": [
+ "HS256",
+ "HS384",
+ "HS512",
+ "RS256",
+ "RS384",
+ "RS512",
+ "ES256",
+ "ES384",
+ "ES512"
+ ],
+ "device_authorization_endpoint": "https://example.com/oauth2/v1/device/authorize",
+ "pushed_authorization_request_endpoint": "https://example.com/oauth2/v1/par",
+ "backchannel_token_delivery_modes_supported": [
+ "poll"
+ ],
+ "backchannel_authentication_request_signing_alg_values_supported": [
+ "HS256",
+ "HS384",
+ "HS512",
+ "RS256",
+ "RS384",
+ "RS512",
+ "ES256",
+ "ES384",
+ "ES512"
+ ],
+ "dpop_signing_alg_values_supported": [
+ "RS256",
+ "RS384",
+ "RS512",
+ "ES256",
+ "ES384",
+ "ES512"
+ ]
+}
\ No newline at end of file