diff --git a/.gitignore b/.gitignore index 1a4e9b5..d282cad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ -__pycache__/ -.pytest_cache/ -.env -node_modules -build -.svelte-kit -package -vite.config.js.timestamp-* -vite.config.ts.timestamp-* +__pycache__/ +.pytest_cache/ +.env +node_modules +build +.svelte-kit +package +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/foxnouns/auth.py b/foxnouns/auth.py deleted file mode 100644 index 83d2765..0000000 --- a/foxnouns/auth.py +++ /dev/null @@ -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 diff --git a/foxnouns/blueprints/__init__.py b/foxnouns/blueprints/__init__.py index 492e8af..7f0b0be 100644 --- a/foxnouns/blueprints/__init__.py +++ b/foxnouns/blueprints/__init__.py @@ -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.meta import bp as meta_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, +] diff --git a/foxnouns/blueprints/v2/auth/__init__.py b/foxnouns/blueprints/v2/auth/__init__.py index 6461084..32a26bb 100644 --- a/foxnouns/blueprints/v2/auth/__init__.py +++ b/foxnouns/blueprints/v2/auth/__init__.py @@ -1,27 +1,50 @@ from datetime import datetime +from authlib.integrations.httpx_client import AsyncOAuth2Client from pydantic import BaseModel, Field from quart import Blueprint 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 - 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): discord: str | None = Field(default=None) google: 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) async def urls(): - # TODO: build authorization URLs + callback URLs, store state in Redis - raise NotImplementedError() + if settings.DISCORD_CLIENT_ID and settings.DISCORD_CLIENT_SECRET: + 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): diff --git a/foxnouns/blueprints/v2/auth/discord.py b/foxnouns/blueprints/v2/auth/discord.py index 5f873b7..f641490 100644 --- a/foxnouns/blueprints/v2/auth/discord.py +++ b/foxnouns/blueprints/v2/auth/discord.py @@ -1,14 +1,16 @@ from quart import Blueprint 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 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_response(BaseCallbackResponse) async def discord_callback(data: OAuthCallbackRequest): diff --git a/foxnouns/blueprints/v2/members.py b/foxnouns/blueprints/v2/members.py index 613fa35..614bce4 100644 --- a/foxnouns/blueprints/v2/members.py +++ b/foxnouns/blueprints/v2/members.py @@ -3,10 +3,10 @@ from quart import Blueprint, g from quart_schema import validate_request, validate_response from foxnouns import tasks -from foxnouns.auth import require_auth from foxnouns.db import Member from foxnouns.db.aio import async_session from foxnouns.db.util import user_from_ref +from foxnouns.decorators import require_auth from foxnouns.exceptions import ErrorCode, NotFoundError from foxnouns.models.member import FullMemberModel, MemberPatchModel from foxnouns.settings import BASE_DOMAIN diff --git a/foxnouns/blueprints/v2/users.py b/foxnouns/blueprints/v2/users.py index 2f42cfd..7f1798b 100644 --- a/foxnouns/blueprints/v2/users.py +++ b/foxnouns/blueprints/v2/users.py @@ -4,11 +4,11 @@ from quart_schema import validate_request, validate_response from sqlalchemy import select from foxnouns import tasks -from foxnouns.auth import require_auth from foxnouns.db import User from foxnouns.db.aio import async_session from foxnouns.db.snowflake import Snowflake 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.models import BasePatchModel from foxnouns.models.user import SelfUserModel, UserModel, check_username diff --git a/foxnouns/db/__init__.py b/foxnouns/db/__init__.py index 224bb4c..274527a 100644 --- a/foxnouns/db/__init__.py +++ b/foxnouns/db/__init__.py @@ -1,5 +1,6 @@ from .base import Base from .member import Member +from .redis import redis from .user import AuthMethod, FediverseApp, Token, User -__all__ = [Base, User, Token, AuthMethod, FediverseApp, Member] +__all__ = [Base, User, Token, AuthMethod, FediverseApp, Member, redis] diff --git a/foxnouns/db/redis.py b/foxnouns/db/redis.py index 4791790..61877f8 100644 --- a/foxnouns/db/redis.py +++ b/foxnouns/db/redis.py @@ -3,3 +3,7 @@ from redis import asyncio as aioredis from foxnouns.settings import REDIS_URL redis = aioredis.from_url(REDIS_URL) + + +def discord_state_key(state: str) -> str: + return f"discord-state:{state}" diff --git a/foxnouns/decorators.py b/foxnouns/decorators.py new file mode 100644 index 0000000..4738a49 --- /dev/null +++ b/foxnouns/decorators.py @@ -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 diff --git a/foxnouns/exceptions.py b/foxnouns/exceptions.py index 63739cc..832a3ec 100644 --- a/foxnouns/exceptions.py +++ b/foxnouns/exceptions.py @@ -80,3 +80,14 @@ class ForbiddenError(ExpectedError): def __init__(self, msg: str, type=ErrorCode.Forbidden): self.type = 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, + ) diff --git a/foxnouns/settings.py b/foxnouns/settings.py index 480243d..b89d796 100644 --- a/foxnouns/settings.py +++ b/foxnouns/settings.py @@ -27,6 +27,10 @@ with env.prefixed("MINIO_"): "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. BASE_DOMAIN = env("BASE_DOMAIN") # The base domain for short URLs. diff --git a/poetry.lock b/poetry.lock index 8c56861..ac9f11a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -59,6 +59,27 @@ files = [ {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]] name = "argon2-cffi" 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)"] 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]] name = "billiard" version = "4.2.0" @@ -425,6 +461,61 @@ files = [ {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]] name = "environs" version = "11.0.0" @@ -581,6 +672,53 @@ files = [ {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]] name = "hypercorn" version = "0.16.0" @@ -617,6 +755,18 @@ files = [ {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]] name = "iniconfig" version = "2.0.0" @@ -1304,6 +1454,18 @@ files = [ {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]] name = "sqlalchemy" version = "2.0.28" @@ -1513,4 +1675,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "3013b1d9175f34895133e708e35dea7d3401a07b5869c857e30d0aac41ce7add" +content-hash = "b3f536cfeeaa31d6df0ebf4debcffaf80492aca3d4f97a44ef1aa4a62e42b3eb" diff --git a/pyproject.toml b/pyproject.toml index 7a9e749..026d7af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ quart-cors = "^0.7.0" minio = "^7.2.5" pyvips = "^2.2.2" redis = "^5.0.3" +authlib = "^1.3.0" +httpx = "^0.27.0" [tool.poetry.group.dev] optional = true