aiohttp-msal 0.5.2__tar.gz → 0.6__tar.gz

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.
Files changed (26) hide show
  1. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/LICENSE +1 -1
  2. aiohttp_msal-0.6/PKG-INFO +123 -0
  3. aiohttp_msal-0.6/README.md +87 -0
  4. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/__init__.py +4 -10
  5. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/msal_async.py +35 -12
  6. aiohttp_msal-0.6/aiohttp_msal/redis_tools.py +87 -0
  7. aiohttp_msal-0.6/aiohttp_msal.egg-info/PKG-INFO +123 -0
  8. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal.egg-info/SOURCES.txt +1 -0
  9. aiohttp_msal-0.6/aiohttp_msal.egg-info/requires.txt +16 -0
  10. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/setup.cfg +15 -8
  11. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/setup.py +2 -2
  12. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/tests/test_init.py +1 -0
  13. aiohttp_msal-0.5.2/PKG-INFO +0 -23
  14. aiohttp_msal-0.5.2/README.md +0 -1
  15. aiohttp_msal-0.5.2/aiohttp_msal.egg-info/PKG-INFO +0 -23
  16. aiohttp_msal-0.5.2/aiohttp_msal.egg-info/requires.txt +0 -6
  17. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/routes.py +0 -0
  18. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/settings.py +0 -0
  19. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/settings_base.py +0 -0
  20. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal/user_info.py +0 -0
  21. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal.egg-info/dependency_links.txt +0 -0
  22. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal.egg-info/top_level.txt +0 -0
  23. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/aiohttp_msal.egg-info/zip-safe +0 -0
  24. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/tests/__init__.py +0 -0
  25. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/tests/test_msal_async.py +0 -0
  26. {aiohttp_msal-0.5.2 → aiohttp_msal-0.6}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2022 kellerza
