aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-07-03 15:49:08 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-07-03 15:49:14 +0200
commit7784c60c03ec277b456db6e2709383e302d9382b (patch)
treea008def666f04041fe77740693d42cfef89af558
parentfb28c648390464c75ba4903b0e1e0ff2642c8ffb (diff)
downloadserver-7784c60c03ec277b456db6e2709383e302d9382b.tar.gz
server-7784c60c03ec277b456db6e2709383e302d9382b.tar.bz2
server-7784c60c03ec277b456db6e2709383e302d9382b.zip
Added basic oauth flow
-rw-r--r--authentication/backend.py48
-rw-r--r--authentication/endpoints.py87
-rw-r--r--authentication/templates/authorize.html5
-rw-r--r--authentication/templates/base.html63
-rw-r--r--authentication/templates/login.html18
-rw-r--r--conf/__init__.py1
-rw-r--r--conf/local.py1
-rw-r--r--db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py56
-rw-r--r--db/models.py20
-rw-r--r--db/session.py2
-rw-r--r--pyproject.toml4
-rw-r--r--ttun_server/__init__.py7
-rw-r--r--ttun_server/endpoints.py5
-rw-r--r--uv.lock79
14 files changed, 387 insertions, 9 deletions
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 @@
1from datetime import datetime, UTC
2from typing import Optional
3
4from sqlalchemy import update
5from sqlmodel import select
6from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser
7from starlette.requests import HTTPConnection
8
9from db.models import Session, User
10from db.session import get_session_context
11
12
13class AuthBackend(AuthenticationBackend):
14 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]:
15 if "id" not in request.session:
16 return None
17
18 query = (
19 select(Session)
20 .join(User)
21 .where(
22 Session.id == request.session['id']
23 )
24 )
25
26
27 with get_session_context() as db_session:
28 session: Session = db_session.exec(query).first()
29
30 if session is not None:
31 db_session.execute(
32 update(Session).where(Session.id == session.id)
33 )
34 db_session.commit()
35
36 return (
37 AuthCredentials(
38 [
39 "authenticated",
40 *[
41 connection.type for connection in session.user.app_connections
42 ]
43 ]
44 if session is not None
45 else []
46 ),
47 session.user if session is not None else UnauthenticatedUser()
48 )
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 @@
1from typing import Annotated
2from uuid import uuid7
3
4from fastapi import FastAPI, Request, Form, Query, HTTPException
5from pydantic import BaseModel, EmailStr, ValidationError
6from starlette.middleware.authentication import AuthenticationMiddleware
7from starlette.middleware.sessions import SessionMiddleware
8from starlette.responses import HTMLResponse, RedirectResponse
9from starlette.templating import Jinja2Templates
10
11import conf
12from authentication.backend import AuthBackend
13from authentication.utils import bcrypt_sha256_verify
14from db.models import User, Session, ClientApplication
15from db.session import get_session_context
16from proxy.utils import get_path_with_query_string
17
18authentication = FastAPI()
19authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
20
21oauth = FastAPI()
22oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend())
23oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
24
25templates = Jinja2Templates(directory='authentication/templates')
26
27class LoginForm(BaseModel):
28 username: EmailStr
29 password: str
30
31async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None):
32 return templates.TemplateResponse(request, 'login.html', context={
33 'form': form,
34 'errors': errors,
35 })
36
37@authentication.get('/login/', response_class=HTMLResponse)
38async def login(request: Request):
39 return await base_login(request)
40
41@authentication.post('/login/', response_class=HTMLResponse)
42async def login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', redirect: Annotated[str, Query()] = None):
43 form = None
44 errors = {}
45
46 try:
47 form = LoginForm.model_validate({
48 'username': username,
49 'password': password
50 })
51 with get_session_context() as db_session:
52 user = db_session.query(User).filter(User.username == form.username).first()
53
54 if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None:
55 errors.update({'password': 'Invalid username or password'})
56
57 with get_session_context() as db_session:
58 session = Session(id=uuid7(), user=user)
59 db_session.add(session)
60
61 if redirect is not None:
62 return RedirectResponse(redirect)
63
64 except ValidationError as exc:
65 errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() })
66
67 return await base_login(request, form, errors)
68
69
70async def base_authorize(request: Request, client_id: str, redirect_uri: str):
71 with get_session_context() as session:
72 app = session.query(ClientApplication).where(ClientApplication.client_id == client_id).first()
73
74 if app is None:
75 raise HTTPException(status_code=404, detail='Invalid client id')
76
77 if not request.user.is_authenticated:
78 return RedirectResponse(f'/auth/login/?redirect={get_path_with_query_string(request)}')
79
80
81@oauth.get('/authorize/')
82async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]):
83 return await base_authorize(request, client_id, redirect_uri)
84
85@oauth.post('/authorize/')
86async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]):
87 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 @@
1{% extends "./base.html" %}
2
3{% block content %}
4 <h1>AUTH</h1>
5{% 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 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <title>TTUN</title>
6 <style>
7/* http://meyerweb.com/eric/tools/css/reset/
8 v2.0 | 20110126
9 License: none (public domain)
10*/
11
12html, body, div, span, applet, object, iframe,
13h1, h2, h3, h4, h5, h6, p, blockquote, pre,
14a, abbr, acronym, address, big, cite, code,
15del, dfn, em, img, ins, kbd, q, s, samp,
16small, strike, strong, sub, sup, tt, var,
17b, u, i, center,
18dl, dt, dd, ol, ul, li,
19fieldset, form, label, legend,
20table, caption, tbody, tfoot, thead, tr, th, td,
21article, aside, canvas, details, embed,
22figure, figcaption, footer, header, hgroup,
23menu, nav, output, ruby, section, summary,
24time, mark, audio, video {
25 margin: 0;
26 padding: 0;
27 border: 0;
28 font-size: 100%;
29 font: inherit;
30 vertical-align: baseline;
31}
32/* HTML5 display-role reset for older browsers */
33article, aside, details, figcaption, figure,
34footer, header, hgroup, menu, nav, section {
35 display: block;
36}
37body {
38 line-height: 1;
39}
40ol, ul {
41 list-style: none;
42}
43blockquote, q {
44 quotes: none;
45}
46blockquote:before, blockquote:after,
47q:before, q:after {
48 content: '';
49 content: none;
50}
51table {
52 border-collapse: collapse;
53 border-spacing: 0;
54}
55 </style>
56</head>
57<body>
58 <div>
59
60 </div>
61 {% block content %}{% endblock %}
62</body>
63</html>
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 @@
1{% extends "./base.html" %}
2
3{% block content %}
4 <form method="post">
5
6 <input type="email" name="username" {% if form %}value="{{ form.username }}"{% endif %} placeholder="username" />
7 {% if errors and errors.username %}
8 {{ errors.username }}
9 {% endif %}
10
11
12 <input type="password" name="password" />
13 {% if errors and errors.password%}
14 {{ errors.password }}
15 {% endif %}
16 <button type="submit">Login</button>
17 </form>
18{% 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:
6 pass 6 pass
7 7
8DATABASE_URL = os.environ.get('DATABASE_URL') 8DATABASE_URL = os.environ.get('DATABASE_URL')
9SECRET_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 @@
1import os 1import os
2 2
3os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun') 3os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun')
4os.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 @@
1"""Added more fields and session
2
3Revision ID: 69d8b4938a12
4Revises: ac4fbf1fea31
5Create Date: 2026-07-03 14:40:55.628816
6
7"""
8from typing import Sequence, Union
9
10from alembic import op
11import sqlalchemy as sa
12import sqlmodel
13
14
15# revision identifiers, used by Alembic.
16revision: str = '69d8b4938a12'
17down_revision: Union[str, Sequence[str], None] = 'ac4fbf1fea31'
18branch_labels: Union[str, Sequence[str], None] = None
19depends_on: Union[str, Sequence[str], None] = None
20
21
22def upgrade() -> None:
23 """Upgrade schema."""
24 # ### commands auto generated by Alembic - please adjust! ###
25 op.create_table('session',
26 sa.Column('id', sa.Uuid(), nullable=False),
27 sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
28 sa.Column('updated', sa.DateTime(timezone=True), nullable=True),
29 sa.Column('user_id', sa.Uuid(), nullable=False),
30 sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
31 sa.PrimaryKeyConstraint('id')
32 )
33 op.add_column('authcode', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
34 op.add_column('authcode', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
35 op.add_column('authtoken', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
36 op.add_column('authtoken', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
37 op.add_column('clientapplication', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
38 op.add_column('clientapplication', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
39 op.add_column('user', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
40 op.add_column('user', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
41 # ### end Alembic commands ###
42
43
44def downgrade() -> None:
45 """Downgrade schema."""
46 # ### commands auto generated by Alembic - please adjust! ###
47 op.drop_column('user', 'updated')
48 op.drop_column('user', 'created')
49 op.drop_column('clientapplication', 'updated')
50 op.drop_column('clientapplication', 'created')
51 op.drop_column('authtoken', 'updated')
52 op.drop_column('authtoken', 'created')
53 op.drop_column('authcode', 'updated')
54 op.drop_column('authcode', 'created')
55 op.drop_table('session')
56 # ### 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 @@
1from datetime import datetime, UTC
1from uuid import uuid4, UUID, uuid7 2from uuid import uuid4, UUID, uuid7
2 3
3from sqlmodel import SQLModel, Field 4from sqlalchemy import DateTime
5from sqlmodel import SQLModel, Field, Relationship
4from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin 6from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin
5 7
6from authentication.utils import bcrypt_sha256_hash 8from authentication.utils import bcrypt_sha256_hash
@@ -8,6 +10,19 @@ from authentication.utils import bcrypt_sha256_hash
8 10
9class BaseModel(SQLModel): 11class BaseModel(SQLModel):
10 id: UUID | None = Field(primary_key=True, default_factory=uuid7) 12 id: UUID | None = Field(primary_key=True, default_factory=uuid7)
13 created: datetime = Field(
14 default_factory=lambda: datetime.now(tz=UTC),
15 sa_type=DateTime(timezone=True),
16 )
17 updated: datetime = Field(
18 default=None,
19 nullable=True,
20 sa_column_kwargs={
21 'onupdate': lambda: datetime.now(tz=UTC),
22 },
23 sa_type=DateTime(timezone=True),
24 )
25
11 26
12class User(BaseModel, table=True): 27class User(BaseModel, table=True):
13 username: str = Field(unique=True, index=True) 28 username: str = Field(unique=True, index=True)
@@ -18,6 +33,9 @@ class User(BaseModel, table=True):
18 def set_password(self, value): 33 def set_password(self, value):
19 self.password = bcrypt_sha256_hash(value) 34 self.password = bcrypt_sha256_hash(value)
20 35
36class Session(BaseModel, table=True):
37 user_id: UUID = Field(foreign_key='user.id', exclude=True)
38 user: User = Relationship()
21 39
22class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): 40class ClientApplication(BaseModel, OAuth2ClientMixin, table=True):
23 name: str 41 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 @@
1from contextlib import contextmanager 1from contextlib import contextmanager
2 2
3from sqlalchemy import create_engine
4from sqlalchemy.orm import sessionmaker, scoped_session 3from sqlalchemy.orm import sessionmaker, scoped_session
4from sqlmodel import create_engine
5from sqlmodel import Session 5from sqlmodel import Session
6 6
7from conf import DATABASE_URL 7from 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 = [
6 "alembic>=1.18.5", 6 "alembic>=1.18.5",
7 "authlib>=1.7.2", 7 "authlib>=1.7.2",
8 "bcrypt>=5.0.0", 8 "bcrypt>=5.0.0",
9 "csrfmiddleware>=1.3",
9 "fastapi[standard]>=0.138.2", 10 "fastapi[standard]>=0.138.2",
11 "itsdangerous>=2.2.0",
12 "jinja2>=3.1.6",
10 "passlib>=1.7.4", 13 "passlib>=1.7.4",
11 "psycopg2>=2.9.12", 14 "psycopg2>=2.9.12",
15 "python-multipart>=0.0.32",
12 "redis[hiredis]~=7.4.0", 16 "redis[hiredis]~=7.4.0",
13 "sqlmodel>=0.0.39", 17 "sqlmodel>=0.0.39",
14 "uvicorn[standard]~=0.44.0", 18 "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
2import os 2import os
3 3
4from fastapi import FastAPI 4from fastapi import FastAPI
5from starlette.routing import Host, Route, WebSocketRoute 5from starlette.routing import Host, Route, WebSocketRoute, Mount
6 6
7from authentication.endpoints import authentication, oauth
7from proxy import app as proxy_app 8from proxy import app as proxy_app
8from ttun_server.endpoints import health, base_endpoints 9from ttun_server.endpoints import health
9from ttun_server.websockets import tunnel 10from ttun_server.websockets import tunnel
10 11
11logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) 12logging.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')))
13app = FastAPI( 14app = FastAPI(
14 debug=True, 15 debug=True,
15 routes=[ 16 routes=[
17 Mount('/auth/', app=authentication),
18 Mount('/oauth/', app=oauth),
16 WebSocketRoute('/tunnel/', endpoint=tunnel), 19 WebSocketRoute('/tunnel/', endpoint=tunnel),
17 Route('/health/', endpoint=health), 20 Route('/health/', endpoint=health),
18 Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) 21 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 @@
1from fastapi import FastAPI
2
3base_endpoints = FastAPI()
4
5@base_endpoints.get('/health/')
6async def health(): 1async def health():
7 return 'OK' 2 return 'OK'
diff --git a/uv.lock b/uv.lock
index 83840ef..d63779e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -111,6 +111,15 @@ wheels = [
111] 111]
112 112
113[[package]] 113[[package]]
114name = "beaker"
115version = "1.14.1"
116source = { registry = "https://pypi.org/simple" }
117sdist = { 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" }
118wheels = [
119 { 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" },
120]
121
122[[package]]
114name = "certifi" 123name = "certifi"
115version = "2026.6.17" 124version = "2026.6.17"
116source = { registry = "https://pypi.org/simple" } 125source = { registry = "https://pypi.org/simple" }
@@ -224,6 +233,17 @@ wheels = [
224] 233]
225 234
226[[package]] 235[[package]]
236name = "csrfmiddleware"
237version = "1.3"
238source = { registry = "https://pypi.org/simple" }
239dependencies = [
240 { name = "beaker" },
241 { name = "paste" },
242 { name = "webob" },
243]
244sdist = { 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" }
245
246[[package]]
227name = "detect-installer" 247name = "detect-installer"
228version = "0.1.0" 248version = "0.1.0"
229source = { registry = "https://pypi.org/simple" } 249source = { registry = "https://pypi.org/simple" }
@@ -498,6 +518,15 @@ wheels = [
498] 518]
499 519
500[[package]] 520[[package]]
521name = "itsdangerous"
522version = "2.2.0"
523source = { registry = "https://pypi.org/simple" }
524sdist = { 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" }
525wheels = [
526 { 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" },
527]
528
529[[package]]
501name = "jinja2" 530name = "jinja2"
502version = "3.1.6" 531version = "3.1.6"
503source = { registry = "https://pypi.org/simple" } 532source = { registry = "https://pypi.org/simple" }
@@ -522,6 +551,15 @@ wheels = [
522] 551]
523 552
524[[package]] 553[[package]]
554name = "legacy-cgi"
555version = "2.6.4"
556source = { registry = "https://pypi.org/simple" }
557sdist = { 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" }
558wheels = [
559 { 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" },
560]
561
562[[package]]
525name = "mako" 563name = "mako"
526version = "1.3.12" 564version = "1.3.12"
527source = { registry = "https://pypi.org/simple" } 565source = { registry = "https://pypi.org/simple" }
@@ -594,6 +632,18 @@ wheels = [
594] 632]
595 633
596[[package]] 634[[package]]
635name = "paste"
636version = "3.10.1"
637source = { registry = "https://pypi.org/simple" }
638dependencies = [
639 { name = "setuptools" },
640]
641sdist = { 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" }
642wheels = [
643 { 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" },
644]
645
646[[package]]
597name = "psycopg2" 647name = "psycopg2"
598version = "2.9.12" 648version = "2.9.12"
599source = { registry = "https://pypi.org/simple" } 649source = { registry = "https://pypi.org/simple" }
@@ -845,6 +895,15 @@ wheels = [
845] 895]
846 896
847[[package]] 897[[package]]
898name = "setuptools"
899version = "82.0.1"
900source = { registry = "https://pypi.org/simple" }
901sdist = { 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" }
902wheels = [
903 { 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" },
904]
905
906[[package]]
848name = "shellingham" 907name = "shellingham"
849version = "1.5.4" 908version = "1.5.4"
850source = { registry = "https://pypi.org/simple" } 909source = { registry = "https://pypi.org/simple" }
@@ -914,9 +973,13 @@ dependencies = [
914 { name = "alembic" }, 973 { name = "alembic" },
915 { name = "authlib" }, 974 { name = "authlib" },
916 { name = "bcrypt" }, 975 { name = "bcrypt" },
976 { name = "csrfmiddleware" },
917 { name = "fastapi", extra = ["standard"] }, 977 { name = "fastapi", extra = ["standard"] },
978 { name = "itsdangerous" },
979 { name = "jinja2" },
918 { name = "passlib" }, 980 { name = "passlib" },
919 { name = "psycopg2" }, 981 { name = "psycopg2" },
982 { name = "python-multipart" },
920 { name = "redis", extra = ["hiredis"] }, 983 { name = "redis", extra = ["hiredis"] },
921 { name = "sqlmodel" }, 984 { name = "sqlmodel" },
922 { name = "uvicorn", extra = ["standard"] }, 985 { name = "uvicorn", extra = ["standard"] },
@@ -927,9 +990,13 @@ requires-dist = [
927 { name = "alembic", specifier = ">=1.18.5" }, 990 { name = "alembic", specifier = ">=1.18.5" },
928 { name = "authlib", specifier = ">=1.7.2" }, 991 { name = "authlib", specifier = ">=1.7.2" },
929 { name = "bcrypt", specifier = ">=5.0.0" }, 992 { name = "bcrypt", specifier = ">=5.0.0" },
993 { name = "csrfmiddleware", specifier = ">=1.3" },
930 { name = "fastapi", extras = ["standard"], specifier = ">=0.138.2" }, 994 { name = "fastapi", extras = ["standard"], specifier = ">=0.138.2" },
995 { name = "itsdangerous", specifier = ">=2.2.0" },
996 { name = "jinja2", specifier = ">=3.1.6" },
931 { name = "passlib", specifier = ">=1.7.4" }, 997 { name = "passlib", specifier = ">=1.7.4" },
932 { name = "psycopg2", specifier = ">=2.9.12" }, 998 { name = "psycopg2", specifier = ">=2.9.12" },
999 { name = "python-multipart", specifier = ">=0.0.32" },
933 { name = "redis", extras = ["hiredis"], specifier = "~=7.4.0" }, 1000 { name = "redis", extras = ["hiredis"], specifier = "~=7.4.0" },
934 { name = "sqlmodel", specifier = ">=0.0.39" }, 1001 { name = "sqlmodel", specifier = ">=0.0.39" },
935 { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, 1002 { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" },
@@ -1059,6 +1126,18 @@ wheels = [
1059] 1126]
1060 1127
1061[[package]] 1128[[package]]
1129name = "webob"
1130version = "1.8.10"
1131source = { registry = "https://pypi.org/simple" }
1132dependencies = [
1133 { name = "legacy-cgi" },
1134]
1135sdist = { 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" }
1136wheels = [
1137 { 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" },
1138]
1139
1140[[package]]
1062name = "websockets" 1141name = "websockets"
1063version = "16.0" 1142version = "16.0"
1064source = { registry = "https://pypi.org/simple" } 1143source = { registry = "https://pypi.org/simple" }