Skip to content

Commit 7e5ff92

Browse files
committed
WIP
1 parent baf5254 commit 7e5ff92

File tree

6 files changed

+190
-207
lines changed

6 files changed

+190
-207
lines changed

pulp-glue/pulp_glue/common/authentication.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,125 @@
44
import requests
55

66

7+
class AuthProviderBase:
8+
"""
9+
Base class for auth providers.
10+
11+
This abstract base class will analyze the authentication proposals of the openapi specs.
12+
Different authentication schemes should be implemented by subclasses.
13+
Returned auth objects need to be compatible with `requests.auth.AuthBase`.
14+
"""
15+
16+
def __init__(self) -> None:
17+
self._oauth2_token: str | None = None
18+
self._oauth2_expires: datetime = datetime.now()
19+
20+
def can_complete_http_basic(self) -> bool:
21+
return False
22+
23+
def can_complete_mutualTLS(self) -> bool:
24+
return False
25+
26+
def can_complete_oauth2_client_credentials(self, scopes: list[str]) -> bool:
27+
return False
28+
29+
def can_complete_scheme(self, scheme: dict[str, t.Any], scopes: list[str]) -> bool:
30+
if scheme["type"] == "http":
31+
if scheme["scheme"] == "basic":
32+
return self.can_complete_http_basic()
33+
elif scheme["type"] == "mutualTLS":
34+
return self.can_complete_mutualTLS()
35+
elif scheme["type"] == "oauth2":
36+
for flow_name, flow in scheme["flows"].items():
37+
if (
38+
flow_name == "clientCredentials"
39+
and self.can_complete_oauth2_client_credentials(flow["scopes"])
40+
):
41+
return True
42+
return False
43+
44+
def can_complete(
45+
self, proposal: dict[str, list[str]], security_schemes: dict[str, dict[str, t.Any]]
46+
) -> bool:
47+
for name, scopes in proposal.items():
48+
scheme = security_schemes.get(name)
49+
if scheme is None or not self.can_complete_scheme(scheme, scopes):
50+
return False
51+
# This covers the case where `[]` allows for no auth at all.
52+
return True
53+
54+
async def http_basic_credentials(self) -> tuple[bytes, bytes]:
55+
raise NotImplementedError()
56+
57+
async def oauth2_client_credentials(self) -> tuple[bytes, bytes]:
58+
raise NotImplementedError()
59+
60+
61+
class BasicAuthProvider(AuthProviderBase):
62+
"""
63+
AuthProvider providing basic auth with fixed `username`, `password`.
64+
"""
65+
66+
def __init__(self, username: t.AnyStr, password: t.AnyStr):
67+
super().__init__()
68+
self.username: bytes = username.encode("latin1") if isinstance(username, str) else username
69+
self.password: bytes = password.encode("latin1") if isinstance(password, str) else password
70+
71+
def can_complete_http_basic(self) -> bool:
72+
return True
73+
74+
async def http_basic_credentials(self) -> tuple[bytes, bytes]:
75+
return self.username, self.password
76+
77+
78+
class GlueAuthProvider(AuthProviderBase):
79+
"""
80+
AuthProvider allowing to be used with prepared credentials.
81+
"""
82+
83+
def __init__(
84+
self,
85+
username: t.AnyStr | None = None,
86+
password: t.AnyStr | None = None,
87+
client_id: t.AnyStr | None = None,
88+
client_secret: t.AnyStr | None = None,
89+
):
90+
super().__init__()
91+
self.username: bytes | None = None
92+
self.password: bytes | None = None
93+
self.client_id: bytes | None = None
94+
self.client_secret: bytes | None = None
95+
if username is not None:
96+
assert password is not None
97+
self.username = username.encode("latin1") if isinstance(username, str) else username
98+
self.password = password.encode("latin1") if isinstance(password, str) else password
99+
if client_id is not None:
100+
assert client_secret is not None
101+
self.client_id = client_id.encode("latin1") if isinstance(client_id, str) else client_id
102+
self.client_secret = (
103+
client_secret.encode("latin1") if isinstance(client_secret, str) else client_secret
104+
)
105+
106+
def can_complete_http_basic(self) -> bool:
107+
return self.username is not None
108+
109+
def can_complete_oauth2_client_credentials(self, scopes: list[str]) -> bool:
110+
return self.client_id is not None
111+
112+
async def http_basic_credentials(self) -> tuple[bytes, bytes]:
113+
assert self.username is not None
114+
assert self.password is not None
115+
return self.username, self.password
116+
117+
async def oauth2_client_credentials(self) -> tuple[bytes, bytes]:
118+
assert self.client_id is not None
119+
assert self.client_secret is not None
120+
return self.client_id, self.client_secret
121+
122+
123+
# ----------------------8<----8<------------------------
124+
125+
7126
class OAuth2ClientCredentialsAuth(requests.auth.AuthBase):
8127
"""
9128
This implements the OAuth2 ClientCredentials Grant authentication flow.

pulp-glue/pulp_glue/common/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from packaging.specifiers import SpecifierSet
1111

12+
from pulp_glue.common.authentication import BasicAuthProvider
1213
from pulp_glue.common.exceptions import (
1314
NotImplementedFake,
1415
OpenAPIError,
@@ -19,7 +20,7 @@
1920
UnsafeCallError,
2021
)
2122
from pulp_glue.common.i18n import get_translation
22-
from pulp_glue.common.openapi import BasicAuthProvider, OpenAPI
23+
from pulp_glue.common.openapi import OpenAPI
2324

2425
if sys.version_info >= (3, 11):
2526
import tomllib

pulp-glue/pulp_glue/common/openapi.py

Lines changed: 32 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66
import typing as t
77
import warnings
88
from base64 import b64encode
9-
from collections import defaultdict
109
from dataclasses import dataclass
10+
from datetime import datetime, timedelta
1111
from functools import cached_property
1212
from io import BufferedReader
1313
from urllib.parse import urlencode, urljoin
1414

1515
import aiofiles
1616
import aiofiles.os
1717
import aiohttp
18-
import requests
1918
from multidict import CIMultiDict, CIMultiDictProxy, MutableMultiMapping
2019

2120
from pulp_glue.common import __version__
21+
from pulp_glue.common.authentication import AuthProviderBase
2222
from 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-
20361
class _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

262143
class OpenAPI:
263144
"""

pulp-glue/tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
import pytest
66

7+
from pulp_glue.common.authentication import BasicAuthProvider
78
from pulp_glue.common.context import PulpContext
8-
from pulp_glue.common.openapi import BasicAuthProvider, OpenAPI
9+
from pulp_glue.common.openapi import OpenAPI
910

1011
FAKE_OPENAPI_SPEC = json.dumps(
1112
{

0 commit comments

Comments
 (0)