aiohttp-msal 0.5.4__py3-none-any.whl → 0.6__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 +1 -1
- aiohttp_msal/msal_async.py +35 -12
- aiohttp_msal/redis_tools.py +87 -0
- {aiohttp_msal-0.5.4.dist-info → aiohttp_msal-0.6.dist-info}/METADATA +16 -17
- aiohttp_msal-0.6.dist-info/RECORD +17 -0
- {aiohttp_msal-0.5.4.dist-info → aiohttp_msal-0.6.dist-info}/WHEEL +1 -1
- tests/test_init.py +1 -0
- aiohttp_msal-0.5.4.dist-info/RECORD +0 -16
- {aiohttp_msal-0.5.4.dist-info → aiohttp_msal-0.6.dist-info}/LICENSE +0 -0
- {aiohttp_msal-0.5.4.dist-info → aiohttp_msal-0.6.dist-info}/top_level.txt +0 -0
- {aiohttp_msal-0.5.4.dist-info → aiohttp_msal-0.6.dist-info}/zip-safe +0 -0
aiohttp_msal/__init__.py
CHANGED
aiohttp_msal/msal_async.py
CHANGED
|
@@ -7,7 +7,7 @@ Once you have the OAuth tokens store in the session, you are free to make reques
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import json
|
|
9
9
|
from functools import partial, wraps
|
|
10
|
-
from typing import Any, Callable, Optional
|
|
10
|
+
from typing import Any, Callable, Optional, Union
|
|
11
11
|
|
|
12
12
|
from aiohttp import web
|
|
13
13
|
from aiohttp.client import ClientResponse, ClientSession, _RequestContextManager
|
|
@@ -96,11 +96,21 @@ class AsyncMSAL:
|
|
|
96
96
|
_app: ConfidentialClientApplication = None
|
|
97
97
|
_clientsession: ClientSession = None # type: ignore
|
|
98
98
|
|
|
99
|
-
def __init__(
|
|
100
|
-
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
session: Union[Session, dict[str, str]],
|
|
102
|
+
save_cache: Optional[Callable[[Union[Session, dict[str, str]]], None]] = None,
|
|
103
|
+
):
|
|
104
|
+
"""Init the class.
|
|
105
|
+
|
|
106
|
+
**save_token_cache** will be called if the token cache changes. Optional.
|
|
107
|
+
Not required when the session parameter is an aiohttp_session.Session.
|
|
108
|
+
"""
|
|
101
109
|
self.session = session
|
|
102
|
-
if
|
|
103
|
-
|
|
110
|
+
if save_cache:
|
|
111
|
+
self.save_token_cache = save_cache
|
|
112
|
+
if not isinstance(session, (Session, dict)):
|
|
113
|
+
raise ValueError(f"session or dict-like object required {session}")
|
|
104
114
|
|
|
105
115
|
@property
|
|
106
116
|
def token_cache(self) -> SerializableTokenCache:
|
|
@@ -134,11 +144,13 @@ class AsyncMSAL:
|
|
|
134
144
|
"""Save the token cache if it changed."""
|
|
135
145
|
if self.token_cache.has_state_changed:
|
|
136
146
|
self.session[TOKEN_CACHE] = self.token_cache.serialize()
|
|
147
|
+
if hasattr(self, "save_token_cache"):
|
|
148
|
+
self.save_token_cache(self.token_cache)
|
|
137
149
|
|
|
138
150
|
def build_auth_code_flow(self, redirect_uri: str) -> str:
|
|
139
151
|
"""First step - Start the flow."""
|
|
140
|
-
self.session[TOKEN_CACHE] = None
|
|
141
|
-
self.session[USER_EMAIL] = None
|
|
152
|
+
self.session[TOKEN_CACHE] = None # type: ignore
|
|
153
|
+
self.session[USER_EMAIL] = None # type: ignore
|
|
142
154
|
self.session[FLOW_CACHE] = res = self.app.initiate_auth_code_flow(
|
|
143
155
|
MY_SCOPE,
|
|
144
156
|
redirect_uri=redirect_uri,
|
|
@@ -149,8 +161,7 @@ class AsyncMSAL:
|
|
|
149
161
|
# https://msal-python.readthedocs.io/en/latest/#msal.ClientApplication.initiate_auth_code_flow
|
|
150
162
|
return str(res["auth_uri"])
|
|
151
163
|
|
|
152
|
-
|
|
153
|
-
def async_acquire_token_by_auth_code_flow(self, auth_response: Any) -> None:
|
|
164
|
+
def acquire_token_by_auth_code_flow(self, auth_response: Any) -> None:
|
|
154
165
|
"""Second step - Acquire token."""
|
|
155
166
|
# Assume we have it in the cache (added by /login)
|
|
156
167
|
# will raise keryerror if no cache
|
|
@@ -165,8 +176,13 @@ class AsyncMSAL:
|
|
|
165
176
|
"preferred_username"
|
|
166
177
|
)
|
|
167
178
|
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
async def async_acquire_token_by_auth_code_flow(self, auth_response: Any) -> None:
|
|
180
|
+
"""Second step - Acquire token, async version."""
|
|
181
|
+
await asyncio.get_event_loop().run_in_executor(
|
|
182
|
+
None, self.acquire_token_by_auth_code_flow, auth_response
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def get_token(self) -> Optional[dict[str, Any]]:
|
|
170
186
|
"""Acquire a token based on username."""
|
|
171
187
|
accounts = self.app.get_accounts()
|
|
172
188
|
if accounts:
|
|
@@ -175,6 +191,10 @@ class AsyncMSAL:
|
|
|
175
191
|
return result
|
|
176
192
|
return None
|
|
177
193
|
|
|
194
|
+
async def async_get_token(self) -> Optional[dict[str, Any]]:
|
|
195
|
+
"""Acquire a token based on username."""
|
|
196
|
+
return await asyncio.get_event_loop().run_in_executor(None, self.get_token)
|
|
197
|
+
|
|
178
198
|
async def request(self, method: str, url: str, **kwargs: Any) -> ClientResponse:
|
|
179
199
|
"""Make a request to url using an oauth session.
|
|
180
200
|
|
|
@@ -188,6 +208,8 @@ class AsyncMSAL:
|
|
|
188
208
|
AsyncMSAL._clientsession = ClientSession(trust_env=True)
|
|
189
209
|
|
|
190
210
|
token = await self.async_get_token()
|
|
211
|
+
if token is None:
|
|
212
|
+
raise web.HTTPClientError(text="No login token available.")
|
|
191
213
|
|
|
192
214
|
kwargs = kwargs.copy()
|
|
193
215
|
# Ensure headers exist & make a copy
|
|
@@ -195,7 +217,8 @@ class AsyncMSAL:
|
|
|
195
217
|
|
|
196
218
|
headers["Authorization"] = "Bearer " + token["access_token"]
|
|
197
219
|
|
|
198
|
-
|
|
220
|
+
if method not in HTTP_ALLOWED:
|
|
221
|
+
raise web.HTTPClientError(text=f"HTTP method {method} not allowed")
|
|
199
222
|
|
|
200
223
|
if method == HTTP_GET:
|
|
201
224
|
kwargs.setdefault("allow_redirects", True)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Redis tools for sessions."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, AsyncGenerator, Optional
|
|
7
|
+
|
|
8
|
+
from redis.asyncio import Redis, from_url
|
|
9
|
+
|
|
10
|
+
from aiohttp_msal.msal_async import AsyncMSAL
|
|
11
|
+
from aiohttp_msal.settings import ENV
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SES_KEYS = ("mail", "name", "m_mail", "m_name")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_redis() -> Redis:
|
|
19
|
+
"""Get a Redis connection."""
|
|
20
|
+
_LOGGER.info("Connect to Redis %s", ENV.REDIS)
|
|
21
|
+
ENV.database = from_url(ENV.REDIS) # pylint: disable=no-member
|
|
22
|
+
return ENV.database
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def iter_redis(
|
|
26
|
+
redis: Redis, *, clean: bool = False, match: Optional[dict[str, str]] = None
|
|
27
|
+
) -> AsyncGenerator[tuple[str, str, dict], None]:
|
|
28
|
+
"""Iterate over the Redis keys to find a specific session."""
|
|
29
|
+
async for key in redis.scan_iter(count=100, match=f"{ENV.COOKIE_NAME}*"):
|
|
30
|
+
sval = await redis.get(key)
|
|
31
|
+
if not isinstance(sval, str):
|
|
32
|
+
if clean:
|
|
33
|
+
await redis.delete(key)
|
|
34
|
+
continue
|
|
35
|
+
val = json.loads(sval)
|
|
36
|
+
ses = val.get("session")
|
|
37
|
+
created = val.get("created")
|
|
38
|
+
if clean and not ses or not created:
|
|
39
|
+
await redis.delete(key)
|
|
40
|
+
continue
|
|
41
|
+
if match:
|
|
42
|
+
for mkey, mval in match.items():
|
|
43
|
+
if mval not in ses[mkey]:
|
|
44
|
+
continue
|
|
45
|
+
created = val.get("created") or "0"
|
|
46
|
+
session = val.get("session") or {}
|
|
47
|
+
yield key, created, session
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def clean_redis(redis: Redis, max_age: int = 90) -> None:
|
|
51
|
+
"""Clear session entries older than max_age days."""
|
|
52
|
+
expire = int(time.time() - max_age * 24 * 60 * 60)
|
|
53
|
+
async for key, created, ses in iter_redis(redis, clean=True):
|
|
54
|
+
for key in SES_KEYS:
|
|
55
|
+
if not ses.get(key):
|
|
56
|
+
await redis.delete(key)
|
|
57
|
+
continue
|
|
58
|
+
if int(created) < expire:
|
|
59
|
+
await redis.delete(key)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _session_factory(key: str, created: str, session: dict) -> AsyncMSAL:
|
|
63
|
+
"""Create a session with a save callback."""
|
|
64
|
+
|
|
65
|
+
async def async_save_cache(_: dict) -> None:
|
|
66
|
+
"""Save the token cache to Redis."""
|
|
67
|
+
rd2 = get_redis()
|
|
68
|
+
try:
|
|
69
|
+
await rd2.set(key, json.dumps({"created": created, "session": session}))
|
|
70
|
+
finally:
|
|
71
|
+
await rd2.close()
|
|
72
|
+
|
|
73
|
+
def save_cache(*args: Any) -> None:
|
|
74
|
+
"""Save the token cache to Redis."""
|
|
75
|
+
try:
|
|
76
|
+
asyncio.get_event_loop().create_task(async_save_cache(*args))
|
|
77
|
+
except RuntimeError:
|
|
78
|
+
asyncio.run(async_save_cache(*args))
|
|
79
|
+
|
|
80
|
+
return AsyncMSAL(session, save_cache=save_cache)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def get_session(red: Redis, email: str) -> AsyncMSAL:
|
|
84
|
+
"""Get a session from Redis."""
|
|
85
|
+
async for key, created, session in iter_redis(red, match={"mail": email}):
|
|
86
|
+
return _session_factory(key, created, session)
|
|
87
|
+
raise ValueError(f"Session for {email} not found")
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: aiohttp-msal
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Helper Library to use MSAL with aiohttp
|
|
3
|
+
Version: 0.6
|
|
4
|
+
Summary: Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
|
|
5
5
|
Home-page: https://github.com/kellerza/aiohttp_msal
|
|
6
6
|
Author: Johann Kellerman
|
|
7
7
|
Author-email: kellerza@gmail.com
|
|
8
8
|
License: MIT
|
|
9
9
|
Keywords: msal,oauth,aiohttp,asyncio
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Natural Language :: English
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -15,16 +15,17 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.8
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
19
|
Requires-Python: >=3.9
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: msal
|
|
22
|
-
Requires-Dist: aiohttp-session
|
|
23
|
-
Requires-Dist: aiohttp
|
|
22
|
+
Requires-Dist: msal >=1.24.1
|
|
23
|
+
Requires-Dist: aiohttp-session >=2.12
|
|
24
|
+
Requires-Dist: aiohttp >=3.8
|
|
24
25
|
Provides-Extra: redis
|
|
25
|
-
Requires-Dist: aiohttp-session[aioredis]
|
|
26
|
+
Requires-Dist: aiohttp-session[aioredis] >=2.12 ; extra == 'redis'
|
|
26
27
|
Provides-Extra: tests
|
|
27
|
-
Requires-Dist: black
|
|
28
|
+
Requires-Dist: black ==23.9.1 ; extra == 'tests'
|
|
28
29
|
Requires-Dist: pylint ; extra == 'tests'
|
|
29
30
|
Requires-Dist: flake8 ; extra == 'tests'
|
|
30
31
|
Requires-Dist: pytest-aiohttp ; extra == 'tests'
|
|
@@ -41,11 +42,10 @@ Blocking MSAL functions are executed in the executor thread. Should be useful un
|
|
|
41
42
|
|
|
42
43
|
Tested with MSAL Python 1.21.0 onward - [MSAL Python docs](https://github.com/AzureAD/microsoft-authentication-library-for-python)
|
|
43
44
|
|
|
44
|
-
|
|
45
45
|
## AsycMSAL class
|
|
46
46
|
|
|
47
47
|
The AsyncMSAL class wraps the behavior in the following example app
|
|
48
|
-
https://github.com/Azure-Samples/ms-identity-python-webapp/blob/master/app.py#L76
|
|
48
|
+
<https://github.com/Azure-Samples/ms-identity-python-webapp/blob/master/app.py#L76>
|
|
49
49
|
|
|
50
50
|
It is responsible to manage tokens & token refreshes and as a client to retrieve data using these tokens.
|
|
51
51
|
|
|
@@ -53,7 +53,7 @@ It is responsible to manage tokens & token refreshes and as a client to retrieve
|
|
|
53
53
|
|
|
54
54
|
Firstly you should get the tokens via OAuth
|
|
55
55
|
|
|
56
|
-
1.
|
|
56
|
+
1. `initiate_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
|
|
57
57
|
|
|
58
58
|
The caller is expected to:
|
|
59
59
|
1. somehow store this content, typically inside the current session of the server,
|
|
@@ -63,8 +63,7 @@ Firstly you should get the tokens via OAuth
|
|
|
63
63
|
|
|
64
64
|
**Step 1** and part of **Step 3** is stored by this class in the aiohttp_session
|
|
65
65
|
|
|
66
|
-
2.
|
|
67
|
-
|
|
66
|
+
2. `acquire_token_by_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
|
|
68
67
|
|
|
69
68
|
### Use the token
|
|
70
69
|
|
|
@@ -77,11 +76,11 @@ async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
|
|
|
77
76
|
res = await res.json()
|
|
78
77
|
```
|
|
79
78
|
|
|
80
|
-
|
|
79
|
+
## Example web server
|
|
81
80
|
|
|
82
81
|
Complete routes can be found in [routes.py](./aiohttp_msal/routes.py)
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
### Start the login process
|
|
85
84
|
|
|
86
85
|
```python
|
|
87
86
|
@ROUTES.get("/user/login")
|
|
@@ -96,7 +95,7 @@ async def user_login(request: web.Request) -> web.Response:
|
|
|
96
95
|
return web.HTTPFound(redir)
|
|
97
96
|
```
|
|
98
97
|
|
|
99
|
-
|
|
98
|
+
### Acquire the token after being redirected back to the server
|
|
100
99
|
|
|
101
100
|
```python
|
|
102
101
|
@ROUTES.post(URI_USER_AUTHORIZED)
|
|
@@ -113,7 +112,7 @@ async def user_authorized(request: web.Request) -> web.Response:
|
|
|
113
112
|
|
|
114
113
|
- `@ROUTES.get("/user/photo")`
|
|
115
114
|
|
|
116
|
-
Serve the user's photo from
|
|
115
|
+
Serve the user's photo from their Microsoft profile
|
|
117
116
|
|
|
118
117
|
- `get_user_info`
|
|
119
118
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
aiohttp_msal/__init__.py,sha256=SUmd1YvN_mSr192WMoMNf9h9TWZJWFRDzwoHjgEghzw,2999
|
|
2
|
+
aiohttp_msal/msal_async.py,sha256=lSwTK2utBVjhQQ921aoq34hNa0z-AJsQleSHXxh9STk,9779
|
|
3
|
+
aiohttp_msal/redis_tools.py,sha256=OQVuugad_46hWDIOC7I0uzq-E6koVmOXvq_qC5q9k7s,2896
|
|
4
|
+
aiohttp_msal/routes.py,sha256=c-w5wHaLAYGEqZvfZ8PnzzRh60asLqdUa30lvSANdYM,8319
|
|
5
|
+
aiohttp_msal/settings.py,sha256=ZZn7D6QmIyQSvuqCAoTacKRXYfopqK4P74eVdPCw-uI,1231
|
|
6
|
+
aiohttp_msal/settings_base.py,sha256=pmVmzTtaGEgRh-AMGy0HdhF1JvoZhZp42G3PL_ILHLw,2892
|
|
7
|
+
aiohttp_msal/user_info.py,sha256=oIu90lgi71G27MVkw4pGH8wZqjXk8kxmW0fn2LKxOHc,1753
|
|
8
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tests/test_init.py,sha256=sHmt7yNDlcu5JHrpM_gim0NeLce0NwUAMM3HAdGoo58,75
|
|
10
|
+
tests/test_msal_async.py,sha256=31MCoAbUiyUhc4SkebUKpjLDHozEBko-QgEBSHjfSoM,332
|
|
11
|
+
tests/test_settings.py,sha256=z-qtUs1zl5Q9NEux051eebyPnArLZ_OfZu65FKz0N4Y,333
|
|
12
|
+
aiohttp_msal-0.6.dist-info/LICENSE,sha256=H1aGfkSfZFwK3q4INn9mUldOJGZy-ZXu5-65K9Glunw,1080
|
|
13
|
+
aiohttp_msal-0.6.dist-info/METADATA,sha256=e3p4JvDqsf2BPlJklTd2S2pCSpclj-B1GobMpLFdP98,4205
|
|
14
|
+
aiohttp_msal-0.6.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
|
|
15
|
+
aiohttp_msal-0.6.dist-info/top_level.txt,sha256=QPWOi5JtacVEdbaU5bJExc9o-cCT2Lufx0QhUpsv5_E,19
|
|
16
|
+
aiohttp_msal-0.6.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
17
|
+
aiohttp_msal-0.6.dist-info/RECORD,,
|
tests/test_init.py
CHANGED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
aiohttp_msal/__init__.py,sha256=KzrwHvSLoH65kYGQ9YhfkP3H07uABTs3PcTtCbrqjQw,3001
|
|
2
|
-
aiohttp_msal/msal_async.py,sha256=194tsWvx_V0iLR8aZoKLvK7lLAq0CH0f5t8_bLWYtQI,8656
|
|
3
|
-
aiohttp_msal/routes.py,sha256=c-w5wHaLAYGEqZvfZ8PnzzRh60asLqdUa30lvSANdYM,8319
|
|
4
|
-
aiohttp_msal/settings.py,sha256=ZZn7D6QmIyQSvuqCAoTacKRXYfopqK4P74eVdPCw-uI,1231
|
|
5
|
-
aiohttp_msal/settings_base.py,sha256=pmVmzTtaGEgRh-AMGy0HdhF1JvoZhZp42G3PL_ILHLw,2892
|
|
6
|
-
aiohttp_msal/user_info.py,sha256=oIu90lgi71G27MVkw4pGH8wZqjXk8kxmW0fn2LKxOHc,1753
|
|
7
|
-
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
tests/test_init.py,sha256=HPcL6bXaM95b3J908tMKLznHnwVf1fvKM3tuFhSuX84,40
|
|
9
|
-
tests/test_msal_async.py,sha256=31MCoAbUiyUhc4SkebUKpjLDHozEBko-QgEBSHjfSoM,332
|
|
10
|
-
tests/test_settings.py,sha256=z-qtUs1zl5Q9NEux051eebyPnArLZ_OfZu65FKz0N4Y,333
|
|
11
|
-
aiohttp_msal-0.5.4.dist-info/LICENSE,sha256=H1aGfkSfZFwK3q4INn9mUldOJGZy-ZXu5-65K9Glunw,1080
|
|
12
|
-
aiohttp_msal-0.5.4.dist-info/METADATA,sha256=HedY_5K8jH5vv-Lc9SBHWVlI2rINbnztEdSAGfaKiPA,4129
|
|
13
|
-
aiohttp_msal-0.5.4.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
|
|
14
|
-
aiohttp_msal-0.5.4.dist-info/top_level.txt,sha256=QPWOi5JtacVEdbaU5bJExc9o-cCT2Lufx0QhUpsv5_E,19
|
|
15
|
-
aiohttp_msal-0.5.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
16
|
-
aiohttp_msal-0.5.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|