aiohttp-msal 0.6.7__py3-none-any.whl → 0.7.0__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
@@ -1,8 +1,9 @@
1
1
  """aiohttp_msal."""
2
+
2
3
  import logging
4
+ import typing
3
5
  from functools import wraps
4
6
  from inspect import getfullargspec, iscoroutinefunction
5
- from typing import Any, Awaitable, Callable, Union
6
7
 
7
8
  from aiohttp import ClientSession, web
8
9
  from aiohttp_session import get_session
@@ -13,26 +14,29 @@ from aiohttp_msal.settings import ENV
13
14
 
14
15
  _LOGGER = logging.getLogger(__name__)
15
16
 
16
- VERSION = "0.6.7"
17
+ VERSION = "0.7.0"
17
18
 
18
19
 
19
- def msal_session(*args: Callable[[AsyncMSAL], Union[Any, Awaitable[Any]]]) -> Callable:
20
+ def msal_session(
21
+ *callbacks: typing.Callable[[AsyncMSAL], bool | typing.Awaitable[bool]],
22
+ at_least_one: bool | None = False,
23
+ ) -> typing.Callable:
20
24
  """Session decorator.
21
25
 
22
26
  Arguments can include a list of function to perform login tests etc.
23
27
  """
24
28
 
25
- def _session(func: Callable) -> Callable:
29
+ def _session(func: typing.Callable) -> typing.Callable:
26
30
  @wraps(func)
27
- async def __session(request: web.Request) -> Callable:
28
- _ses = AsyncMSAL(session=await get_session(request))
29
- for arg in args:
30
- if iscoroutinefunction(arg):
31
- if not await arg(_ses):
32
- raise web.HTTPForbidden
33
- elif not arg(_ses):
31
+ async def __session(request: web.Request) -> typing.Callable:
32
+ ses = AsyncMSAL(session=await get_session(request))
33
+ for c_b in callbacks:
34
+ _ok = await c_b(ses) if iscoroutinefunction(c_b) else c_b(ses)
35
+ if at_least_one and _ok:
36
+ break
37
+ if not at_least_one and not _ok:
34
38
  raise web.HTTPForbidden
35
- return await func(request=request, ses=_ses)
39
+ return await func(request=request, ses=ses)
36
40
 
37
41
  assert iscoroutinefunction(func), f"Function needs to be a coroutine: {func}"
38
42
  spec = getfullargspec(func)
@@ -47,6 +51,26 @@ def authenticated(ses: AsyncMSAL) -> bool:
47
51
  return bool(ses.mail)
48
52
 
49
53
 
54
+ def auth_or(
55
+ *args: typing.Callable[[AsyncMSAL], bool | typing.Awaitable[bool]]
56
+ ) -> typing.Callable[[AsyncMSAL], typing.Awaitable[bool]]:
57
+ """Ensure either of the methods is valid. An alternative to at_least_one=True.
58
+
59
+ Arguments can include a list of function to perform login tests etc."""
60
+
61
+ async def or_auth(ses: AsyncMSAL) -> bool:
62
+ """Or."""
63
+ for arg in args:
64
+ if iscoroutinefunction(arg):
65
+ if await arg(ses):
66
+ return True
67
+ elif arg(ses):
68
+ return True
69
+ raise web.HTTPForbidden
70
+
71
+ return or_auth
72
+
73
+
50
74
  async def app_init_redis_session(
51
75
  app: web.Application, max_age: int = 3600 * 24 * 90
52
76
  ) -> None:
@@ -4,10 +4,11 @@ The AsyncMSAL class contains more info to perform OAuth & get the required token
4
4
  Once you have the OAuth tokens store in the session, you are free to make requests
