Skip to content

Commit 3debeb6

Browse files
committed
Add documentation for DPoP support
Closes gh-2009
1 parent 86b5607 commit 3debeb6

File tree

4 files changed

+144
-0
lines changed

4 files changed

+144
-0
lines changed

docs/modules/ROOT/pages/overview.adoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ Spring Authorization Server supports the following features:
5858
* JSON Web Token (JWT) (https://tools.ietf.org/html/rfc7519[RFC 7519])
5959
* JSON Web Signature (JWS) (https://tools.ietf.org/html/rfc7515[RFC 7515])
6060

61+
|Token Types
62+
|
63+
* xref:protocol-endpoints.adoc#oauth2-token-endpoint-dpop-bound-access-tokens[DPoP-bound Access Tokens]
64+
|
65+
* OAuth 2.0 Demonstrating Proof of Possession (DPoP) (https://datatracker.ietf.org/doc/html/rfc9449[RFC 9449])
66+
6167
|xref:configuration-model.adoc#configuring-client-authentication[Client Authentication]
6268
|
6369
* `client_secret_basic`

docs/modules/ROOT/pages/protocol-endpoints.adoc

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,138 @@ static class CustomScopeValidator implements Consumer<OAuth2ClientCredentialsAut
349349
}
350350
----
351351

352+
[[oauth2-token-endpoint-dpop-bound-access-tokens]]
353+
=== DPoP-bound Access Tokens
354+
355+
https://datatracker.ietf.org/doc/html/rfc9449[RFC 9449 OAuth 2.0 Demonstrating Proof of Possession (DPoP)] is an application-level mechanism for sender-constraining an access token.
356+
357+
The primary goal of DPoP is to prevent unauthorized or illegitimate clients from using leaked or stolen access tokens, by binding an access token to a public key upon issuance by the authorization server and requiring that the client proves possession of the corresponding private key when using the access token at the resource server.
358+
359+
Access tokens that are sender-constrained via DPoP stand in contrast to the typical bearer token, which can be used by any client in possession of the access token.
360+
361+
DPoP introduces the concept of a https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-proof-jwts[DPoP Proof], which is a JWT created by the client and sent as a header in an HTTP request.
362+
A client uses a DPoP proof to prove the possession of a private key corresponding to a certain public key.
363+
364+
When the client initiates an access token request, it attaches a DPoP proof to the request in an HTTP header.
365+
The authorization server binds (sender-constrains) the access token to the public key associated in the DPoP proof.
366+
367+
When the client initiates a protected resource request, it again attaches a DPoP proof to the request in an HTTP header.
368+
369+
The resource server obtains information about the public key bound to the access token, either directly in the access token (JWT) or via the <<oauth2-token-introspection-endpoint,OAuth2 Token Introspection endpoint>>.
370+
The resource server then verifies that the public key bound to the access token matches the public key in the DPoP proof.
371+
It also verifies that the access token hash in the DPoP proof matches the access token in the request.
372+
373+
[[oauth2-token-endpoint-dpop-access-token-request]]
374+
==== DPoP Access Token Request
375+
376+
To request an access token that is bound to a public key using DPoP, the client MUST provide a valid DPoP proof in the `DPoP` header when making an access token request to the OAuth2 Token endpoint.
377+
This is applicable for all access token requests regardless of authorization grant type (e.g. `authorization_code`, `refresh_token`, `client_credentials`, etc).
378+
379+
The following HTTP request shows an `authorization_code` access token request with a DPoP proof in the `DPoP` header:
380+
381+
[source,shell]
382+
----
383+
POST /oauth2/token HTTP/1.1
384+
Host: server.example.com
385+
Content-Type: application/x-www-form-urlencoded
386+
DPoP: eyJraWQiOiJyc2EtandrLWtpZCIsInR5cCI6ImRwb3Arand0IiwiYWxnIjoiUlMyNTYiLCJqd2siOnsia3R5IjoiUlNBIiwiZSI6IkFRQUIiLCJraWQiOiJyc2EtandrLWtpZCIsIm4iOiIzRmxxSnI1VFJza0lRSWdkRTNEZDdEOWxib1dkY1RVVDhhLWZKUjdNQXZRbTdYWE5vWWttM3Y3TVFMMU5ZdER2TDJsOENBbmMwV2RTVElOVTZJUnZjNUtxbzJRNGNzTlg5U0hPbUVmem9ST2pRcWFoRWN2ZTFqQlhsdW9DWGRZdVlweDRfMXRmUmdHNmlpNFVoeGg2aUk4cU5NSlFYLWZMZnFoYmZZZnhCUVZSUHl3QmtBYklQNHgxRUFzYkM2RlNObWtoQ3hpTU5xRWd4YUlwWThDMmtKZEpfWklWLVdXNG5vRGR6cEtxSGN3bUI4RnNydW1sVllfRE5WdlVTRElpcGlxOVBiUDRIOTlUWE4xbzc0Nm9SYU5hMDdycTFob0NnTVNTeS04NVNhZ0NveGxteUUtRC1vZjlTc01ZOE9sOXQwcmR6cG9iQnVoeUpfbzVkZnZqS3cifX0.eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9zZXJ2ZXIuZXhhbXBsZS5jb20vb2F1dGgyL3Rva2VuIiwiaWF0IjoxNzQ2ODA2MzA1LCJqdGkiOiI0YjIzNDBkMi1hOTFmLTQwYTUtYmFhOS1kZDRlNWRlYWM4NjcifQ.wq8gJ_G6vpiEinfaY3WhereqCCLoeJOG8tnWBBAzRWx9F1KU5yAAWq-ZVCk_k07-h6DIqz2wgv6y9dVbNpRYwNwDUeik9qLRsC60M8YW7EFVyI3n_NpujLwzZeub_nDYMVnyn4ii0NaZrYHtoGXOlswQfS_-ET-jpC0XWm5nBZsCdUEXjOYtwaACC6Js-pyNwKmSLp5SKIk11jZUR5xIIopaQy521y9qJHhGRwzj8DQGsP7wMZ98UFL0E--1c-hh4rTy8PMeWCqRHdwjj_ry_eTe0DJFcxxYQdeL7-0_0CIO4Ayx5WHEpcUOIzBRoN32RsNpDZc-5slDNj9ku004DA
387+
388+
grant_type=authorization_code\
389+
&client_id=s6BhdRkqt\
390+
&code=SplxlOBeZQQYbYS6WxSbIA\
391+
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb\
392+
&code_verifier=bEaL42izcC-o-xBk0K2vuJ6U-y1p9r_wW2dFWIWgjz-
393+
----
394+
395+
The following shows a representation of the DPoP Proof JWT header and claims:
396+
397+
[source,json]
398+
----
399+
{
400+
"typ": "dpop+jwt",
401+
"alg": "RS256",
402+
"jwk": {
403+
"kty": "RSA",
404+
"e": "AQAB",
405+
"n": "3FlqJr5TRskIQIgdE3Dd7D9lboWdcTUT8a-fJR7MAvQm7XXNoYkm3v7MQL1NYtDvL2l8CAnc0WdSTINU6IRvc5Kqo2Q4csNX9SHOmEfzoROjQqahEcve1jBXluoCXdYuYpx4_1tfRgG6ii4Uhxh6iI8qNMJQX-fLfqhbfYfxBQVRPywBkAbIP4x1EAsbC6FSNmkhCxiMNqEgxaIpY8C2kJdJ_ZIV-WW4noDdzpKqHcwmB8FsrumlVY_DNVvUSDIipiq9PbP4H99TXN1o746oRaNa07rq1hoCgMSSy-85SagCoxlmyE-D-of9SsMY8Ol9t0rdzpobBuhyJ_o5dfvjKw"
406+
}
407+
}
408+
----
409+
410+
[source,json]
411+
----
412+
{
413+
"htm": "POST",
414+
"htu": "https://server.example.com/oauth2/token",
415+
"iat": 1746806305,
416+
"jti": "4b2340d2-a91f-40a5-baa9-dd4e5deac867"
417+
}
418+
----
419+
420+
The following code shows an example of how to generate the DPoP Proof JWT:
421+
422+
[source,java]
423+
----
424+
RSAKey rsaKey = ...
425+
JWKSource<SecurityContext> jwkSource = (jwkSelector, securityContext) -> jwkSelector
426+
.select(new JWKSet(rsaKey));
427+
NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource);
428+
429+
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256)
430+
.type("dpop+jwt")
431+
.jwk(rsaKey.toPublicJWK().toJSONObject())
432+
.build();
433+
JwtClaimsSet claims = JwtClaimsSet.builder()
434+
.issuedAt(Instant.now())
435+
.claim("htm", "POST")
436+
.claim("htu", "https://server.example.com/oauth2/token")
437+
.id(UUID.randomUUID().toString())
438+
.build();
439+
440+
Jwt dPoPProof = jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
441+
----
442+
443+
After the authorization server successfully validates the DPoP proof, the public key from the DPoP proof will be bound (sender-constrained) to the issued access token.
444+
445+
The following access token response shows the `token_type` parameter as `DPoP` to signal to the client that the access token was bound to its DPoP proof public key:
446+
447+
[source,shell]
448+
----
449+
HTTP/1.1 200 OK
450+
Content-Type: application/json
451+
Cache-Control: no-store
452+
453+
{
454+
"access_token": "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
455+
"token_type": "DPoP",
456+
"expires_in": 2677
457+
}
458+
----
459+
460+
[[oauth2-token-endpoint-dpop-public-key-confirmation]]
461+
==== Public Key Confirmation
462+
463+
Resource servers MUST be able to identify whether an access token is DPoP-bound and verify the binding to the public key of the DPoP proof.
464+
The binding is accomplished by associating the public key with the access token in a way that can be accessed by the resource server, such as embedding the public key hash in the access token directly (JWT) or through token introspection.
465+
466+
When an access token is represented as a JWT, the public key hash is contained in the `jkt` claim under the confirmation method (`cnf`) claim.
467+
468+
The following example shows the claims of a JWT access token containing a `cnf` claim with a `jkt` claim, which is the JWK SHA-256 Thumbprint of the DPoP proof public key:
469+
470+
[source,json]
471+
----
472+
{
473+
474+
"iss":"https://server.example.com",
475+
"nbf":1562262611,
476+
"exp":1562266216,
477+
"cnf":
478+
{
479+
"jkt":"CQMknzRoZ5YUi7vS58jck1q8TmZT8wiIiXrCN1Ny4VU"
480+
}
481+
}
482+
----
483+
352484
[[oauth2-token-introspection-endpoint]]
353485
== OAuth2 Token Introspection Endpoint
354486

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/DPoPProofVerifier.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@
2626
import org.springframework.util.StringUtils;
2727

2828
/**
29+
* A verifier for DPoP Proof {@link Jwt}'s.
30+
*
2931
* @author Joe Grandja
3032
* @since 1.5
33+
* @see DPoPProofJwtDecoderFactory
34+
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc9449">RFC 9449
35+
* OAuth 2.0 Demonstrating Proof of Possession (DPoP)</a>
3136
*/
3237
final class DPoPProofVerifier {
3338

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenContext.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
public interface OAuth2TokenContext extends Context {
4646

4747
/**
48+
* The key used for the DPoP Proof {@link Jwt} (if available).
4849
* @since 1.5
4950
*/
5051
String DPOP_PROOF_KEY = Jwt.class.getName().concat(".DPOP_PROOF");

0 commit comments

Comments
 (0)