aiohttp-msal 1.0.4__py3-none-any.whl → 1.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiohttp_msal/helpers.py +11 -71
- aiohttp_msal/msal_async.py +107 -5
- aiohttp_msal/routes.py +6 -21
- {aiohttp_msal-1.0.4.dist-info → aiohttp_msal-1.0.5.dist-info}/METADATA +3 -1
- aiohttp_msal-1.0.5.dist-info/RECORD +11 -0
- {aiohttp_msal-1.0.4.dist-info → aiohttp_msal-1.0.5.dist-info}/WHEEL +1 -1
- aiohttp_msal-1.0.4.dist-info/RECORD +0 -11
aiohttp_msal/helpers.py
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
"""Graph User Info."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Mapping, Sequence
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
5
|
|
|
6
6
|
from aiohttp import web
|
|
7
|
-
from aiohttp_session import get_session
|
|
8
7
|
|
|
9
|
-
from aiohttp_msal import ENV
|
|
10
|
-
from aiohttp_msal.msal_async import AsyncMSAL
|
|
8
|
+
from aiohttp_msal.settings import ENV
|
|
11
9
|
from aiohttp_msal.utils import retry
|
|
12
10
|
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from aiohttp_msal.msal_async import AsyncMSAL
|
|
13
|
+
|
|
13
14
|
|
|
14
15
|
@retry
|
|
15
|
-
async def get_user_info(aiomsal: AsyncMSAL) -> None:
|
|
16
|
+
async def get_user_info(aiomsal: "AsyncMSAL") -> None:
|
|
16
17
|
"""Load user info from MS graph API. Requires User.Read permissions."""
|
|
17
18
|
async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
|
|
18
19
|
body = await res.json()
|
|
@@ -26,7 +27,7 @@ async def get_user_info(aiomsal: AsyncMSAL) -> None:
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
@retry
|
|
29
|
-
async def get_manager_info(aiomsal: AsyncMSAL) -> None:
|
|
30
|
+
async def get_manager_info(aiomsal: "AsyncMSAL") -> None:
|
|
30
31
|
"""Load manager info from MS graph API. Requires User.Read.All permissions."""
|
|
31
32
|
async with aiomsal.get("https://graph.microsoft.com/v1.0/me/manager") as res:
|
|
32
33
|
body = await res.json()
|
|
@@ -69,68 +70,7 @@ def html_wrap(msgs: Sequence[str]) -> str:
|
|
|
69
70
|
"""
|
|
70
71
|
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
request: web.Request,
|
|
77
|
-
asyncmsal_class: type[TA] = AsyncMSAL, # type:ignore[assignment]
|
|
78
|
-
get_info: Literal["user", "manager", ""] = "manager",
|
|
79
|
-
) -> tuple[TA | None, list[str]]:
|
|
80
|
-
"""Parse the MS auth response."""
|
|
81
|
-
# Expecting response_mode="form_post"
|
|
82
|
-
auth_response = dict(await request.post())
|
|
83
|
-
|
|
84
|
-
msg = list[str]()
|
|
85
|
-
|
|
86
|
-
# Ensure all expected variables were returned...
|
|
87
|
-
if not all(auth_response.get(k) for k in ["code", "session_state", "state"]):
|
|
88
|
-
msg.append("Expected 'code', 'session_state', 'state' in auth_response")
|
|
89
|
-
msg.append(f"Received auth_response: {list(auth_response)}")
|
|
90
|
-
return None, msg
|
|
91
|
-
|
|
92
|
-
if not request.cookies.get(ENV.COOKIE_NAME):
|
|
93
|
-
cookies = dict(request.cookies.items())
|
|
94
|
-
msg.append(f"<b>Expected '{ENV.COOKIE_NAME}' in cookies</b>")
|
|
95
|
-
msg.append(html_table(cookies))
|
|
96
|
-
msg.append("Cookie should be set with Samesite:None")
|
|
97
|
-
|
|
98
|
-
session = await get_session(request)
|
|
99
|
-
if session.new:
|
|
100
|
-
msg.append(
|
|
101
|
-
"Warning: This is a new session and may not have all expected values."
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
if not session.get(asyncmsal_class.flow_cache_key):
|
|
105
|
-
msg.append(f"<b>Expected '{asyncmsal_class.flow_cache_key}' in session</b>")
|
|
106
|
-
msg.append(html_table(session))
|
|
107
|
-
|
|
108
|
-
aiomsal = asyncmsal_class(session)
|
|
109
|
-
aiomsal.redirect = "/" + aiomsal.redirect.lstrip("/")
|
|
110
|
-
|
|
111
|
-
if msg:
|
|
112
|
-
return aiomsal, msg
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
await aiomsal.async_acquire_token_by_auth_code_flow(auth_response)
|
|
116
|
-
except Exception as err:
|
|
117
|
-
msg.append("<b>Could not get token</b> - async_acquire_token_by_auth_code_flow")
|
|
118
|
-
msg.append(str(err))
|
|
119
|
-
|
|
120
|
-
if not msg:
|
|
121
|
-
try:
|
|
122
|
-
if get_info in ("user", "manager"):
|
|
123
|
-
await get_user_info(aiomsal)
|
|
124
|
-
if get_info == "manager":
|
|
125
|
-
await get_manager_info(aiomsal)
|
|
126
|
-
except Exception as err:
|
|
127
|
-
msg.append("Could not get org info from MS graph")
|
|
128
|
-
msg.append(str(err))
|
|
129
|
-
aiomsal.mail = ""
|
|
130
|
-
aiomsal.name = ""
|
|
131
|
-
|
|
132
|
-
if session.get("mail"):
|
|
133
|
-
for lcb in ENV.login_callback:
|
|
134
|
-
await lcb(aiomsal)
|
|
135
|
-
|
|
136
|
-
return aiomsal, msg
|
|
73
|
+
def get_url(request: web.Request, path: str = "", https_proxy: bool = True) -> str:
|
|
74
|
+
"""Return the full outside URL."""
|
|
75
|
+
res = str(request.url.with_path(path))
|
|
76
|
+
return res.replace("http://", "https://") if https_proxy else res
|
aiohttp_msal/msal_async.py
CHANGED
|
@@ -7,9 +7,10 @@ Once you have the OAuth tokens store in the session, you are free to make reques
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import json
|
|
10
|
+
import logging
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from functools import cached_property, partialmethod
|
|
12
|
-
from typing import Any, ClassVar, Literal, Unpack, cast
|
|
13
|
+
from typing import Any, ClassVar, Literal, Self, TypeVar, Unpack, cast
|
|
13
14
|
|
|
14
15
|
import attrs
|
|
15
16
|
from aiohttp import web
|
|
@@ -20,12 +21,15 @@ from aiohttp.client import (
|
|
|
20
21
|
_RequestOptions,
|
|
21
22
|
)
|
|
22
23
|
from aiohttp.typedefs import StrOrURL
|
|
23
|
-
from aiohttp_session import Session
|
|
24
|
+
from aiohttp_session import Session, get_session, new_session
|
|
24
25
|
from msal import ConfidentialClientApplication, SerializableTokenCache
|
|
25
26
|
|
|
27
|
+
from aiohttp_msal import helpers
|
|
26
28
|
from aiohttp_msal.settings import ENV
|
|
27
29
|
from aiohttp_msal.utils import dict_property
|
|
28
30
|
|
|
31
|
+
_LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
29
33
|
HttpMethods = Literal["get", "post", "put", "patch", "delete"]
|
|
30
34
|
HTTP_GET = "get"
|
|
31
35
|
HTTP_POST = "post"
|
|
@@ -34,6 +38,8 @@ HTTP_PATCH = "patch"
|
|
|
34
38
|
HTTP_DELETE = "delete"
|
|
35
39
|
HTTP_ALLOWED = [HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE]
|
|
36
40
|
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
37
43
|
|
|
38
44
|
@attrs.define(slots=False)
|
|
39
45
|
class AsyncMSAL:
|
|
@@ -54,14 +60,46 @@ class AsyncMSAL:
|
|
|
54
60
|
"""
|
|
55
61
|
app_kwargs: dict[str, Any] | None = None
|
|
56
62
|
"""ConfidentialClientApplication kwargs."""
|
|
57
|
-
client_session: ClassVar[ClientSession | None] = None
|
|
58
63
|
|
|
64
|
+
client_session: ClassVar[ClientSession | None] = None
|
|
59
65
|
token_cache_key: ClassVar[str] = "token_cache"
|
|
60
66
|
user_email_key: ClassVar[str] = "mail"
|
|
61
67
|
flow_cache_key: ClassVar[str] = "flow_cache"
|
|
62
|
-
redirect_key = "redirect"
|
|
68
|
+
redirect_key: ClassVar[str] = "redirect"
|
|
63
69
|
default_scopes: ClassVar[list[str]] = ["User.Read", "User.Read.All"]
|
|
64
70
|
|
|
71
|
+
@classmethod
|
|
72
|
+
async def from_request(
|
|
73
|
+
cls,
|
|
74
|
+
request: web.Request,
|
|
75
|
+
/,
|
|
76
|
+
allow_new: bool = False,
|
|
77
|
+
app_kwargs: dict[str, Any] | None = None,
|
|
78
|
+
) -> Self:
|
|
79
|
+
"""Get the session or raise an exception."""
|
|
80
|
+
try:
|
|
81
|
+
session = await get_session(request)
|
|
82
|
+
return cls(session, app_kwargs=app_kwargs)
|
|
83
|
+
except TypeError as err:
|
|
84
|
+
cookie = request.get(ENV.COOKIE_NAME)
|
|
85
|
+
_LOG.error(
|
|
86
|
+
"Invalid session, %s: %s [cookie: %s]",
|
|
87
|
+
"create new" if allow_new else "fail",
|
|
88
|
+
err,
|
|
89
|
+
request.get(ENV.COOKIE_NAME),
|
|
90
|
+
cookie,
|
|
91
|
+
)
|
|
92
|
+
if allow_new:
|
|
93
|
+
return cls(await new_session(request), app_kwargs=app_kwargs)
|
|
94
|
+
|
|
95
|
+
text = "Invalid session. Login for a new session: '/usr/login'"
|
|
96
|
+
|
|
97
|
+
if not cookie:
|
|
98
|
+
cookies = dict(request.cookies.items())
|
|
99
|
+
text = f"Cookie empty. Expected '{ENV.COOKIE_NAME}' in cookies: {list(cookies)}. Cookie should be set with Samesite:None"
|
|
100
|
+
|
|
101
|
+
raise web.HTTPException(text=text) from None
|
|
102
|
+
|
|
65
103
|
@cached_property
|
|
66
104
|
def app(self) -> ConfidentialClientApplication:
|
|
67
105
|
"""Get the app."""
|
|
@@ -118,7 +156,7 @@ class AsyncMSAL:
|
|
|
118
156
|
) -> None:
|
|
119
157
|
"""Second step - Acquire token."""
|
|
120
158
|
# Assume we have it in the cache (added by /login)
|
|
121
|
-
# will raise
|
|
159
|
+
# will raise KeyError if not in cache
|
|
122
160
|
auth_code_flow = self.session.pop(self.flow_cache_key)
|
|
123
161
|
result = self.app.acquire_token_by_auth_code_flow(
|
|
124
162
|
auth_code_flow, auth_response, scopes=scopes
|
|
@@ -208,3 +246,67 @@ class AsyncMSAL:
|
|
|
208
246
|
manager_name = dict_property("session", "m_name")
|
|
209
247
|
manager_mail = dict_property("session", "m_mail")
|
|
210
248
|
redirect = dict_property("session", redirect_key)
|
|
249
|
+
|
|
250
|
+
async def async_acquire_token_by_auth_code_flow_plus(
|
|
251
|
+
self,
|
|
252
|
+
request: web.Request,
|
|
253
|
+
get_info: Literal["user", "manager", ""] = "manager",
|
|
254
|
+
) -> tuple[bool, list[str]]:
|
|
255
|
+
"""Enhanced version of async_acquire_token_by_auth_code_flow. Returns issues.
|
|
256
|
+
|
|
257
|
+
Parse the auth response from the request, checks for valid keys,
|
|
258
|
+
acquire the token and get_info.
|
|
259
|
+
|
|
260
|
+
response_mode for the auth_code flow should be "form_post"
|
|
261
|
+
"""
|
|
262
|
+
auth_response = dict(await request.post())
|
|
263
|
+
assert isinstance(self.session, Session)
|
|
264
|
+
|
|
265
|
+
msg = list[str]()
|
|
266
|
+
|
|
267
|
+
# Ensure all expected variables were returned...
|
|
268
|
+
if not all(auth_response.get(k) for k in ["code", "session_state", "state"]):
|
|
269
|
+
msg.append("Expected 'code', 'session_state', 'state' in auth_response")
|
|
270
|
+
msg.append(f"Received auth_response: {list(auth_response)}")
|
|
271
|
+
return False, msg
|
|
272
|
+
|
|
273
|
+
if self.session.new:
|
|
274
|
+
msg.append(
|
|
275
|
+
"Warning: This is a new session and may not have all expected values."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if not self.session.get(self.flow_cache_key):
|
|
279
|
+
msg.append(f"<b>Expected '{self.flow_cache_key}' in session</b>")
|
|
280
|
+
msg.append(helpers.html_table(self.session))
|
|
281
|
+
|
|
282
|
+
self.redirect = "/" + self.redirect.lstrip("/")
|
|
283
|
+
|
|
284
|
+
if msg:
|
|
285
|
+
return False, msg
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
await self.async_acquire_token_by_auth_code_flow(auth_response)
|
|
289
|
+
except Exception as err:
|
|
290
|
+
msg.append(
|
|
291
|
+
"<b>Could not get token</b> - async_acquire_token_by_auth_code_flow"
|
|
292
|
+
)
|
|
293
|
+
msg.append(str(err))
|
|
294
|
+
return False, msg
|
|
295
|
+
|
|
296
|
+
if not msg:
|
|
297
|
+
try:
|
|
298
|
+
if get_info in ("user", "manager"):
|
|
299
|
+
await helpers.get_user_info(self)
|
|
300
|
+
if get_info == "manager":
|
|
301
|
+
await helpers.get_manager_info(self)
|
|
302
|
+
except Exception as err:
|
|
303
|
+
msg.append("Could not get org info from MS graph")
|
|
304
|
+
msg.append(str(err))
|
|
305
|
+
self.mail = ""
|
|
306
|
+
self.name = ""
|
|
307
|
+
|
|
308
|
+
if self.session.get("mail"):
|
|
309
|
+
for lcb in ENV.login_callback:
|
|
310
|
+
await lcb(self)
|
|
311
|
+
|
|
312
|
+
return True, msg
|
aiohttp_msal/routes.py
CHANGED
|
@@ -9,12 +9,7 @@ from aiohttp import web
|
|
|
9
9
|
from aiohttp_session import get_session, new_session
|
|
10
10
|
|
|
11
11
|
from aiohttp_msal import ENV, auth_ok, msal_session
|
|
12
|
-
from aiohttp_msal.helpers import
|
|
13
|
-
check_auth_response,
|
|
14
|
-
get_manager_info,
|
|
15
|
-
get_user_info,
|
|
16
|
-
html_wrap,
|
|
17
|
-
)
|
|
12
|
+
from aiohttp_msal.helpers import get_manager_info, get_url, get_user_info, html_wrap
|
|
18
13
|
from aiohttp_msal.msal_async import AsyncMSAL
|
|
19
14
|
|
|
20
15
|
ROUTES = web.RouteTableDef()
|
|
@@ -24,17 +19,6 @@ URI_USER_AUTHORIZED = "/user/authorized"
|
|
|
24
19
|
SESSION_REDIRECT = "redirect"
|
|
25
20
|
|
|
26
21
|
|
|
27
|
-
def get_route(request: web.Request, url: str) -> str:
|
|
28
|
-
"""Retrieve server route from request.
|
|
29
|
-
|
|
30
|
-
localhost and production on http:// with nginx proxy that adds TLS/SSL.
|
|
31
|
-
"""
|
|
32
|
-
url = str(request.url.origin() / url)
|
|
33
|
-
if "localhost" not in url:
|
|
34
|
-
url = url.replace("http:", "https:", 1)
|
|
35
|
-
return url
|
|
36
|
-
|
|
37
|
-
|
|
38
22
|
@ROUTES.get(URI_USER_LOGIN)
|
|
39
23
|
@ROUTES.get(f"{URI_USER_LOGIN}/{{to:.+$}}")
|
|
40
24
|
async def user_login(request: web.Request) -> web.Response:
|
|
@@ -47,7 +31,7 @@ async def user_login(request: web.Request) -> web.Response:
|
|
|
47
31
|
_to = "/"
|
|
48
32
|
session[SESSION_REDIRECT] = urljoin(_to, request.match_info.get("to", ""))
|
|
49
33
|
|
|
50
|
-
msredirect =
|
|
34
|
+
msredirect = get_url(request, URI_USER_AUTHORIZED.lstrip("/"))
|
|
51
35
|
redir = AsyncMSAL(session).initiate_auth_code_flow(redirect_uri=msredirect)
|
|
52
36
|
raise web.HTTPFound(redir)
|
|
53
37
|
|
|
@@ -55,9 +39,10 @@ async def user_login(request: web.Request) -> web.Response:
|
|
|
55
39
|
@ROUTES.post(URI_USER_AUTHORIZED)
|
|
56
40
|
async def user_authorized(request: web.Request) -> web.Response:
|
|
57
41
|
"""Complete the auth code flow."""
|
|
58
|
-
aiomsal
|
|
42
|
+
aiomsal = await AsyncMSAL.from_request(request)
|
|
43
|
+
ok, msg = await aiomsal.async_acquire_token_by_auth_code_flow_plus(request)
|
|
59
44
|
|
|
60
|
-
if
|
|
45
|
+
if ok and not msg:
|
|
61
46
|
try:
|
|
62
47
|
raise web.HTTPFound(aiomsal.redirect)
|
|
63
48
|
finally:
|
|
@@ -149,7 +134,7 @@ async def user_logout(request: web.Request, ses: AsyncMSAL) -> web.Response:
|
|
|
149
134
|
if ref:
|
|
150
135
|
_to = urljoin(ref, _to)
|
|
151
136
|
else:
|
|
152
|
-
_to =
|
|
137
|
+
_to = get_url(request, _to)
|
|
153
138
|
|
|
154
139
|
return web.HTTPFound(
|
|
155
140
|
f"https://login.microsoftonline.com/common/oauth2/logout?post_logout_redirect_uri={_to}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: aiohttp-msal
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
4
4
|
Summary: Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
|
|
5
5
|
Keywords: aiohttp,asyncio,msal,oauth
|
|
6
6
|
Author: Johann Kellerman
|
|
@@ -137,4 +137,6 @@ uv sync --all-extras
|
|
|
137
137
|
uv tool install ruff
|
|
138
138
|
uv tool install codespell
|
|
139
139
|
uv tool install pyproject-fmt
|
|
140
|
+
uv tool install prek
|
|
141
|
+
prek install # add pre-commit hooks
|
|
140
142
|
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aiohttp_msal/__init__.py,sha256=nrjPAIy0kNbBvHZsy9qUSKs5r_-Kiea-KJM17bsBIkw,4375
|
|
2
|
+
aiohttp_msal/helpers.py,sha256=L0DFIZD9OfCepckfk64ZtxrVX0-y0aAqRFowBYipepE,2474
|
|
3
|
+
aiohttp_msal/msal_async.py,sha256=VG5L6PGBHzRBNg_XfIqvfU1V-Snznv_Srd_kXzBgTts,11702
|
|
4
|
+
aiohttp_msal/redis_tools.py,sha256=6kCw0_zDQcvIcsJaPfG-zHUvT3vzkrNySNTV5y1tckE,6539
|
|
5
|
+
aiohttp_msal/routes.py,sha256=Cc6FHqs8dyHSTCC6AaT-yPttSDyBlsngoiz7j9QCKRU,5143
|
|
6
|
+
aiohttp_msal/settings.py,sha256=sArlq9vBDMsikLf9sTRw-UXE2_QRK_G-kzmtHvZcbwA,1559
|
|
7
|
+
aiohttp_msal/settings_base.py,sha256=WBI7HS780i9zKWUy1ZnztDbRsfoDMVr3K-otHZOhNCc,3026
|
|
8
|
+
aiohttp_msal/utils.py,sha256=ll303J58nwCEB9QCAm13urUOa6cPqsAE7z_iP9OlRJw,2390
|
|
9
|
+
aiohttp_msal-1.0.5.dist-info/WHEEL,sha256=Jb20R3Ili4n9P1fcwuLup21eQ5r9WXhs4_qy7VTrgPI,79
|
|
10
|
+
aiohttp_msal-1.0.5.dist-info/METADATA,sha256=OR8PATJQ8sdTo2vifYRzTmj4iYt9Vcra-CxoADp39Ac,4572
|
|
11
|
+
aiohttp_msal-1.0.5.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
aiohttp_msal/__init__.py,sha256=nrjPAIy0kNbBvHZsy9qUSKs5r_-Kiea-KJM17bsBIkw,4375
|
|
2
|
-
aiohttp_msal/helpers.py,sha256=8sW-7FW4t1Ztazql11ng67SqdKBlbOWuOMjOBRfBc_c,4495
|
|
3
|
-
aiohttp_msal/msal_async.py,sha256=RUV_XlhNNCOZslT1sEkEPyaYcHDOmsIyiGmRpZuA3I0,8097
|
|
4
|
-
aiohttp_msal/redis_tools.py,sha256=6kCw0_zDQcvIcsJaPfG-zHUvT3vzkrNySNTV5y1tckE,6539
|
|
5
|
-
aiohttp_msal/routes.py,sha256=3rAejWcHfL5czVA91TY3wZGT5EkRxmfyvc8vr6rhyxU,5438
|
|
6
|
-
aiohttp_msal/settings.py,sha256=sArlq9vBDMsikLf9sTRw-UXE2_QRK_G-kzmtHvZcbwA,1559
|
|
7
|
-
aiohttp_msal/settings_base.py,sha256=WBI7HS780i9zKWUy1ZnztDbRsfoDMVr3K-otHZOhNCc,3026
|
|
8
|
-
aiohttp_msal/utils.py,sha256=ll303J58nwCEB9QCAm13urUOa6cPqsAE7z_iP9OlRJw,2390
|
|
9
|
-
aiohttp_msal-1.0.4.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
10
|
-
aiohttp_msal-1.0.4.dist-info/METADATA,sha256=OBl_U9OTy602OfSL9-d5myNJPUYuaGjtBF7voW5lu2Y,4514
|
|
11
|
-
aiohttp_msal-1.0.4.dist-info/RECORD,,
|