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 token, can be effectively 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 with credentials that need to have the extra security of a cryptographically sender-constrained token should make use of mTLS extension (RFC 8705).

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

Client DPoP

How to generate a client signing EC key and use it to make a DPoP secured token request and 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 ecJWK = new ECKeyGenerator(Curve.P_256)
    .keyID("1")
    .generate();

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

// Token request with DPoP for a public OAuth 2.0 client
ClientID clientID = new ClientID("123");
AuthorizationCode code = new AuthorizationCode("ohyahhaht0vee0ech7Kieleephieheif");
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();

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 = /* ... */;

try {
    verifier.verify(httpMethod, httpURI, dPoPIssuer, dPoPProof, accessToken, cnf);
} 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