Skip to content

Commit 69e6107

Browse files
committed
add new DPoPTokenSerializer class for [de]serializing DPoPToken
for #122
1 parent 49f0739 commit 69e6107

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
@@ -84,6 +84,7 @@
8484
from .dpop import (
8585
DPoPKey,
8686
DPoPToken,
87+
DPoPTokenSerializer,
8788
InvalidDPoPAccessToken,
8889
InvalidDPoPAlg,
8990
InvalidDPoPKey,

requests_oauth2client/authorization_request.py

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

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:
@@ -349,6 +349,71 @@ def handle_rs_provided_dpop_nonce(self, response: requests.Response) -> None:
349349
self.rs_nonce = nonce
350350

351351

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

requests_oauth2client/tokens.py

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

648-
def default_loader(self, serialized: str, token_class: type[BearerToken] = BearerToken) -> BearerToken:
648+
@staticmethod
649+
def default_loader(serialized: str, token_class: type[BearerToken] = BearerToken) -> BearerToken:
649650
"""Deserialize a BearerToken.
650651
651652
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)