3
+ Copyright (c) 2022-2023 kellerza
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.1
2
+ Name: aiohttp_msal
3
+ Version: 0.6
4
+ Summary: Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
5
+ Home-page: https://github.com/kellerza/aiohttp_msal
6
+ Author: Johann Kellerman
7
+ Author-email: kellerza@gmail.com
8
+ License: MIT
9
+ Keywords: msal,oauth,aiohttp,asyncio
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: msal>=1.24.1
23
+ Requires-Dist: aiohttp_session>=2.12
24
+ Requires-Dist: aiohttp>=3.8
25
+ Provides-Extra: redis
26
+ Requires-Dist: aiohttp_session[aioredis]>=2.12; extra == "redis"
27
+ Provides-Extra: tests
28
+ Requires-Dist: black==23.9.1; extra == "tests"
29
+ Requires-Dist: pylint; extra == "tests"
30
+ Requires-Dist: flake8; extra == "tests"
31
+ Requires-Dist: pytest-aiohttp; extra == "tests"
32
+ Requires-Dist: pytest; extra == "tests"
33
+ Requires-Dist: pytest-cov; extra == "tests"
34
+ Requires-Dist: pytest-asyncio; extra == "tests"
35
+ Requires-Dist: pytest-env; extra == "tests"
36
+
37
+ # aiohttp_msal Python library
38
+
39
+ AsyncIO based OAuth using the Microsoft Authentication Library (MSAL) for Python.
40
+
41
+ Blocking MSAL functions are executed in the executor thread. Should be useful until such time as MSAL Python gets a true async version...
42
+
43
+ Tested with MSAL Python 1.21.0 onward - [MSAL Python docs](https://github.com/AzureAD/microsoft-authentication-library-for-python)
44
+
45
+ ## AsycMSAL class
46
+
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>
49
+
50
+ It is responsible to manage tokens & token refreshes and as a client to retrieve data using these tokens.
51
+
52
+ ### Acquire the token
53
+
54
+ Firstly you should get the tokens via OAuth
55
+
56
+ 1. `initiate_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
57
+
58
+ The caller is expected to:
59
+ 1. somehow store this content, typically inside the current session of the server,
60
+ 2. guide the end user (i.e. resource owner) to visit that auth_uri, typically with a redirect
61
+ 3. and then relay this dict and subsequent auth response to
62
+ acquire_token_by_auth_code_flow().
63
+
64
+ **Step 1** and part of **Step 3** is stored by this class in the aiohttp_session
65
+
66
+ 2. `acquire_token_by_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
67
+
68
+ ### Use the token
69
+
70
+ Now you are free to make requests (typically from an aiohttp server)
71
+
72
+ ```python
73
+ session = await get_session(request)
74
+ aiomsal = AsyncMSAL(session)
75
+ async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
76
+ res = await res.json()
77
+ ```
78
+
79
+ ## Example web server
80
+
81
+ Complete routes can be found in [routes.py](./aiohttp_msal/routes.py)
82
+
83
+ ### Start the login process
84
+
85
+ ```python
86
+ @ROUTES.get("/user/login")
87
+ async def user_login(request: web.Request) -> web.Response:
88
+ """Redirect to MS login page."""
89
+ session = await new_session(request)
90
+
91
+ redir = AsyncMSAL(session).build_auth_code_flow(
92
+ redirect_uri=get_route(request, URI_USER_AUTHORIZED)
93
+ )
94
+
95
+ return web.HTTPFound(redir)
96
+ ```
97
+
98
+ ### Acquire the token after being redirected back to the server
99
+
100
+ ```python
101
+ @ROUTES.post(URI_USER_AUTHORIZED)
102
+ async def user_authorized(request: web.Request) -> web.Response:
103
+ """Complete the auth code flow."""
104
+ session = await get_session(request)
105
+ auth_response = dict(await request.post())
106
+
107
+ aiomsal = AsyncMSAL(session)
108
+ await aiomsal.async_acquire_token_by_auth_code_flow(auth_response)
109
+ ```
110
+
111
+ ## Helper methods
112
+
113
+ - `@ROUTES.get("/user/photo")`
114
+
115
+ Serve the user's photo from their Microsoft profile
116
+
117
+ - `get_user_info`
118
+
119
+ Get the user's email and display name from MS Graph
120
+
121
+ - `get_manager_info`
122
+
123
+ Get the user's manager info from MS Graph
@@ -0,0 +1,87 @@
1
+ # aiohttp_msal Python library
2
+
3
+ AsyncIO based OAuth using the Microsoft Authentication Library (MSAL) for Python.
4
+
5
+ Blocking MSAL functions are executed in the executor thread. Should be useful until such time as MSAL Python gets a true async version...
6
+
7
+ Tested with MSAL Python 1.21.0 onward - [MSAL Python docs](https://github.com/AzureAD/microsoft-authentication-library-for-python)
8
+
9
+ ## AsycMSAL class
10
+
11
+ The AsyncMSAL class wraps the behavior in the following example app
12
+ <https://github.com/Azure-Samples/ms-identity-python-webapp/blob/master/app.py#L76>
13
+
14
+ It is responsible to manage tokens & token refreshes and as a client to retrieve data using these tokens.
15
+
16
+ ### Acquire the token
17
+
18
+ Firstly you should get the tokens via OAuth
19
+
20
+ 1. `initiate_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
21
+
22
+ The caller is expected to:
23
+ 1. somehow store this content, typically inside the current session of the server,
24
+ 2. guide the end user (i.e. resource owner) to visit that auth_uri, typically with a redirect
25
+ 3. and then relay this dict and subsequent auth response to
26
+ acquire_token_by_auth_code_flow().
27
+
28
+ **Step 1** and part of **Step 3** is stored by this class in the aiohttp_session
29
+
30
+ 2. `acquire_token_by_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
31
+
32
+ ### Use the token
33
+
34
+ Now you are free to make requests (typically from an aiohttp server)
35
+
36
+ ```python
37
+ session = await get_session(request)
38
+ aiomsal = AsyncMSAL(session)
39
+ async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
40
+ res = await res.json()
41
+ ```
42
+
43
+ ## Example web server
44
+
45
+ Complete routes can be found in [routes.py](./aiohttp_msal/routes.py)
46
+
47
+ ### Start the login process
48
+
49
+ ```python
50
+ @ROUTES.get("/user/login")
51
+ async def user_login(request: web.Request) -> web.Response:
52
+ """Redirect to MS login page."""
53
+ session = await new_session(request)
54
+
55
+ redir = AsyncMSAL(session).build_auth_code_flow(
56
+ redirect_uri=get_route(request, URI_USER_AUTHORIZED)
57
+ )
58
+
59
+ return web.HTTPFound(redir)
60
+ ```
61
+
62
+ ### Acquire the token after being redirected back to the server
63
+
64
+ ```python
65
+ @ROUTES.post(URI_USER_AUTHORIZED)
66
+ async def user_authorized(request: web.Request) -> web.Response:
67
+ """Complete the auth code flow."""
68
+ session = await get_session(request)
69
+ auth_response = dict(await request.post())
70
+
71
+ aiomsal = AsyncMSAL(session)
72
+ await aiomsal.async_acquire_token_by_auth_code_flow(auth_response)
73
+ ```
74
+
75
+ ## Helper methods
76
+
77
+ - `@ROUTES.get("/user/photo")`
78
+
79
+ Serve the user's photo from their Microsoft profile
80
+
81
+ - `get_user_info`
82
+
83
+ Get the user's email and display name from MS Graph
84
+
85
+ - `get_manager_info`
86
+
87
+ Get the user's manager info from MS Graph
@@ -3,7 +3,6 @@ import logging
3
3
  from functools import wraps
4
4
  from inspect import getfullargspec, iscoroutinefunction
5
5
  from typing import Any, Awaitable, Callable, Union
6
- from urllib.parse import urlparse
7
6
 
8
7
  from aiohttp import ClientSession, web
9
8
  from aiohttp_session import get_session
@@ -14,7 +13,7 @@ from .settings import ENV
14
13
 
15
14
  _LOGGER = logging.getLogger(__name__)
16
15
 
17
- VERSION = "0.5.2"
16
+ VERSION = "0.6"
18
17
 
19
18
 
20
19
  def msal_session(*args: Callable[[AsyncMSAL], Union[Any, Awaitable[Any]]]) -> Callable:
@@ -56,20 +55,15 @@ async def app_init_redis_session(
56
55
  You can initialize your own aiohttp_session & storage provider.
57
56
  """
58
57
  # pylint: disable=import-outside-toplevel
59
- import aioredis
60
58
  from aiohttp_session import redis_storage
59
+ from redis.asyncio import from_url
61
60
 
62
61
  await check_proxy()
63
62
 
64
63
  _LOGGER.info("Connect to Redis %s", ENV.REDIS)
65
- red = urlparse(ENV.REDIS)
66
64
  try:
67
- if hasattr(aioredis, "create_redis_pool"):
68
- ENV.database = await aioredis.create_redis_pool((red.hostname, red.port))
69
- else:
70
- # when aioredis migrates to 2.0...
71
- ENV.database = aioredis.from_url(ENV.REDIS) # pylint: disable=no-member
72
- # , encoding="utf-8", decode_responses=True
65
+ ENV.database = from_url(ENV.REDIS) # pylint: disable=no-member
66
+ # , encoding="utf-8", decode_responses=True
73
67
  except ConnectionRefusedError as err:
74
68
  raise ConnectionError("Could not connect to REDIS server") from err
75
69
 
@@ -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")
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.1
2
+ Name: aiohttp-msal
3
+ Version: 0.6
4
+ Summary: Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
5
+ Home-page: https://github.com/kellerza/aiohttp_msal
6
+ Author: Johann Kellerman
7
+ Author-email: kellerza@gmail.com
8
+ License: MIT
9
+ Keywords: msal,oauth,aiohttp,asyncio
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: msal>=1.24.1
23
+ Requires-Dist: aiohttp_session>=2.12
24
+ Requires-Dist: aiohttp>=3.8
25
+ Provides-Extra: redis
26
+ Requires-Dist: aiohttp_session[aioredis]>=2.12; extra == "redis"
27
+ Provides-Extra: tests
28
+ Requires-Dist: black==23.9.1; extra == "tests"
29
+ Requires-Dist: pylint; extra == "tests"
30
+ Requires-Dist: flake8; extra == "tests"
31
+ Requires-Dist: pytest-aiohttp; extra == "tests"
32
+ Requires-Dist: pytest; extra == "tests"
33
+ Requires-Dist: pytest-cov; extra == "tests"
34
+ Requires-Dist: pytest-asyncio; extra == "tests"
35
+ Requires-Dist: pytest-env; extra == "tests"
36
+
37
+ # aiohttp_msal Python library
38
+
39
+ AsyncIO based OAuth using the Microsoft Authentication Library (MSAL) for Python.
40
+
41
+ Blocking MSAL functions are executed in the executor thread. Should be useful until such time as MSAL Python gets a true async version...
42
+
43
+ Tested with MSAL Python 1.21.0 onward - [MSAL Python docs](https://github.com/AzureAD/microsoft-authentication-library-for-python)
44
+
45
+ ## AsycMSAL class
46
+
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>
49
+
50
+ It is responsible to manage tokens & token refreshes and as a client to retrieve data using these tokens.
51
+
52
+ ### Acquire the token
53
+
54
+ Firstly you should get the tokens via OAuth
55
+
56
+ 1. `initiate_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
57
+
58
+ The caller is expected to:
59
+ 1. somehow store this content, typically inside the current session of the server,
60
+ 2. guide the end user (i.e. resource owner) to visit that auth_uri, typically with a redirect
61
+ 3. and then relay this dict and subsequent auth response to
62
+ acquire_token_by_auth_code_flow().
63
+
64
+ **Step 1** and part of **Step 3** is stored by this class in the aiohttp_session
65
+
66
+ 2. `acquire_token_by_auth_code_flow` [referernce](https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.initiate_auth_code_flow)
67
+
68
+ ### Use the token
69
+
70
+ Now you are free to make requests (typically from an aiohttp server)
71
+
72
+ ```python
73
+ session = await get_session(request)
74
+ aiomsal = AsyncMSAL(session)
75
+ async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
76
+ res = await res.json()
77
+ ```
78
+
79
+ ## Example web server
80
+
81
+ Complete routes can be found in [routes.py](./aiohttp_msal/routes.py)
82
+
83
+ ### Start the login process
84
+
85
+ ```python
86
+ @ROUTES.get("/user/login")
87
+ async def user_login(request: web.Request) -> web.Response:
88
+ """Redirect to MS login page."""
89
+ session = await new_session(request)
90
+
91
+ redir = AsyncMSAL(session).build_auth_code_flow(
92
+ redirect_uri=get_route(request, URI_USER_AUTHORIZED)
93
+ )
94
+
95
+ return web.HTTPFound(redir)
96
+ ```
97
+
98
+ ### Acquire the token after being redirected back to the server
99
+
100
+ ```python
101
+ @ROUTES.post(URI_USER_AUTHORIZED)
102
+ async def user_authorized(request: web.Request) -> web.Response:
103
+ """Complete the auth code flow."""
104
+ session = await get_session(request)
105
+ auth_response = dict(await request.post())
106
+
107
+ aiomsal = AsyncMSAL(session)
108
+ await aiomsal.async_acquire_token_by_auth_code_flow(auth_response)
109
+ ```
110
+
111
+ ## Helper methods
112
+
113
+ - `@ROUTES.get("/user/photo")`
114
+
115
+ Serve the user's photo from their Microsoft profile
116
+
117
+ - `get_user_info`
118
+
119
+ Get the user's email and display name from MS Graph
120
+
121
+ - `get_manager_info`
122
+
123
+ Get the user's manager info from MS Graph
@@ -4,6 +4,7 @@ setup.cfg
4
4
  setup.py
5
5
  aiohttp_msal/__init__.py
6
6
  aiohttp_msal/msal_async.py
7
+ aiohttp_msal/redis_tools.py
7
8
  aiohttp_msal/routes.py
8
9
  aiohttp_msal/settings.py
9
10
  aiohttp_msal/settings_base.py
@@ -0,0 +1,16 @@
1
+ msal>=1.24.1
2
+ aiohttp_session>=2.12
3
+ aiohttp>=3.8
4
+
5
+ [redis]
6
+ aiohttp_session[aioredis]>=2.12
7
+
8
+ [tests]
9
+ black==23.9.1
10
+ pylint
11
+ flake8
12
+ pytest-aiohttp
13
+ pytest
14
+ pytest-cov
15
+ pytest-asyncio
16
+ pytest-env
@@ -1,7 +1,7 @@
1
1
  [metadata]
2
2
  name = aiohttp_msal
3
3
  version = attr: aiohttp_msal.VERSION
4
- description = Helper Library to use MSAL with aiohttp
4
+ description = Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
7
7
  url = https://github.com/kellerza/aiohttp_msal
@@ -10,7 +10,7 @@ author_email = kellerza@gmail.com
10
10
  license = MIT
11
11
  license_file = LICENSE
12
12
  classifiers =
13
- Development Status :: 2 - Pre-Alpha
13
+ Development Status :: 4 - Beta
14
14
  Intended Audience :: Developers
15
15
  Natural Language :: English
16
16
  Programming Language :: Python :: 3
@@ -18,6 +18,7 @@ classifiers =
18
18
  Programming Language :: Python :: 3.8
19
19
  Programming Language :: Python :: 3.9
20
20
  Programming Language :: Python :: 3.10
21
+ Programming Language :: Python :: 3.11
21
22
  keywords = msal, oauth, aiohttp, asyncio
22
23
 
23
24
  [options]
@@ -26,14 +27,23 @@ python_requires = >=3.9
26
27
  include_package_data = True
27
28
  tests_requires = file: requirements_test.txt
28
29
  install_requires =
29
- msal>=1.18.0b1
30
- aiohttp_session>=2.11
30
+ msal>=1.24.1
31
+ aiohttp_session>=2.12
31
32
  aiohttp>=3.8
32
33
  zip_safe = true
33
34
 
34
35
  [options.extras_require]
35
36
  redis =
36
- aiohttp_session[redis]
37
+ aiohttp_session[aioredis]>=2.12
38
+ tests =
39
+ black==23.9.1
40
+ pylint
41
+ flake8
42
+ pytest-aiohttp
43
+ pytest
44
+ pytest-cov
45
+ pytest-asyncio
46
+ pytest-env
37
47
 
38
48
  [isort]
39
49
  profile = black
@@ -47,9 +57,6 @@ disallow_untyped_defs = True
47
57
  [mypy-msal.*]
48
58
  ignore_missing_imports = True
49
59
 
50
- [mypy-aioredis.*]
51
- ignore_missing_imports = True
52
-
53
60
  [pydocstyle]
54
61
  match_dir = aiohttp_msal
55
62
  convention = google
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python
2
2
  """aiohttp_msal library setup."""
3
- import setuptools
3
+ from setuptools import setup
4
4
 
5
5
  if __name__ == "__main__":
6
- setuptools.setup()
6
+ setup()
@@ -1,2 +1,3 @@
1
1
  """Init."""
2
2
  import aiohttp_msal # noqa
3
+ import aiohttp_msal.routes # noqa
@@ -1,23 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: aiohttp_msal
3
- Version: 0.5.2
4
- Summary: Helper Library to use MSAL with aiohttp
5
- Home-page: https://github.com/kellerza/aiohttp_msal
6
- Author: Johann Kellerman
7
- Author-email: kellerza@gmail.com
8
- License: MIT
9
- Keywords: msal,oauth,aiohttp,asyncio
10
- Classifier: Development Status :: 2 - Pre-Alpha
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Natural Language :: English
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.8
16
- Classifier: Programming Language :: Python :: 3.9
17
- Classifier: Programming Language :: Python :: 3.10
18
- Requires-Python: >=3.9
19
- Description-Content-Type: text/markdown
20
- Provides-Extra: redis
21
- License-File: LICENSE
22
-
23
- aiohttp_msal library
@@ -1 +0,0 @@
1
- aiohttp_msal library
@@ -1,23 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: aiohttp-msal
3
- Version: 0.5.2
4
- Summary: Helper Library to use MSAL with aiohttp
5
- Home-page: https://github.com/kellerza/aiohttp_msal
6
- Author: Johann Kellerman
7
- Author-email: kellerza@gmail.com
8
- License: MIT
9
- Keywords: msal,oauth,aiohttp,asyncio
10
- Classifier: Development Status :: 2 - Pre-Alpha
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Natural Language :: English
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.8
16
- Classifier: Programming Language :: Python :: 3.9
17
- Classifier: Programming Language :: Python :: 3.10
18
- Requires-Python: >=3.9
19
- Description-Content-Type: text/markdown
20
- Provides-Extra: redis
21
- License-File: LICENSE
22
-
23
- aiohttp_msal library
@@ -1,6 +0,0 @@
1
- msal>=1.18.0b1
2
- aiohttp_session>=2.11
3
- aiohttp>=3.8
4
-
5
- [redis]
6
- aiohttp_session[redis]