From fb433e27bec6598645d96d034a7c083a9593d21b Mon Sep 17 00:00:00 2001 From: phil Date: Mon, 17 Feb 2025 02:42:38 +0100 Subject: [PATCH] Add postgres db (messy) --- pyproject.toml | 2 + src/oidc_test/auth/utils.py | 131 +++++++++++++++++-------------- src/oidc_test/database.py | 81 ++++++++++++++----- src/oidc_test/main.py | 45 +++++++---- src/oidc_test/models.py | 37 ++++++--- src/oidc_test/registry.py | 1 + src/oidc_test/resource_server.py | 5 +- src/oidc_test/settings.py | 22 ++++++ uv.lock | 44 +++++++++++ 9 files changed, 257 insertions(+), 111 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b1e6504..5f140e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,11 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "asyncpg>=0.30.0", "authlib>=1.4.0", "cachetools>=5.5.0", "fastapi[standard]>=0.115.6", + "greenlet>=3.1.1", "httpx>=0.28.1", "itsdangerous>=2.2.0", "passlib[bcrypt]>=1.7.4", diff --git a/src/oidc_test/auth/utils.py b/src/oidc_test/auth/utils.py index 7dd0e3d..aeadfeb 100644 --- a/src/oidc_test/auth/utils.py +++ b/src/oidc_test/auth/utils.py @@ -4,6 +4,7 @@ import logging from fastapi import HTTPException, Request, Depends, status from fastapi.security import OAuth2PasswordBearer +from sqlmodel.ext.asyncio.session import AsyncSession from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError @@ -12,7 +13,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token from oidc_test.auth.provider import Provider from oidc_test.models import User -from oidc_test.database import db, TokenNotInDb, UserNotInDB +from oidc_test.database import db, get_db_session, TokenNotInDb, UserNotInDB from oidc_test.settings import settings from oidc_test.auth_providers import providers @@ -57,7 +58,11 @@ def init_providers(): provider_settings_dict = provider_settings.model_dump() # Add an anonymous user, that cannot be identified but has provided a valid access token provider_settings_dict["unknown_auth_user"] = User( - sub="", auth_provider_id=provider_settings.id + sub="", + auth_provider_id=provider_settings.id, + roles=[], + userinfo={}, + access_token_decoded={}, ) provider = Provider(**provider_settings_dict) if provider.disabled: @@ -119,17 +124,32 @@ def get_auth_provider(request: Request) -> Provider: return provider -async def get_current_user(request: Request) -> User: +async def get_current_user( + request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], +) -> User: + """Return the user from the request's session. + It can be used in Depends()""" + if user := await get_current_user_or_none(request, db_session): + return user + else: + raise HTTPException(status.HTTP_401_UNAUTHORIZED) + + +async def get_current_user_or_none( + request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], +) -> User | None: """Get the current user from a request object. Also validates the token expiration time. ... TODO: complete about refresh token """ if (user_sub := request.session.get("user_sub")) is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) - token = await get_token_from_session(request) - user = await db.get_user(user_sub) + return None + token = await get_token_from_session_or_none(request, db_session) ## Check if the token is expired - if token.is_expired(): + breakpoint() + if token is not None and token.is_expired(): provider = get_auth_provider(request=request) ## Ask a new refresh token from the provider logger.info(f"Token expired for user {user.name}") @@ -143,20 +163,28 @@ async def get_current_user(request: Request) -> User: # raise HTTPException( # status.HTTP_401_UNAUTHORIZED, "Token expired, cannot refresh" # ) - + user = await db.get_or_add_user( + user_sub, db_session=db_session, auth_provider=provider, token=token + ) return user -async def get_token_from_session_or_none(request: Request) -> OAuth2Token | None: +async def get_token_from_session_or_none( + request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], +) -> OAuth2Token | None: """Return the auth token from the session or None. Can be used in Depends()""" try: - return await get_token_from_session(request) + return await get_token_from_session(request, db_session) except HTTPException: return None -async def get_token_from_session(request: Request) -> OAuth2Token: +async def get_token_from_session( + request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], +) -> OAuth2Token: """Return the token from the session. Can be used in Depends()""" try: @@ -166,60 +194,15 @@ async def get_token_from_session(request: Request) -> OAuth2Token: request.session.pop("user_sub", None) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid provider") try: - return await db.get_token( - provider, - request.session.get("sid"), - ) + return await db.get_token(provider, request.session.get("sid"), db_session) except (TokenNotInDb, InvalidKeyError, DecodeError) as err: raise HTTPException(status.HTTP_401_UNAUTHORIZED, err.__class__.__name__) -async def get_current_user_or_none(request: Request) -> User | None: - """Return the user from a request object, from the session. - It can be used in Depends()""" - try: - return await get_current_user(request) - except HTTPException: - return None - - -def hasrole(required_roles: Union[str, list[str]] = []): - """Decorator for RBAC permissions""" - required_roles_set: set[str] - if isinstance(required_roles, str): - required_roles_set = set([required_roles]) - else: - required_roles_set = set(required_roles) - - def decorator(func): - @wraps(func) - async def wrapper(request=None, *args, **kwargs): - if request is None: - raise HTTPException( - 500, - "Functions decorated with hasrole must have a request:Request argument", - ) - user: User = await get_current_user(request) - if not any(required_roles_set.intersection(user.roles_as_set)): - raise HTTPException(status.HTTP_401_UNAUTHORIZED) - return await func(request, *args, **kwargs) - - return wrapper - - return decorator - - -def get_token_info(token: dict) -> dict: - token_info = dict() - for key in token: - if key != "userinfo": - token_info[key] = token[key] - return token_info - - async def get_user_from_token( token: Annotated[str, Depends(oauth2_scheme)], request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], ) -> User: try: auth_provider_id = request.headers["auth_provider"] @@ -257,9 +240,10 @@ async def get_user_from_token( try: user_id = payload["sub"] except KeyError: + logger.info(f"'sub' not found in the token, using {auth_provider_id}'s default user") return provider.unknown_auth_user try: - user = await db.get_user(user_id) + user = await db.get_user(user_id, db_session) if user.access_token != token: user.access_token = token except UserNotInDB: @@ -278,11 +262,12 @@ async def get_user_from_token( async def get_user_from_token_or_none( token: Annotated[str | None, Depends(oauth2_scheme_optional)], request: Request, + db_session: Annotated[AsyncSession, Depends(get_db_session)], ) -> User | None: if token is None: return None try: - return await get_user_from_token(token, request) + return await get_user_from_token(token, request, db_session) except HTTPException: return None @@ -302,3 +287,29 @@ class UserWithRole: status.HTTP_401_UNAUTHORIZED, f"Not of any required role {', '.join(self.roles)}" ) return user + + +# def hasrole(required_roles: Union[str, list[str]] = []): +# """Decorator for RBAC permissions""" +# required_roles_set: set[str] +# if isinstance(required_roles, str): +# required_roles_set = set([required_roles]) +# else: +# required_roles_set = set(required_roles) +# +# def decorator(func): +# @wraps(func) +# async def wrapper(request=None, *args, **kwargs): +# if request is None: +# raise HTTPException( +# 500, +# "Functions decorated with hasrole must have a request:Request argument", +# ) +# user: User = await get_current_user(request) +# if not any(required_roles_set.intersection(user.roles_as_set)): +# raise HTTPException(status.HTTP_401_UNAUTHORIZED) +# return await func(request, *args, **kwargs) +# +# return wrapper +# +# return decorator diff --git a/src/oidc_test/database.py b/src/oidc_test/database.py index 8d87a48..0b4b63f 100644 --- a/src/oidc_test/database.py +++ b/src/oidc_test/database.py @@ -1,17 +1,26 @@ """Fake in-memory database interface for demo purpose""" import logging +from collections.abc import AsyncGenerator from authlib.oauth2.rfc6749 import OAuth2Token from jwt import PyJWTError -from oidc_test.auth.provider import Provider +from sqlmodel import SQLModel, create_engine, select +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine -from oidc_test.models import User, Role +from oidc_test.auth import provider +from oidc_test.settings import settings +from oidc_test.auth.provider import Provider +from oidc_test.models import User, Role, Token from oidc_test.auth_providers import providers logger = logging.getLogger("oidc-test") +engine = create_async_engine(settings.db.sqla_url) +sync_engine = create_engine(settings.db.sqla_url) + class UserNotInDB(Exception): pass @@ -21,6 +30,11 @@ class TokenNotInDb(Exception): pass +async def get_db_session() -> AsyncGenerator[AsyncSession]: + async with AsyncSession(engine) as db_session: + yield db_session + + class Database: users: dict[str, User] = {} # TODO: key of the token table should be provider: sid @@ -31,20 +45,21 @@ class Database: async def add_user( self, sub: str, - user_info: dict, auth_provider: Provider, - access_token: str, + token: OAuth2Token, + # access_token: str, access_token_decoded: dict | None = None, ) -> User: if access_token_decoded is None: assert auth_provider.name is not None provider = providers[auth_provider.id] try: - access_token_decoded = provider.decode(access_token) + access_token_decoded = provider.decode(token["access_token"]) except PyJWTError: access_token_decoded = {} - user_info["auth_provider_id"] = auth_provider.id - user = User(**user_info) + user_info: dict = token["user_info"] + sub = user_info["sub"] + user = User(auth_provider_id=auth_provider.id, **user_info) user.userinfo = user_info # user.access_token = access_token # user.access_token_decoded = access_token_decoded @@ -63,34 +78,62 @@ class Database: roles.update(r) except KeyError: pass - user.roles = [Role(name=role_name) for role_name in roles] + # user.roles = [Role(name=role_name) for role_name in roles] + user.roles = [] self.users[sub] = user return user - async def get_user(self, sub: str) -> User: - if sub not in self.users: + async def get_user(self, sub: str, db_session: AsyncSession) -> User: + query = select(User).where(User.sub == sub) + user = (await db_session.exec(query)).first() + if user is None: raise UserNotInDB - return self.users[sub] + return user - async def add_token(self, provider: Provider, token: OAuth2Token) -> None: + async def get_or_add_user( + self, sub: str, db_session: AsyncSession, auth_provider: Provider, token: OAuth2Token + ): + if user := self.get_user(sub, db_session): + return user + else: + return await self.add_user(sub=sub, auth_provider=auth_provider, token=token) + + async def add_token( + self, provider: Provider, token: OAuth2Token, db_session: AsyncSession + ) -> None: """Store a token using as key the sid (auth provider's session id) in the id_token""" sid = provider.get_session_key(token["userinfo"]) - self.tokens[sid] = token + if existing_token := await db_session.get(Token, sid): + # The token already exists: update it + # XXX: check is token is different? + existing_token.token = token + db_session.add(existing_token) + await db_session.commit() + else: + token = Token(sid=sid, token=token) + db_session.add(token) + await db_session.commit() async def get_token( - self, - provider: Provider, - sid: str | None, + self, provider: Provider, sid: str | None, db_session: AsyncSession ) -> OAuth2Token: # TODO: key of the token table should be provider: sid assert isinstance(provider, Provider) if sid is None: raise TokenNotInDb - try: - return self.tokens[sid] - except KeyError: + if token := await db_session.get(Token, sid): + return OAuth2Token.from_dict(token.token) + else: raise TokenNotInDb +async def create_db(drop=False): + logger.debug(f"Connect to database with config: {settings.db}") + async with engine.begin() as conn: + if drop: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + + db = Database() diff --git a/src/oidc_test/main.py b/src/oidc_test/main.py index 9f5e746..b101ca9 100644 --- a/src/oidc_test/main.py +++ b/src/oidc_test/main.py @@ -16,6 +16,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware from jwt import PyJWTError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select from starlette.middleware.sessions import SessionMiddleware from authlib.integrations.starlette_client.apps import StarletteOAuth2App from authlib.integrations.base_client import OAuthError @@ -41,7 +43,7 @@ from oidc_test.auth.utils import init_providers from oidc_test.settings import settings from oidc_test.auth_providers import providers from oidc_test.models import User -from oidc_test.database import TokenNotInDb, db +from oidc_test.database import TokenNotInDb, db, create_db, get_db_session from oidc_test.resource_server import resource_server logger = logging.getLogger("oidc-test") @@ -52,6 +54,7 @@ templates = Jinja2Templates(Path(__file__).parent / "templates") @asynccontextmanager async def lifespan(app: FastAPI): assert app is not None + await create_db() init_providers() registry.make_registry() for provider in list(providers.values()): @@ -98,7 +101,7 @@ async def home( "now": datetime.now(), "auth_provider": provider, } - if provider is None or token is None: + if provider is None or token is None or user is None: context["providers"] = providers context["access_token"] = None context["id_token_parsed"] = None @@ -168,6 +171,7 @@ async def login(request: Request, auth_provider_id: str) -> RedirectResponse: async def auth( request: Request, auth_provider_id: str, + db_session: Annotated[AsyncSession, Depends(get_db_session)], ) -> RedirectResponse: """Decrypt the auth token, store it to the session (cookie based) and response to the browser with a redirect to a "welcome user" page. @@ -202,24 +206,30 @@ async def auth( # user_info_from_endpoint = {} # Build and remember the user in the session request.session["user_sub"] = sub - # Store the user in the database, which also verifies the token validity and signature try: - user = await db.add_user( - sub, - user_info=userinfo, - auth_provider=providers[auth_provider_id], - access_token=token["access_token"], - ) - except PyJWTError as err: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail=f"Token invalid: {err.__class__.__name__}", - ) - assert isinstance(user, User) + user = db.get_or_add_user(sub, db_session, auth_provider=provider, token=token) + query = select(User).where(User.sub == sub) + user = (await db_session.exec(query)).first() + assert user is not None + except Exception as err: + # Store the user in the database, which also verifies the token validity and signature + logger.info(f"New user {userinfo}") + try: + user = await db.add_user( + sub, + user_info=userinfo, + auth_provider=providers[auth_provider_id], + access_token=token["access_token"], + ) + except PyJWTError as err: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail=f"Token invalid: {err.__class__.__name__}", + ) # Add the provider session id to the session request.session["sid"] = provider.get_session_key(userinfo) # Add the token to the db because it is used for logout - await db.add_token(provider, token) + await db.add_token(provider, token, db_session) # Send the user to the home: (s)he is authenticated return RedirectResponse(url=request.url_for("home")) else: @@ -255,7 +265,7 @@ async def logout( try: token = await db.get_token(provider, request.session.pop("sid", None)) except TokenNotInDb: - logger.warn("No session in db for the token or no token") + logger.warning("No session in db for the token or no token") return RedirectResponse(request.url_for("home")) logout_url = ( provider_logout_uri @@ -301,6 +311,7 @@ async def refresh( await update_token(provider.id, new_token) return RedirectResponse(url=request.url_for("home")) + # Snippet for running standalone # Mostly useful for the --version option, # as running with uvicorn is easy and provides better flexibility, eg. diff --git a/src/oidc_test/models.py b/src/oidc_test/models.py index 7b6fd0e..7ca8869 100644 --- a/src/oidc_test/models.py +++ b/src/oidc_test/models.py @@ -2,41 +2,49 @@ import logging from functools import cached_property from typing import Any +from sqlalchemy.types import JSON from pydantic import ( + BaseModel, computed_field, + field_validator, AnyHttpUrl, EmailStr, ConfigDict, ) -from sqlmodel import SQLModel, Field +from sqlmodel import Relationship, SQLModel, Field logger = logging.getLogger("oidc-test") -class Role(SQLModel, extra="ignore"): +class Role(SQLModel, table=True): + id: str = Field(primary_key=True) name: str -class UserBase(SQLModel, extra="ignore"): - id: str | None = None +class UserBase(SQLModel): sid: str | None = None name: str | None = None email: EmailStr | None = None - picture: AnyHttpUrl | None = None - roles: list[Role] = [] + picture: str | None = None + + @classmethod + @field_validator("picture") + def _valid_url(cls, v): + return AnyHttpUrl(v) -class User(UserBase): - model_config = ConfigDict(arbitrary_types_allowed=True) # type:ignore - +class User(UserBase, table=True): + # model_config = ConfigDict(arbitrary_types_allowed=True) # type:ignore + id: int | None = Field(primary_key=True, default=None) + roles: list[str] = Field(sa_type=JSON, default=[]) # Relationship(link_model=Role) sub: str = Field( description="""subject id of the user given by the oidc provider, also the key for the database 'table'""", ) - userinfo: dict = {} - access_token: str | None = None - access_token_decoded: dict[str, Any] | None = None auth_provider_id: str + access_token: str | None = None + userinfo: dict[str, Any] = Field(sa_type=JSON) + access_token_decoded: dict[str, Any] | None = Field(sa_type=JSON) @computed_field @cached_property @@ -64,3 +72,8 @@ class User(UserBase): def get_scope(self, verify_signature: bool = True): return self.decode_access_token(verify_signature=verify_signature)["scope"] + + +class Token(SQLModel, table=True): + sid: str | None = Field(primary_key=True, default=None) + token: dict[str, Any] = Field(sa_type=JSON) diff --git a/src/oidc_test/registry.py b/src/oidc_test/registry.py index 794a843..cf29428 100644 --- a/src/oidc_test/registry.py +++ b/src/oidc_test/registry.py @@ -12,6 +12,7 @@ class ProcessResult(BaseModel): model_config = ConfigDict( extra="allow", ) + msg: str | None = None class ProcessError(Exception): diff --git a/src/oidc_test/resource_server.py b/src/oidc_test/resource_server.py index ee4ff10..128585e 100644 --- a/src/oidc_test/resource_server.py +++ b/src/oidc_test/resource_server.py @@ -3,8 +3,7 @@ import logging from authlib.oauth2.rfc6749 import OAuth2Token from httpx import AsyncClient -from jwt.exceptions import ExpiredSignatureError, InvalidTokenError -from fastapi import FastAPI, HTTPException, Depends, Request, status +from fastapi import FastAPI, HTTPException, Depends, status from fastapi.middleware.cors import CORSMiddleware # from starlette.middleware.sessions import SessionMiddleware @@ -116,7 +115,7 @@ async def get_auth_provider_resource( provider: Provider, resource_name: str, token: OAuth2Token | None, user: User ) -> ProcessResult: if token is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token") access_token = token["access_token"] resource = [r for r in provider.resources if r.resource_name == resource_name][0] async with AsyncClient() as client: diff --git a/src/oidc_test/settings.py b/src/oidc_test/settings.py index 3e7001c..94317e0 100644 --- a/src/oidc_test/settings.py +++ b/src/oidc_test/settings.py @@ -86,6 +86,27 @@ class Insecure(BaseModel): skip_verify_signature: bool = False +class DB(BaseModel): + host: str = "localhost" + port: int = 5432 + db: str = "oidc-test" + user: str = "oidc-test" + password: str = "oidc-test" + debug: bool = False + pool_size: int = 10 + max_overflow: int = 10 + echo: bool = False + + @property + def sqla_url(self): + return ( + f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}" + ) + + def get_pg_url(self): + return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}" + + class Settings(BaseSettings): """Settings wil be read from an .env file""" @@ -96,6 +117,7 @@ class Settings(BaseSettings): secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16)) log: bool = False insecure: Insecure = Insecure() + db: DB = DB() cors_origins: list[str] = [] debug_token: bool = False show_token: bool = False diff --git a/uv.lock b/uv.lock index 01b64de..0217f83 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, ] +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 }, +] + [[package]] name = "authlib" version = "1.4.0" @@ -283,6 +299,30 @@ standard = [ { name = "uvicorn", extra = ["standard"] }, ] +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -485,9 +525,11 @@ name = "oidc-fastapi-test" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "asyncpg" }, { name = "authlib" }, { name = "cachetools" }, { name = "fastapi", extra = ["standard"] }, + { name = "greenlet" }, { name = "httpx" }, { name = "itsdangerous" }, { name = "passlib", extra = ["bcrypt"] }, @@ -507,9 +549,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncpg", specifier = ">=0.30.0" }, { name = "authlib", specifier = ">=1.4.0" }, { name = "cachetools", specifier = ">=5.5.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" }, + { name = "greenlet", specifier = ">=3.1.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },