From 7784c60c03ec277b456db6e2709383e302d9382b Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Fri, 3 Jul 2026 15:49:08 +0200 Subject: Added basic oauth flow --- authentication/backend.py | 48 ++++++++++++ authentication/endpoints.py | 87 ++++++++++++++++++++++ authentication/templates/authorize.html | 5 ++ authentication/templates/base.html | 63 ++++++++++++++++ authentication/templates/login.html | 18 +++++ conf/__init__.py | 1 + conf/local.py | 1 + ...0-69d8b4938a12_added_more_fields_and_session.py | 56 ++++++++++++++ db/models.py | 20 ++++- db/session.py | 2 +- pyproject.toml | 4 + ttun_server/__init__.py | 7 +- ttun_server/endpoints.py | 5 -- uv.lock | 79 ++++++++++++++++++++ 14 files changed, 387 insertions(+), 9 deletions(-) create mode 100644 authentication/backend.py create mode 100644 authentication/endpoints.py create mode 100644 authentication/templates/authorize.html create mode 100644 authentication/templates/base.html create mode 100644 authentication/templates/login.html create mode 100644 db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py diff --git a/authentication/backend.py b/authentication/backend.py new file mode 100644 index 0000000..29408e3 --- /dev/null +++ b/authentication/backend.py @@ -0,0 +1,48 @@ +from datetime import datetime, UTC +from typing import Optional + +from sqlalchemy import update +from sqlmodel import select +from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser +from starlette.requests import HTTPConnection + +from db.models import Session, User +from db.session import get_session_context + + +class AuthBackend(AuthenticationBackend): + async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: + if "id" not in request.session: + return None + + query = ( + select(Session) + .join(User) + .where( + Session.id == request.session['id'] + ) + ) + + + with get_session_context() as db_session: + session: Session = db_session.exec(query).first() + + if session is not None: + db_session.execute( + update(Session).where(Session.id == session.id) + ) + db_session.commit() + + return ( + AuthCredentials( + [ + "authenticated", + *[ + connection.type for connection in session.user.app_connections + ] + ] + if session is not None + else [] + ), + session.user if session is not None else UnauthenticatedUser() + ) diff --git a/authentication/endpoints.py b/authentication/endpoints.py new file mode 100644 index 0000000..ebdc7a1 --- /dev/null +++ b/authentication/endpoints.py @@ -0,0 +1,87 @@ +from typing import Annotated +from uuid import uuid7 + +from fastapi import FastAPI, Request, Form, Query, HTTPException +from pydantic import BaseModel, EmailStr, ValidationError +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.templating import Jinja2Templates + +import conf +from authentication.backend import AuthBackend +from authentication.utils import bcrypt_sha256_verify +from db.models import User, Session, ClientApplication +from db.session import get_session_context +from proxy.utils import get_path_with_query_string + +authentication = FastAPI() +authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) + +oauth = FastAPI() +oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend()) +oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) + +templates = Jinja2Templates(directory='authentication/templates') + +class LoginForm(BaseModel): + username: EmailStr + password: str + +async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): + return templates.TemplateResponse(request, 'login.html', context={ + 'form': form, + 'errors': errors, + }) + +@authentication.get('/login/', response_class=HTMLResponse) +async def login(request: Request): + return await base_login(request) + +@authentication.post('/login/', response_class=HTMLResponse) +async def login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', redirect: Annotated[str, Query()] = None): + form = None + errors = {} + + try: + form = LoginForm.model_validate({ + 'username': username, + 'password': password + }) + with get_session_context() as db_session: + user = db_session.query(User).filter(User.username == form.username).first() + + if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: + errors.update({'password': 'Invalid username or password'}) + + with get_session_context() as db_session: + session = Session(id=uuid7(), user=user) + db_session.add(session) + + if redirect is not None: + return RedirectResponse(redirect) + + except ValidationError as exc: + errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) + + return await base_login(request, form, errors) + + +async def base_authorize(request: Request, client_id: str, redirect_uri: str): + with get_session_context() as session: + app = session.query(ClientApplication).where(ClientApplication.client_id == client_id).first() + + if app is None: + raise HTTPException(status_code=404, detail='Invalid client id') + + if not request.user.is_authenticated: + return RedirectResponse(f'/auth/login/?redirect={get_path_with_query_string(request)}') + + +@oauth.get('/authorize/') +async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): + return await base_authorize(request, client_id, redirect_uri) + +@oauth.post('/authorize/') +async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): + return await base_authorize(request, client_id, redirect_uri) diff --git a/authentication/templates/authorize.html b/authentication/templates/authorize.html new file mode 100644 index 0000000..1ed6506 --- /dev/null +++ b/authentication/templates/authorize.html @@ -0,0 +1,5 @@ +{% extends "./base.html" %} + +{% block content %} +

AUTH

