Add postgres db (messy)
Some checks failed
/ build (push) Failing after 6s
/ test (push) Failing after 6s

This commit is contained in:
phil 2025-02-17 02:42:38 +01:00
parent 4008036bca
commit fb433e27be
9 changed files with 257 additions and 111 deletions

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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.

View file

@ -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)

View file

@ -12,6 +12,7 @@ class ProcessResult(BaseModel):
model_config = ConfigDict(
extra="allow",
)
msg: str | None = None
class ProcessError(Exception):

View file

@ -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:

View file

@ -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

44
uv.lock generated
View file

@ -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" },