5
5
  (typically from an aiohttp server's inside a request)
6
6
  """
7
+
7
8
  import asyncio
8
9
  import json
9
10
  from functools import partial, wraps
10
- from typing import Any, Callable, Literal, Optional, Union
11
+ from typing import Any, Callable, Literal
11
12
 
12
13
  from aiohttp import web
13
14
  from aiohttp.client import ClientResponse, ClientSession, _RequestContextManager
@@ -32,7 +33,7 @@ def async_wrap(func: Callable) -> Callable:
32
33
  @wraps(func)
33
34
  async def run(
34
35
  *args: Any,
35
- loop: Optional[asyncio.AbstractEventLoop] = None,
36
+ loop: asyncio.AbstractEventLoop | None = None,
36
37
  executor: Any = None,
37
38
  **kwargs: dict[str, Any],
38
39
  ) -> Callable:
@@ -67,8 +68,8 @@ class AsyncMSAL:
67
68
 
68
69
  def __init__(
69
70
  self,
70
- session: Union[Session, dict[str, str]],
71
- save_cache: Optional[Callable[[Union[Session, dict[str, str]]], None]] = None,
71
+ session: Session | dict[str, str],
72
+ save_cache: Callable[[Session | dict[str, str]], None] | None = None,
72
73
  ):
73
74
  """Init the class.
74
75
 
@@ -119,8 +120,8 @@ class AsyncMSAL:
119
120
  def build_auth_code_flow(
120
121
  self,
121
122
  redirect_uri: str,
122
- scopes: Optional[list[str]] = None,
123
- prompt: Optional[Literal["login", "consent", "select_account", "none"]] = None,
123
+ scopes: list[str] | None = None,
124
+ prompt: Literal["login", "consent", "select_account", "none"] | None = None,
124
125
  **kwargs: Any,
125
126
  ) -> str:
126
127
  """First step - Start the flow."""
@@ -131,7 +132,7 @@ class AsyncMSAL:
131
132
  redirect_uri=redirect_uri,
132
133
  response_mode="form_post",
133
134
  prompt=prompt,
134
- **kwargs
135
+ **kwargs,
135
136
  # max_age=1209600,
136
137
  # max allowed 86400 - 1 day
137
138
  )
@@ -159,7 +160,7 @@ class AsyncMSAL:
159
160
  None, self.acquire_token_by_auth_code_flow, auth_response
160
161
  )
161
162
 
162
- def get_token(self, scopes: Optional[list[str]] = None) -> Optional[dict[str, Any]]:
163
+ def get_token(self, scopes: list[str] | None = None) -> dict[str, Any] | None:
163
164
  """Acquire a token based on username."""
164
165
  accounts = self.app.get_accounts()
165
166
  if accounts:
@@ -170,7 +171,7 @@ class AsyncMSAL:
170
171
  return result
171
172
  return None
172
173
 
173
- async def async_get_token(self) -> Optional[dict[str, Any]]:
174
+ async def async_get_token(self) -> dict[str, Any] | None:
174
175
  """Acquire a token based on username."""
175
176
  return await asyncio.get_event_loop().run_in_executor(None, self.get_token)
176
177
 
@@ -1,4 +1,5 @@
1
1
  """Redis tools for sessions."""
2
+
2
3
  import asyncio
3
4
  import json
4
5
  import logging
@@ -9,7 +10,7 @@ from typing import Any, AsyncGenerator, Optional
9
10
  from redis.asyncio import Redis, from_url
10
11
 
11
12
  from aiohttp_msal.msal_async import AsyncMSAL
12
- from aiohttp_msal.settings import ENV
13
+ from aiohttp_msal.settings import ENV as MENV
13
14
 
14
15
  _LOGGER = logging.getLogger(__name__)
15
16
 
@@ -19,15 +20,17 @@ SES_KEYS = ("mail", "name", "m_mail", "m_name")
19
20
  @asynccontextmanager
20
21
  async def get_redis() -> AsyncGenerator[Redis, None]:
21
22
  """Get a Redis connection."""
22
- if ENV.database:
23
+ if MENV.database:
23
24
  _LOGGER.debug("Using redis from environment")
24
- yield ENV.database
25
+ yield MENV.database
25
26
  return
26
- _LOGGER.info("Connect to Redis %s", ENV.REDIS)
27
- redis = from_url(ENV.REDIS)
27
+ _LOGGER.info("Connect to Redis %s", MENV.REDIS)
28
+ redis = from_url(MENV.REDIS) # decode_responses=True not allowed aiohttp_session
29
+ MENV.database = redis
28
30
  try:
29
31
  yield redis
30
32
  finally:
33
+ MENV.database = None # type:ignore
31
34
  await redis.close()
32
35
 
33
36
 
