start oauth

This commit is contained in:
sam 2024-04-10 20:25:28 +02:00
parent aeb044d1a0
commit e4cd62d741
14 changed files with 283 additions and 46 deletions

View file

@ -1,24 +0,0 @@
from functools import wraps
from quart import g
from foxnouns.exceptions import ErrorCode, ForbiddenError
def require_auth(*, scope: str | None = None):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
if "user" not in g or "token" not in g:
raise ForbiddenError("Not authenticated", type=ErrorCode.Forbidden)
if scope and not g.token.has_scope(scope):
raise ForbiddenError(
f"Missing scope '{scope}'", type=ErrorCode.MissingScope
)
return await func(*args, **kwargs)
return wrapper
return decorator

View file

@ -1,6 +1,13 @@
from .v2.auth import bp as auth_blueprint
from .v2.auth.discord import bp as discord_auth_blueprint
from .v2.members import bp as members_blueprint from .v2.members import bp as members_blueprint
from .v2.meta import bp as meta_blueprint from .v2.meta import bp as meta_blueprint
from .v2.users import bp as users_blueprint from .v2.users import bp as users_blueprint
from .v2.auth.discord import bp as discord_auth_blueprint
__all__ = [users_blueprint, members_blueprint, meta_blueprint, discord_auth_blueprint] __all__ = [
users_blueprint,
members_blueprint,
meta_blueprint,
discord_auth_blueprint,
auth_blueprint,
]

View file

@ -1,27 +1,50 @@
from datetime import datetime from datetime import datetime
from authlib.integrations.httpx_client import AsyncOAuth2Client
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from quart import Blueprint from quart import Blueprint
from quart_schema import validate_response from quart_schema import validate_response
from foxnouns.settings import BASE_DOMAIN from foxnouns import settings
from foxnouns.db import redis
from foxnouns.db.redis import discord_state_key
from foxnouns.models.user import SelfUserModel from foxnouns.models.user import SelfUserModel
bp = Blueprint("auth_v2", __name__) bp = Blueprint("auth_v2", __name__)
def discord_oauth_client(*, state: str | None = None):
"""Returns a new Discord OAuth client with the given state.
It is the caller's responsibility to check whether the client ID and client secret are set."""
return AsyncOAuth2Client(
settings.DISCORD_CLIENT_ID,
settings.DISCORD_CLIENT_SECRET,
state=state,
)
class URLsResponse(BaseModel): class URLsResponse(BaseModel):
discord: str | None = Field(default=None) discord: str | None = Field(default=None)
google: str | None = Field(default=None) google: str | None = Field(default=None)
tumblr: str | None = Field(default=None) tumblr: str | None = Field(default=None)
@bp.post("/api/v2/auth/urls", host=BASE_DOMAIN) @bp.post("/api/v2/auth/urls", host=settings.BASE_DOMAIN)
@validate_response(URLsResponse, 200) @validate_response(URLsResponse, 200)
async def urls(): async def urls():
# TODO: build authorization URLs + callback URLs, store state in Redis if settings.DISCORD_CLIENT_ID and settings.DISCORD_CLIENT_SECRET:
raise NotImplementedError() client = discord_oauth_client()
discord_url, state = client.create_authorization_url(
"https://discord.com/api/oauth2/authorize"
)
await redis.set(
discord_state_key(state), state, ex=1800
) # Expire after 30 minutes
# TODO: return the other OAuth URLs
return URLsResponse(discord=discord_url)
class OAuthCallbackRequest(BaseModel): class OAuthCallbackRequest(BaseModel):

View file

@ -1,14 +1,16 @@
from quart import Blueprint from quart import Blueprint
from quart_schema import validate_request, validate_response from quart_schema import validate_request, validate_response
from foxnouns.settings import BASE_DOMAIN from foxnouns import settings
from foxnouns.decorators import require_config_key
from . import BaseCallbackResponse, OAuthCallbackRequest from . import BaseCallbackResponse, OAuthCallbackRequest
bp = Blueprint("discord_v2", __name__) bp = Blueprint("discord_v2", __name__)
@bp.post("/api/v2/auth/discord/callback", host=BASE_DOMAIN) @bp.post("/api/v2/auth/discord/callback", host=settings.BASE_DOMAIN)
@require_config_key(keys=[settings.DISCORD_CLIENT_ID, settings.DISCORD_CLIENT_SECRET])
@validate_request(OAuthCallbackRequest) @validate_request(OAuthCallbackRequest)
@validate_response(BaseCallbackResponse) @validate_response(BaseCallbackResponse)
async def discord_callback(data: OAuthCallbackRequest): async def discord_callback(data: OAuthCallbackRequest):

View file

@ -3,10 +3,10 @@ from quart import Blueprint, g
from quart_schema import validate_request, validate_response from quart_schema import validate_request, validate_response
from foxnouns import tasks from foxnouns import tasks
from foxnouns.auth import require_auth
from foxnouns.db import Member from foxnouns.db import Member
from foxnouns.db.aio import async_session from foxnouns.db.aio import async_session
from foxnouns.db.util import user_from_ref from foxnouns.db.util import user_from_ref
from foxnouns.decorators import require_auth
from foxnouns.exceptions import ErrorCode, NotFoundError from foxnouns.exceptions import ErrorCode, NotFoundError
from foxnouns.models.member import FullMemberModel, MemberPatchModel from foxnouns.models.member import FullMemberModel, MemberPatchModel
from foxnouns.settings import BASE_DOMAIN from foxnouns.settings import BASE_DOMAIN

View file

@ -4,11 +4,11 @@ from quart_schema import validate_request, validate_response
from sqlalchemy import select from sqlalchemy import select
from foxnouns import tasks from foxnouns import tasks
from foxnouns.auth import require_auth
from foxnouns.db import User from foxnouns.db import User
from foxnouns.db.aio import async_session from foxnouns.db.aio import async_session
from foxnouns.db.snowflake import Snowflake from foxnouns.db.snowflake import Snowflake
from foxnouns.db.util import create_token, generate_token, is_self, user_from_ref from foxnouns.db.util import create_token, generate_token, is_self, user_from_ref
from foxnouns.decorators import require_auth
from foxnouns.exceptions import ErrorCode, NotFoundError from foxnouns.exceptions import ErrorCode, NotFoundError
from foxnouns.models import BasePatchModel from foxnouns.models import BasePatchModel
from foxnouns.models.user import SelfUserModel, UserModel, check_username from foxnouns.models.user import SelfUserModel, UserModel, check_username

View file

@ -1,5 +1,6 @@
from .base import Base from .base import Base
from .member import Member from .member import Member
from .redis import redis
from .user import AuthMethod, FediverseApp, Token, User from .user import AuthMethod, FediverseApp, Token, User
__all__ = [Base, User, Token, AuthMethod, FediverseApp, Member] __all__ = [Base, User, Token, AuthMethod, FediverseApp, Member, redis]

View file

@ -3,3 +3,7 @@ from redis import asyncio as aioredis
from foxnouns.settings import REDIS_URL from foxnouns.settings import REDIS_URL
redis = aioredis.from_url(REDIS_URL) redis = aioredis.from_url(REDIS_URL)
def discord_state_key(state: str) -> str:
return f"discord-state:{state}"

45
foxnouns/decorators.py Normal file
View file

@ -0,0 +1,45 @@
from functools import wraps
from typing import Any
from quart import g
from foxnouns.exceptions import ErrorCode, ForbiddenError, UnsupportedEndpointError
def require_auth(*, scope: str | None = None):
"""Decorator that requires a token with the given scopes.
If no token is given or the required scopes aren't set on it, execution is aborted."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
if "user" not in g or "token" not in g:
raise ForbiddenError("Not authenticated", type=ErrorCode.Forbidden)
if scope and not g.token.has_scope(scope):
raise ForbiddenError(
f"Missing scope '{scope}'", type=ErrorCode.MissingScope
)
return await func(*args, **kwargs)
return wrapper
return decorator
def require_config_key(*, keys: list[Any]):
"""Decorator that requires one or more config keys to be set.
If any of them are None, execution is aborted."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
for key in keys:
if not key:
raise UnsupportedEndpointError()
return await func(*args, **kwargs)
return wrapper
return decorator

