diff --git a/impl/src/main/java/com/okta/sdk/impl/client/DefaultClientBuilder.java b/impl/src/main/java/com/okta/sdk/impl/client/DefaultClientBuilder.java
index ed90c988bdd..0a6074e244a 100644
--- a/impl/src/main/java/com/okta/sdk/impl/client/DefaultClientBuilder.java
+++ b/impl/src/main/java/com/okta/sdk/impl/client/DefaultClientBuilder.java
@@ -42,6 +42,7 @@
import com.okta.sdk.impl.io.ResourceFactory;
import com.okta.sdk.impl.oauth2.AccessTokenRetrieverService;
import com.okta.sdk.impl.oauth2.AccessTokenRetrieverServiceImpl;
+import com.okta.sdk.impl.oauth2.DPoPInterceptor;
import com.okta.sdk.impl.oauth2.OAuth2ClientCredentials;
import com.okta.sdk.impl.serializer.GroupProfileSerializer;
import com.okta.sdk.impl.serializer.UserProfileSerializer;
@@ -359,7 +360,7 @@ public ApiClient build() {
validateOAuth2ClientConfig(this.clientConfig);
- if (Strings.hasText(this.clientConfig.getOAuth2AccessToken())) {
+ if (hasAccessToken()) {
log.debug("Will use client provided Access token for OAuth2 authentication (private key, if supplied would be ignored)");
apiClient.setAccessToken(this.clientConfig.getOAuth2AccessToken());
} else {
@@ -386,7 +387,7 @@ public ApiClient build() {
* @return an {@link HttpClientBuilder} initialized with default configuration
*/
protected HttpClientBuilder createHttpClientBuilder(ClientConfiguration clientConfig) {
- return HttpClients.custom()
+ HttpClientBuilder httpClientBuilder = HttpClients.custom()
.setDefaultRequestConfig(createHttpRequestConfigBuilder(clientConfig).build())
.setConnectionManager(createHttpClientConnectionManagerBuilder(clientConfig).build())
.setRetryStrategy(new OktaHttpRequestRetryStrategy(clientConfig.getRetryMaxAttempts()))
@@ -394,6 +395,10 @@ protected HttpClientBuilder createHttpClientBuilder(ClientConfiguration clientCo
.setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy())
.setConnectionReuseStrategy(new DefaultConnectionReuseStrategy())
.disableCookieManagement();
+ if (isOAuth2Flow() && !hasAccessToken()) {
+ httpClientBuilder.addExecInterceptorLast("dpop", new DPoPInterceptor());
+ }
+ return httpClientBuilder;
}
/**
@@ -595,6 +600,10 @@ boolean isOAuth2Flow() {
return this.getClientConfiguration().getAuthorizationMode() == AuthorizationMode.PRIVATE_KEY;
}
+ private boolean hasAccessToken() {
+ return Strings.hasText(this.clientConfig.getOAuth2AccessToken());
+ }
+
public ClientConfiguration getClientConfiguration() {
return clientConfig;
}
diff --git a/impl/src/main/java/com/okta/sdk/impl/oauth2/AccessTokenRetrieverServiceImpl.java b/impl/src/main/java/com/okta/sdk/impl/oauth2/AccessTokenRetrieverServiceImpl.java
index 77e7c0281a3..591faaaaca2 100644
--- a/impl/src/main/java/com/okta/sdk/impl/oauth2/AccessTokenRetrieverServiceImpl.java
+++ b/impl/src/main/java/com/okta/sdk/impl/oauth2/AccessTokenRetrieverServiceImpl.java
@@ -58,7 +58,7 @@
public class AccessTokenRetrieverServiceImpl implements AccessTokenRetrieverService {
private static final Logger log = LoggerFactory.getLogger(AccessTokenRetrieverServiceImpl.class);
- private static final String TOKEN_URI = "/oauth2/v1/token";
+ static final String TOKEN_URI = "/oauth2/v1/token";
private final ClientConfiguration tokenClientConfiguration;
private final ApiClient apiClient;
@@ -109,6 +109,11 @@ public OAuth2AccessToken getOAuth2AccessToken() throws IOException, InvalidKeyEx
apiClient.setAccessToken(oAuth2AccessToken.getAccessToken());
return oAuth2AccessToken;
+ } catch (DPoPHandshakeException e) {
+ if (e.continueHandshake) {
+ return getOAuth2AccessToken();
+ }
+ throw new OAuth2HttpException(e.getMessage(), e, false);
} catch (ApiException e) {
throw new OAuth2HttpException(e.getMessage(), e, e.getCode() == 401);
} catch (Exception e) {
diff --git a/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPHandshakeException.java b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPHandshakeException.java
new file mode 100644
index 00000000000..ba6eb0db443
--- /dev/null
+++ b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPHandshakeException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024-Present Okta, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.okta.sdk.impl.oauth2;
+
+public class DPoPHandshakeException extends RuntimeException {
+
+ final boolean continueHandshake;
+ final String responseBody;
+
+ DPoPHandshakeException(DPopHandshakeState status, String error) {
+ super(status.message + ". Error response body: " + error);
+ this.continueHandshake = status.continueHandshake;
+ this.responseBody = error;
+ }
+
+}
diff --git a/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPInterceptor.java b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPInterceptor.java
new file mode 100644
index 00000000000..4ce8e8dc263
--- /dev/null
+++ b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPInterceptor.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2024-Present Okta, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.okta.sdk.impl.oauth2;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.jsonwebtoken.JwtBuilder;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Encoders;
+import io.jsonwebtoken.security.Jwks;
+import io.jsonwebtoken.security.PrivateJwk;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hc.client5.http.classic.ExecChain;
+import org.apache.hc.client5.http.classic.ExecChainHandler;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.Date;
+import java.util.UUID;
+
+import static com.okta.sdk.impl.oauth2.AccessTokenRetrieverServiceImpl.TOKEN_URI;
+
+/**
+ * Interceptor that handle DPoP handshake during auth and adds DPoP header to regular requests.
+ * It is always enabled, but is only active when a DPoP error is received during auth.
+ *
+ * @see documentation
+ */
+public class DPoPInterceptor implements ExecChainHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(DPoPInterceptor.class);
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String DPOP_HEADER = "DPoP";
+ //nonce is valid for 24 hours, but can only refresh it when doing a token request => start refreshing after 22 hours
+ private static final int NONCE_VALID_SECONDS = 60 * 60 * 22;
+ private static final MessageDigest SHA256; //required to sign ath claim
+
+ static {
+ try {
+ SHA256 = MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ //if null, means dpop is not enabled yet
+ private PrivateJwk jwk;
+ private String nonce;
+ private Instant nonceValidUntil;
+
+ @Override
+ public ClassicHttpResponse execute(ClassicHttpRequest request, ExecChain.Scope scope, ExecChain execChain)
+ throws IOException, HttpException {
+ boolean tokenRequest = request.getRequestUri().equals(TOKEN_URI);
+ if (tokenRequest && nonce != null && nonceValidUntil.isBefore(Instant.now())) {
+ log.debug("DPoP nonce expired, will refresh it");
+ nonce = null;
+ nonceValidUntil = null;
+ }
+ if (jwk != null) {
+ processRequest(request, tokenRequest);
+ }
+ ClassicHttpResponse response = execChain.proceed(request, scope);
+ if (tokenRequest) {
+ if (response.getCode() == 200 && nonce != null) {
+ log.info("DPoP handshake successful");
+ }
+ if (response.getCode() == 400) {
+ JsonNode errorBody = OBJECT_MAPPER.readTree(response.getEntity().getContent());
+ Header nonceHeader = response.getFirstHeader("dpop-nonce");
+ DPopHandshakeState handshakeState = handleHandshakeResponse(errorBody.get("error"), nonceHeader);
+ throw new DPoPHandshakeException(handshakeState, OBJECT_MAPPER.writeValueAsString(errorBody));
+ }
+ }
+ return response;
+ }
+
+ private void processRequest(HttpRequest request, boolean tokenRequest) {
+ JwtBuilder builder = Jwts.builder()
+ .header()
+ .type("dpop+jwt")
+ .jwk(jwk.toPublicJwk())
+ .and()
+ .claim("htm", request.getMethod())
+ .claim("htu", getUriWithoutQueryString(request))
+ .claim("jti", UUID.randomUUID().toString())
+ .issuedAt(new Date());
+ Header authorization = request.getFirstHeader("Authorization");
+ if (authorization != null) {
+ //already authenticated, need to replace Authorization header prefix and set ath claim
+ String token = authorization.getValue().replaceFirst("^Bearer ", "");
+ request.setHeader("Authorization", DPOP_HEADER + " " + token);
+ byte[] ath = SHA256.digest(token.getBytes(StandardCharsets.US_ASCII));
+ builder.claim("ath", Encoders.BASE64URL.encode(ath));
+ } else if (tokenRequest && nonce != null) {
+ //still in handshake, need to set nonce
+ builder.claim("nonce", nonce);
+ }
+ request.addHeader(DPOP_HEADER, builder.signWith(jwk.toKeyPair().getPrivate()).compact());
+ }
+
+ private String getUriWithoutQueryString(HttpRequest request) {
+ try {
+ return URLDecoder.decode(StringUtils.substringBefore(request.getUri().toString(), "?"), StandardCharsets.UTF_8);
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private DPopHandshakeState handleHandshakeResponse(JsonNode errorField, Header nonceHeader) {
+ if (errorField != null && errorField.isTextual()) {
+ switch (errorField.textValue()) {
+ case "invalid_dpop_proof": {
+ if (jwk != null) {
+ return DPopHandshakeState.REPEATED_INVALID_DPOP_PROOF;
+ }
+ log.info("DPoP detected, beginning handshake");
+ this.jwk = Jwks.builder().keyPair(Jwts.SIG.ES256.keyPair().build()).build();
+ return DPopHandshakeState.FIRST_INVALID_DPOP_PROOF;
+ }
+ case "use_dpop_nonce": {
+ if (nonce != null) {
+ return DPopHandshakeState.REPEATED_USE_DPOP_NONCE;
+ }
+ if (nonceHeader == null) {
+ return DPopHandshakeState.MISSING_DPOP_NONCE_HEADER;
+ }
+ log.info("DPoP nonce obtained, finalizing handshake");
+ this.nonce = nonceHeader.getValue();
+ this.nonceValidUntil = Instant.now().plusSeconds(NONCE_VALID_SECONDS);
+ return DPopHandshakeState.FIRST_USE_DPOP_NONCE;
+ }
+ }
+ }
+ return DPopHandshakeState.UNEXPECTED_STATE;
+ }
+
+}
diff --git a/impl/src/main/java/com/okta/sdk/impl/oauth2/DPopHandshakeState.java b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPopHandshakeState.java
new file mode 100644
index 00000000000..4ad95a649ec
--- /dev/null
+++ b/impl/src/main/java/com/okta/sdk/impl/oauth2/DPopHandshakeState.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024-Present Okta, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.okta.sdk.impl.oauth2;
+
+enum DPopHandshakeState {
+
+ //invalid states
+ REPEATED_INVALID_DPOP_PROOF(false, "Invalid sequence, already received invalid_dpop_proof error"),
+ REPEATED_USE_DPOP_NONCE(false, "Invalid sequence, already received use_dpop_nonce error"),
+ MISSING_DPOP_NONCE_HEADER(false, "Invalid sequence, missing dpop-nonce header on use_dpop_nonce error response"),
+ UNEXPECTED_STATE(false, "Unexpected authentication error"),
+
+ //valid states
+ FIRST_INVALID_DPOP_PROOF(true, "Received invalid_dpop_proof, will provide DPoP header"),
+ FIRST_USE_DPOP_NONCE(true, "Received use_dpop_nonce, will provide nonce");
+
+ final boolean continueHandshake;
+ final String message;
+
+ DPopHandshakeState(boolean continueHandshake, String message) {
+ this.continueHandshake = continueHandshake;
+ this.message = message;
+ }
+
+}