Supporting Okta with DPoP in an OIDC-based Spring Security application
Dear Joris,
At my current company we’re using Spring Security for authentication and authorization purposes. We were using an internal OpenID Connect provider to let end-users log in, but now the whole organization is forced to migrate to Okta. That may not sound like a problem (because hey, standards, right?), but the global security team has decreed that all new applications connecting to Okta must support this thing called DPoP. Pfff, until we got the memo I hadn’t even heard of DPoP!
From what I gather it’s mainly targeted at public clients like single-page applications. However, now even our traditional server-side web apps that let clients log in via Okta no longer work and it looks like the framework doesn’t provide support for this new standard yet.
Dear Joris, I don’t know what to do: my manager is asking me every day when I will have resolved this blocking issue, but I don’t even know where to start! Can you please offer some of your advice? Ideally for free?
Restless in Rotterdam
Dear Restless in Rotterdam,
I can see how this issue is keeping you awake at night: this “Demonstrating Proof of Possession” standard is pretty new and indeed not yet supported when you configure Spring Security with OIDC.
However, please keep in mind that Spring Security is a framework: it has been created with extensibility in mind, so the solution lies in finding the right extension points to then implement the necessary support yourself.
As I can imagine that other readers of this advice column are struggling with the same issue that you’re facing, I’ve created a sample application that shows how to go about this. Fear not: I’m sure that with my help you’ll be able to solve your problems in no time! Well, the DPoP-related ones, at least.
Demonstrating what and how, exactly?
Proof of Possession means that you can prove that when you’re sending JWTs with OIDC, they really came from you and weren’t leaked to be used for things like replay attacks. There’s plenty of info on the web that provides more background, but for Okta you should at least read their documentation on their support.
What you can see there is that in order to successfully interact with the /token
endpoint, you have to add a DPoP request header with a JWT that’s signed using a dedicated RSA-based JWK.
When doing that for the first time, the server will respond with a 400 Bad Request that contains a nonce as a response header. You then need to repeat your request with that nonce incorporated as a claim in your signed JWT. You should cache that nonce and refresh it every 24 hours.
When you’ve managed to do that, you’re not done yet. A Spring Security application using OIDC will also interact with a /userinfo
endpoint if the response of the /token
endpoint includes some of the expected scopes. For that, DPoP requires you to use the returned access token as an “Authorization: DPoP <token>
” header AND to send a DPoP header with an additional “ath
” claim containing the Base64-encoded SHA-256 hash of that same access token (which is an Okta-specific requirement, BTW).
If you’re just feeling yourself tumbling into the pit of despair after reading all of that, fear not: I’ll walk you through the required steps.
Sign on the dotted line
At the core of DPoP is the creation of the signed JWT that’s your DPoP header value. Let’s create a token manager that knows how to do that.
@Component
public class DpopTokenManager {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final JWSSignerFactory jwsSignerFactory = new DefaultJWSSignerFactory();
private volatile JWK jwk;
private volatile String nonce;
/**
* Should be reset every 24 hours.
* @see <a href="https://developer.okta.com/docs/guides/dpop/nonoktaresourceserver/main/#configure-dpop">Okta docs</a>
*/
@Scheduled(fixedDelay = 1000L * 60L * 60L * 24L)
public void resetNonce() {
this.nonce = null;
logger.info("DPoP nonce reset");
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
// ...
private String generateDpopToken(RequestEntity<?> request, String ath) {
try {
// could also be fixated via config using RSAKey.parse()
if (this.jwk == null) {
this.jwk = new RSAKeyGenerator(2048).keyID("1").generate();
}
var claimsBuilder = new JWTClaimsSet.Builder()
.claim("htu", request.getUrl().toString())
.claim("htm", request.getMethod().toString())
.issueTime(new Date())
.jwtID(UUID.randomUUID().toString());
if (ath != null) {
claimsBuilder.claim("ath", ath);
} else if (this.nonce != null) {
claimsBuilder.claim("nonce", this.nonce);
}
var signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256)
.jwk(this.jwk.toPublicJWK())
.type(new JOSEObjectType("dpop+jwt"))
.build(),
claimsBuilder.build());
signedJWT.sign(jwsSignerFactory.createJWSSigner(this.jwk));
return signedJWT.serialize();
} catch (JOSEException e) {
throw new RuntimeException("Can't generate DpopToken", e);
}
}
}
Spring Security’s OAuth 2 client support comes with a dependency on the Nimbus JOSE + JWT library. We’re using that to generate an RSA-based JWK if one isn’t explicitly configured and to create and sign a JWT with the header and claims that we need for DPoP.
We’ll add methods to this class to enrich the HTTP requests we send to Okta in a moment.
Go with the flow
We’ll start to implement first steps of the DPoP flow as shown in Okta’s documentation:
Note that Okta acts as the Authorization server in our case and our own application is the OIDC client. That’s because our security is handled in the backend application, rather than an application client.
Step 1 has been implemented in the code already shown. So where do we start to add support for the other steps?
It turns out that in Spring Security, the steps used to obtain an access and ID token are coordinated by an OAuth2AccessTokenResponseClient
. The default implementation for that is the DefaultAuthorizationCodeTokenResponseClient
(or, in the latest Spring Security version which deprecates that class, the RestClientAuthorizationCodeTokenResponseClient
; I’ll focus on the former for this column, as most readers won’t be on the very latest yet).
That default has no support for DPoP, however, and assumes that it’ll receive a Bearer token rather than a DPoP token as a response. Let’s fix that by providing our own implementation, based on the default.
What do we need to customize? As it turns out, several things:
- Add the DPoP header to our HTTP request;
- Respond to the nonce-challenge by extracting and caching the nonce and making a second request with an updated DPoP header;
- Ensure that we can handle a response with a DPoP token rather than a Bearer token, as Spring Security expects the latter;
- Similarly, be able to handle errors communicated in a WWW-Authenticate response header that start with “DPoP” rather than “Bearer”, as Spring Security only supports the latter by default.
All moving parts of the framework that handle the regular OIDC flow can be replaced through configuration. Here’s an implementation:
@Component
public class DpopAuthorizationCodeTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response";
private final DpopTokenManager tokenManager;
private final OAuth2AuthorizationCodeGrantRequestEntityConverter requestEntityConverter
= new OAuth2AuthorizationCodeGrantRequestEntityConverter();
private final RestTemplate restTemplate;
public DpopAuthorizationCodeTokenResponseClient(DpopTokenManager tokenManager) {
this.tokenManager = tokenManager;
var oAuth2AccessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
oAuth2AccessTokenResponseHttpMessageConverter.setAccessTokenResponseConverter(
new CustomTokenResponseConverter());
this.restTemplate = new RestTemplate(List.of(
new FormHttpMessageConverter(), oAuth2AccessTokenResponseHttpMessageConverter));
restTemplate.setErrorHandler(new DPopAwareOAuth2ErrorResponseErrorHandler());
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) {
RequestEntity<?> request = requestEntityConverter.convert(authorizationGrantRequest);
ResponseEntity<OAuth2AccessTokenResponse> response;
try {
try {
response = this.restTemplate.exchange(tokenManager.withDpopHeader(request), OAuth2AccessTokenResponse.class);
} catch (DpopChallengeExceptionException e) {
tokenManager.setNonce(e.nonce);
response = this.restTemplate.exchange(tokenManager.withDpopHeader(request), OAuth2AccessTokenResponse.class);
}
} catch (RestClientException ex) {
var oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + ex.getMessage(), null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
OAuth2AccessTokenResponse tokenResponse = response.getBody();
Assert.notNull(tokenResponse,
"The authorization server responded to this Authorization Code grant request with an empty body; as such, it cannot be materialized into an OAuth2AccessTokenResponse instance. Please check the HTTP response code in your server logs for more details.");
return tokenResponse;
}
}
Let’s break that down a bit. In the constructor we’re configuring a custom access token response converter to be used by the HTTP message converter of our RestTemplate
. The reason for that is that the DefaultMapOAuth2AccessTokenResponseConverter
doesn’t work for DPoP tokens, as it only accepts Bearer tokens. This custom converter basically does the same work, but hardcodes the TokenType
to Bearer
even if it isn’t because the framework doesn’t have a built-in type for DPoP just yet (but there’s an open issue to address this).
We also configure a custom error handler for that RestTemplate
, which recognizes DPoP-specific errors. See below for the code of that error handler.
The getTokenResponse
method implements the flow as shown in the diagram. Like the default implementation, it converts the OAuth2AuthorizationCodeGrantRequest
into a RequestEntity
for sending to Okta. However, before actually sending the request it’s passed to a “withDpopHeader
” method of the injected TokenManager
.
That method is quite straightforward to code, it looks like this:
public RequestEntity<?> withDpopHeader(RequestEntity<?> request) {
HttpHeaders headers = new HttpHeaders(request.getHeaders());
headers.set("DPoP", generateDpopToken(request, null));
return new RequestEntity<>(
request.getBody(),
headers,
request.getMethod(),
request.getUrl());
}
Our custom error handler will throw a custom DpopChallengeExceptionException
containing the nonce from the error response if we don’t include a nonce, to which we respond by telling the TokenManager
to use that nonce and retry the request.
Everything else is just copied from the default implementation.
Facing your problems
I mentioned our custom error handler. It needs to do two things:
- Most importantly it needs to recognize the error response containing the nonce to use;
- In addition, it should also be able to extract errors returned in a WWW-Authenticate error. The default error handler only does this for values starting with “Bearer”, not “DPoP”.
Here’s what that looks like (you could also use delegation instead of inheritance, if that makes you feel better about yourself):
public class DPopAwareOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHandler {
@Override
public void handleError(ClientHttpResponse errorResponse) throws IOException {
if (errorResponse.getStatusCode() == HttpStatus.BAD_REQUEST) {
OAuth2Error oauth2Error = readErrorFromWwwAuthenticate(errorResponse.getHeaders());
if (oauth2Error != null) {
throw new OAuth2AuthorizationException(oauth2Error);
}
String nonce = errorResponse.getHeaders().getFirst("dpop-nonce");
if (nonce != null) {
throw new DpopChallengeExceptionException(nonce);
}
}
super.handleError(errorResponse);
}
/**
* Based on {@link OAuth2ErrorResponseErrorHandler#readErrorFromWwwAuthenticate(HttpHeaders)},
* but for DPoP-based errors.
*/
private OAuth2Error readErrorFromWwwAuthenticate(HttpHeaders headers) {
// check for DPoP error in WWW-Authenticate response header
String wwwAuthenticateHeader = headers.getFirst(HttpHeaders.WWW_AUTHENTICATE);
if (StringUtils.hasText(wwwAuthenticateHeader) && wwwAuthenticateHeader.startsWith("DPoP")) {
try {
DPoPTokenError tokenError = DPoPTokenError.parse(wwwAuthenticateHeader);
String errorCode = tokenError.getCode() != null ? tokenError.getCode() : OAuth2ErrorCodes.SERVER_ERROR;
String errorDescription = tokenError.getDescription();
String errorUri = tokenError.getURI() != null ? tokenError.getURI().toString() : null;
return new OAuth2Error(errorCode, errorDescription, errorUri);
} catch (Exception e) {
// ignore, let parent handle this
}
}
return null;
}
}
Getting some closure
OK, so what’s left? Let’s look at the Okta diagram:
In our case, the Authorization server and Okta are the same. This is because our security is handled on the backend, as mentioned already.
In our TokenManager
we already have code that can add the ath
claim, but how do we obtain it and set it at the right time, i.e. when the /userinfo
endpoint is called?
The component responsible for this is the requestEntityConverter
of the DefaultOAuth2UserService
. Its default implementation can be replaced with a DPoP-aware version, which looks like this:
@Component
public class DPopAwareOAuth2UserRequestEntityConverter implements Converter<OAuth2UserRequest, RequestEntity<?>> {
private final OAuth2UserRequestEntityConverter delegate = new OAuth2UserRequestEntityConverter();
private final DpopTokenManager tokenManager;
public DPopAwareOAuth2UserRequestEntityConverter(DpopTokenManager tokenManager) {
this.tokenManager = tokenManager;
}
@Override
public RequestEntity<?> convert(OAuth2UserRequest oAuth2UserRequest) {
RequestEntity<?> request = delegate.convert(oAuth2UserRequest);
return tokenManager.withDpopAndAuthorizationHeader(request, oAuth2UserRequest.getAccessToken());
}
}
Similarly to the code in the access token response client, we’re using the result of the default Spring Security code and process that before we send the resulting RequestEntity
to Okta.
The implementation of the additional TokenManager
method looks like this, per Okta’s documentation:
public RequestEntity<?> withDpopAndAuthorizationHeader(RequestEntity<?> request, OAuth2AccessToken accessToken) {
HttpHeaders headers = new HttpHeaders(request.getHeaders());
headers.set("Authorization", "DPoP " + accessToken.getTokenValue());
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(
accessToken.getTokenValue().getBytes(UTF_8));
headers.set("DpOP", generateDpopToken(request, Base64URL.encode(hash).toString()));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
return new RequestEntity<>(
request.getBody(),
headers,
request.getMethod(),
request.getUrl());
}
Final step is to configure all of this in our security configuration:
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
DpopAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient,
DPopAwareOAuth2UserRequestEntityConverter requestEntityConverter) throws Exception
{
return http
.authorizeHttpRequests(auth ->
auth.anyRequest().authenticated())
.oauth2Login(login ->
login.tokenEndpoint(tep ->
tep.accessTokenResponseClient(authorizationCodeTokenResponseClient))
.userInfoEndpoint(uie ->
uie.oidcUserService(dpopAwareOidcUserService(requestEntityConverter))))
.build();
}
OidcUserService dpopAwareOidcUserService(DPopAwareOAuth2UserRequestEntityConverter requestEntityConverter) {
var oAuth2UserService = new DefaultOAuth2UserService();
oAuth2UserService.setRequestEntityConverter(requestEntityConverter);
var restTemplateForOAuth2UserService = new RestTemplate();
restTemplateForOAuth2UserService.setErrorHandler(new DPopAwareOAuth2ErrorResponseErrorHandler());
oAuth2UserService.setRestOperations(restTemplateForOAuth2UserService);
var oidcUserService = new OidcUserService();
oidcUserService.setOauth2UserService(oAuth2UserService);
return oidcUserService;
}
In Conclusion
Dear Restless in Rotterdam, I hope that this elaborate answer has given you enough clues to implement a solution. This is, however, an advice column and not a professional consultation.
Should you require more help from an expert in the field, then don’t hesitate to contact me!