11"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636)."""
22
33import time
4- from urllib .parse import urlparse , urlsplit , urlunsplit
4+ from urllib .parse import SplitResult , urlparse , urlsplit , urlunsplit
55
66from pydantic import AnyUrl , HttpUrl
77
88
9+ def _canonical_netloc (parsed : SplitResult ) -> str :
10+ """Normalize netloc by lowercasing and stripping explicit default ports (RFC 3986 §6.2.3)."""
11+ scheme = parsed .scheme .lower ()
12+ netloc = parsed .netloc .lower ()
13+ port = parsed .port
14+
15+ if (scheme == "http" and port == 80 ) or (scheme == "https" and port == 443 ):
16+ # Strip default port while preserving userinfo and IPv6 brackets
17+ userinfo = ""
18+ if "@" in netloc :
19+ userinfo = netloc .split ("@" , 1 )[0 ] + "@"
20+ hostname = parsed .hostname .lower () if parsed .hostname else ""
21+ if ":" in hostname : # IPv6 literal
22+ hostname = f"[{ hostname } ]"
23+ return f"{ userinfo } { hostname } "
24+ return netloc
25+
26+
927def resource_url_from_server_url (url : str | HttpUrl | AnyUrl ) -> str :
1028 """Convert server URL to canonical resource URL per RFC 8707.
1129
1230 RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
13- Returns absolute URI with lowercase scheme/host for canonical form.
31+ RFC 3986 section 6.2.3 specifies normalization of default ports (80 for http, 443 for https).
32+ Returns absolute URI with lowercase scheme/host and stripped default ports for canonical form.
1433
1534 Args:
1635 url: Server URL to convert
@@ -23,7 +42,8 @@ def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
2342
2443 # Parse the URL and remove fragment, create canonical form
2544 parsed = urlsplit (url_str )
26- canonical = urlunsplit (parsed ._replace (scheme = parsed .scheme .lower (), netloc = parsed .netloc .lower (), fragment = "" ))
45+ canonical_netloc = _canonical_netloc (parsed )
46+ canonical = urlunsplit (parsed ._replace (scheme = parsed .scheme .lower (), netloc = canonical_netloc , fragment = "" ))
2747
2848 return canonical
2949
@@ -43,9 +63,13 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) ->
4363 Returns:
4464 True if the requested resource matches the configured resource
4565 """
46- # Parse both URLs
47- requested = urlparse (requested_resource )
48- configured = urlparse (configured_resource )
66+ # Canonicalize both resource URLs (RFC 8707 & RFC 3986 default port normalization)
67+ requested_canonical = resource_url_from_server_url (requested_resource )
68+ configured_canonical = resource_url_from_server_url (configured_resource )
69+
70+ # Parse both canonical URLs
71+ requested = urlparse (requested_canonical )
72+ configured = urlparse (configured_canonical )
4973
5074 # Compare scheme, host, and port (origin)
5175 if requested .scheme .lower () != configured .scheme .lower () or requested .netloc .lower () != configured .netloc .lower ():
0 commit comments