From cf53f426eec6dd8539d02309c87a3a7b454db68b Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:56 -0700 Subject: [PATCH] fix: preserve semicolon path parameters in DPoP proofs --- okta/utils.py | 7 +++---- tests/test_dpop.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/okta/utils.py b/okta/utils.py index 8b1e83b9..36687773 100644 --- a/okta/utils.py +++ b/okta/utils.py @@ -17,7 +17,7 @@ from datetime import datetime as dt from enum import Enum from typing import Any -from urllib.parse import urlsplit, urlunsplit, urlparse, urlunparse +from urllib.parse import urlsplit, urlunsplit from okta.constants import DATETIME_FORMAT, EPOCH_DAY, EPOCH_MONTH, EPOCH_YEAR @@ -48,7 +48,7 @@ def normalize_dpop_url(url: str) -> str: >>> normalize_dpop_url('https://example.com/api/users?limit=10#section1') 'https://example.com/api/users' """ - parsed = urlparse(url) + parsed = urlsplit(url) # Validate that URL has required components for DPoP htu claim if not parsed.scheme or not parsed.netloc: @@ -57,11 +57,10 @@ def normalize_dpop_url(url: str) -> str: "URL must include scheme (https) and netloc (domain)." ) - return urlunparse(( + return urlunsplit(( parsed.scheme, # scheme (http/https) parsed.netloc, # network location (host:port) parsed.path, # path - '', # params (deprecated, kept for compatibility) '', # query (empty per RFC 9449) '' # fragment (empty per RFC 9449) )) diff --git a/tests/test_dpop.py b/tests/test_dpop.py index 2a213802..b082ac43 100644 --- a/tests/test_dpop.py +++ b/tests/test_dpop.py @@ -162,6 +162,27 @@ def test_url_parsing_strips_query_and_fragment(self): # htu should be clean self.assertEqual(decoded['htu'], 'https://example.okta.com/api/v1/users') + def test_url_parsing_preserves_path_parameters(self): + """Signed proofs retain the complete path while excluding query/fragment.""" + paths = ( + '/api/v1/users;view=full', + '/api/v1/users;', + '/api/v1/users;x=1;y=2', + '/api/v1/users%3Bview=full', + '/api/v1/teams;view=full/users', + ) + for path in paths: + with self.subTest(path=path): + expected = 'https://example.okta.com' + path + proof = self.generator.generate_proof_jwt( + 'GET', expected + '?limit=10#section' + ) + public_key = jwt.PyJWK.from_dict( + jwt.get_unverified_header(proof)['jwk'] + ).key + claims = jwt.decode(proof, public_key, algorithms=['RS256']) + self.assertEqual(claims['htu'], expected) + def test_generate_proof_with_nonce(self): """Test DPoP proof generation with nonce.""" proof = self.generator.generate_proof_jwt(