66import typing as t
77import warnings
88from base64 import b64encode
9- from collections import defaultdict
109from dataclasses import dataclass
10+ from datetime import datetime , timedelta
1111from functools import cached_property
1212from io import BufferedReader
1313from urllib .parse import urlencode , urljoin
1414
1515import aiofiles
1616import aiofiles .os
1717import aiohttp
18- import requests
1918from multidict import CIMultiDict , CIMultiDictProxy , MutableMultiMapping
2019
2120from pulp_glue .common import __version__
21+ from pulp_glue .common .authentication import AuthProviderBase
2222from pulp_glue .common .exceptions import (
2323 OpenAPIError ,
2424 PulpAuthenticationFailed ,
@@ -58,156 +58,14 @@ class _Response:
5858 body : bytes
5959
6060
61- class AuthProviderBase :
62- """
63- Base class for auth providers.
64-
65- This abstract base class will analyze the authentication proposals of the openapi specs.
66- Different authentication schemes should be implemented by subclasses.
67- Returned auth objects need to be compatible with `requests.auth.AuthBase`.
68- """
69-
70- def can_complete_http_basic (self ) -> bool :
71- return False
72-
73- def can_complete_mutualTLS (self ) -> bool :
74- return False
75-
76- def can_complete_oauth2_client_credentials (self , scopes : list [str ]) -> bool :
77- return False
78-
79- def can_complete_scheme (self , scheme : dict [str , t .Any ], scopes : list [str ]) -> bool :
80- if scheme ["type" ] == "http" :
81- if scheme ["scheme" ] == "basic" :
82- return self .can_complete_http_basic ()
83- elif scheme ["type" ] == "mutualTLS" :
84- return self .can_complete_mutualTLS ()
85- elif scheme ["type" ] == "oauth2" :
86- for flow_name , flow in scheme ["flows" ].items ():
87- if (
88- flow_name == "clientCredentials"
89- and self .can_complete_oauth2_client_credentials (flow ["scopes" ])
90- ):
91- return True
92- return False
93-
94- def can_complete (
95- self , proposal : dict [str , list [str ]], security_schemes : dict [str , dict [str , t .Any ]]
96- ) -> bool :
97- for name , scopes in proposal .items ():
98- scheme = security_schemes .get (name )
99- if scheme is None or not self .can_complete_scheme (scheme , scopes ):
100- return False
101- # This covers the case where `[]` allows for no auth at all.
102- return True
103-
104- async def http_basic_credentials (self ) -> tuple [bytes , bytes ]:
105- raise NotImplementedError ()
106-
107- async def oauth2_client_credentials (self ) -> tuple [bytes , bytes ]:
108- raise NotImplementedError ()
109-
110- def basic_auth (self , scopes : list [str ]) -> requests .auth .AuthBase | None :
111- """Implement this to provide means of http basic auth."""
112- return None
113-
114- def http_auth (
115- self , security_scheme : dict [str , t .Any ], scopes : list [str ]
116- ) -> requests .auth .AuthBase | None :
117- """Select a suitable http auth scheme or return None."""
118- # https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
119- if security_scheme ["scheme" ] == "basic" :
120- result = self .basic_auth (scopes )
121- if result :
122- return result
123- return None
124-
125- def oauth2_client_credentials_auth (
126- self , flow : t .Any , scopes : list [str ]
127- ) -> requests .auth .AuthBase | None :
128- """Implement this to provide other authentication methods."""
129- return None
130-
131- def oauth2_auth (
132- self , security_scheme : dict [str , t .Any ], scopes : list [str ]
133- ) -> requests .auth .AuthBase | None :
134- """Select a suitable oauth2 flow or return None."""
135- # Check flows by preference.
136- if "clientCredentials" in security_scheme ["flows" ]:
137- flow = security_scheme ["flows" ]["clientCredentials" ]
138- # Select this flow only if it claims to provide all the necessary scopes.
139- # This will allow subsequent auth proposals to be considered.
140- if set (scopes ) - set (flow ["scopes" ]):
141- return None
142-
143- result = self .oauth2_client_credentials_auth (flow , scopes )
144- if result :
145- return result
146- return None
147-
148- def __call__ (
149- self ,
150- security : list [dict [str , list [str ]]],
151- security_schemes : dict [str , dict [str , t .Any ]],
152- ) -> requests .auth .AuthBase | None :
153-
154- # Reorder the proposals by their type to prioritize properly.
155- # Select only single mechanism proposals on the way.
156- proposed_schemes : dict [str , dict [str , list [str ]]] = defaultdict (dict )
157- for proposal in security :
158- if len (proposal ) == 0 :
159- # Empty proposal: No authentication needed. Shortcut return.
160- return None
161- if len (proposal ) == 1 :
162- name , scopes = list (proposal .items ())[0 ]
163- proposed_schemes [security_schemes [name ]["type" ]][name ] = scopes
164- # Ignore all proposals with more than one required auth mechanism.
165-
166- # Check for auth schemes by preference.
167- if "oauth2" in proposed_schemes :
168- for name , scopes in proposed_schemes ["oauth2" ].items ():
169- result = self .oauth2_auth (security_schemes [name ], scopes )
170- if result :
171- return result
172-
173- # if we get here, either no-oauth2, OR we couldn't find creds
174- if "http" in proposed_schemes :
175- for name , scopes in proposed_schemes ["http" ].items ():
176- result = self .http_auth (security_schemes [name ], scopes )
177- if result :
178- return result
179-
180- raise OpenAPIError (_ ("No suitable auth scheme found." ))
181-
182-
183- class BasicAuthProvider (AuthProviderBase ):
184- """
185- Implementation for AuthProviderBase providing basic auth with fixed `username`, `password`.
186- """
187-
188- def __init__ (self , username : t .AnyStr , password : t .AnyStr ):
189- self .username : bytes = username .encode ("latin1" ) if isinstance (username , str ) else username
190- self .password : bytes = password .encode ("latin1" ) if isinstance (password , str ) else password
191- self .auth = requests .auth .HTTPBasicAuth (username , password )
192-
193- def can_complete_http_basic (self ) -> bool :
194- return True
195-
196- async def http_basic_credentials (self ) -> tuple [bytes , bytes ]:
197- return self .username , self .password
198-
199- def basic_auth (self , scopes : list [str ]) -> requests .auth .AuthBase | None :
200- return self .auth
201-
202-
20361class _Middleware :
20462 def __init__ (
20563 self ,
20664 openapi : "OpenAPI" ,
20765 security : t .Optional [t .List [t .Dict [str , t .List [str ]]]],
20866 ):
20967 self ._openapi = openapi
210- # self.method_spec may be more interesting...
68+ # Would be nicer to carry this with the request, but found no way:
21169 self ._security = security
21270
21371 async def __call__ (
@@ -235,20 +93,20 @@ async def __call__(
23593 await self ._openapi ._auth_provider .http_basic_credentials ()
23694 )
23795 secret = b64encode (username + b":" + password )
238- request .headers . add ( "Authorization" , "Basic " + secret .decode ())
96+ request .headers [ "Authorization" ] = f "Basic { secret .decode ()} "
23997 else :
24098 raise NotImplementedError ("Auth scheme: http " + scheme ["scheme" ])
241- elif scheme ["type" ] == "mutualTLS" :
242- # At this point, we assume the cert has already been loaded into the sslcontext.
243- pass
24499 elif scheme ["type" ] == "oauth2" :
245100 flow = scheme ["flows" ].get ("clientCredentials" )
246101 if flow is None :
247102 raise NotImplementedError (
248103 "OAuth2: Only client credential flow is available."
249104 )
250- token = "DEADBEEF"
251- request .headers .add ("Authorization" , f"Bearer { token } " )
105+ token = await self .oauth2_token (flow , request )
106+ request .headers ["Authorization" ] = f"Bearer { token } "
107+ elif scheme ["type" ] == "mutualTLS" :
108+ # At this point, we assume the cert has already been loaded into the sslcontext.
109+ pass
252110 else :
253111 raise NotImplementedError ("Auth type: " + scheme ["type" ])
254112
@@ -258,6 +116,29 @@ async def __call__(
258116 self ._openapi ._set_correlation_id (response .headers ["Correlation-Id" ])
259117 return response
260118
119+ async def oauth2_token (self , flow : dict [str , t .Any ], request : aiohttp .ClientRequest ) -> str :
120+ # TODO implement retry the request with new token.
121+ auth_provider = self ._openapi ._auth_provider
122+ assert auth_provider is not None
123+
124+ now = datetime .now ()
125+ if auth_provider ._oauth2_token is not None and auth_provider ._oauth2_expires > now :
126+ return auth_provider ._oauth2_token
127+
128+ client_id , client_secret = await auth_provider .oauth2_client_credentials ()
129+ secret = b64encode (client_id + b":" + client_secret )
130+ response = await request .session .post (
131+ flow ["tokenUrl" ],
132+ data = {"grant_type" : "client_credentials" },
133+ headers = {"Authorization" : f"Basic { secret .decode ()} " },
134+ ssl = request .ssl ,
135+ )
136+ response .raise_for_status ()
137+ result = await response .json ()
138+ auth_provider ._oauth2_token = result ["access_token" ]
139+ auth_provider ._oauth2_expires = now + timedelta (seconds = result ["expires_in" ])
140+ return auth_provider ._oauth2_token
141+
261142
262143class OpenAPI :
263144 """
0 commit comments