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 CHANGED
@@ -13,7 +13,7 @@ from .settings import ENV
13
13
 
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
- VERSION = "0.5.4"
16
+ VERSION = "0.6"
17
17
 
18
18
 
19
19
  def msal_session(*args: Callable[[AsyncMSAL], Union[Any, Awaitable[Any]]]) -> Callable:
@@ -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__(self, session: Session):
100
- """Init the class."""
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 not isinstance(session, Session):
103
- raise ValueError(f"session required {session}")
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
- @async_wrap
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
- @async_wrap
169
- def async_get_token(self) -> Optional[dict[str, Any]]:
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
- assert method in HTTP_ALLOWED, "Method must be one of the allowed ones"
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.5.4
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 :: 2 - Pre-Alpha
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 (>=1.21.0)
22
- Requires-Dist: aiohttp-session (>=2.12)
23
- Requires-Dist: aiohttp (>=3.8)
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] (>=2.12) ; extra == 'redis'
26
+ Requires-Dist: aiohttp-session[aioredis] >=2.12 ; extra == 'redis'
26
27
  Provides-Extra: tests
27
- Requires-Dist: black (==23.3.0) ; extra == 'tests'
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. `initiate_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
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. `acquire_token_by_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
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
- # Example web server
79
+ ## Example web server
81
80
 
82
81
  Complete routes can be found in [routes.py](./aiohttp_msal/routes.py)
83
82
 
84
- ## Start the login process
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
- ## Acquire the token after being redirected back to the server
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 his Microsoft profile
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.40.0)
2
+ Generator: bdist_wheel (0.41.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
tests/test_init.py CHANGED
@@ -1,2 +1,3 @@
1
1
  """Init."""
2
2
  import aiohttp_msal # noqa
3
+ import aiohttp_msal.routes # noqa
@@ -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,,