aiohttp-msal 1.0.0__py3-none-any.whl → 1.0.2__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.
@@ -8,9 +8,10 @@ Once you have the OAuth tokens store in the session, you are free to make reques
8
8
  import asyncio
9
9
  import json
10
10
  from collections.abc import Callable
11
- from functools import partial, partialmethod, wraps
11
+ from functools import cached_property, partialmethod
12
12
  from typing import Any, ClassVar, Literal, Unpack
13
13
 
14
+ import attrs
14
15
  from aiohttp import web
15
16
  from aiohttp.client import (
16
17
  ClientResponse,
@@ -35,30 +36,13 @@ HTTP_ALLOWED = [HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE]
35
36
  DEFAULT_SCOPES = ["User.Read", "User.Read.All"]
36
37
 
37
38
 
38
- def async_wrap(func: Callable) -> Callable:
39
- """Wrap a function doing I/O to run in an executor thread."""
40
-
41
- @wraps(func)
42
- async def run(
43
- *args: Any,
44
- loop: asyncio.AbstractEventLoop | None = None,
45
- executor: Any = None,
46
- **kwargs: dict[str, Any],
47
- ) -> Callable:
48
- if loop is None:
49
- loop = asyncio.get_event_loop()
50
- pfunc = partial(func, *args, **kwargs)
51
- return await loop.run_in_executor(executor, pfunc)
52
-
53
- return run
54
-
55
-
56
39
  # These keys will be used on the aiohttp session
57
40
  TOKEN_CACHE = "token_cache"
58
41
  FLOW_CACHE = "flow_cache"
59
42
  USER_EMAIL = "mail"
60
43
 
61
44
 
45
+ @attrs.define()
62
46
  class AsyncMSAL:
63
47
  """AsycMSAL class.
64
48
 
@@ -70,52 +54,37 @@ class AsyncMSAL:
70
54
  Use until such time as MSAL Python gets a true async version.
71
55
  """
72
56
 
73
- _token_cache: SerializableTokenCache
74
- _app: ConfidentialClientApplication
75
- client_session: ClassVar[ClientSession | None] = None
76
-
77
- def __init__(
78
- self,
79
- session: Session | dict[str, Any],
80
- save_callback: Callable[[Session | dict[str, Any]], None] | None = None,
81
- ):
82
- """Init the class.
57
+ session: Session | dict[str, Any]
58
+ save_callback: Callable[[Session | dict[str, Any]], None] | None = None
59
+ """Called if the token cache changes. Optional.
60
+ Not required when the session parameter is an aiohttp_session.Session.
61
+ """
62
+ app: ConfidentialClientApplication = attrs.field(init=False)
83
63
 
84
- **save_callback** will be called if the token cache changes. Optional.
85
- Not required when the session parameter is an aiohttp_session.Session.
86
- """
87
- self.session = session
88
- self.save_callback = save_callback
89
- if not isinstance(session, Session | dict):
90
- raise ValueError(f"session or dict-like object required {session}")
64
+ app_kwargs: ClassVar[dict[str, Any] | None] = None
65
+ """ConfidentialClientApplication kwargs."""
66
+ client_session: ClassVar[ClientSession | None] = None
91
67
 
92
- @property
68
+ def __attrs_post_init__(self) -> None:
69
+ """Init."""
70
+ kwargs = dict(self.app_kwargs) if self.app_kwargs else {}
71
+ for key, val in {
72
+ "client_id": ENV.SP_APP_ID,
73
+ "client_credential": ENV.SP_APP_PW,
74
+ "authority": ENV.SP_AUTHORITY,
75
+ "validate_authority": False,
76
+ "token_cache": self.token_cache,
77
+ }.items():
78
+ kwargs.setdefault(key, val)
79
+ self.app = ConfidentialClientApplication(**kwargs)
80
+
81
+ @cached_property
93
82
  def token_cache(self) -> SerializableTokenCache:
94
83
  """Get the token_cache."""
95
- if not self._token_cache:
96
- self._token_cache = SerializableTokenCache()
97
- # _load_token_cache
98
- if self.session and self.session.get(TOKEN_CACHE):
99
- self._token_cache.deserialize(self.session[TOKEN_CACHE])
100
-
101
- return self._token_cache
102
-
103
- @property
104
- def app(self) -> ConfidentialClientApplication:
105
- """Create the application using the cache.
106
-
107
- Based on: https://github.com/Azure-Samples/ms-identity-python-webapp/blob/master/app.py#L76
108
- """
109
- if not self._app:
110
- token_cache = self.token_cache
111
- self._app = ConfidentialClientApplication(
112
- client_id=ENV.SP_APP_ID,
113
- client_credential=ENV.SP_APP_PW,
114
- authority=ENV.SP_AUTHORITY, # common/oauth2/v2.0/token'
115
- validate_authority=False,
116
- token_cache=token_cache,
117
- )
118
- return self._app
84
+ res = SerializableTokenCache()
85
+ if self.session and self.session.get(TOKEN_CACHE):
86
+ res.deserialize(self.session[TOKEN_CACHE])
87
+ return res
119
88
 
120
89
  def save_token_cache(self) -> None:
121
90
  """Save the token cache if it changed."""
@@ -124,7 +93,7 @@ class AsyncMSAL:
124
93
  if self.save_callback:
125
94
  self.save_callback(self.session)
126
95
 
127
- def build_auth_code_flow(
96
+ def initiate_auth_code_flow(
128
97
  self,
129
98
  redirect_uri: str,
130
99
  scopes: list[str] | None = None,
@@ -146,12 +115,16 @@ class AsyncMSAL:
146
115
  # https://msal-python.readthedocs.io/en/latest/#msal.ClientApplication.initiate_auth_code_flow
147
116
  return str(res["auth_uri"])
148
117
 
149
- def acquire_token_by_auth_code_flow(self, auth_response: Any) -> None:
118
+ def acquire_token_by_auth_code_flow(
119
+ self, auth_response: Any, scopes: list[str] | None = None
120
+ ) -> None:
150
121
  """Second step - Acquire token."""
151
122
  # Assume we have it in the cache (added by /login)
152
123
  # will raise keryerror if no cache
153
124
  auth_code_flow = self.session.pop(FLOW_CACHE)
154
- result = self.app.acquire_token_by_auth_code_flow(auth_code_flow, auth_response)
125
+ result = self.app.acquire_token_by_auth_code_flow(
126
+ auth_code_flow, auth_response, scopes=scopes
127
+ )
155
128
  if "error" in result:
156
129
  raise web.HTTPBadRequest(text=str(result["error"]))
157
130
  if "id_token_claims" not in result:
@@ -227,18 +200,10 @@ class AsyncMSAL:
227
200
  get = partialmethod(request_ctx, HTTP_GET)
228
201
  post = partialmethod(request_ctx, HTTP_POST)
229
202
 
230
- # def get(self, url: str, **kwargs: Any) -> _RequestContextManager:
231
- # """GET Request."""
232
- # return _RequestContextManager(self.request(HTTP_GET, url, **kwargs))
233
-
234
- # def post(self, url: str, **kwargs: Any) -> _RequestContextManager:
235
- # """POST request."""
236
- # return _RequestContextManager(self.request(HTTP_POST, url, **kwargs))
237
-
238
203
  @property
239
204
  def mail(self) -> str:
240
205
  """User email."""
241
- return self.session.get("mail", "")
206
+ return self.session.get(USER_EMAIL, "")
242
207
 
243
208
  @property
244
209
  def manager_mail(self) -> str:
aiohttp_msal/routes.py CHANGED
@@ -44,7 +44,7 @@ async def user_login(request: web.Request) -> web.Response:
44
44
  session[SESSION_REDIRECT] = urljoin(_to, request.match_info.get("to", ""))
45
45
 
46
46
  msredirect = get_route(request, URI_USER_AUTHORIZED.lstrip("/"))
47
- redir = AsyncMSAL(session).build_auth_code_flow(redirect_uri=msredirect)
47
+ redir = AsyncMSAL(session).initiate_auth_code_flow(redirect_uri=msredirect)
48
48
  return web.HTTPFound(redir)
49
49
 
50
50
 
aiohttp_msal/settings.py CHANGED
@@ -9,8 +9,6 @@ from aiohttp_msal.settings_base import VAR_REQ, VAR_REQ_HIDE, SettingsBase
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from redis.asyncio import Redis
12
- else:
13
- Redis = None
14
12
 
15
13
 
16
14
  @attrs.define
@@ -28,7 +26,7 @@ class MSALSettings(SettingsBase):
28
26
  "https://login.microsoftonline.com/common" # For multi-tenant app
29
27
  "https://login.microsoftonline.com/Tenant_Name_or_UUID_Here"."""
30
28
 
31
- DOMAIN: str = "mydomain.com"
29
+ DOMAIN: str = attrs.field(metadata=VAR_REQ, default="")
32
30
  """Your domain. Used by routes & Redis functions."""
33
31
 
34
32
  COOKIE_NAME: str = "AIOHTTP_SESSION"
@@ -41,7 +39,7 @@ class MSALSettings(SettingsBase):
41
39
 
42
40
  REDIS: str = "redis://redis1:6379"
43
41
  """OPTIONAL: Redis database connection used by app_init_redis_session()."""
44
- database: Redis = None # type: ignore[assignment]
42
+ database: "Redis" = attrs.field(init=False)
45
43
  """Store the Redis connection when using app_init_redis_session()."""
46
44
 
47
45
 
@@ -14,11 +14,6 @@ VAR_REQ = {KEY_REQ: True}
14
14
  VAR_HIDE = {KEY_HIDE: True}
15
15
 
16
16
 
17
- def _is_hidden(atr: attrs.Attribute) -> bool:
18
- """Is this field hidden."""
19
- return bool(atr.metadata.get(KEY_HIDE))
20
-
21
-
22
17
  @attrs.define
23
18
  class SettingsBase:
24
19
  """Retrieve Settings from environment variables.
aiohttp_msal/user_info.py CHANGED
@@ -1,34 +1,7 @@
1
1
  """Graph User Info."""
2
2
 
3
- import asyncio
4
- from collections.abc import Awaitable, Callable
5
- from functools import wraps
6
- from typing import ParamSpec, TypeVar
7
-
8
3
  from aiohttp_msal.msal_async import AsyncMSAL
9
-
10
- _T = TypeVar("_T")
11
- _P = ParamSpec("_P")
12
-
13
-
14
- def retry(func: Callable[_P, Awaitable[_T]]) -> Callable[_P, Awaitable[_T]]:
15
- """Retry if tenacity is installed."""
16
-
17
- @wraps(func)
18
- async def _retry(*args: _P.args, **kwargs: _P.kwargs) -> _T:
19
- """Retry the request."""
20
- retries = [2, 4, 8]
21
- while True:
22
- try:
23
- res = await func(*args, **kwargs)
24
- return res
25
- except Exception as err:
26
- if retries:
27
- await asyncio.sleep(retries.pop())
28
- else:
29
- raise err
30
-
31
- return _retry
4
+ from aiohttp_msal.utils import retry
32
5
 
33
6
 
34
7
  @retry
aiohttp_msal/utils.py ADDED
@@ -0,0 +1,51 @@
1
+ """Graph User Info."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from functools import wraps
6
+ from typing import ParamSpec, TypeVar, Any
7
+ from functools import partial
8
+
9
+
10
+ T = TypeVar("T")
11
+ P = ParamSpec("P")
12
+
13
+
14
+ def async_wrap(
15
+ func: Callable[..., T],
16
+ ) -> Callable[..., Awaitable[T]]:
17
+ """Wrap a function doing I/O to run in an executor thread."""
18
+
19
+ @wraps(func)
20
+ async def run(
21
+ loop: asyncio.AbstractEventLoop | None = None,
22
+ executor: Any = None,
23
+ *args: Any,
24
+ **kwargs: Any,
25
+ ) -> T:
26
+ if loop is None:
27
+ loop = asyncio.get_event_loop()
28
+ pfunc = partial(func, *args, **kwargs)
29
+ return await loop.run_in_executor(executor, pfunc)
30
+
31
+ return run
32
+
33
+
34
+ def retry(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
35
+ """Retry if tenacity is installed."""
36
+
37
+ @wraps(func)
38
+ async def _retry(*args: P.args, **kwargs: P.kwargs) -> T:
39
+ """Retry the request."""
40
+ retries = [2, 4, 8]
41
+ while True:
42
+ try:
43
+ res = await func(*args, **kwargs)
44
+ return res
45
+ except Exception as err:
46
+ if retries:
47
+ await asyncio.sleep(retries.pop())
48
+ else:
49
+ raise err
50
+
51
+ return _retry
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aiohttp-msal
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -23,7 +23,7 @@ Project-URL: Homepage, https://github.com/kellerza/aiohttp_msal
23
23
  Provides-Extra: aioredis
24
24
  Description-Content-Type: text/markdown
25
25
 
26
- # aiohttp_msal Python library
26
+ # Async based MSAL helper for aiohttp - aiohttp_msal Python library
27
27
 
28
28
  Authorization Code Flow Helper. Learn more about auth-code-flow at
29
29
  <https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow>
@@ -124,8 +124,8 @@ from aiohttp_msal.redis_tools import get_session
124
124
  def main()
125
125
  # Uses the redis.asyncio driver to retrieve the current token
126
126
  # Will update the token_cache if a RefreshToken was used
127
- ases = asyncio.run(get_session(MYEMAIL))
128
- client = GraphClient(ases.get_token)
127
+ ses = asyncio.run(get_session(MYEMAIL))
128
+ client = GraphClient(ses.get_token)
129
129
  # ...
130
130
  # use the Graphclient
131
131
  ```
@@ -0,0 +1,11 @@
1
+ aiohttp_msal/__init__.py,sha256=hnyifyJykI7NMvM93KrHIsTlrrfCVUrpKdbRKL6Gubw,4027
2
+ aiohttp_msal/msal_async.py,sha256=JUtyTro57rLiHQYqgYq8LFv3keznyhISP1emgN3y31E,8232
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.2.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
10
+ aiohttp_msal-1.0.2.dist-info/METADATA,sha256=b9HRcY4HaOKhXVhBQIuYK-h0Cia9g922FjOdTyOuPpw,4514
11
+ aiohttp_msal-1.0.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.12
2
+ Generator: uv 0.8.13
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- aiohttp_msal/__init__.py,sha256=867ca27f2272908ecd32f33ddcaac722c4e5aeb7c2554ae929d6d128be86b9bc,4027
2
- aiohttp_msal/msal_async.py,sha256=8efdf9608c55e41f99ed66294a18303192e921739f49986cf90a015b07a50c55,9470
3
- aiohttp_msal/redis_tools.py,sha256=ea40b0d3fcc341cbc872c25a3df1becc752f4f7bf392b37248d4d5e72d6d7241,6539
4
- aiohttp_msal/routes.py,sha256=f305368d4f6a4a5a87e5fabd92f901647020af141d981f83bed402f969576e1d,8135
5
- aiohttp_msal/settings.py,sha256=b6d6ea19bd97d6bec3b0bbca6f50250c32816195a32301cb1c8f926bef0afa52,1562
6
- aiohttp_msal/settings_base.py,sha256=b516e3829851d6dbc70ab14271f394868140c949eafd79c90553b834d88f74d8,3150
7
- aiohttp_msal/user_info.py,sha256=b4efaf03f9313ec787b1e5b136584673f3650df80919d7f538c09a1a6bc37fc4,1875
8
- aiohttp_msal-1.0.0.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
9
- aiohttp_msal-1.0.0.dist-info/METADATA,sha256=dd4a69bc47da5e6c559fda12c41505fdc047c8edde36d498d25a93fc0b7b4ec8,4478
10
- aiohttp_msal-1.0.0.dist-info/RECORD,,