Skip to content

Commit 0d3c242

Browse files
committed
add new DPoPTokenSerializer class for [de]serializing DPoPToken
for guillp#122
1 parent 56b93c9 commit 0d3c242

File tree

6 files changed

+108
-3
lines changed

6 files changed

+108
-3
lines changed

requests_oauth2client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
from .dpop import (
8080
DPoPKey,
8181
DPoPToken,
82+
DPoPTokenSerializer,
8283
InvalidDPoPAccessToken,
8384
InvalidDPoPAlg,
8485
InvalidDPoPKey,

requests_oauth2client/authorization_request.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,8 @@ def default_dumper(azr: AuthorizationRequest) -> str:
932932
Serialize an AuthorizationRequest as JSON, then compress with deflate, then encodes as
933933
base64url.
934934
935+
WARNING: If the `AuthorizationRequest` has `DPoPKey`, this does not serialize custom `jti_generator`, `iat_generator` or `dpop_token_class`!
936+
935937
Args:
936938
azr: the `AuthorizationRequest` to serialize
937939

requests_oauth2client/dpop.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
from uuid import uuid4
1010

1111
import jwskate
12-
from attrs import define, field, frozen, setters
12+
from attrs import asdict, define, field, frozen, setters
1313
from binapy import BinaPy
1414
from furl import furl # type: ignore[import-untyped]
1515
from requests import codes
1616
from typing_extensions import Self
1717

18-
from .tokens import AccessTokenTypes, BearerToken, IdToken, id_token_converter
18+
from .tokens import AccessTokenTypes, BearerToken, BearerTokenSerializer, IdToken, id_token_converter
1919
from .utils import accepts_expires_in
2020

2121
if TYPE_CHECKING:
@@ -347,6 +347,71 @@ def handle_rs_provided_dpop_nonce(self, response: requests.Response) -> None:
347347
self.rs_nonce = nonce
348348

349349

350+
class DPoPTokenSerializer(BearerTokenSerializer):
351+
"""A helper class to serialize `DPoPToken`s.
352+
353+
This may be used to store DPoPTokens in session or cookies.
354+
355+
It needs a `dumper` and a `loader` functions that will respectively serialize and deserialize
356+
DPoPTokens. Default implementations are provided with use gzip and base64url on the serialized
357+
JSON representation.
358+
359+
Args:
360+
dumper: a function to serialize a token into a `str`.
361+
loader: a function to deserialize a serialized token representation.
362+
363+
"""
364+
365+
@staticmethod
366+
def default_dumper(token: DPoPToken) -> str:
367+
"""Serialize a token as JSON, then compress with deflate, then encodes as base64url.
368+
369+
WARNING: This does not serialize custom `jti_generator`, `iat_generator` or `dpop_token_class` in `DPoPKey`!
370+
371+
Args:
372+
token: the `DPoPToken` to serialize
373+
374+
Returns:
375+
the serialized value
376+
377+
"""
378+
d = asdict(token)
379+
d.update(**d.pop("kwargs", {}))
380+
d["dpop_key"]["private_key"] = token.dpop_key.private_key.to_pem()
381+
d["dpop_key"].pop("jti_generator", None)
382+
d["dpop_key"].pop("iat_generator", None)
383+
d["dpop_key"].pop("dpop_token_class", None)
384+
return (
385+
BinaPy.serialize_to("json", {k: w for k, w in d.items() if w is not None}).to("deflate").to("b64u").ascii()
386+
)
387+
388+
@staticmethod
389+
def default_loader(serialized: str, token_class: type[DPoPToken] = DPoPToken) -> DPoPToken:
390+
"""Deserialize a `DPoPToken`.
391+
392+
This does the opposite operations than `default_dumper`.
393+
394+
Args:
395+
serialized: the serialized token
396+
token_class: class to use to deserialize the Token
397+
398+
Returns:
399+
a DPoPToken
400+
401+
"""
402+
attrs = BinaPy(serialized).decode_from("b64u").decode_from("deflate").parse_from("json")
403+
404+
expires_at = attrs.get("expires_at")
405+
if expires_at:
406+
attrs["expires_at"] = datetime.fromtimestamp(expires_at, tz=timezone.utc)
407+
408+
if dpop_key := attrs.pop("dpop_key", None):
409+
dpop_key["private_key"] = jwskate.Jwk.from_pem(dpop_key["private_key"])
410+
attrs["_dpop_key"] = DPoPKey(**dpop_key)
411+
412+
return token_class(**attrs)
413+
414+
350415
def validate_dpop_proof( # noqa: C901
351416
proof: str | bytes,
352417
*,

requests_oauth2client/tokens.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,8 @@ def default_dumper(token: BearerToken) -> str:
643643
BinaPy.serialize_to("json", {k: w for k, w in d.items() if w is not None}).to("deflate").to("b64u").ascii()
644644
)
645645

646-
def default_loader(self, serialized: str, token_class: type[BearerToken] = BearerToken) -> BearerToken:
646+
@staticmethod
647+
def default_loader(serialized: str, token_class: type[BearerToken] = BearerToken) -> BearerToken:
647648
"""Deserialize a BearerToken.
648649
649650
This does the opposite operations than `default_dumper`.

tests/unit_tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
ClientSecretBasic,
1818
ClientSecretJwt,
1919
ClientSecretPost,
20+
DPoPKey,
21+
DPoPToken,
2022
OAuth2Client,
2123
PrivateKeyJwt,
2224
PublicApp,
@@ -50,6 +52,16 @@ def bearer_auth(access_token: str) -> BearerToken:
5052
return BearerToken(access_token)
5153

5254

55+
@pytest.fixture(scope="session")
56+
def dpop_key() -> DPoPKey:
57+
return DPoPKey.generate()
58+
59+
60+
@pytest.fixture(scope="session")
61+
def dpop_token(access_token: str, dpop_key: DPoPKey) -> DPoPToken:
62+
return DPoPToken(access_token=access_token, _dpop_key=dpop_key)
63+
64+
5365
@pytest.fixture(scope="session")
5466
def target_api() -> str:
5567
return "https://myapi.local/root/"

tests/unit_tests/test_dpop.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
from datetime import datetime, timedelta, timezone
12
import secrets
23

34
import pytest
45
import requests
56
from binapy import BinaPy
67
from freezegun import freeze_time
8+
from freezegun.api import FrozenDateTimeFactory
79
from jwskate import Jwk, Jwt, KeyManagementAlgs, SignatureAlgs, SignedJwt
810

911
from requests_oauth2client import (
1012
DPoPKey,
1113
DPoPToken,
14+
DPoPTokenSerializer,
1215
InvalidDPoPAccessToken,
1316
InvalidDPoPAlg,
1417
InvalidDPoPKey,
@@ -703,3 +706,24 @@ def test_rs_dpop_nonce_loop(
703706
resp = requests.get(target_api, auth=dpop_token)
704707
assert resp.status_code == 401
705708
assert resp.headers["DPoP-Nonce"] == "nonce2"
709+
710+
711+
def test_token_serializer(dpop_token: DPoPToken, freezer: FrozenDateTimeFactory) -> None:
712+
freezer.move_to("2024-08-01")
713+
serializer = DPoPTokenSerializer()
714+
candidate = serializer.dumps(dpop_token)
715+
freezer.move_to(datetime.now(tz=timezone.utc) + timedelta(days=365))
716+
deserialized = serializer.loads(candidate)
717+
718+
# Can't just check deserialized == dpop_token because DPoPKey iat_generator,
719+
# jti_generator, and dpop_token_class defaults are different per instance
720+
assert deserialized.access_token == dpop_token.access_token
721+
assert deserialized.refresh_token == dpop_token.refresh_token
722+
assert deserialized.expires_at == dpop_token.expires_at
723+
assert deserialized.token_type == dpop_token.token_type
724+
725+
assert deserialized.dpop_key.private_key == dpop_token.dpop_key.private_key
726+
assert deserialized.dpop_key.alg == dpop_token.dpop_key.alg
727+
assert deserialized.dpop_key.jwt_typ == dpop_token.dpop_key.jwt_typ
728+
assert deserialized.dpop_key.as_nonce == dpop_token.dpop_key.as_nonce
729+
assert deserialized.dpop_key.rs_nonce == dpop_token.dpop_key.rs_nonce

0 commit comments

Comments
 (0)