Skip to content

RFC-9449: DPoP#808

Open
zachswasey wants to merge 7 commits intoauthlib:mainfrom
zachswasey:rfc9449
Open

RFC-9449: DPoP#808
zachswasey wants to merge 7 commits intoauthlib:mainfrom
zachswasey:rfc9449

Conversation

@zachswasey
Copy link

@zachswasey zachswasey commented Aug 27, 2025

This is a WIP to request feedback early on in the approach before I clean things up, write tests, and submit an official PR.

This implements RFC-9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), #315 .

I'm supporting ATProto (Bluesky) in my application, and their implementation requires DPoP and PAR (which I also have a change I'm testing that I'll put up as a WIP PR later). You can read more about their specific requirements here for context.

Please ignore the requests/django integration at the moment, as I've only focused on HTTPX in FastAPI at the moment. I'll update the other integrations once the approach is more finalized.

To use in Client:

  • A dev would instantiate a rfc9449.DPoPProof, and pass that to the OAuthClient.
  • The session JWK is created internally, and passed back on the token to be persisted along with the token since it needs to remain the same for the lifespan of the auth session and used for all interactions after /authorize or /par.
  • A JWK can also be passed initially if desired, taken off the token that's passed into the client (if available), or have the mechanism to generate the JWK overridden if something besides ES256 is needed.
  • TokenAuth/ClientAuth support retrying the request when a new nonce is returned from the auth/resource servers.

I explored refactoring TokenAuth/ClientAuth to support multiple Auths (separating Authorization header/client_secret_* modifications, and the DPoP proof header modifications), but that turned out to be more complicated than I originally expected. This would've introduced a new CompositeAuth to handle the pairs: TokenAuth/DPoPAuth, ClientAuth/DPoPAuth. I'm not against further exploring this idea, if it's desired by the maintainers. For now, I've implemented DPoP Proof directly in TokenAuth/ClientAuth implementations.

To use in Server:

dpop_nonce_generator = DefaultDPoPNonceGenerator()
dpop_proof_validator = DPoPProofValidator(nonce_generator=dpop_nonce_generator)
dpop_extension = DPoP(proof_validator=dpop_proof_validator)
server.register_grant(MyAuthorizationCodeGrant, [dpop_extension])
server.register_grant(MyRefreshTokenGrant, [dpop_extension])

require_oauth = ResourceProtector()
require_oauth.register_token_validator(MyDPoPTokenValidator(proof_validator=dpop_proof_validator))

Feel free to give any comments or ask questions as necessary about the implementation. Once I've got go ahead for the approach I'll clean up, implement the further integrations, add tests, and put up a final PR.

Thanks!

TODO:

  • Other integrations
  • Tests
  • Code in proper rfc* locations
  • An authorization server MAY elect to issue access tokens that are not DPoP bound, which is signaled to the client with a value of Bearer in the token_type parameter of the access token response per [RFC6750]. For a public client that is also issued a refresh token, this has the effect of DPoP-binding the refresh token alone, which can improve the security posture even when protected resources are not updated to support DPoP.
  • Add dpop_signing_alg_values_supported to metadata, and check alg against it
  • dpop_jkt parameter
  • server support (validate proofs, generate nonces, appropriate errors)
  • Add nonce timestamp checking to proof iat checking

@zachswasey zachswasey marked this pull request as ready for review August 27, 2025 21:11
@zachswasey
Copy link
Author

Whoops, I wasn't aware that a Draft PR wouldn't notify anybody to actually take a look. So, marked as Ready for Review, but again, still just WIP and looking for high level feedback before finalizing. Thanks!

@azmeuk
Copy link
Member

azmeuk commented Aug 27, 2025

Hi. Thank you for your contribution. We had the notification even when the PR was in draft.
I'll try to review your work, but as I did not study the DPoP spec yet, it may take some days or weeks.

