aiohttp-msal 1.0.4__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/helpers.py CHANGED
@@ -1,18 +1,19 @@
1
1
  """Graph User Info."""
2
2
 
3
3
  from collections.abc import Mapping, Sequence
4
- from typing import Any, Literal, TypeVar
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from aiohttp import web
7
- from aiohttp_session import get_session
8
7
 
9
- from aiohttp_msal import ENV
10
- from aiohttp_msal.msal_async import AsyncMSAL
8
+ from aiohttp_msal.settings import ENV
11
9
  from aiohttp_msal.utils import retry
12
10
 
11
+ if TYPE_CHECKING:
12
+ from aiohttp_msal.msal_async import AsyncMSAL
13
+
13
14
 
14
15
  @retry
15
- async def get_user_info(aiomsal: AsyncMSAL) -> None:
16
+ async def get_user_info(aiomsal: "AsyncMSAL") -> None:
16
17
  """Load user info from MS graph API. Requires User.Read permissions."""
17
18
  async with aiomsal.get("https://graph.microsoft.com/v1.0/me") as res:
18
19
  body = await res.json()
@@ -26,7 +27,7 @@ async def get_user_info(aiomsal: AsyncMSAL) -> None:
26
27
 
27
28
 
28
29
  @retry
29
- async def get_manager_info(aiomsal: AsyncMSAL) -> None:
30
+ async def get_manager_info(aiomsal: "AsyncMSAL") -> None:
30
31
  """Load manager info from MS graph API. Requires User.Read.All permissions."""
31
32
  async with aiomsal.get("https://graph.microsoft.com/v1.0/me/manager") as res:
32
33
  body = await res.json()
@@ -69,68 +70,7 @@ def html_wrap(msgs: Sequence[str]) -> str:
69
70
  """
70
71
 
71
72
 
72
- TA = TypeVar("TA", bound=AsyncMSAL)
73
-
74
-
75
- async def check_auth_response(
76
- request: web.Request,
77
- asyncmsal_class: type[TA] = AsyncMSAL, # type:ignore[assignment]
78
- get_info: Literal["user", "manager", ""] = "manager",
79
- ) -> tuple[TA | None, list[str]]:
80
- """Parse the MS auth response."""
81
- # Expecting response_mode="form_post"
82
- auth_response = dict(await request.post())
83
-
84
- msg = list[str]()
85
-
86
- # Ensure all expected variables were returned...
87
- if not all(auth_response.get(k) for k in ["code", "session_state", "state"]):
88
- msg.append("Expected 'code', 'session_state', 'state' in auth_response")
89
- msg.append(f"Received auth_response: {list(auth_response)}")
90
- return None, msg
91
-
92
- if not request.cookies.get(ENV.COOKIE_NAME):
93
- cookies = dict(request.cookies.items())
94
- msg.append(f"<b>Expected '{ENV.COOKIE_NAME}' in cookies</b>")
95
- msg.append(html_table(cookies))
96
- msg.append("Cookie should be set with Samesite:None")
97
-
98
- session = await get_session(request)
99
- if session.new:
100
- msg.append(
101
- "Warning: This is a new session and may not have all expected values."
102
- )
103
-
104
- if not session.get(asyncmsal_class.flow_cache_key):
105
- msg.append(f"<b>Expected '{asyncmsal_class.flow_cache_key}' in session</b>")
106
- msg.append(html_table(session))
107
-
108
- aiomsal = asyncmsal_class(session)
109
- aiomsal.redirect = "/" + aiomsal.redirect.lstrip("/")
110
-
111
- if msg:
112
- return aiomsal, msg
113
-
114
- try:
115
- await aiomsal.async_acquire_token_by_auth_code_flow(auth_response)
116
- except Exception as err:
117
- msg.append("<b>Could not get token</b> - async_acquire_token_by_auth_code_flow")
118
- msg.append(str(err))
119
-
120
- if not msg:
121
- try:
122
- if get_info in ("user", "manager"):
123
- await get_user_info(aiomsal)
124
- if get_info == "manager":
125
- await get_manager_info(aiomsal)
126
- except Exception as err:
127
- msg.append("Could not get org info from MS graph")
128
- msg.append(str(err))
129
- aiomsal.mail = ""
130
- aiomsal.name = ""
131
-
132
- if session.get("mail"):
133
- for lcb in ENV.login_callback:
134
- await lcb(aiomsal)
135
-
136
- return aiomsal, msg
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
@@ -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, cast
13
+ from typing import Any, ClassVar, Literal, Self, TypeVar, Unpack, cast
13
14
 
14
15
  import attrs
15
16
  from aiohttp import web
@@ -20,12 +21,15 @@ 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
27
29
  from aiohttp_msal.utils import dict_property
28
30
 
31
+ _LOG = logging.getLogger(__name__)
32
+
29
33
  HttpMethods = Literal["get", "post", "put", "patch", "delete"]
30
34
  HTTP_GET = "get"
31
35
  HTTP_POST = "post"
@@ -34,6 +38,8 @@ HTTP_PATCH = "patch"
34
38
  HTTP_DELETE = "delete"
35
39
  HTTP_ALLOWED = [HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE]
36
40
 
41
+ T = TypeVar("T")
42
+
37
43
 
38
44
  @attrs.define(slots=False)
39
45
  class AsyncMSAL:
@@ -54,14 +60,46 @@ class AsyncMSAL:
54
60
  """
55
61
  app_kwargs: dict[str, Any] | None = None
56
62
  """ConfidentialClientApplication kwargs."""
57
- client_session: ClassVar[ClientSession | None] = None
58
63
 
64
+ client_session: ClassVar[ClientSession | None] = None
59
65
  token_cache_key: ClassVar[str] = "token_cache"
60
66
  user_email_key: ClassVar[str] = "mail"
61
67
  flow_cache_key: ClassVar[str] = "flow_cache"
62
- redirect_key = "redirect"
68
+ redirect_key: ClassVar[str] = "redirect"
63
69
  default_scopes: ClassVar[list[str]] = ["User.Read", "User.Read.All"]
64
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'"
96
+
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
102
+
65
103
  @cached_property
66
104
  def app(self) -> ConfidentialClientApplication:
67
105
  """Get the app."""
@@ -118,7 +156,7 @@ class AsyncMSAL:
118
156
  ) -> None:
119
157
  """Second step - Acquire token."""
120
158
  # Assume we have it in the cache (added by /login)
121
- # will raise keryerror if no cache
159
+ # will raise KeyError if not in cache
122
160
  auth_code_flow = self.session.pop(self.flow_cache_key)
123
161
  result = self.app.acquire_token_by_auth_code_flow(
124
162
  auth_code_flow, auth_response, scopes=scopes
@@ -208,3 +246,67 @@ class AsyncMSAL:
208
246
  manager_name = dict_property("session", "m_name")
209
247
  manager_mail = dict_property("session", "m_mail")
210
248
  redirect = dict_property("session", redirect_key)
249
+
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.
256
+
257
+ Parse the auth response from the request, checks for valid keys,
258
+ acquire the token and get_info.
259
+
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
@@ -9,12 +9,7 @@ from aiohttp import web
9
9
  from aiohttp_session import get_session, new_session
10
10
 
11
11
  from aiohttp_msal import ENV, auth_ok, msal_session
12
- from aiohttp_msal.helpers import (
13
- check_auth_response,
14
- get_manager_info,
15
- get_user_info,
16
- html_wrap,
17
- )
12
+ from aiohttp_msal.helpers import get_manager_info, get_url, get_user_info, html_wrap
18
13
  from aiohttp_msal.msal_async import AsyncMSAL
19
14
 
20
15
  ROUTES = web.RouteTableDef()
@@ -24,17 +19,6 @@ URI_USER_AUTHORIZED = "/user/authorized"
24
19
  SESSION_REDIRECT = "redirect"
25
20
 
26
21
 
27
- def get_route(request: web.Request, url: str) -> str:
28
- """Retrieve server route from request.
29
-
30
- localhost and production on http:// with nginx proxy that adds TLS/SSL.
31
- """
32
- url = str(request.url.origin() / url)
33
- if "localhost" not in url:
34
- url = url.replace("http:", "https:", 1)
35
- return url
36
-
37
-
38
22
  @ROUTES.get(URI_USER_LOGIN)
39
23
  @ROUTES.get(f"{URI_USER_LOGIN}/{{to:.+$}}")
40
24
  async def user_login(request: web.Request) -> web.Response:
@@ -47,7 +31,7 @@ async def user_login(request: web.Request) -> web.Response:
47
31
  _to = "/"
48
32
  session[SESSION_REDIRECT] = urljoin(_to, request.match_info.get("to", ""))
49
33
 
50
- msredirect = get_route(request, URI_USER_AUTHORIZED.lstrip("/"))
34
+ msredirect = get_url(request, URI_USER_AUTHORIZED.lstrip("/"))
51
35
  redir = AsyncMSAL(session).initiate_auth_code_flow(redirect_uri=msredirect)
52
36
  raise web.HTTPFound(redir)
53
37
 
@@ -55,9 +39,10 @@ async def user_login(request: web.Request) -> web.Response:
55
39
  @ROUTES.post(URI_USER_AUTHORIZED)
56
40
  async def user_authorized(request: web.Request) -> web.Response:
57
41
  """Complete the auth code flow."""
58
- aiomsal, msg = await check_auth_response(request, AsyncMSAL)
42
+ aiomsal = await AsyncMSAL.from_request(request)
43
+ ok, msg = await aiomsal.async_acquire_token_by_auth_code_flow_plus(request)
59
44
 
60
- if aiomsal and not msg:
45
+ if ok and not msg:
61
46
  try:
62
47
  raise web.HTTPFound(aiomsal.redirect)
63
48
  finally:
@@ -149,7 +134,7 @@ async def user_logout(request: web.Request, ses: AsyncMSAL) -> web.Response:
149
134
  if ref:
150
135
  _to = urljoin(ref, _to)
151
136
  else:
152
- _to = get_route(request, _to)
137
+ _to = get_url(request, _to)
153
138
 
154
139
  return web.HTTPFound(
155
140
  f"https://login.microsoftonline.com/common/oauth2/logout?post_logout_redirect_uri={_to}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aiohttp-msal
3
- Version: 1.0.4
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.13
2
+ Generator: uv 0.8.15
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- aiohttp_msal/__init__.py,sha256=nrjPAIy0kNbBvHZsy9qUSKs5r_-Kiea-KJM17bsBIkw,4375
2
- aiohttp_msal/helpers.py,sha256=8sW-7FW4t1Ztazql11ng67SqdKBlbOWuOMjOBRfBc_c,4495
3
- aiohttp_msal/msal_async.py,sha256=RUV_XlhNNCOZslT1sEkEPyaYcHDOmsIyiGmRpZuA3I0,8097
4
- aiohttp_msal/redis_tools.py,sha256=6kCw0_zDQcvIcsJaPfG-zHUvT3vzkrNySNTV5y1tckE,6539
5
- aiohttp_msal/routes.py,sha256=3rAejWcHfL5czVA91TY3wZGT5EkRxmfyvc8vr6rhyxU,5438
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.4.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
10
- aiohttp_msal-1.0.4.dist-info/METADATA,sha256=OBl_U9OTy602OfSL9-d5myNJPUYuaGjtBF7voW5lu2Y,4514
11
- aiohttp_msal-1.0.4.dist-info/RECORD,,