+{% endblock %} diff --git a/authentication/templates/base.html b/authentication/templates/base.html new file mode 100644 index 0000000..47d007e --- /dev/null +++ b/authentication/templates/base.html @@ -0,0 +1,63 @@ + + + + + TTUN + + + +
+ +
+ {% block content %}{% endblock %} + + diff --git a/authentication/templates/login.html b/authentication/templates/login.html new file mode 100644 index 0000000..fcba5cd --- /dev/null +++ b/authentication/templates/login.html @@ -0,0 +1,18 @@ +{% extends "./base.html" %} + +{% block content %} +
+ + + {% if errors and errors.username %} + {{ errors.username }} + {% endif %} + + + + {% if errors and errors.password%} + {{ errors.password }} + {% endif %} + +
+{% endblock %} diff --git a/conf/__init__.py b/conf/__init__.py index c08dcee..bcdb9c5 100644 --- a/conf/__init__.py +++ b/conf/__init__.py @@ -6,3 +6,4 @@ except ImportError: pass DATABASE_URL = os.environ.get('DATABASE_URL') +SECRET_KEY = os.environ.get('SECRET_KEY') diff --git a/conf/local.py b/conf/local.py index 608e0e5..19b304e 100644 --- a/conf/local.py +++ b/conf/local.py @@ -1,3 +1,4 @@ import os os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun') +os.environ.setdefault('SECRET_KEY', 'secret') diff --git a/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py new file mode 100644 index 0000000..ec5031b --- /dev/null +++ b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py @@ -0,0 +1,56 @@ +"""Added more fields and session + +Revision ID: 69d8b4938a12 +Revises: ac4fbf1fea31 +Create Date: 2026-07-03 14:40:55.628816 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '69d8b4938a12' +down_revision: Union[str, Sequence[str], None] = 'ac4fbf1fea31' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('session', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), + sa.Column('updated', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('authcode', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + op.add_column('authcode', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) + op.add_column('authtoken', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + op.add_column('authtoken', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) + op.add_column('clientapplication', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + op.add_column('clientapplication', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) + op.add_column('user', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + op.add_column('user', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'updated') + op.drop_column('user', 'created') + op.drop_column('clientapplication', 'updated') + op.drop_column('clientapplication', 'created') + op.drop_column('authtoken', 'updated') + op.drop_column('authtoken', 'created') + op.drop_column('authcode', 'updated') + op.drop_column('authcode', 'created') + op.drop_table('session') + # ### end Alembic commands ### diff --git a/db/models.py b/db/models.py index e758671..7e0afb0 100644 --- a/db/models.py +++ b/db/models.py @@ -1,6 +1,8 @@ +from datetime import datetime, UTC from uuid import uuid4, UUID, uuid7 -from sqlmodel import SQLModel, Field +from sqlalchemy import DateTime +from sqlmodel import SQLModel, Field, Relationship from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin from authentication.utils import bcrypt_sha256_hash @@ -8,6 +10,19 @@ from authentication.utils import bcrypt_sha256_hash class BaseModel(SQLModel): id: UUID | None = Field(primary_key=True, default_factory=uuid7) + created: datetime = Field( + default_factory=lambda: datetime.now(tz=UTC), + sa_type=DateTime(timezone=True), + ) + updated: datetime = Field( + default=None, + nullable=True, + sa_column_kwargs={ + 'onupdate': lambda: datetime.now(tz=UTC), + }, + sa_type=DateTime(timezone=True), + ) + class User(BaseModel, table=True): username: str = Field(unique=True, index=True) @@ -18,6 +33,9 @@ class User(BaseModel, table=True): def set_password(self, value): self.password = bcrypt_sha256_hash(value) +class Session(BaseModel, table=True): + user_id: UUID = Field(foreign_key='user.id', exclude=True) + user: User = Relationship() class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): name: str diff --git a/db/session.py b/db/session.py index ec700bd..82c0211 100644 --- a/db/session.py +++ b/db/session.py @@ -1,7 +1,7 @@ from contextlib import contextmanager -from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, scoped_session +from sqlmodel import create_engine from sqlmodel import Session from conf import DATABASE_URL diff --git a/pyproject.toml b/pyproject.toml index 7355e22..6a3117f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,13 @@ dependencies = [ "alembic>=1.18.5", "authlib>=1.7.2", "bcrypt>=5.0.0", + "csrfmiddleware>=1.3", "fastapi[standard]>=0.138.2", + "itsdangerous>=2.2.0", + "jinja2>=3.1.6", "passlib>=1.7.4", "psycopg2>=2.9.12", + "python-multipart>=0.0.32", "redis[hiredis]~=7.4.0", "sqlmodel>=0.0.39", "uvicorn[standard]~=0.44.0", diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py index d54227a..4af6ca0 100644 --- a/ttun_server/__init__.py +++ b/ttun_server/__init__.py @@ -2,10 +2,11 @@ import logging import os from fastapi import FastAPI -from starlette.routing import Host, Route, WebSocketRoute +from starlette.routing import Host, Route, WebSocketRoute, Mount +from authentication.endpoints import authentication, oauth from proxy import app as proxy_app -from ttun_server.endpoints import health, base_endpoints +from ttun_server.endpoints import health from ttun_server.websockets import tunnel logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) @@ -13,6 +14,8 @@ logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) app = FastAPI( debug=True, routes=[ + Mount('/auth/', app=authentication), + Mount('/oauth/', app=oauth), WebSocketRoute('/tunnel/', endpoint=tunnel), Route('/health/', endpoint=health), Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) diff --git a/ttun_server/endpoints.py b/ttun_server/endpoints.py index 51c17aa..61e3f21 100644 --- a/ttun_server/endpoints.py +++ b/ttun_server/endpoints.py @@ -1,7 +1,2 @@ -from fastapi import FastAPI - -base_endpoints = FastAPI() - -@base_endpoints.get('/health/') async def health(): return 'OK' diff --git a/uv.lock b/uv.lock index 83840ef..d63779e 100644 --- a/uv.lock +++ b/uv.lock @@ -110,6 +110,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "beaker" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/6b/3cd3dcf40417e3be31a3a2257957144b0c058ffaf9ca32d2c83c85567cb6/beaker-1.14.1.tar.gz", hash = "sha256:886f52a51810703fdbc0a3e54fca40886288ff530b2070582edce72bf1945447", size = 39076, upload-time = "2026-05-30T12:02:22.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/32/9ec40a82a4831ec8c280756e3312b3bbd95754a74a69574a240274318f35/beaker-1.14.1-py3-none-any.whl", hash = "sha256:b435a4f5ac67d0367e02351ef7ce8df89e74b6450f1f4c3fb981c6d8c4071213", size = 47639, upload-time = "2026-05-30T12:02:21.837Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -223,6 +232,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] +[[package]] +name = "csrfmiddleware" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beaker" }, + { name = "paste" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/cf/76b75cdb5ab7496c8ac24fd2fe6e1c4a7caa988cca19d74895d308e816db/csrfmiddleware-1.3.tar.gz", hash = "sha256:5315a188c72d99914f3899449479fa6ac62c55447ece9decdf7339bea08849e7", size = 3521, upload-time = "2009-02-20T20:45:43.803Z" } + [[package]] name = "detect-installer" version = "0.1.0" @@ -497,6 +517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -521,6 +550,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/80/d1b30336582cced4dce0dae776508a6011723e32f907bc7a702c0b25890a/joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5", size = 70426, upload-time = "2026-06-29T09:03:09.393Z" }, ] +[[package]] +name = "legacy-cgi" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, +] + [[package]] name = "mako" version = "1.3.12" @@ -593,6 +631,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, ] +[[package]] +name = "paste" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/1c/6bc9040bf9b4cfc9334f66d2738f952384c106c48882adf6097fed3da966/paste-3.10.1.tar.gz", hash = "sha256:1c3d12065a5e8a7a18c0c7be1653a97cf38cc3e9a5a0c8334a9dd992d3a05e4a", size = 652629, upload-time = "2024-05-01T11:41:08.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/14/032895c25726a859bf48b8ed68944c3efc7a3decd920533ed929f12f08a1/Paste-3.10.1-py3-none-any.whl", hash = "sha256:995e9994b6a94a2bdd8bd9654fb70ca3946ffab75442468bacf31b4d06481c3d", size = 289253, upload-time = "2024-05-01T11:41:05.31Z" }, +] + [[package]] name = "psycopg2" version = "2.9.12" @@ -844,6 +894,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -914,9 +973,13 @@ dependencies = [ { name = "alembic" }, { name = "authlib" }, { name = "bcrypt" }, + { name = "csrfmiddleware" }, { name = "fastapi", extra = ["standard"] }, + { name = "itsdangerous" }, + { name = "jinja2" }, { name = "passlib" }, { name = "psycopg2" }, + { name = "python-multipart" }, { name = "redis", extra = ["hiredis"] }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, @@ -927,9 +990,13 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, { name = "authlib", specifier = ">=1.7.2" }, { name = "bcrypt", specifier = ">=5.0.0" }, + { name = "csrfmiddleware", specifier = ">=1.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.138.2" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "passlib", specifier = ">=1.7.4" }, { name = "psycopg2", specifier = ">=2.9.12" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "redis", extras = ["hiredis"], specifier = "~=7.4.0" }, { name = "sqlmodel", specifier = ">=0.0.39" }, { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, @@ -1058,6 +1125,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] +[[package]] +name = "webob" +version = "1.8.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "legacy-cgi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f9/974eafebfd0bd442b8848899fe7d30675c93f750c313e1a6fe61acbde1e3/webob-1.8.10.tar.gz", hash = "sha256:1c963a11f307bc3f624fbab9dde737701eae255f32981b7a5486a88db1767c2b", size = 280796, upload-time = "2026-06-02T19:56:47.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/21/fce134877fb6fc6ad3c464e4a07ede0ee9219f705d26a981ae58ea36ca13/webob-1.8.10-py2.py3-none-any.whl", hash = "sha256:e68ad87fda378191081965ab02a185391c26e4e926adec855c3b0286a8369d49", size = 115825, upload-time = "2026-06-02T19:56:44.765Z" }, +] + [[package]] name = "websockets" version = "16.0" -- cgit v1.2.3