* Add DPoP extension for AuthorizationCodeGrant and RefreshTokenGrant
* Add DPoPTokenValidator for ResourceProtector
* Add DPoPProofValidator to validate proofs
* Add DPoPNonceGenerator protocol for server-side nonce creation and management
* Add DPoPNonceCache protocol for client-side nonce cache
* Add DPoPTokenGenerator for public-key bound access tokens of DPoP type
* Remove update_nonces, as nonce-loss is self-correcting
Comment on lines +54 to +62
@property
def dpop_jkt(self):
if self._dpop_jkt:
return self._dpop_jkt
return self.data.get("dpop_jkt")

@dpop_jkt.setter
def dpop_jkt(self, value):
self._dpop_jkt = value
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixin, if possible.

Comment on lines +41 to +64
def validate_token_type(self, token, request):
from ..rfc6750.errors import InvalidTokenError
from ..rfc9449 import DPoPTokenValidator
if token.get_dpop_jkt():
if self.TOKEN_TYPE != DPoPTokenValidator.TOKEN_TYPE:
raise InvalidTokenError(description=f"Access token is bound to a DPoP proof, but token type is {self.TOKEN_TYPE}",
token_type="DPoP",
realm=self.realm,
extra_attributes=self.extra_attributes)
else:
if "DPoP" in request.headers and self.TOKEN_TYPE != DPoPTokenValidator.TOKEN_TYPE:
raise InvalidTokenError(
token_type=self.TOKEN_TYPE,
description=f"DPoP proof not expected for {self.TOKEN_TYPE} token type",
realm=self.realm,
extra_attributes=self.extra_attributes
)

saved_token_type = token.get_token_type()
if saved_token_type.lower() != self.TOKEN_TYPE:
raise InvalidTokenError(description=f"Access token is of type {saved_token_type}, but token type is {self.TOKEN_TYPE}",
token_type=saved_token_type,
realm=self.realm,
extra_attributes=self.extra_attributes)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixin, if possible. Though this was a bit more difficult to do, but I'll give it another look.

@zachswasey
Copy link
Author

Based on the discussions on #814 I'll likely refactor the server-side component a bit, so take the existing structure with a grain of salt.

@zachswasey
Copy link
Author

Refactored and addressed most of the comments I left above, and from ideas from the PAR #814 PR. The one remaining bit is in rfc6749/resource_protector.py and rfc6749/requests.py, but I can address that after overall architecture comments.

I haven't fully finished the httpx/requests integrations yet, but I refactored their Auth classes to remove code duplication and make it easier to add new extension Auths in the future via a CompositeAuth, and handle retrying for auth reasons.

Again, please give feedback on the overall approach before I address cleanup, tests, docs, etc. I added an overall server extension which automatically hooks into AuthorizationCodeGrant and the RefreshTokenGrant, so developers don't have to add the same extension multiple times (like in the original top-level comment).

dpop_nonce_generator = DefaultDPoPNonceGenerator()
dpop_proof_validator = DPoPProofValidator(nonce_generator=dpop_nonce_generator)
server.register_extension(DPoP(proof_validator=dpop_proof_validator)

require_oauth = ResourceProtector()
require_oauth.register_token_validator(MyDPoPTokenValidator(proof_validator=dpop_proof_validator))

self.dpop_proof = dpop_proof
if self.dpop_proof:
self.dpop_proof.generate_jwk(token, metadata.get("dpop_signing_alg_values_supported", None))
self.dpop_auth = DPoPAuth(self.dpop_proof)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add None above.


return True

def validate_token_type(self, token, request):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding this validate_token_type in TokenValidator, you should subclass TokenValidator and create a new DPoPTokenValidator.

Subclass this validator to register into ResourceProtector instance.

raise ValueError(f'"{key}" MUST be JSON array')


def _validate_boolean_value(metadata, key):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An underscore method is a private method, if you need to import this method elsewhere, better to make it a public method by removing the first underscore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants