supython 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/auth/router.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
|
6
|
+
from fastapi.responses import RedirectResponse
|
|
7
|
+
|
|
8
|
+
from .. import db, tokens
|
|
9
|
+
from ..settings import get_settings
|
|
10
|
+
from . import ratelimit, service
|
|
11
|
+
from .schemas import (
|
|
12
|
+
MagicLinkRequest,
|
|
13
|
+
OtpRequest,
|
|
14
|
+
OtpVerifyRequest,
|
|
15
|
+
RecoverRequest,
|
|
16
|
+
RecoverVerifyRequest,
|
|
17
|
+
RefreshRequest,
|
|
18
|
+
SignUpRequest,
|
|
19
|
+
TokenRequest,
|
|
20
|
+
TokenResponse,
|
|
21
|
+
UserResponse,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/auth/v1", tags=["auth"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_token_response(
|
|
28
|
+
user: UserResponse, access: str, refresh: str, ttl: int
|
|
29
|
+
) -> TokenResponse:
|
|
30
|
+
return TokenResponse(
|
|
31
|
+
access_token=access,
|
|
32
|
+
expires_in=ttl,
|
|
33
|
+
refresh_token=refresh,
|
|
34
|
+
user=user,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _auth_error(exc: service.AuthError) -> HTTPException:
|
|
39
|
+
return HTTPException(
|
|
40
|
+
status_code=exc.status,
|
|
41
|
+
detail={"code": exc.code, "message": exc.message},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _current_user_id(
|
|
46
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
47
|
+
) -> UUID:
|
|
48
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
49
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
|
|
50
|
+
token = authorization.split(" ", 1)[1]
|
|
51
|
+
try:
|
|
52
|
+
claims = tokens.decode_access_token(token)
|
|
53
|
+
except jwt.PyJWTError as exc:
|
|
54
|
+
raise HTTPException(
|
|
55
|
+
status.HTTP_401_UNAUTHORIZED, f"Invalid token: {exc}"
|
|
56
|
+
) from exc
|
|
57
|
+
try:
|
|
58
|
+
return UUID(claims["sub"])
|
|
59
|
+
except (KeyError, ValueError) as exc:
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status.HTTP_401_UNAUTHORIZED, "Token missing valid sub claim"
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _client_ip(request: Request) -> str:
|
|
66
|
+
if request.client is None:
|
|
67
|
+
return "unknown"
|
|
68
|
+
return request.client.host
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _client_ip_or_none(request: Request) -> str | None:
|
|
72
|
+
"""Return the client IP for audit logging, or None when unavailable.
|
|
73
|
+
|
|
74
|
+
Unlike _client_ip, returns None instead of "unknown" so callers can safely
|
|
75
|
+
pass the result to a Postgres inet column without a cast error.
|
|
76
|
+
"""
|
|
77
|
+
if request.client is None:
|
|
78
|
+
return None
|
|
79
|
+
return request.client.host
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rate_limit_dep(prefix: str, rule_attr: str):
|
|
83
|
+
async def dep(request: Request) -> None:
|
|
84
|
+
settings = get_settings()
|
|
85
|
+
if not settings.auth_rate_limit_enabled:
|
|
86
|
+
return
|
|
87
|
+
rule = ratelimit.RateLimit(
|
|
88
|
+
limit=getattr(settings, rule_attr),
|
|
89
|
+
window_seconds=settings.auth_rate_limit_window_seconds,
|
|
90
|
+
)
|
|
91
|
+
bucket = f"{prefix}:{_client_ip(request)}"
|
|
92
|
+
async with db.as_service_role() as conn:
|
|
93
|
+
blocked, _ = await ratelimit.hit(conn, bucket=bucket, rule=rule)
|
|
94
|
+
if blocked:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
97
|
+
detail={"code": "rate_limited", "message": "Too many requests"},
|
|
98
|
+
headers={"Retry-After": str(rule.window_seconds)},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return dep
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_rl_token = _rate_limit_dep("auth.token", "auth_rate_limit_token_per_window")
|
|
105
|
+
_rl_signup = _rate_limit_dep("auth.signup", "auth_rate_limit_signup_per_window")
|
|
106
|
+
_rl_recover = _rate_limit_dep("auth.recover", "auth_rate_limit_recover_per_window")
|
|
107
|
+
_rl_otp = _rate_limit_dep("auth.otp", "auth_rate_limit_otp_per_window")
|
|
108
|
+
_rl_magiclink = _rate_limit_dep(
|
|
109
|
+
"auth.magiclink", "auth_rate_limit_magiclink_per_window"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.post(
|
|
114
|
+
"/signup",
|
|
115
|
+
response_model=TokenResponse,
|
|
116
|
+
status_code=201,
|
|
117
|
+
dependencies=[Depends(_rl_signup)],
|
|
118
|
+
)
|
|
119
|
+
async def signup(payload: SignUpRequest) -> TokenResponse:
|
|
120
|
+
try:
|
|
121
|
+
async with db.acquire() as conn:
|
|
122
|
+
result = await service.signup(conn, payload.email, payload.password)
|
|
123
|
+
except service.AuthError as exc:
|
|
124
|
+
raise _auth_error(exc) from exc
|
|
125
|
+
return _to_token_response(*result)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.post("/token", response_model=TokenResponse, dependencies=[Depends(_rl_token)])
|
|
129
|
+
async def token(payload: TokenRequest) -> TokenResponse:
|
|
130
|
+
try:
|
|
131
|
+
async with db.acquire() as conn:
|
|
132
|
+
result = await service.password_grant(
|
|
133
|
+
conn, payload.email, payload.password
|
|
134
|
+
)
|
|
135
|
+
except service.AuthError as exc:
|
|
136
|
+
raise _auth_error(exc) from exc
|
|
137
|
+
return _to_token_response(*result)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("/refresh", response_model=TokenResponse)
|
|
141
|
+
async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
|
142
|
+
try:
|
|
143
|
+
async with db.acquire() as conn:
|
|
144
|
+
result = await service.refresh_grant(
|
|
145
|
+
conn,
|
|
146
|
+
payload.refresh_token,
|
|
147
|
+
ip=_client_ip_or_none(request),
|
|
148
|
+
ua=request.headers.get("user-agent"),
|
|
149
|
+
)
|
|
150
|
+
except service.AuthError as exc:
|
|
151
|
+
raise _auth_error(exc) from exc
|
|
152
|
+
return _to_token_response(*result)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.post("/logout", status_code=204)
|
|
156
|
+
async def logout(payload: RefreshRequest) -> None:
|
|
157
|
+
async with db.acquire() as conn:
|
|
158
|
+
await service.logout(conn, payload.refresh_token)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get("/user", response_model=UserResponse)
|
|
162
|
+
async def me(
|
|
163
|
+
user_id: Annotated[UUID, Depends(_current_user_id)],
|
|
164
|
+
) -> UserResponse:
|
|
165
|
+
async with db.acquire() as conn:
|
|
166
|
+
user = await service.get_user(conn, user_id)
|
|
167
|
+
if not user:
|
|
168
|
+
raise HTTPException(404, "User not found")
|
|
169
|
+
return user
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.post("/recover", status_code=202, dependencies=[Depends(_rl_recover)])
|
|
173
|
+
async def request_recover(payload: RecoverRequest) -> None:
|
|
174
|
+
async with db.acquire() as conn:
|
|
175
|
+
await service.request_recover(conn, payload.email)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@router.post(
|
|
179
|
+
"/recover/verify",
|
|
180
|
+
response_model=TokenResponse,
|
|
181
|
+
dependencies=[Depends(_rl_recover)],
|
|
182
|
+
)
|
|
183
|
+
async def verify_recover(payload: RecoverVerifyRequest, request: Request) -> TokenResponse:
|
|
184
|
+
try:
|
|
185
|
+
async with db.acquire() as conn:
|
|
186
|
+
result = await service.verify_recover(
|
|
187
|
+
conn,
|
|
188
|
+
payload.email,
|
|
189
|
+
payload.token,
|
|
190
|
+
payload.password,
|
|
191
|
+
ip=_client_ip_or_none(request),
|
|
192
|
+
ua=request.headers.get("user-agent"),
|
|
193
|
+
)
|
|
194
|
+
except service.AuthError as exc:
|
|
195
|
+
raise _auth_error(exc) from exc
|
|
196
|
+
return _to_token_response(*result)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.post("/magiclink", status_code=202, dependencies=[Depends(_rl_magiclink)])
|
|
200
|
+
async def request_magic_link(payload: MagicLinkRequest) -> None:
|
|
201
|
+
async with db.acquire() as conn:
|
|
202
|
+
await service.request_magic_link(conn, payload.email)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@router.get(
|
|
206
|
+
"/magiclink/verify",
|
|
207
|
+
response_model=TokenResponse,
|
|
208
|
+
dependencies=[Depends(_rl_magiclink)],
|
|
209
|
+
)
|
|
210
|
+
async def verify_magic_link(
|
|
211
|
+
token: Annotated[str, Query(description="Raw magic-link token from the email")],
|
|
212
|
+
) -> TokenResponse:
|
|
213
|
+
try:
|
|
214
|
+
async with db.acquire() as conn:
|
|
215
|
+
result = await service.verify_magic_link(conn, token)
|
|
216
|
+
except service.AuthError as exc:
|
|
217
|
+
raise _auth_error(exc) from exc
|
|
218
|
+
return _to_token_response(*result)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.post("/otp", status_code=202, dependencies=[Depends(_rl_otp)])
|
|
222
|
+
async def request_otp(payload: OtpRequest) -> None:
|
|
223
|
+
async with db.acquire() as conn:
|
|
224
|
+
await service.request_otp(conn, payload.email)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.post("/otp/verify", response_model=TokenResponse, dependencies=[Depends(_rl_otp)])
|
|
228
|
+
async def verify_otp(payload: OtpVerifyRequest) -> TokenResponse:
|
|
229
|
+
try:
|
|
230
|
+
async with db.acquire() as conn:
|
|
231
|
+
result = await service.verify_otp(conn, payload.email, payload.token)
|
|
232
|
+
except service.AuthError as exc:
|
|
233
|
+
raise _auth_error(exc) from exc
|
|
234
|
+
return _to_token_response(*result)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# OAuth
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@router.get("/authorize/{provider}", status_code=302)
|
|
243
|
+
async def oauth_authorize(
|
|
244
|
+
provider: str,
|
|
245
|
+
redirect_uri: Annotated[str, Query(description="URL to redirect back to after login")],
|
|
246
|
+
) -> RedirectResponse:
|
|
247
|
+
"""Initiate an OAuth2 Authorization Code flow for the given provider."""
|
|
248
|
+
try:
|
|
249
|
+
url = await service.oauth_start(provider, redirect_uri)
|
|
250
|
+
except service.AuthError as exc:
|
|
251
|
+
raise _auth_error(exc) from exc
|
|
252
|
+
return RedirectResponse(url, status_code=302)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@router.get("/callback/{provider}", status_code=302)
|
|
256
|
+
async def oauth_callback(
|
|
257
|
+
provider: str,
|
|
258
|
+
code: Annotated[str, Query()],
|
|
259
|
+
state: Annotated[str, Query()],
|
|
260
|
+
request: Request,
|
|
261
|
+
) -> RedirectResponse:
|
|
262
|
+
"""Handle the provider callback, exchange the code, and redirect with tokens."""
|
|
263
|
+
try:
|
|
264
|
+
async with db.acquire() as conn:
|
|
265
|
+
result = await service.oauth_finish(
|
|
266
|
+
conn,
|
|
267
|
+
provider,
|
|
268
|
+
code,
|
|
269
|
+
state,
|
|
270
|
+
ip=_client_ip_or_none(request),
|
|
271
|
+
ua=request.headers.get("user-agent"),
|
|
272
|
+
)
|
|
273
|
+
except service.AuthError as exc:
|
|
274
|
+
raise _auth_error(exc) from exc
|
|
275
|
+
_user, access, refresh, ttl, redirect_uri = result
|
|
276
|
+
fragment = (
|
|
277
|
+
f"access_token={access}"
|
|
278
|
+
f"&refresh_token={refresh}"
|
|
279
|
+
f"&expires_in={ttl}"
|
|
280
|
+
f"&token_type=bearer"
|
|
281
|
+
)
|
|
282
|
+
return RedirectResponse(f"{redirect_uri}#{fragment}", status_code=302)
|
supython/auth/schemas.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
6
|
+
|
|
7
|
+
# Per-field input caps. The body-size middleware already rejects absurdly
|
|
8
|
+
# large payloads, but per-field caps give the user a precise 422 instead
|
|
9
|
+
# of a generic 413 and stop unbounded values from reaching argon2 / the
|
|
10
|
+
# DB. RFC 5321 caps email at 254; argon2id is happy with arbitrary
|
|
11
|
+
# password lengths but slow on huge ones, so we mirror what the underlying
|
|
12
|
+
# password column / hash assumes.
|
|
13
|
+
MAX_EMAIL_LEN = 254
|
|
14
|
+
MIN_PASSWORD_LEN = 8
|
|
15
|
+
MAX_PASSWORD_LEN = 128
|
|
16
|
+
# Refresh tokens / one-time tokens are short opaque strings (under 100
|
|
17
|
+
# chars in practice). 4096 keeps a generous head-room for any future
|
|
18
|
+
# encoding while still rejecting "1 MB token" abuse.
|
|
19
|
+
MAX_TOKEN_LEN = 4096
|
|
20
|
+
|
|
21
|
+
EmailField = Annotated[EmailStr, Field(max_length=MAX_EMAIL_LEN)]
|
|
22
|
+
PasswordField = Annotated[
|
|
23
|
+
str, Field(min_length=MIN_PASSWORD_LEN, max_length=MAX_PASSWORD_LEN)
|
|
24
|
+
]
|
|
25
|
+
TokenField = Annotated[str, Field(min_length=1, max_length=MAX_TOKEN_LEN)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserResponse(BaseModel):
|
|
29
|
+
id: UUID
|
|
30
|
+
email: EmailStr
|
|
31
|
+
created_at: datetime
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SignUpRequest(BaseModel):
|
|
35
|
+
email: EmailField
|
|
36
|
+
password: PasswordField
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TokenRequest(BaseModel):
|
|
40
|
+
email: EmailField
|
|
41
|
+
# Login deliberately does not enforce ``min_length``: an old account
|
|
42
|
+
# may predate the current minimum. The max cap still applies so we
|
|
43
|
+
# don't argon2-hash megabyte payloads.
|
|
44
|
+
password: Annotated[str, Field(max_length=MAX_PASSWORD_LEN)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RefreshRequest(BaseModel):
|
|
48
|
+
refresh_token: TokenField
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TokenResponse(BaseModel):
|
|
52
|
+
access_token: str
|
|
53
|
+
token_type: str = "bearer"
|
|
54
|
+
expires_in: int
|
|
55
|
+
refresh_token: str
|
|
56
|
+
user: UserResponse
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RecoverRequest(BaseModel):
|
|
60
|
+
email: EmailField
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RecoverVerifyRequest(BaseModel):
|
|
64
|
+
email: EmailField
|
|
65
|
+
token: TokenField
|
|
66
|
+
password: PasswordField
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MagicLinkRequest(BaseModel):
|
|
70
|
+
email: EmailField
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OtpRequest(BaseModel):
|
|
74
|
+
email: EmailField
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OtpVerifyRequest(BaseModel):
|
|
78
|
+
email: EmailField
|
|
79
|
+
token: TokenField
|