DPoP sender-constrained access tokens

DPoP is an OAuth 2.0 security extension for binding access and refresh tokens to a private key that belongs to the client. The binding makes the DPoP access token sender-constrained and its replay, if leaked or stolen, can be detected and prevented, as opposed to the common Bearer token.

DPoP is intended for securing the tokens of public OAuth 2.0 clients, such as single-page applications (SPA) and mobile applications. Confidential clients (clients with credentials) can also benefit from DPoP, but should generally consider the sender-constrained tokens of the OAuth 2.0 mTLS extension (RFC 8705) as it gives the tokens an additional binding to the TLS channel.

Querying DPoP support at the authorisation server

An OAuth server advertises support for DPoP in a dpop_signing_alg_values_supported metadata parameter which lists the JWS algorithms that clients can use to sign their key-possession proof JWTs.

To query the DPoP metadata programmatically:

Issuer issuer = new Issuer("https://c2id.com");
AuthorizationServerMetadata metadata = AuthorizationServerMetadata.resolve(issuer);

if (metadata.getDPoPJWSAlgs() != null) {
    System.out.println("Supported DPoP proof JWS algorithms:");
    for (JWSAlgorithm jwsAlg: metadata.getDPoPJWSAlgs()) {
        System.out.println(jwsAlg);
    }
}

This SDK supports all standard RSA (RSxxx, PSxxx) and ECDSA (ESxxx) algorithms as well as EdDSA with Ed25519 for signing and verifying DPoP proofs.

Client DPoP

Basic usage

How to generate a client signing EC key and use it to make a DPoP secured token request followed by a DPoP secured protected resource request:

// Generate an EC key pair for signing the DPoP proofs with the
// ES256 JWS algorithm. The OAuth 2.0 client should store this
// key securely for the duration of its use.
ECKey jwk = new ECKeyGenerator(Curve.P_256)
    .keyID("1")
    .generate();

// Create a DPoP proof factory for the EC key
DPoPProofFactory proofFactory = new DefaultDPoPProofFactory(
    jwk,
    JWSAlgorithm.ES256);

// Token request with DPoP for a public OAuth 2.0 client
ClientID clientID = new ClientID("123");
AuthorizationCode code = new AuthorizationCode("Nooz9mohqu7Tha2E");
URI redirectURI = new URI("https://example.com/callback");

TokenRequest tokenRequest = new TokenRequest(
    new URI("https://c2id.com/token"),
    clientID,
    new AuthorizationCodeGrant(code, redirectURI));

HTTPRequest httpRequest = tokenRequest.toHTTPRequest();

// Generate a new DPoP proof for the token request
SignedJWT proof = proofFactory.createDPoPJWT(
    httpRequest.getMethod().name(),
    httpRequest.getURI());
httpRequest.setDPoP(proof);

// Send the token request to the OAuth 2.0 server
HTTPResponse httpResponse = httpRequest.send();

TokenResponse tokenResponse = TokenResponse.parse(httpResponse);

if (! tokenResponse.indicatesSuccess()) {
    // The token request failed
    ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
    System.err.println(error.getHTTPStatusCode());
    System.err.println(error.getCode());
    return;
}

Tokens tokens = tokenResponse.toSuccessResponse().getTokens();
DPoPAccessToken dPoPAccessToken = tokens.getDPoPAccessToken();

if (dPoPAccessToken == null) {
    // The access token is not of type DPoP. Depending on
    // its security policy the OAuth 2.0 client may choose
    // to abort here
    return;
}

// Access some DPoP aware resource with the token
httpRequest = new HTTPRequest(
    HTTPRequest.Method.GET,
    new URI("https://api.example.com/accounts"));
httpRequest.setAuthorization(dPoPAccessToken.toAuthorizationHeader());

// Generate a new DPoP proof for the resource request
proof = proofFactory.createDPoPJWT(
    httpRequest.getMethod().name(),
    httpRequest.getURI());
httpRequest.setDPoP(proof);

// Make the request
httpRequest.send();

End-to-end DPoP binding

DPoP can be brought forward in the authorisation code flow, to secure it end-to-end. A client can do this by including the DPoP JWK SHA-256 thumbprint in the dpop_jkt authorisation request parameter.

Note, the dpop_jkt is an optional DPoP feature and if a DPoP-enabled OAuth 2.0 servers doesn't support it, it will be silently ignored.

// The DPoP key
ECKey jwk = /* ... */;

// PKCE is a recommended countermeasure against code injection attacks
CodeVerifier pkceVerifier = new CodeVerifier();

