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