aiohttp-msal 1.0.3__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/__init__.py +15 -3
- aiohttp_msal/helpers.py +76 -0
- aiohttp_msal/msal_async.py +120 -31
- aiohttp_msal/routes.py +27 -116
- aiohttp_msal/utils.py +32 -4
- {aiohttp_msal-1.0.3.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.3.dist-info → aiohttp_msal-1.0.5.dist-info}/WHEEL +1 -1
- aiohttp_msal/user_info.py +0 -32
- aiohttp_msal-1.0.3.dist-info/RECORD +0 -11
aiohttp_msal/__init__.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""aiohttp_msal."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import logging
|
|
4
5
|
from collections.abc import Awaitable, Callable
|
|
5
6
|
from functools import wraps
|
|
6
7
|
from inspect import getfullargspec, iscoroutinefunction
|
|
7
|
-
from typing import TypeVar, TypeVarTuple, cast
|
|
8
|
+
from typing import Any, TypeVar, TypeVarTuple, cast
|
|
8
9
|
|
|
9
10
|
from aiohttp import ClientSession, web
|
|
10
11
|
from aiohttp_session import get_session
|
|
@@ -12,6 +13,7 @@ from aiohttp_session import setup as _setup
|
|
|
12
13
|
|
|
13
14
|
from aiohttp_msal.msal_async import AsyncMSAL
|
|
14
15
|
from aiohttp_msal.settings import ENV
|
|
16
|
+
from aiohttp_msal.utils import retry
|
|
15
17
|
|
|
16
18
|
_LOGGER = logging.getLogger(__name__)
|
|
17
19
|
|
|
@@ -87,7 +89,11 @@ def auth_or(
|
|
|
87
89
|
|
|
88
90
|
|
|
89
91
|
async def app_init_redis_session(
|
|
90
|
-
app: web.Application,
|
|
92
|
+
app: web.Application,
|
|
93
|
+
max_age: int = 3600 * 24 * 90,
|
|
94
|
+
check_proxy_cb: Callable[[], Awaitable[None]] | None = None,
|
|
95
|
+
encoder: Callable[[object], str] = json.dumps,
|
|
96
|
+
decoder: Callable[[str], Any] = json.loads,
|
|
91
97
|
) -> None:
|
|
92
98
|
"""Init an aiohttp_session with Redis storage helper.
|
|
93
99
|
|
|
@@ -96,7 +102,10 @@ async def app_init_redis_session(
|
|
|
96
102
|
from aiohttp_session import redis_storage
|
|
97
103
|
from redis.asyncio import from_url
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
if check_proxy_cb:
|
|
106
|
+
await check_proxy_cb()
|
|
107
|
+
else:
|
|
108
|
+
await check_proxy()
|
|
100
109
|
|
|
101
110
|
_LOGGER.info("Connect to Redis %s", ENV.REDIS)
|
|
102
111
|
try:
|
|
@@ -114,10 +123,13 @@ async def app_init_redis_session(
|
|
|
114
123
|
secure=True,
|
|
115
124
|
domain=ENV.DOMAIN,
|
|
116
125
|
cookie_name=ENV.COOKIE_NAME,
|
|
126
|
+
encoder=encoder,
|
|
127
|
+
decoder=decoder,
|
|
117
128
|
)
|
|
118
129
|
_setup(app, storage)
|
|
119
130
|
|
|
120
131
|
|
|
132
|
+
@retry
|
|
121
133
|
async def check_proxy() -> None:
|
|
122
134
|
"""Test if we have Internet connectivity through proxies etc."""
|
|
123
135
|
try:
|
aiohttp_msal/helpers.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Graph User Info."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from aiohttp import web
|
|
7
|
+
|
|
8
|
+
from aiohttp_msal.settings import ENV
|
|
9
|
+
from aiohttp_msal.utils import retry
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from aiohttp_msal.msal_async import AsyncMSAL
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@retry
|
|
16
|
+
async def get_user_info(aiomsal: "AsyncMSAL") -> None:
|
|
17
|
+
"""Load user info from MS graph API. Requires User.Read permissions."""
|
|
18
|
+
async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
|
|
19
|
+
body = await res.json()
|
|
20
|
+
try:
|
|
21
|
+
aiomsal.mail = body["mail"]
|
|
22
|
+
aiomsal.name = body["displayName"]
|
|
23
|
+
except KeyError as err:
|
|
24
|
+
raise KeyError(
|
|
25
|
+
f"Unexpected return from Graph endpoint: {body}: {err}"
|
|
26
|
+
) from err
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@retry
|
|
30
|
+
async def get_manager_info(aiomsal: "AsyncMSAL") -> None:
|
|
31
|
+
"""Load manager info from MS graph API. Requires User.Read.All permissions."""
|
|
32
|
+
async with aiomsal.get("https://graph.microsoft.com/v1.0/me/manager") as res:
|
|
33
|
+
body = await res.json()
|
|
34
|
+
try:
|
|
35
|
+
aiomsal.manager_mail = body["mail"]
|
|
36
|
+
aiomsal.manager_name = body["displayName"]
|
|
37
|
+
except KeyError as err:
|
|
38
|
+
raise KeyError(
|
|
39
|
+
f"Unexpected return from Graph endpoint: {body}: {err}"
|
|
40
|
+
) from err
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def html_table(items: Mapping[Any, Any]) -> str:
|
|
44
|
+
"""Return a table HTML."""
|
|
45
|
+
res = "<table style='width:80%;border:1px solid black;'>"
|
|
46
|
+
for key, val in items.items():
|
|
47
|
+
res += f"<tr><td>{key}</td><td>{val}</td></tr>"
|
|
48
|
+
res += "</table>"
|
|
49
|
+
return res
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def html_wrap(msgs: Sequence[str]) -> str:
|
|
53
|
+
"""Return proper HTML when login fails."""
|
|
54
|
+
html = "</li><li>".join(msgs)
|
|
55
|
+
return f"""
|
|
56
|
+
<h2>Login failed</h2>
|
|
57
|
+
|
|
58
|
+
<p>Retry at <a href='/user/login'>/user/login</a></p>
|
|
59
|
+
|
|
60
|
+
<p>Try clearing the cookies for <b>.{ENV.DOMAIN}<b> by navigating to the correct
|
|
61
|
+
address for your browser:
|
|
62
|
+
<ul>
|
|
63
|
+
<li>chrome://settings/siteData?searchSubpage={ENV.DOMAIN}</li>
|
|
64
|
+
<li>brave://settings/siteData?searchSubpage={ENV.DOMAIN}</li>
|
|
65
|
+
<li>edge://settings/siteData (you will have to search for {ENV.DOMAIN} cookies)</li>
|
|
66
|
+
</ul></p>
|
|
67
|
+
|
|
68
|
+
<h4>Debug info</h4>
|
|
69
|
+
<ul><li>{html}</li></ul>
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
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
|
|
13
|
+
from typing import Any, ClassVar, Literal, Self, TypeVar, Unpack, cast
|
|
13
14
|
|
|
14
15
|
import attrs
|
|
15
16
|
from aiohttp import web
|
|
@@ -20,10 +21,14 @@ 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
|
|
29
|
+
from aiohttp_msal.utils import dict_property
|
|
30
|
+
|
|
31
|
+
_LOG = logging.getLogger(__name__)
|
|
27
32
|
|
|
28
33
|
HttpMethods = Literal["get", "post", "put", "patch", "delete"]
|
|
29
34
|
HTTP_GET = "get"
|
|
@@ -33,11 +38,10 @@ HTTP_PATCH = "patch"
|
|
|
33
38
|
HTTP_DELETE = "delete"
|
|
34
39
|
HTTP_ALLOWED = [HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE]
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
FLOW_CACHE = "flow_cache"
|
|
41
|
+
T = TypeVar("T")
|
|
38
42
|
|
|
39
43
|
|
|
40
|
-
@attrs.define()
|
|
44
|
+
@attrs.define(slots=False)
|
|
41
45
|
class AsyncMSAL:
|
|
42
46
|
"""AsycMSAL class.
|
|
43
47
|
|
|
@@ -56,10 +60,45 @@ class AsyncMSAL:
|
|
|
56
60
|
"""
|
|
57
61
|
app_kwargs: dict[str, Any] | None = None
|
|
58
62
|
"""ConfidentialClientApplication kwargs."""
|
|
63
|
+
|
|
59
64
|
client_session: ClassVar[ClientSession | None] = None
|
|
65
|
+
token_cache_key: ClassVar[str] = "token_cache"
|
|
66
|
+
user_email_key: ClassVar[str] = "mail"
|
|
67
|
+
flow_cache_key: ClassVar[str] = "flow_cache"
|
|
68
|
+
redirect_key: ClassVar[str] = "redirect"
|
|
69
|
+
default_scopes: ClassVar[list[str]] = ["User.Read", "User.Read.All"]
|
|
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'"
|
|
60
96
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
102
|
|
|
64
103
|
@cached_property
|
|
65
104
|
def app(self) -> ConfidentialClientApplication:
|
|
@@ -100,8 +139,8 @@ class AsyncMSAL:
|
|
|
100
139
|
"""First step - Start the flow."""
|
|
101
140
|
self.session.pop(self.token_cache_key, None)
|
|
102
141
|
self.session.pop(self.user_email_key, None)
|
|
103
|
-
self.session[
|
|
104
|
-
scopes or
|
|
142
|
+
self.session[self.flow_cache_key] = res = self.app.initiate_auth_code_flow(
|
|
143
|
+
scopes or self.default_scopes,
|
|
105
144
|
redirect_uri=redirect_uri,
|
|
106
145
|
response_mode="form_post",
|
|
107
146
|
prompt=prompt,
|
|
@@ -117,8 +156,8 @@ class AsyncMSAL:
|
|
|
117
156
|
) -> None:
|
|
118
157
|
"""Second step - Acquire token."""
|
|
119
158
|
# Assume we have it in the cache (added by /login)
|
|
120
|
-
# will raise
|
|
121
|
-
auth_code_flow = self.session.pop(
|
|
159
|
+
# will raise KeyError if not in cache
|
|
160
|
+
auth_code_flow = self.session.pop(self.flow_cache_key)
|
|
122
161
|
result = self.app.acquire_token_by_auth_code_flow(
|
|
123
162
|
auth_code_flow, auth_response, scopes=scopes
|
|
124
163
|
)
|
|
@@ -141,7 +180,7 @@ class AsyncMSAL:
|
|
|
141
180
|
accounts = self.app.get_accounts()
|
|
142
181
|
if accounts:
|
|
143
182
|
result = self.app.acquire_token_silent(
|
|
144
|
-
scopes=scopes or
|
|
183
|
+
scopes=scopes or self.default_scopes, account=accounts[0]
|
|
145
184
|
)
|
|
146
185
|
self.save_token_cache()
|
|
147
186
|
return result
|
|
@@ -198,26 +237,76 @@ class AsyncMSAL:
|
|
|
198
237
|
post = partialmethod(request_ctx, HTTP_POST)
|
|
199
238
|
|
|
200
239
|
@property
|
|
201
|
-
def
|
|
202
|
-
"""
|
|
203
|
-
return self.session.get(self.user_email_key
|
|
240
|
+
def authenticated(self) -> bool:
|
|
241
|
+
"""If the user is logged in."""
|
|
242
|
+
return bool(self.session.get(self.user_email_key))
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
name = cast(str, dict_property("session", "name"))
|
|
245
|
+
mail = dict_property("session", user_email_key)
|
|
246
|
+
manager_name = dict_property("session", "m_name")
|
|
247
|
+
manager_mail = dict_property("session", "m_mail")
|
|
248
|
+
redirect = dict_property("session", redirect_key)
|
|
209
249
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
214
256
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
"""User's display name."""
|
|
218
|
-
return self.session.get("name", "")
|
|
257
|
+
Parse the auth response from the request, checks for valid keys,
|
|
258
|
+
acquire the token and get_info.
|
|
219
259
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""The user blueprint."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from collections.abc import Mapping, Sequence
|
|
5
4
|
from inspect import iscoroutinefunction
|
|
6
5
|
from typing import Any
|
|
7
6
|
from urllib.parse import urljoin
|
|
@@ -9,9 +8,9 @@ from urllib.parse import urljoin
|
|
|
9
8
|
from aiohttp import web
|
|
10
9
|
from aiohttp_session import get_session, new_session
|
|
11
10
|
|
|
12
|
-
from aiohttp_msal import
|
|
13
|
-
from aiohttp_msal.
|
|
14
|
-
from aiohttp_msal.
|
|
11
|
+
from aiohttp_msal import ENV, auth_ok, msal_session
|
|
12
|
+
from aiohttp_msal.helpers import get_manager_info, get_url, get_user_info, html_wrap
|
|
13
|
+
from aiohttp_msal.msal_async import AsyncMSAL
|
|
15
14
|
|
|
16
15
|
ROUTES = web.RouteTableDef()
|
|
17
16
|
|
|
@@ -20,17 +19,6 @@ URI_USER_AUTHORIZED = "/user/authorized"
|
|
|
20
19
|
SESSION_REDIRECT = "redirect"
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
def get_route(request: web.Request, url: str) -> str:
|
|
24
|
-
"""Retrieve server route from request.
|
|
25
|
-
|
|
26
|
-
localhost and production on http:// with nginx proxy that adds TLS/SSL.
|
|
27
|
-
"""
|
|
28
|
-
url = str(request.url.origin() / url)
|
|
29
|
-
if "localhost" not in url:
|
|
30
|
-
url = url.replace("p:", "ps:", 1)
|
|
31
|
-
return url
|
|
32
|
-
|
|
33
|
-
|
|
34
22
|
@ROUTES.get(URI_USER_LOGIN)
|
|
35
23
|
@ROUTES.get(f"{URI_USER_LOGIN}/{{to:.+$}}")
|
|
36
24
|
async def user_login(request: web.Request) -> web.Response:
|
|
@@ -43,85 +31,38 @@ async def user_login(request: web.Request) -> web.Response:
|
|
|
43
31
|
_to = "/"
|
|
44
32
|
session[SESSION_REDIRECT] = urljoin(_to, request.match_info.get("to", ""))
|
|
45
33
|
|
|
46
|
-
msredirect =
|
|
34
|
+
msredirect = get_url(request, URI_USER_AUTHORIZED.lstrip("/"))
|
|
47
35
|
redir = AsyncMSAL(session).initiate_auth_code_flow(redirect_uri=msredirect)
|
|
48
|
-
|
|
36
|
+
raise web.HTTPFound(redir)
|
|
49
37
|
|
|
50
38
|
|
|
51
39
|
@ROUTES.post(URI_USER_AUTHORIZED)
|
|
52
40
|
async def user_authorized(request: web.Request) -> web.Response:
|
|
53
41
|
"""Complete the auth code flow."""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# build a plain dict from the aiohttp server request's url parameters
|
|
57
|
-
# pre-0.1.18. Now we have response_mode="form_post"
|
|
58
|
-
# auth_response = dict(request.rel_url.query.items())
|
|
59
|
-
auth_response = dict(await request.post())
|
|
60
|
-
|
|
61
|
-
msg = []
|
|
62
|
-
response_cookie = 0
|
|
63
|
-
|
|
64
|
-
# Ensure all expected variables were returned...
|
|
65
|
-
if not all(auth_response.get(k) for k in ["code", "session_state", "state"]):
|
|
66
|
-
msg.append(
|
|
67
|
-
f"<b>Expecting code,state,session_state in post body.</b>auth_response: {auth_response}"
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
if not request.cookies.get(ENV.COOKIE_NAME):
|
|
71
|
-
cookies = dict(request.cookies.items())
|
|
72
|
-
msg.append(f"<b>Expecting '{ENV.COOKIE_NAME}' in cookies</b>")
|
|
73
|
-
_LOGGER.fatal("Cookie should be set with Samesite:None")
|
|
74
|
-
msg.append(html_table(cookies))
|
|
75
|
-
|
|
76
|
-
elif not session.get(FLOW_CACHE):
|
|
77
|
-
msg.append(f"<b>Expecting '{FLOW_CACHE}' in session</b>")
|
|
78
|
-
msg.append(f"- Session.new: {session.new}")
|
|
79
|
-
msg.append(html_table(session))
|
|
80
|
-
|
|
81
|
-
aiomsal = AsyncMSAL(session)
|
|
42
|
+
aiomsal = await AsyncMSAL.from_request(request)
|
|
43
|
+
ok, msg = await aiomsal.async_acquire_token_by_auth_code_flow_plus(request)
|
|
82
44
|
|
|
83
|
-
if not msg:
|
|
45
|
+
if ok and not msg:
|
|
84
46
|
try:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for lcb in ENV.login_callback:
|
|
103
|
-
await lcb(aiomsal)
|
|
104
|
-
|
|
105
|
-
if msg:
|
|
106
|
-
resp = web.Response(
|
|
107
|
-
body=html_wrap(msg),
|
|
108
|
-
content_type="text/html",
|
|
47
|
+
raise web.HTTPFound(aiomsal.redirect)
|
|
48
|
+
finally:
|
|
49
|
+
aiomsal.redirect = ""
|
|
50
|
+
|
|
51
|
+
resp = web.Response(
|
|
52
|
+
body=html_wrap(msg),
|
|
53
|
+
content_type="text/html",
|
|
54
|
+
)
|
|
55
|
+
if aiomsal is None:
|
|
56
|
+
resp.set_cookie(
|
|
57
|
+
ENV.COOKIE_NAME,
|
|
58
|
+
"",
|
|
59
|
+
path="/",
|
|
60
|
+
httponly=True,
|
|
61
|
+
secure=True,
|
|
62
|
+
samesite="Strict",
|
|
63
|
+
domain=ENV.DOMAIN,
|
|
109
64
|
)
|
|
110
|
-
|
|
111
|
-
resp.set_cookie(
|
|
112
|
-
ENV.COOKIE_NAME,
|
|
113
|
-
"",
|
|
114
|
-
path="/",
|
|
115
|
-
httponly=True,
|
|
116
|
-
secure=True,
|
|
117
|
-
samesite="Strict",
|
|
118
|
-
domain=ENV.DOMAIN,
|
|
119
|
-
)
|
|
120
|
-
return resp
|
|
121
|
-
|
|
122
|
-
redirect = session.pop(SESSION_REDIRECT, "") or "/fr/"
|
|
123
|
-
|
|
124
|
-
return web.HTTPFound(redirect)
|
|
65
|
+
return resp
|
|
125
66
|
|
|
126
67
|
|
|
127
68
|
@ROUTES.get("/user/debug")
|
|
@@ -193,7 +134,7 @@ async def user_logout(request: web.Request, ses: AsyncMSAL) -> web.Response:
|
|
|
193
134
|
if ref:
|
|
194
135
|
_to = urljoin(ref, _to)
|
|
195
136
|
else:
|
|
196
|
-
_to =
|
|
137
|
+
_to = get_url(request, _to)
|
|
197
138
|
|
|
198
139
|
return web.HTTPFound(
|
|
199
140
|
f"https://login.microsoftonline.com/common/oauth2/logout?post_logout_redirect_uri={_to}"
|
|
@@ -226,33 +167,3 @@ async def user_photo(request: web.Request, ses: AsyncMSAL) -> web.StreamResponse
|
|
|
226
167
|
|
|
227
168
|
# await response.write_eof()
|
|
228
169
|
return response
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def html_table(items: Mapping[Any, Any]) -> str:
|
|
232
|
-
"""Return a table HTML."""
|
|
233
|
-
res = "<table style='width:80%;border:1px solid black;'>"
|
|
234
|
-
for key, val in items.items():
|
|
235
|
-
res += f"<tr><td>{key}</td><td>{val}</td></tr>"
|
|
236
|
-
res += "</table>"
|
|
237
|
-
return res
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def html_wrap(msgs: Sequence[str]) -> str:
|
|
241
|
-
"""Return proper HTML when login fails."""
|
|
242
|
-
html = "</li><li>".join(msgs)
|
|
243
|
-
return f"""
|
|
244
|
-
<h2>Login failed</h2>
|
|
245
|
-
|
|
246
|
-
<p>Retry at <a href='/user/login'>/user/login</a></p>
|
|
247
|
-
|
|
248
|
-
<p>Try clearing the cookies for <b>.{ENV.DOMAIN}<b> by navigating to the correct
|
|
249
|
-
address for your browser:
|
|
250
|
-
<ul>
|
|
251
|
-
<li>chrome://settings/siteData?searchSubpage={ENV.DOMAIN}</li>
|
|
252
|
-
<li>brave://settings/siteData?searchSubpage={ENV.DOMAIN}</li>
|
|
253
|
-
<li>edge://settings/siteData (you will have to search for {ENV.DOMAIN} cookies)</li>
|
|
254
|
-
</ul></p>
|
|
255
|
-
|
|
256
|
-
<h4>Debug info</h4>
|
|
257
|
-
<ul><li>{html}</li></ul>
|
|
258
|
-
"""
|
aiohttp_msal/utils.py
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
|
-
from functools import wraps
|
|
6
|
-
from typing import ParamSpec, TypeVar
|
|
7
|
-
from functools import partial
|
|
8
|
-
|
|
5
|
+
from functools import partial, wraps
|
|
6
|
+
from typing import Any, ParamSpec, TypeVar
|
|
9
7
|
|
|
10
8
|
T = TypeVar("T")
|
|
11
9
|
P = ParamSpec("P")
|
|
@@ -31,6 +29,36 @@ def async_wrap(
|
|
|
31
29
|
return run
|
|
32
30
|
|
|
33
31
|
|
|
32
|
+
class dict_property(property):
|
|
33
|
+
"""Property."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, dict_name: str, prop_name: str) -> None:
|
|
36
|
+
"""Initialize the property."""
|
|
37
|
+
self.dict_name = dict_name
|
|
38
|
+
self.prop_name = prop_name
|
|
39
|
+
|
|
40
|
+
def __get__(self, instance: Any, owner: type | None = None, /) -> Any:
|
|
41
|
+
"""Getter."""
|
|
42
|
+
return getattr(instance, self.dict_name, {}).get(self.prop_name, "")
|
|
43
|
+
|
|
44
|
+
def __set__(self, instance: Any, value: Any, /) -> None:
|
|
45
|
+
"""Setter."""
|
|
46
|
+
if value == "":
|
|
47
|
+
getattr(instance, self.dict_name, {}).pop(self.prop_name, None)
|
|
48
|
+
else:
|
|
49
|
+
getattr(instance, self.dict_name, {}).__setitem__(self.prop_name, value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# def dict_property(dict_name: str, prop_name: str) -> property:
|
|
53
|
+
# """Create properties for a dictionary."""
|
|
54
|
+
# return property(
|
|
55
|
+
# fget=lambda self: str(getattr(self, dict_name).get(prop_name, "")),
|
|
56
|
+
# fset=lambda self, v: getattr(self, dict_name).set(prop_name, v),
|
|
57
|
+
# fdel=lambda self: getattr(self, dict_name).pop(prop_name, None),
|
|
58
|
+
# doc=f'self.{dict_name}["{prop_name}"]',
|
|
59
|
+
# )
|
|
60
|
+
|
|
61
|
+
|
|
34
62
|
def retry(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
35
63
|
"""Retry if tenacity is installed."""
|
|
36
64
|
|
|
@@ -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,,
|
aiohttp_msal/user_info.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""Graph User Info."""
|
|
2
|
-
|
|
3
|
-
from aiohttp_msal.msal_async import AsyncMSAL
|
|
4
|
-
from aiohttp_msal.utils import retry
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@retry
|
|
8
|
-
async def get_user_info(aiomsal: AsyncMSAL) -> None:
|
|
9
|
-
"""Load user info from MS graph API. Requires User.Read permissions."""
|
|
10
|
-
async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
|
|
11
|
-
body = await res.json()
|
|
12
|
-
try:
|
|
13
|
-
aiomsal.session["mail"] = body["mail"]
|
|
14
|
-
aiomsal.session["name"] = body["displayName"]
|
|
15
|
-
except KeyError as err:
|
|
16
|
-
raise KeyError(
|
|
17
|
-
f"Unexpected return from Graph endpoint: {body}: {err}"
|
|
18
|
-
) from err
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@retry
|
|
22
|
-
async def get_manager_info(aiomsal: AsyncMSAL) -> None:
|
|
23
|
-
"""Load manager info from MS graph API. Requires User.Read.All permissions."""
|
|
24
|
-
async with aiomsal.get("https://graph.microsoft.com/v1.0/me/manager") as res:
|
|
25
|
-
body = await res.json()
|
|
26
|
-
try:
|
|
27
|
-
aiomsal.session["m_mail"] = body["mail"]
|
|
28
|
-
aiomsal.session["m_name"] = body["displayName"]
|
|
29
|
-
except KeyError as err:
|
|
30
|
-
raise KeyError(
|
|
31
|
-
f"Unexpected return from Graph endpoint: {body}: {err}"
|
|
32
|
-
) from err
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
aiohttp_msal/__init__.py,sha256=hnyifyJykI7NMvM93KrHIsTlrrfCVUrpKdbRKL6Gubw,4027
|
|
2
|
-
aiohttp_msal/msal_async.py,sha256=urQvaMTi0mJnCboCsj8A9F9VpWcjPOYaechpZ5XrnbY,8153
|
|
3
|
-
aiohttp_msal/redis_tools.py,sha256=6kCw0_zDQcvIcsJaPfG-zHUvT3vzkrNySNTV5y1tckE,6539
|
|
4
|
-
aiohttp_msal/routes.py,sha256=WyLBuoPMkkG6Cx4gFUu_ER71FyJbeXKhOQRQu5ALG2M,8138
|
|
5
|
-
aiohttp_msal/settings.py,sha256=sArlq9vBDMsikLf9sTRw-UXE2_QRK_G-kzmtHvZcbwA,1559
|
|
6
|
-
aiohttp_msal/settings_base.py,sha256=WBI7HS780i9zKWUy1ZnztDbRsfoDMVr3K-otHZOhNCc,3026
|
|
7
|
-
aiohttp_msal/user_info.py,sha256=lxjFxjm16rvC-0LS81y7SG5pCOa5Zl0s62uxi97yu_k,1171
|
|
8
|
-
aiohttp_msal/utils.py,sha256=SgGpE1eFdVh48FaKvtbnQqJKTReXa9OPBKiYGY7SYq8,1303
|
|
9
|
-
aiohttp_msal-1.0.3.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
10
|
-
aiohttp_msal-1.0.3.dist-info/METADATA,sha256=raujaCawOODO6HYM79crhTECU28f_IjlkzIPmR8ck48,4514
|
|
11
|
-
aiohttp_msal-1.0.3.dist-info/RECORD,,
|