Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/auth0_server_python/auth_schemes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .bearer_auth import BearerAuth
from .dpop_auth import DPoPAuth

__all__ = ["BearerAuth"]
__all__ = ["BearerAuth", "DPoPAuth"]
88 changes: 88 additions & 0 deletions src/auth0_server_python/auth_schemes/dpop_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import base64
import hashlib
import time
import uuid

import httpx
from jwcrypto import jwk
from jwcrypto import jwt as jwcrypto_jwt


def _base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def make_dpop_proof_for_token_endpoint(key: "jwk.JWK", method: str, url: str, nonce: str = None) -> str:
"""
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 §4.2).
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
because no access token exists yet at issuance time.
"""
public_jwk = key.export_public(as_dict=True)
htu = url.split("?")[0].split("#")[0]
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
payload = {
"jti": str(uuid.uuid4()),
"htm": method.upper(),
"htu": htu,
"iat": int(time.time()),
}
if nonce is not None:
payload["nonce"] = nonce
token = jwcrypto_jwt.JWT(header=header, claims=payload)
token.make_signed_token(key)
return token.serialize()


class DPoPAuth(httpx.Auth):
def __init__(self, token: str, key: "jwk.JWK") -> None:
public_jwk = key.export_public(as_dict=True)
if public_jwk.get("kty") != "EC" or public_jwk.get("crv") != "P-256":
raise ValueError("DPoP key must be an EC P-256 key")
try:
token.encode("ascii")
except UnicodeEncodeError:
raise ValueError("Access token must contain only ASCII characters")
self._token = token
self._key = key
self._public_jwk = public_jwk

def __repr__(self) -> str:
return "DPoPAuth(token=[REDACTED], key=[REDACTED])"

def __str__(self) -> str:
return "DPoPAuth(token=[REDACTED], key=[REDACTED])"

def auth_flow(self, request: httpx.Request):
proof = self._make_proof(request.method, str(request.url))
request.headers["Authorization"] = f"DPoP {self._token}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two DPoP gaps:

  1. DPoPAuth has no DPoP-Nonce handling. Auth0/Okta commonly responds 401 + a DPoP-Nonce header and expects the proof retried with that nonce, so the first call against a nonce-requiring resource server will fail. Can we track nonce-retry even if it's deferred past GA?
  2. Separately, signin_with_passkey does a plain token POST with no DPoP proof, and PasskeyTokenResponse.token_type defaults to Bearer, but the Test Plan says /oauth/token (webauthn) returns Bearer or DPoP based on tenant setup. If the tenant issues a DPoP-bound token, the SDK ends up holding a token bound to a key it never used. Can we confirm the GA scope for DPoP-bound passkey tokens?

request.headers["DPoP"] = proof
response = yield request

# RFC 9449 §8.2 — server-nonce retry
if (
response.status_code == 401
and response.headers.get("DPoP-Nonce")
):
nonce = response.headers["DPoP-Nonce"]
request.headers["DPoP"] = self._make_proof(request.method, str(request.url), nonce=nonce)
yield request

def _make_proof(self, method: str, url: str, nonce: str = None) -> str:
htu = url.split("?")[0].split("#")[0]
ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())

header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": self._public_jwk}
payload = {
"jti": str(uuid.uuid4()),
"htm": method.upper(),
"htu": htu,
"iat": int(time.time()),
"ath": ath,
}
if nonce is not None:
payload["nonce"] = nonce

token = jwcrypto_jwt.JWT(header=header, claims=payload)
token.make_signed_token(self._key)
return token.serialize()
Loading
Loading