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; + } + +}