99import secrets
1010import string
1111import time
12- from collections .abc import AsyncGenerator , Awaitable , Callable
12+ from collections .abc import AsyncGenerator , Awaitable , Callable , Mapping
1313from dataclasses import dataclass , field
1414from typing import Any , Protocol
15- from urllib .parse import quote , urlencode , urljoin , urlparse
15+ from urllib .parse import parse_qsl , quote , urlencode , urljoin , urlparse , urlunparse
1616
1717import anyio
1818import httpx
5353logger = logging .getLogger (__name__ )
5454
5555
56+ def _append_url_query_params (url : str , params : Mapping [str , str ]) -> str :
57+ parsed = urlparse (url )
58+ query_params = parse_qsl (parsed .query , keep_blank_values = True )
59+ query_params .extend (params .items ())
60+ return urlunparse (parsed ._replace (query = urlencode (query_params )))
61+
62+
5663class PKCEParameters (BaseModel ):
5764 """PKCE (Proof Key for Code Exchange) parameters."""
5865
@@ -327,14 +334,17 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
327334
328335 if not self .context .client_info :
329336 raise OAuthFlowError ("No client info available for authorization" ) # pragma: no cover
337+ client_id = self .context .client_info .client_id
338+ if not client_id :
339+ raise OAuthFlowError ("No client ID available for authorization" ) # pragma: no cover
330340
331341 # Generate PKCE parameters
332342 pkce_params = PKCEParameters .generate ()
333343 state = secrets .token_urlsafe (32 )
334344
335- auth_params = {
345+ auth_params : dict [ str , str ] = {
336346 "response_type" : "code" ,
337- "client_id" : self . context . client_info . client_id ,
347+ "client_id" : client_id ,
338348 "redirect_uri" : str (self .context .client_metadata .redirect_uris [0 ]),
339349 "state" : state ,
340350 "code_challenge" : pkce_params .code_challenge ,
@@ -345,15 +355,16 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
345355 if self .context .should_include_resource_param (self .context .protocol_version ):
346356 auth_params ["resource" ] = self .context .get_resource_url () # RFC 8707
347357
348- if self .context .client_metadata .scope : # pragma: no branch
349- auth_params ["scope" ] = self .context .client_metadata .scope
358+ scope = self .context .client_metadata .scope
359+ if scope : # pragma: no branch
360+ auth_params ["scope" ] = scope
350361
351362 # OIDC requires prompt=consent when offline_access is requested
352363 # https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
353- if "offline_access" in self . context . client_metadata . scope .split ():
364+ if "offline_access" in scope .split ():
354365 auth_params ["prompt" ] = "consent"
355366
356- authorization_url = f" { auth_endpoint } ? { urlencode ( auth_params )} "
367+ authorization_url = _append_url_query_params ( auth_endpoint , auth_params )
357368 await self .context .redirect_handler (authorization_url )
358369
359370 # Wait for callback
0 commit comments