View file

@ -80,3 +80,14 @@ class ForbiddenError(ExpectedError):
def __init__(self, msg: str, type=ErrorCode.Forbidden): def __init__(self, msg: str, type=ErrorCode.Forbidden):
self.type = type self.type = type
super().__init__(msg, type) super().__init__(msg, type)
class UnsupportedEndpointError(ExpectedError):
status_code = 404
def __init__(self):
self.type = ErrorCode.NotFound
super().__init__(
"Endpoint is not supported on this instance",
type=ErrorCode.NotFound,
)

View file

@ -27,6 +27,10 @@ with env.prefixed("MINIO_"):
"REGION": env("REGION", "auto"), "REGION": env("REGION", "auto"),
} }
# Discord OAuth credentials. If these are not set the Discord OAuth endpoints will not work.
DISCORD_CLIENT_ID = env("DISCORD_CLIENT_ID", None)
DISCORD_CLIENT_SECRET = env("DISCORD_CLIENT_SECRET", None)
# The base domain the API is served on. This must be set. # The base domain the API is served on. This must be set.
BASE_DOMAIN = env("BASE_DOMAIN") BASE_DOMAIN = env("BASE_DOMAIN")
# The base domain for short URLs. # The base domain for short URLs.

164
poetry.lock generated
View file

@ -59,6 +59,27 @@ files = [
{file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
] ]
[[package]]
name = "anyio"
version = "4.3.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
{file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
version = "23.1.0" version = "23.1.0"
@ -188,6 +209,21 @@ async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""}
docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"]
test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"]
[[package]]
name = "authlib"
version = "1.3.0"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "Authlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:9637e4de1fb498310a56900b3e2043a206b03cb11c05422014b0302cbc814be3"},
{file = "Authlib-1.3.0.tar.gz", hash = "sha256:959ea62a5b7b5123c5059758296122b57cd2585ae2ed1c0622c21b371ffdae06"},
]
[package.dependencies]
cryptography = "*"
[[package]] [[package]]
name = "billiard" name = "billiard"
version = "4.2.0" version = "4.2.0"
@ -425,6 +461,61 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "cryptography"
version = "42.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"},
{file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"},
{file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"},
{file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"},
{file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"},
{file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"},
{file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"},
{file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"},
{file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"},
{file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"},
{file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"},
{file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"},
{file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"},
{file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"},
{file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"},
{file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"},
{file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"},
{file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"},
{file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"},
{file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"},
{file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"},
{file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"},
{file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"},
{file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"},
{file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"},
{file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"},
{file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"},
{file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"},
{file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"},
{file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"},
{file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"},
{file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"},
]
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
nox = ["nox"]
pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]] [[package]]
name = "environs" name = "environs"
version = "11.0.0" version = "11.0.0"
@ -581,6 +672,53 @@ files = [
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
] ]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
description = "The next generation HTTP client."
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = ">=1.0.0,<2.0.0"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]] [[package]]
name = "hypercorn" name = "hypercorn"
version = "0.16.0" version = "0.16.0"
@ -617,6 +755,18 @@ files = [
{file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"},
] ]
[[package]]
name = "idna"
version = "3.6"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
{file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@ -1304,6 +1454,18 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.28" version = "2.0.28"
@ -1513,4 +1675,4 @@ h11 = ">=0.9.0,<1"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "3013b1d9175f34895133e708e35dea7d3401a07b5869c857e30d0aac41ce7add" content-hash = "b3f536cfeeaa31d6df0ebf4debcffaf80492aca3d4f97a44ef1aa4a62e42b3eb"

View file

@ -26,6 +26,8 @@ quart-cors = "^0.7.0"
minio = "^7.2.5" minio = "^7.2.5"
pyvips = "^2.2.2" pyvips = "^2.2.2"
redis = "^5.0.3" redis = "^5.0.3"
authlib = "^1.3.0"
httpx = "^0.27.0"
[tool.poetry.group.dev] [tool.poetry.group.dev]
optional = true optional = true