// Compute the JWK thumbprint for DPoP
JWKThumbprintConfirmation dpopJKT = JWKThumbprintConfirmation.of(jwk);

// Compose the authorisation request with the dpop_jkt parameter
AuthorizationRequest request = new AuthorizationRequest.Builder(
    ResponseType.CODE, new ClientID("123"))
    .endpointURI(new URI("https://c2id.com/login"))
    .scope(new Scope("read", "write"))
    .state(new State())
    .codeChallenge(pkceVerifier, CodeChallengeMethod.S256)
    .dPoPJWKThumbprintConfirmation(dpopJKT)
    .build();

// Serialise the request:
// https://c2id.com/login?
//  response_type=code
//  &client_id=123
//  &scope=read+write
//  &state=W2GZqkTWxAJphwBM7T2NP5SNJ6_xABoKMDTgmSGUj-w
//  &dpop_jkt=O3osYCGjl-aSm_FbzOKFZtOrzkyFtgATItp5fepwcSA
//  &code_challenge_method=S256
//  &code_challenge=K5mWL42Cly67d3EUJsIGeX_wtqDS2BsKFIFbVlR5Nfw
request.toURI();

Including PAR in the end-to-end DPoP binding

The dpop_jkt parameter can naturally also be included in a pushed authorisation request (PAR) (RFC 9126):

// Compose the authorisation request with the dpop_jkt parameter
AuthorizationRequest request = new AuthorizationRequest.Builder(
    ResponseType.CODE, new ClientID("123"))
    .endpointURI(new URI("https://c2id.com/login"))
    .scope(new Scope("read", "write"))
    .state(new State())
    .codeChallenge(pkceVerifier, CodeChallengeMethod.S256)
    .dPoPJWKThumbprintConfirmation(dpopJKT)
    .build();

// Convert to PAR and send
HTTPResponse httpResponse = new PushedAuthorizationRequest(
    new URI("https://c2id.com/par"),
    request)
    .toHTTPRequest()
    .send();

PushedAuthorizationResponse response = PushedAuthorizationResponse.parse(httpResponse);
if (! response.indicatesSuccess()) {
    // Error
    System.err.println(response.toErrorResponse().getErrorObject());
    return;
}

// Success, extract the PAR URI
URI parURI = response.toSuccessResponse().getRequestURI();

Pushed authorisation requests with DPoP will also accept a DPoP proof header instead of the dpop_jkt authorisation request parameter, just like the token endpoint:

// The DPoP key
ECKey jwk = /* ... */;

// Create a DPoP proof factory
DPoPProofFactory proofFactory = new DefaultDPoPProofFactory(jwk, JWSAlgorithm.ES256);

// PKCE is a recommended countermeasure against code injection attacks
CodeVerifier pkceVerifier = new CodeVerifier();

// Compose the authorisation request, there is no dpop_jkt parameter!
AuthorizationRequest request = new AuthorizationRequest.Builder(
    ResponseType.CODE, new ClientID("123"))
    .endpointURI(new URI("https://c2id.com/login"))
    .scope(new Scope("read", "write"))
    .state(new State())
    .codeChallenge(pkceVerifier, CodeChallengeMethod.S256)
    .build();

// Convert to PAR and add a DPoP proof header
HTTPRequest httpRequest = new PushedAuthorizationRequest(
    new URI("https://c2id.com/par"),
    request)
    .toHTTPRequest();
httpRequest.setDPoP(proofFactory.createDPoPJWT(
    httpRequest.getMethod().name(),
    httpRequest.getURI()));

// Send the HTTP request
HTTPResponse httpResponse = httpRequest.send();
PushedAuthorizationResponse response = PushedAuthorizationResponse.parse(httpResponse);
// Continue...

Nonce

When the endpoint responds with a use_dpop_nonce error (in a HTTP 400 Bad Request response) the client must repeat the previous request with a nonce included. The nonce may be supplied by the endpoint in the HTTP DPoP-Nonce response header; if not the client must generate the nonce itself.

How can a client detect a use_dpop_nonce error in a token response:

// Parse the token response
TokenResponse tokenResponse = TokenResponse.parse(httpResponse);

if (! tokenResponse.indicatesSuccess()) {
    // The token endpoint returned an error
    ErrorObject errorObject = tokenResponse.toErrorResponse().getErrorObject();

    if (OAuth2Error.USE_DPOP_NONCE.equals(errorObject)) {
        // The error is use_dpop_nonce
        Nonce dPoPNonce = httpResponse.getDPoPNonce();

        if (dPoPNonce != null) {
            // Use the server supplied nonce...
        } else {
            // Generate a new nonce
            dPoPNonce = new Nonce();
        }
    }
}

How to create a DPoP proof with a nonce:

// The nonce
Nonce dPoPNonce = /* ... */;

// Generate a new DPoP proof with the nonce for a token request
SignedJWT proof = proofFactory.createDPoPJWT(
    httpRequest.getMethod().name(),
    httpRequest.getURI(),
    dPoPNonce);

DPoP proof and access token binding validation at a resource server

The SDK provides a DPoPProtectedResourceRequestVerifier for resource servers to validate requests from clients with a DPoP proof and access token:

// The accepted DPoP proof JWS algorithms
Set<JWSAlgorithm> acceptedAlgs = new HashSet<>(
    Arrays.asList(
        JWSAlgorithm.RS256,
        JWSAlgorithm.PS256,
        JWSAlgorithm.ES256));

// The max accepted age of the DPoP proof JWTs
long proofMaxAgeSeconds = 60;

// DPoP single use checker, caches the DPoP proof JWT jti claims
long cachePurgeIntervalSeconds = 600;
SingleUseChecker<Map.Entry<DPoPIssuer, JWTID>> singleUseChecker =
    new DefaultDPoPSingleUseChecker(
        proofMaxAgeSeconds,
        cachePurgeIntervalSeconds);

// Create the DPoP proof and access token binding verifier,
// the class is thread-safe
DPoPProtectedResourceRequestVerifier verifier =
    new DPoPProtectedResourceRequestVerifier(
        acceptedAlgs,
        proofMaxAgeSeconds,
        singleUseChecker);

// Verify some request

// The HTTP request method and URL
String httpMethod = "GET";
URI httpURI = new URI("https://api.example.com/accounts");

// The DPoP proof, obtained from the HTTP DPoP header
SignedJWT dPoPProof = /* ... */;

// The DPoP access token, obtained from the HTTP Authorization header
DPoPAccessToken accessToken = /* ... */;

// The DPoP proof issuer, typically the client ID obtained from the
// access token introspection
DPoPIssuer dPoPIssuer = new DPoPIssuer(new ClientID("123"));

// The JWK SHA-256 thumbprint confirmation, obtained from the
// access token introspection
JWKThumbprintConfirmation cnf = /* ... */;

// The expected nonce, if any
Nonce nonce = /* ... */;

try {
    verifier.verify(httpMethod, httpURI, dPoPIssuer, dPoPProof,
                    accessToken, cnf, nonce);
} catch (InvalidDPoPProofException e) {
    System.err.println("Invalid DPoP proof: " + e.getMessage());
    return;
} catch (AccessTokenValidationException e) {
    System.err.println("Invalid access token binding: " + e.getMessage());
    return;
} catch (JOSEException e) {
    System.err.println("Internal error: " + e.getMessage());
    return;
}

// The request processing can proceed

Extracting the JWK thumbprint confirmation from a JWT-encoded access token

If the resource server receives JWT-encoded DPoP access tokens the thumbprint of the client key will be set in the cnf.jkt claim. The resource server can extract the parameter like this in order the complete the verification:

JWTClaimsSet tokenClaims = /* ... */;

JWKThumbprintConfirmation cnf = JWKThumbprintConfirmation.parse(tokenClaims);

if (cnf == null) {
    System.out.println("The token is not DPoP bound");
    return;
}

// Continue processing

Introspection of a DPoP access token

If the access token is identifier-based and needs to be introspected at the authorisation server:

// Parse the token introspection response
HTTPResponse httpResponse = /* ... */;
TokenIntrospectionResponse response = TokenIntrospectionResponse.parse(httpResponse);

if (! response.indicatesSuccess()) {
    // The introspection request failed
    System.err.println(response.toErrorResponse().getErrorObject().getHTTPStatusCode());
    System.err.println(response.toErrorResponse().getErrorObject().getCode());
    return;
}

TokenIntrospectionSuccessResponse tokenDetails = response.toSuccessResponse();

if (! tokenDetails.isActive()) {
    System.out.println("Invalid / expired access token");
    return;
}

// Get the JWK SHA-256 thumbprint confirmation, found in the
// cnf.jkt parameter, for use in the DPoPProtectedResourceRequestVerifier
JWKThumbprintConfirmation cnf = tokenDetails.getJWKThumbprintConfirmation();

if (cnf == null) {
    System.out.println("The token is not DPoP bound");
    return;
}

// Continue processing