Skip to content

Commit

Permalink
Support for DPoP (#75)
Browse files Browse the repository at this point in the history
* support for DPoP proofing for token requests and API requests
* use a classvar for DPoP Header name instead of hardcoding
* support for Authorization Code DPoP binding
* add `BearerToken.access_token_jwt` property
  • Loading branch information
guillp authored Oct 4, 2024
1 parent 362aabd commit d1d9e2f
Show file tree
Hide file tree
Showing 17 changed files with 1,365 additions and 270 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
hooks:
- id: ruff-format
- id: ruff
args: [ --fix ]
- id: ruff-format
Expand Down
295 changes: 210 additions & 85 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ exclude_also = [
[tool.docformatter]
black = true
recursive = true
wrap-summaries = 100
wrap-descriptions = 100
wrap-summaries = 120
wrap-descriptions = 120
blank = true

[tool.ruff]
Expand Down
18 changes: 18 additions & 0 deletions requests_oauth2client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@
oidc_discovery_document_url,
well_known_uri,
)
from .dpop import (
DPoPKey,
DPoPToken,
InvalidDPoPAccessToken,
InvalidDPoPAlg,
InvalidDPoPKey,
InvalidDPoPProof,
validate_dpop_proof,
)
from .exceptions import (
AccessDenied,
AccountSelectionRequired,
Expand Down Expand Up @@ -113,6 +122,7 @@
UnknownIntrospectionError,
UnknownTokenEndpointError,
UnsupportedTokenType,
UseDPoPNonce,
)
from .pooling import (
BaseTokenEndpointPoolingJob,
Expand Down Expand Up @@ -160,6 +170,8 @@
"ClientSecretPost",
"CodeChallengeMethods",
"ConsentRequired",
"DPoPKey",
"DPoPToken",
"DeviceAuthorizationError",
"DeviceAuthorizationPoolingJob",
"DeviceAuthorizationResponse",
Expand All @@ -183,6 +195,10 @@
"InvalidCodeVerifierParam",
"InvalidDeviceAuthorizationResponse",
"InvalidDiscoveryDocument",
"InvalidDPoPAccessToken",
"InvalidDPoPAlg",
"InvalidDPoPKey",
"InvalidDPoPProof",
"InvalidEndpointUri",
"InvalidGrant",
"InvalidIdToken",
Expand Down Expand Up @@ -247,9 +263,11 @@
"UnknownTokenType",
"UnknownActorTokenType",
"UnknownSubjectTokenType",
"UseDPoPNonce",
"requests",
"oauth2_discovery_document_url",
"oidc_discovery_document_url",
"validate_dpop_proof",
"validate_endpoint_uri",
"validate_issuer_uri",
"well_known_uri",
Expand Down
65 changes: 49 additions & 16 deletions requests_oauth2client/authorization_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
from attrs import asdict, field, fields, frozen
from binapy import BinaPy
from furl import furl # type: ignore[import-untyped]
from jwskate import JweCompact, Jwk, Jwt, SignedJwt
from jwskate import JweCompact, Jwk, Jwt, SignatureAlgs, SignedJwt

from .dpop import DPoPKey
from .exceptions import (
AuthorizationResponseError,
ConsentRequired,
Expand Down Expand Up @@ -207,12 +208,17 @@ class AuthorizationResponse:
extracted from the Authorization Response parameters.
Args:
code: the authorization code returned by the AS
redirect_uri: the redirect_uri that was passed as parameter in the AuthorizationRequest
code_verifier: the code_verifier matching the code_challenge that was passed as
parameter in the AuthorizationRequest
state: the state returned by the AS
**kwargs: other parameters as returned by the AS
code: The authorization `code` returned by the AS.
redirect_uri: The `redirect_uri` that was passed as parameter in the Authorization Request.
code_verifier: the `code_verifier` matching the `code_challenge` that was passed as
parameter in the Authorization Request.
state: the `state` that was was passed as parameter in the Authorization Request and returned by the AS.
nonce: the `nonce` that was was passed as parameter in the Authorization Request.
acr_values: the `acr_values` that was passed as parameter in the Authorization Request.
max_age: the `max_age` that was passed as parameter in the Authorization Request.
issuer: the expected `issuer` identifier.
dpop_key: the `DPoPKey` that was used for Authorization Code DPoP binding.
**kwargs: other parameters as returned by the AS.
"""

Expand All @@ -224,6 +230,7 @@ class AuthorizationResponse:
acr_values: tuple[str, ...] | None
max_age: int | None
issuer: str | None
dpop_key: DPoPKey | None
kwargs: dict[str, Any]

def __init__(
Expand All @@ -237,6 +244,7 @@ def __init__(
acr_values: str | Sequence[str] | None = None,
max_age: int | None = None,
issuer: str | None = None,
dpop_key: DPoPKey | None = None,
**kwargs: str,
) -> None:
if not acr_values:
Expand All @@ -255,6 +263,7 @@ def __init__(
acr_values=acr_values,
max_age=max_age,
issuer=issuer,
dpop_key=dpop_key,
kwargs=kwargs,
)

Expand Down Expand Up @@ -364,6 +373,8 @@ class AuthorizationRequest:
authorization_response_iss_parameter_supported: bool
issuer: str | None

dpop_key: DPoPKey | None = None

exception_classes: ClassVar[dict[str, type[AuthorizationResponseError]]] = {
"interaction_required": InteractionRequired,
"login_required": LoginRequired,
Expand Down Expand Up @@ -397,6 +408,9 @@ def __init__( # noqa: PLR0913, C901
max_age: int | None = None,
issuer: str | None = None,
authorization_response_iss_parameter_supported: bool = False,
dpop: bool = False,
dpop_alg: str = SignatureAlgs.ES256,
dpop_key: DPoPKey | None = None,
**kwargs: Any,
) -> None:
if response_type != ResponseTypes.CODE:
Expand Down Expand Up @@ -441,6 +455,9 @@ def __init__( # noqa: PLR0913, C901
else:
code_verifier = None

if dpop and not dpop_key:
dpop_key = DPoPKey.generate(dpop_alg)

self.__attrs_init__(
authorization_endpoint=authorization_endpoint,
client_id=client_id,
Expand All @@ -455,6 +472,7 @@ def __init__( # noqa: PLR0913, C901
acr_values=acr_values,
max_age=max_age,
authorization_response_iss_parameter_supported=authorization_response_iss_parameter_supported,
dpop_key=dpop_key,
kwargs=kwargs,
)

Expand All @@ -465,6 +483,13 @@ def code_challenge(self) -> str | None:
return PkceUtils.derive_challenge(self.code_verifier, self.code_challenge_method)
return None

@cached_property
def dpop_jkt(self) -> str | None:
"""The DPoP JWK thumbprint that matches ``dpop_key`."""
if self.dpop_key:
return self.dpop_key.dpop_jkt
return None

def as_dict(self) -> dict[str, Any]:
"""Return the full argument dict.
Expand All @@ -487,6 +512,7 @@ def args(self) -> dict[str, Any]:
if d["scope"]:
d["scope"] = " ".join(d["scope"])
d["code_challenge"] = self.code_challenge
d["dpop_jkt"] = self.dpop_jkt
d.update(self.kwargs)

return {key: val for key, val in d.items() if val is not None}
Expand All @@ -495,8 +521,7 @@ def validate_callback(self, response: str) -> AuthorizationResponse:
"""Validate an Authorization Response against this Request.
Validate a given Authorization Response URI against this Authorization Request, and return
an
[AuthorizationResponse][requests_oauth2client.authorization_request.AuthorizationResponse].
an [AuthorizationResponse][requests_oauth2client.authorization_request.AuthorizationResponse].
This includes matching the `state` parameter, checking for returned errors, and extracting
the returned `code` and other parameters.
Expand Down Expand Up @@ -557,6 +582,7 @@ def validate_callback(self, response: str) -> AuthorizationResponse:
nonce=self.nonce,
acr_values=self.acr_values,
max_age=self.max_age,
dpop_key=self.dpop_key,
**response_url.args,
)

Expand Down Expand Up @@ -769,6 +795,7 @@ class RequestParameterAuthorizationRequest:
client_id: str
request: Jwt
expires_at: datetime | None
dpop_key: DPoPKey | None
kwargs: dict[str, Any]

@accepts_expires_in
Expand All @@ -778,6 +805,7 @@ def __init__(
client_id: str,
request: Jwt | str,
expires_at: datetime | None = None,
dpop_key: DPoPKey | None = None,
**kwargs: Any,
) -> None:
if isinstance(request, str):
Expand All @@ -788,6 +816,7 @@ def __init__(
client_id=client_id,
request=request,
expires_at=expires_at,
dpop_key=dpop_key,
kwargs=kwargs,
)

Expand Down Expand Up @@ -823,34 +852,38 @@ class RequestUriParameterAuthorizationRequest:
"""Represent an Authorization Request that includes a `request_uri` parameter.
Args:
authorization_endpoint: the Authorization Endpoint uri
client_id: the client_id
request_uri: the request_uri
expires_at: the expiration date for this request
kwargs: extra parameters to include in the request
authorization_endpoint: The Authorization Endpoint uri.
client_id: The Client ID.
request_uri: The `request_uri`.
expires_at: The expiration date for this request.
kwargs: Extra query parameters to include in the request.
"""

authorization_endpoint: str
client_id: str
request_uri: str
expires_at: datetime | None
dpop_key: DPoPKey | None
kwargs: dict[str, Any]

@accepts_expires_in
def __init__(
self,
authorization_endpoint: str,
*,
client_id: str,
request_uri: str,
expires_at: datetime | None = None,
dpop_key: DPoPKey | None = None,
**kwargs: Any,
) -> None:
self.__attrs_init__(
authorization_endpoint=authorization_endpoint,
client_id=client_id,
request_uri=request_uri,
expires_at=expires_at,
dpop_key=dpop_key,
kwargs=kwargs,
)

Expand Down Expand Up @@ -879,8 +912,8 @@ def __repr__(self) -> str:
class AuthorizationRequestSerializer:
"""(De)Serializer for `AuthorizationRequest` instances.
You might need to store pending authorization requests in session, either server-side or client-
side. This class is here to help you do that.
You might need to store pending authorization requests in session, either server-side or client- side. This class is
here to help you do that.
"""

Expand Down
3 changes: 1 addition & 2 deletions requests_oauth2client/backchannel_authentication.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Implementation of CIBA.
CIBA stands for Client Initiated BackChannel Authentication and is standardised by the OpenID
Fundation.
CIBA stands for Client Initiated BackChannel Authentication and is standardised by the OpenID Fundation.
https://openid.net/specs/openid-client-initiated-backchannel-
authentication-core-1_0.html.
Expand Down
Loading

0 comments on commit d1d9e2f

Please sign in to comment.