Skip to content

Commit

Permalink
Add automatic support for DPoP (#1150)
Browse files Browse the repository at this point in the history
* Add automatic support for DPoP (#1089)

- Add DPoPInterceptor when authentication with PRIVATE_KEY
- Supports nonce refreshing

* minor updates

---------

Co-authored-by: Clément Denis <[email protected]>
  • Loading branch information
arvindkrishnakumar-okta and clementdenis authored May 7, 2024
1 parent c27e0cb commit 596a52a
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -386,14 +387,18 @@ 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()))
.setConnectionBackoffStrategy(new DefaultBackoffStrategy())
.setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy())
.setConnectionReuseStrategy(new DefaultConnectionReuseStrategy())
.disableCookieManagement();
if (isOAuth2Flow() && !hasAccessToken()) {
httpClientBuilder.addExecInterceptorLast("dpop", new DPoPInterceptor());
}
return httpClientBuilder;
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
166 changes: 166 additions & 0 deletions impl/src/main/java/com/okta/sdk/impl/oauth2/DPoPInterceptor.java
Original file line number Diff line number Diff line change
@@ -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 <a href="https://developer.okta.com/docs/guides/dpop/oktaresourceserver/main/">documentation</a>
*/
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<PrivateKey, PublicKey, ?> 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;
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}

0 comments on commit 596a52a

Please sign in to comment.