@@ -45,10 +48,11 @@ async def session_iter(
45
48
  if match and not all(isinstance(v, str) for v in match.values()):
46
49
  raise ValueError("match values must be strings")
47
50
  async for key in redis.scan_iter(
48
- count=100, match=key_match or f"{ENV.COOKIE_NAME}*"
51
+ count=100, match=key_match or f"{MENV.COOKIE_NAME}*"
49
52
  ):
53
+ if not isinstance(key, str):
54
+ key = key.decode()
50
55
  sval = await redis.get(key)
51
- _LOGGER.debug("Session: %s = %s", key, sval)
52
56
  created, ses = 0, {}
53
57
  try:
54
58
  val = json.loads(sval) # type: ignore
@@ -110,11 +114,67 @@ def _session_factory(key: str, created: str, session: dict) -> AsyncMSAL:
110
114
  return AsyncMSAL(session, save_cache=save_cache)
111
115
 
112
116
 
113
- async def get_session(email: str, *, redis: Optional[Redis] = None) -> AsyncMSAL:
117
+ async def get_session(
118
+ email: str, *, redis: Optional[Redis] = None, scope: str = ""
119
+ ) -> AsyncMSAL:
114
120
  """Get a session from Redis."""
121
+ cnt = 0
115
122
  async with AsyncExitStack() as stack:
116
123
  if redis is None:
117
124
  redis = await stack.enter_async_context(get_redis())
118
125
  async for key, created, session in session_iter(redis, match={"mail": email}):
126
+ cnt += 1
127
+ if scope and scope not in str(session.get("token_cache")).lower():
128
+ continue
119
129
  return _session_factory(key, str(created), session)
120
- raise ValueError(f"Session for {email} not found")
130
+ msg = f"Session for {email}"
131
+ if not scope:
132
+ raise ValueError(f"{msg} not found")
133
+ raise ValueError(f"{msg} with scope {scope} not found ({cnt} checked)")
134
+
135
+
136
+ async def redis_get_json(key: str) -> list | dict | None:
137
+ """Get a key from redis."""
138
+ res = await MENV.database.get(key)
139
+ if isinstance(res, (str, bytes, bytearray)):
140
+ return json.loads(res)
141
+ if res is not None:
142
+ _LOGGER.warning("Unexpected type for %s: %s", key, type(res))
143
+ return None
144
+
145
+
146
+ async def redis_get(key: str) -> str | None:
147
+ """Get a key from redis."""
148
+ res = await MENV.database.get(key)
149
+ if isinstance(res, str):
150
+ return res
151
+ if isinstance(res, (bytes, bytearray)):
152
+ return res.decode()
153
+ if res is not None:
154
+ _LOGGER.warning("Unexpected type for %s: %s", key, type(res))
155
+ return None
156
+
157
+
158
+ async def redis_set_set(key: str, new_set: set[str]) -> None:
159
+ """Set the value of a set in redis."""
160
+ cur_set = set(
161
+ s if isinstance(s, str) else s.decode()
162
+ for s in await MENV.database.smembers(key)
163
+ )
164
+ dif = list(cur_set - new_set)
165
+ if dif:
166
+ _LOGGER.warning("%s: removing %s", key, dif)
167
+ await MENV.database.srem(key, *dif)
168
+
169
+ dif = list(new_set - cur_set)
170
+ if dif:
171
+ _LOGGER.info("%s: adding %s", key, dif)
172
+ await MENV.database.sadd(key, *dif)
173
+
174
+
175
+ async def redis_scan(match_str: str) -> list[str]:
176
+ """Return a list of matching keys."""
177
+ return [
178
+ s if isinstance(s, str) else s.decode()
179
+ async for s in MENV.database.scan_iter(match=match_str)
180
+ ]
aiohttp_msal/routes.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """The user blueprint."""
2
+
2
3
  import time
3
4
  from inspect import iscoroutinefunction
4
5
  from typing import Any, Mapping, Sequence
aiohttp_msal/settings.py CHANGED
@@ -1,8 +1,14 @@
1
1
  """Settings."""
2
- from typing import Any, Awaitable, Callable, Union
2
+
3
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
3
4
 
4
5
  from aiohttp_msal.settings_base import SettingsBase, Var
5
6
 
7
+ if TYPE_CHECKING:
8
+ from redis.asyncio import Redis
9
+ else:
10
+ Redis = Any
11
+
6
12
 
7
13
  class MSALSettings(SettingsBase):
8
14
  """Settings."""
@@ -26,12 +32,12 @@ class MSALSettings(SettingsBase):
26
32
 
27
33
  login_callback: list[Callable[[Any], Awaitable[Any]]] = []
28
34
  """A list of callbacks to execute on successful login."""
29
- info: dict[str, Callable[[Any], Union[Any, Awaitable[Any]]]] = {}
35
+ info: dict[str, Callable[[Any], Any | Awaitable[Any]]] = {}
30
36
  """List of attributes to return in /user/info."""
31
37
 
32
38
  REDIS = "redis://redis1:6379"
33
39
  """OPTIONAL: Redis database connection used by app_init_redis_session()."""
34
- database: Any = None
40
+ database: Redis = None # type: ignore
35
41
  """Store the Redis connection when using app_init_redis_session()."""
36
42
 
37
43
 
@@ -1,4 +1,6 @@
1
1
  """Settings Base."""
2
+
3
+ from __future__ import annotations
2
4
  import logging
3
5
  import os
4
6
  from pathlib import Path
@@ -9,7 +11,7 @@ class Var: # pylint: disable=too-few-public-methods
9
11
  """Variable settings."""
10
12
 
11
13
  @staticmethod
12
- def from_value(val: Any): # type: ignore
14
+ def from_value(val: Any) -> Var:
13
15
  """Ensure the return is an instance of Var."""
14
16
  return val if isinstance(val, Var) else Var(type(val))
15
17
 
aiohttp_msal/user_info.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Graph User Info."""
2
+
2
3
  import asyncio
3
4
  from functools import wraps
4
5
  from typing import Any, Callable
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2022-2023 kellerza
3
+ Copyright (c) 2022-2024 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
- Name: aiohttp-msal
3
- Version: 0.6.7
2
+ Name: aiohttp_msal
3
+ Version: 0.7.0
4
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
@@ -12,20 +12,20 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Natural Language :: English
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
- Requires-Python: >=3.9
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: msal >=1.24.1
21
+ Requires-Dist: msal >=1.30.0
22
22
  Requires-Dist: aiohttp-session >=2.12
23
23
  Requires-Dist: aiohttp >=3.8
24
24
  Provides-Extra: redis
25
25
  Requires-Dist: aiohttp-session[aioredis] >=2.12 ; extra == 'redis'
26
26
  Provides-Extra: tests
27
- Requires-Dist: black ==23.9.1 ; extra == 'tests'
28
- Requires-Dist: pylint ; extra == 'tests'
27
+ Requires-Dist: black ==24.8.0 ; extra == 'tests'
28
+ Requires-Dist: pylint ==3.2.6 ; extra == 'tests'
29
29
  Requires-Dist: flake8 ; extra == 'tests'
30
30
  Requires-Dist: pytest-aiohttp ; extra == 'tests'
31
31
  Requires-Dist: pytest ; extra == 'tests'
@@ -0,0 +1,18 @@
1
+ aiohttp_msal/__init__.py,sha256=MnIsA6r73i_pJ0QTJp2zN1cWZfRBVdM1w7NMQoupjE0,3714
2
+ aiohttp_msal/msal_async.py,sha256=afvfh7gZrXk5KO7Umb9jAnQu4jdg_iVlgaTnxS3JgNM,8899
3
+ aiohttp_msal/redis_tools.py,sha256=zgRACVxm2wPkbEHtA6VmArsd-QQlKn-crlq1XlFbjEY,5919
4
+ aiohttp_msal/routes.py,sha256=JNz0tn-avHUiUmI-YeprY7XyVQNnHRdb2kQFynCTBy0,8228
5
+ aiohttp_msal/settings.py,sha256=hWVJdtqcdAkqqN5I4GINJIZSFGhEuoBImM26NrhqY_M,1341
6
+ aiohttp_msal/settings_base.py,sha256=m4tmurnq8xipVNAa-Dh4ii9Rsu6gg39F4aDJNHPLwiI,2919
7
+ aiohttp_msal/user_info.py,sha256=fijBUbl5g1AVgrpOl-2ZY-eQCCWcu4YqcA0QaMQrcWw,1766
8
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ tests/test_init.py,sha256=olI80lEHoG9Bf2owq3lyfJHp6OPYdsoN_jzlrNY0GYk,76
10
+ tests/test_msal_async.py,sha256=7-G6dO3_qWb8zxqC6_SqMMubCBbEERZy513P7UM4vmw,365
11
+ tests/test_redis_tools.py,sha256=uFpPSe6atbDVAuh1_OUtFgeZwyuLDspp42_EECJDSPg,1869
12
+ tests/test_settings.py,sha256=z-qtUs1zl5Q9NEux051eebyPnArLZ_OfZu65FKz0N4Y,333
13
+ aiohttp_msal-0.7.0.dist-info/LICENSE,sha256=BwqFEcF0Ij49hDZx4A_5CzsKnfU_twRjrm87JFwydFc,1080
14
+ aiohttp_msal-0.7.0.dist-info/METADATA,sha256=HylVcQ1RI0eH12hBat9J-zA4ICtNgAKEbOTkJOpvtgM,4724
15
+ aiohttp_msal-0.7.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
16
+ aiohttp_msal-0.7.0.dist-info/top_level.txt,sha256=QPWOi5JtacVEdbaU5bJExc9o-cCT2Lufx0QhUpsv5_E,19
17
+ aiohttp_msal-0.7.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
18
+ aiohttp_msal-0.7.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.3)
2
+ Generator: setuptools (72.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
tests/test_init.py CHANGED
@@ -1,3 +1,4 @@
1
1
  """Init."""
2
+
2
3
  import aiohttp_msal # noqa
3
4
  import aiohttp_msal.routes # noqa
tests/test_msal_async.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Test the AsyncMSAL class."""
2
+
2
3
  from aiohttp_msal.msal_async import AsyncMSAL, Session
3
4
 
4
5
 
tests/test_redis_tools.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Test redis tools."""
2
+
2
3
  from json import dumps
3
4
  from typing import AsyncGenerator
4
5
  from unittest.mock import AsyncMock, MagicMock, Mock, call
@@ -1,18 +0,0 @@
1
- aiohttp_msal/__init__.py,sha256=KADxEo9dYeguRN571wje2dtfOA5qWHuFjVoPCyH6zPQ,3025
2
- aiohttp_msal/msal_async.py,sha256=jHBUVL-QzHnz5fQY89D4uMeCvr0qaOY7P2KWL6U9gCM,8947
3
- aiohttp_msal/redis_tools.py,sha256=3Zyl3-sdT_SgxACdWeeBtWGywBzwyF9Ekm9CC2oCb90,4082
4
- aiohttp_msal/routes.py,sha256=UmUQkFFubFK2N1IAryDPwN7UBMCgow87H6zhNbKiE1I,8227
5
- aiohttp_msal/settings.py,sha256=Nz0GO62IMtQdfdZqJ8o8w331i1sxfGuaIo4j533u4NM,1243
6
- aiohttp_msal/settings_base.py,sha256=pmVmzTtaGEgRh-AMGy0HdhF1JvoZhZp42G3PL_ILHLw,2892
7
- aiohttp_msal/user_info.py,sha256=02DYVbP8lnLNCm1HQEGZY7tktWRHvEAuHf6fLj2Kcp8,1765
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=Vi2Jx8_GG-iMfKPY4UCvmX-0U1Bl2xswrAyWGIRP-Ac,364
11
- tests/test_redis_tools.py,sha256=x9Yd9WOGd2ZW5fJwyiAta5wMRkSM3prsCPhkVe6CS3Q,1868
12
- tests/test_settings.py,sha256=z-qtUs1zl5Q9NEux051eebyPnArLZ_OfZu65FKz0N4Y,333
13
- aiohttp_msal-0.6.7.dist-info/LICENSE,sha256=H1aGfkSfZFwK3q4INn9mUldOJGZy-ZXu5-65K9Glunw,1080
14
- aiohttp_msal-0.6.7.dist-info/METADATA,sha256=C2NtPfYMTs2YbCtkKCJCoXYKbBQt1BrtPDld3ccWj-w,4714
15
- aiohttp_msal-0.6.7.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
16
- aiohttp_msal-0.6.7.dist-info/top_level.txt,sha256=QPWOi5JtacVEdbaU5bJExc9o-cCT2Lufx0QhUpsv5_E,19
17
- aiohttp_msal-0.6.7.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
18
- aiohttp_msal-0.6.7.dist-info/RECORD,,