datagrowth-common 0.3.2__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.
- datagrowth_common/__init__.py +83 -0
- datagrowth_common/api/__init__.py +20 -0
- datagrowth_common/api/errors.py +63 -0
- datagrowth_common/api/pagination.py +51 -0
- datagrowth_common/api/users.py +78 -0
- datagrowth_common/auth/__init__.py +17 -0
- datagrowth_common/auth/local.py +48 -0
- datagrowth_common/auth/router.py +253 -0
- datagrowth_common/auth/session.py +139 -0
- datagrowth_common/auth/session_hmac.py +190 -0
- datagrowth_common/auth/supabase.py +144 -0
- datagrowth_common/auth/supabase_admin.py +164 -0
- datagrowth_common/logger.py +48 -0
- datagrowth_common/result.py +14 -0
- datagrowth_common/shared_models.py +33 -0
- datagrowth_common/supabase.py +46 -0
- datagrowth_common/testing/__init__.py +12 -0
- datagrowth_common/testing/fixtures.py +48 -0
- datagrowth_common/ui/__init__.py +4 -0
- datagrowth_common/ui/loader.py +55 -0
- datagrowth_common/ui/static/css/_tailwind.source.css +41 -0
- datagrowth_common/ui/static/css/tailwind.compiled.css +1 -0
- datagrowth_common/ui/static/css/tokens.css +113 -0
- datagrowth_common/ui/static/js/htmx-events.js +51 -0
- datagrowth_common/ui/static/js/theme.js +49 -0
- datagrowth_common/ui/static/js/toasts.js +22 -0
- datagrowth_common/ui/templates/dg/admin/audit_panel.html +22 -0
- datagrowth_common/ui/templates/dg/admin/roles_panel.html +18 -0
- datagrowth_common/ui/templates/dg/admin/users_panel.html +52 -0
- datagrowth_common/ui/templates/dg/components/badge.html +28 -0
- datagrowth_common/ui/templates/dg/components/button.html +76 -0
- datagrowth_common/ui/templates/dg/components/card.html +33 -0
- datagrowth_common/ui/templates/dg/components/command_palette.html +44 -0
- datagrowth_common/ui/templates/dg/components/data_table.html +42 -0
- datagrowth_common/ui/templates/dg/components/empty_state.html +27 -0
- datagrowth_common/ui/templates/dg/components/form_field.html +99 -0
- datagrowth_common/ui/templates/dg/components/icon.html +90 -0
- datagrowth_common/ui/templates/dg/components/modal.html +39 -0
- datagrowth_common/ui/templates/dg/components/page_header.html +35 -0
- datagrowth_common/ui/templates/dg/components/paginated_table.html +61 -0
- datagrowth_common/ui/templates/dg/components/sidebar_link.html +21 -0
- datagrowth_common/ui/templates/dg/components/skeleton.html +10 -0
- datagrowth_common/ui/templates/dg/components/theme_toggle.html +27 -0
- datagrowth_common/ui/templates/dg/components/toast.html +31 -0
- datagrowth_common/ui/templates/dg/layouts/empty.html +25 -0
- datagrowth_common/ui/templates/dg/layouts/sidebar_shell.html +86 -0
- datagrowth_common/updater/__init__.py +0 -0
- datagrowth_common/updater/checker.py +48 -0
- datagrowth_common-0.3.2.dist-info/METADATA +306 -0
- datagrowth_common-0.3.2.dist-info/RECORD +51 -0
- datagrowth_common-0.3.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""datagrowth-common — utilidades compartidas entre apps Datagrowth.
|
|
2
|
+
|
|
3
|
+
v0.3.1 (mayo 2026):
|
|
4
|
+
- Nuevo subpaquete `datagrowth_common.ui`: Jinja2 partials (`dg/...`),
|
|
5
|
+
tokens CSS con tema light/dark, Tailwind precompilado y JS de theme/
|
|
6
|
+
toasts/htmx-events. Helper `register_ui(app, jinja_env, mount_path)`
|
|
7
|
+
para inyectar todo en una FastAPI host app.
|
|
8
|
+
|
|
9
|
+
v0.3.0 (mayo 2026, BREAKING):
|
|
10
|
+
- Eliminados `datagrowth_common.authentik` y `datagrowth_common.auth_fastapi`
|
|
11
|
+
(forward-auth basado en Authentik). Importarlos lanza `ImportError`.
|
|
12
|
+
- `datagrowth_common.auth.local` renombrado a `datagrowth_common.auth.session_hmac`.
|
|
13
|
+
Se eliminó toda la integración con Authentik OIDC (password_login, refresh,
|
|
14
|
+
userinfo, start_oidc, oidc_callback, register_local, magic_link_request,
|
|
15
|
+
password_reset, TokenPair, OidcStart). Solo se conserva la cookie HMAC.
|
|
16
|
+
`auth.local` queda como alias deprecated (DeprecationWarning); eliminado en v0.4.0.
|
|
17
|
+
- Modelo único de auth: Supabase Auth (cookie JWT en `auth.session` +
|
|
18
|
+
endpoints en `auth.router` + verify en `auth.supabase`).
|
|
19
|
+
|
|
20
|
+
v0.2.2 (anterior):
|
|
21
|
+
- `auth.supabase_admin` añadidos `create_user_with_password` y
|
|
22
|
+
`send_password_reset` para flujos de gestión de usuarios end-to-end.
|
|
23
|
+
|
|
24
|
+
Ver CHANGELOG.md para la guía de migración completa.
|
|
25
|
+
"""
|
|
26
|
+
from .auth.supabase import (
|
|
27
|
+
SupabaseUser,
|
|
28
|
+
SupabaseUserDep,
|
|
29
|
+
require_role,
|
|
30
|
+
require_supabase_jwt,
|
|
31
|
+
verify_supabase_jwt,
|
|
32
|
+
)
|
|
33
|
+
from .auth.session import (
|
|
34
|
+
ACCESS_COOKIE,
|
|
35
|
+
REFRESH_COOKIE,
|
|
36
|
+
SessionTokens,
|
|
37
|
+
SessionUserDep,
|
|
38
|
+
clear_session,
|
|
39
|
+
get_session_tokens,
|
|
40
|
+
refresh_session_tokens,
|
|
41
|
+
require_session,
|
|
42
|
+
set_session,
|
|
43
|
+
)
|
|
44
|
+
from .auth.router import auth_router, make_auth_router
|
|
45
|
+
from .api.users import users_router
|
|
46
|
+
from .result import Result, ok, err
|
|
47
|
+
from .logger import get_logger, setup_logging
|
|
48
|
+
from .ui import UI_VERSION, register_ui
|
|
49
|
+
|
|
50
|
+
__version__ = "0.3.2"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# supabase auth (JWT)
|
|
54
|
+
"SupabaseUser",
|
|
55
|
+
"SupabaseUserDep",
|
|
56
|
+
"require_role",
|
|
57
|
+
"require_supabase_jwt",
|
|
58
|
+
"verify_supabase_jwt",
|
|
59
|
+
"users_router",
|
|
60
|
+
# session (cookie httpOnly server-rendered)
|
|
61
|
+
"ACCESS_COOKIE",
|
|
62
|
+
"REFRESH_COOKIE",
|
|
63
|
+
"SessionTokens",
|
|
64
|
+
"SessionUserDep",
|
|
65
|
+
"set_session",
|
|
66
|
+
"clear_session",
|
|
67
|
+
"get_session_tokens",
|
|
68
|
+
"refresh_session_tokens",
|
|
69
|
+
"require_session",
|
|
70
|
+
# auth router (login + OAuth + refresh + logout)
|
|
71
|
+
"auth_router",
|
|
72
|
+
"make_auth_router",
|
|
73
|
+
# core
|
|
74
|
+
"Result",
|
|
75
|
+
"ok",
|
|
76
|
+
"err",
|
|
77
|
+
"get_logger",
|
|
78
|
+
"setup_logging",
|
|
79
|
+
# ui (datagrowth_common.ui)
|
|
80
|
+
"register_ui",
|
|
81
|
+
"UI_VERSION",
|
|
82
|
+
"__version__",
|
|
83
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""API helpers compartidos: respuesta estandar y paginacion.
|
|
2
|
+
|
|
3
|
+
Para `users_router` (CRUD sobre Supabase Admin API), importar
|
|
4
|
+
explicitamente: `from datagrowth_common.api.users import users_router`.
|
|
5
|
+
Se evita re-exportarlo aqui para no forzar el import de
|
|
6
|
+
`auth.supabase` cuando un consumidor solo necesita helpers genericos.
|
|
7
|
+
"""
|
|
8
|
+
from .errors import ApiResponse, ErrorBody, ErrorCode, err_response, ok_response
|
|
9
|
+
from .pagination import PageMeta, Paginated, paginate_params
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ApiResponse",
|
|
13
|
+
"ErrorBody",
|
|
14
|
+
"ErrorCode",
|
|
15
|
+
"err_response",
|
|
16
|
+
"ok_response",
|
|
17
|
+
"PageMeta",
|
|
18
|
+
"Paginated",
|
|
19
|
+
"paginate_params",
|
|
20
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Respuesta estandar `ApiResponse` y catalogo de errores.
|
|
2
|
+
|
|
3
|
+
Codigo y mensaje van siempre en `error`. Los handlers nunca lanzan
|
|
4
|
+
excepciones a traves de la frontera HTTP: devuelven `ApiResponse` con
|
|
5
|
+
`error` poblado o `data` poblado, exclusivo.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorCode(str, Enum):
|
|
18
|
+
NOT_FOUND = "NOT_FOUND"
|
|
19
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
20
|
+
UNAUTHORIZED = "UNAUTHORIZED"
|
|
21
|
+
FORBIDDEN = "FORBIDDEN"
|
|
22
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
23
|
+
CONFLICT = "CONFLICT"
|
|
24
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ErrorBody(BaseModel):
|
|
28
|
+
code: ErrorCode
|
|
29
|
+
message: str
|
|
30
|
+
details: dict[str, Any] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiResponse(BaseModel, Generic[T]):
|
|
34
|
+
data: T | None = None
|
|
35
|
+
error: ErrorBody | None = None
|
|
36
|
+
meta: dict[str, Any] | None = Field(default=None)
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _xor_data_error(self) -> "ApiResponse[T]":
|
|
40
|
+
if (self.data is None) == (self.error is None):
|
|
41
|
+
raise ValueError("ApiResponse requires exactly one of data|error")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ok_response(data: T, meta: dict[str, Any] | None = None) -> ApiResponse[T]:
|
|
46
|
+
return ApiResponse[T](data=data, meta=meta)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def err_response(
|
|
50
|
+
code: ErrorCode, message: str, details: dict[str, Any] | None = None,
|
|
51
|
+
) -> ApiResponse[Any]:
|
|
52
|
+
return ApiResponse[Any](error=ErrorBody(code=code, message=message, details=details))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
HTTP_STATUS_FOR_CODE: dict[ErrorCode, int] = {
|
|
56
|
+
ErrorCode.NOT_FOUND: 404,
|
|
57
|
+
ErrorCode.VALIDATION_ERROR: 422,
|
|
58
|
+
ErrorCode.UNAUTHORIZED: 401,
|
|
59
|
+
ErrorCode.FORBIDDEN: 403,
|
|
60
|
+
ErrorCode.RATE_LIMITED: 429,
|
|
61
|
+
ErrorCode.CONFLICT: 409,
|
|
62
|
+
ErrorCode.INTERNAL_ERROR: 500,
|
|
63
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Paginacion estandar `limit/offset`.
|
|
2
|
+
|
|
3
|
+
Las queries siempre van con LIMIT (regla de rendimiento §3 del proyecto).
|
|
4
|
+
Este modulo expone `paginate_params` para validar/clip de query params y
|
|
5
|
+
`Paginated[T]` como envolvente de respuesta.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
DEFAULT_LIMIT = 20
|
|
16
|
+
MAX_LIMIT = 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PageMeta(BaseModel):
|
|
20
|
+
total: int = Field(ge=0)
|
|
21
|
+
limit: int = Field(ge=1, le=MAX_LIMIT)
|
|
22
|
+
offset: int = Field(ge=0)
|
|
23
|
+
has_more: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Paginated(BaseModel, Generic[T]):
|
|
27
|
+
data: list[T]
|
|
28
|
+
meta: PageMeta
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def paginate_params(
|
|
32
|
+
limit: int | None = None,
|
|
33
|
+
offset: int | None = None,
|
|
34
|
+
) -> tuple[int, int]:
|
|
35
|
+
"""Valida y clipea limit/offset. Devuelve `(limit_safe, offset_safe)`.
|
|
36
|
+
|
|
37
|
+
`None` se trata como "no especificado" → default. Cualquier entero
|
|
38
|
+
fuera de rango se clipea (limit a [1, MAX_LIMIT], offset a [0, ∞)).
|
|
39
|
+
"""
|
|
40
|
+
raw_limit = DEFAULT_LIMIT if limit is None else int(limit)
|
|
41
|
+
raw_offset = 0 if offset is None else int(offset)
|
|
42
|
+
safe_limit = max(1, min(raw_limit, MAX_LIMIT))
|
|
43
|
+
safe_offset = max(0, raw_offset)
|
|
44
|
+
return safe_limit, safe_offset
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_meta(total: int, limit: int, offset: int) -> PageMeta:
|
|
48
|
+
return PageMeta(
|
|
49
|
+
total=total, limit=limit, offset=offset,
|
|
50
|
+
has_more=offset + limit < total,
|
|
51
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Router CRUD de usuarios sobre Supabase Admin API.
|
|
2
|
+
|
|
3
|
+
Para usar: `app.include_router(users_router)`. La app debe definir las
|
|
4
|
+
variables de entorno `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` y
|
|
5
|
+
`SUPABASE_JWT_SECRET`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, Query
|
|
10
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
11
|
+
|
|
12
|
+
from ..auth.supabase import (
|
|
13
|
+
SupabaseUser,
|
|
14
|
+
SupabaseUserDep,
|
|
15
|
+
require_role,
|
|
16
|
+
)
|
|
17
|
+
from ..auth import supabase_admin
|
|
18
|
+
from ..api.pagination import Paginated, paginate_params
|
|
19
|
+
from .errors import (
|
|
20
|
+
ApiResponse,
|
|
21
|
+
ErrorCode,
|
|
22
|
+
err_response,
|
|
23
|
+
ok_response,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InviteBody(BaseModel):
|
|
31
|
+
email: EmailStr = Field(..., max_length=254)
|
|
32
|
+
role: str = Field(default="user", min_length=1, max_length=50)
|
|
33
|
+
redirect_to: str | None = Field(default=None, max_length=2048)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_AdminUser = Depends(require_role("admin"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@users_router.get("/me", response_model=ApiResponse[SupabaseUser])
|
|
40
|
+
async def get_me(user: SupabaseUserDep) -> ApiResponse[SupabaseUser]:
|
|
41
|
+
return ok_response(user)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@users_router.get("", response_model=ApiResponse[Paginated[SupabaseUser]])
|
|
45
|
+
async def list_users(
|
|
46
|
+
_admin: SupabaseUser = _AdminUser,
|
|
47
|
+
limit: int | None = Query(default=None, ge=1, le=100),
|
|
48
|
+
offset: int | None = Query(default=None, ge=0),
|
|
49
|
+
) -> ApiResponse[Paginated[SupabaseUser]]:
|
|
50
|
+
safe_limit, safe_offset = paginate_params(limit, offset)
|
|
51
|
+
page, exc = await supabase_admin.list_users(limit=safe_limit, offset=safe_offset)
|
|
52
|
+
if exc is not None or page is None:
|
|
53
|
+
return err_response(ErrorCode.INTERNAL_ERROR, "list-users-failed")
|
|
54
|
+
return ok_response(page)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@users_router.post(
|
|
58
|
+
"/invite", response_model=ApiResponse[SupabaseUser], status_code=201,
|
|
59
|
+
)
|
|
60
|
+
async def invite_user(
|
|
61
|
+
body: InviteBody, _admin: SupabaseUser = _AdminUser,
|
|
62
|
+
) -> ApiResponse[SupabaseUser]:
|
|
63
|
+
user, exc = await supabase_admin.invite_user_by_email(
|
|
64
|
+
body.email, role=body.role, redirect_to=body.redirect_to,
|
|
65
|
+
)
|
|
66
|
+
if exc is not None or user is None:
|
|
67
|
+
return err_response(ErrorCode.INTERNAL_ERROR, "invite-failed")
|
|
68
|
+
return ok_response(user)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@users_router.delete("/{user_id}", response_model=ApiResponse[dict])
|
|
72
|
+
async def delete_user(
|
|
73
|
+
user_id: str, _admin: SupabaseUser = _AdminUser,
|
|
74
|
+
) -> ApiResponse[dict]:
|
|
75
|
+
_, exc = await supabase_admin.delete_user(user_id)
|
|
76
|
+
if exc is not None:
|
|
77
|
+
return err_response(ErrorCode.INTERNAL_ERROR, "delete-failed")
|
|
78
|
+
return ok_response({"deleted": user_id})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Helpers de autenticacion para apps Datagrowth (Supabase Auth).
|
|
2
|
+
|
|
3
|
+
- `supabase`: validacion de JWT firmado por Supabase Auth.
|
|
4
|
+
- `supabase_admin`: wrappers async sobre Supabase Admin API (list/invite/delete users).
|
|
5
|
+
- `session`: cookie httpOnly server-rendered (`set_session`, `clear_session`,
|
|
6
|
+
`require_session`, `refresh_session_tokens`). Usar en apps Jinja+HTMX.
|
|
7
|
+
- `router`: `make_auth_router()` / `auth_router` con endpoints de login propio +
|
|
8
|
+
OAuth + refresh + logout, reutilizable entre el backend interno y las apps cliente.
|
|
9
|
+
- `session_hmac`: cookie de sesion firmada con HMAC propio. Solo para apps
|
|
10
|
+
internas que NO usan Supabase Auth (fallback). En v0.2.x se llamaba `local`.
|
|
11
|
+
|
|
12
|
+
`local` se mantiene como alias deprecated de `session_hmac` (DeprecationWarning).
|
|
13
|
+
Eliminado en v0.4.0.
|
|
14
|
+
"""
|
|
15
|
+
from . import router, session, session_hmac, supabase, supabase_admin
|
|
16
|
+
|
|
17
|
+
__all__ = ["router", "session", "session_hmac", "supabase", "supabase_admin"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""DEPRECATED alias: `datagrowth_common.auth.local` → `auth.session_hmac`.
|
|
2
|
+
|
|
3
|
+
En v0.3.0 el archivo se renombró a `session_hmac.py` y se eliminó toda la
|
|
4
|
+
integración con Authentik (OIDC, magic link, password reset, register).
|
|
5
|
+
Solo se conserva la cookie HMAC de sesión propia.
|
|
6
|
+
|
|
7
|
+
Importar desde aquí emite `DeprecationWarning`. Se eliminará en v0.4.0.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
warnings.warn(
|
|
14
|
+
"datagrowth_common.auth.local is deprecated since v0.3.0; "
|
|
15
|
+
"use datagrowth_common.auth.session_hmac instead. "
|
|
16
|
+
"Authentik OIDC helpers (password_login, refresh_token, userinfo, "
|
|
17
|
+
"start_oidc, oidc_callback, register_local, magic_link_request, "
|
|
18
|
+
"password_reset, TokenPair, OidcStart) were removed; use "
|
|
19
|
+
"datagrowth_common.auth.router + datagrowth_common.auth.supabase.",
|
|
20
|
+
DeprecationWarning,
|
|
21
|
+
stacklevel=2,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .session_hmac import ( # noqa: E402,F401
|
|
25
|
+
SessionCookie,
|
|
26
|
+
SessionDep,
|
|
27
|
+
SessionUser,
|
|
28
|
+
clear_session_cookie,
|
|
29
|
+
decode_session,
|
|
30
|
+
encode_session,
|
|
31
|
+
make_session,
|
|
32
|
+
require_group,
|
|
33
|
+
require_session,
|
|
34
|
+
set_session_cookie,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"SessionCookie",
|
|
39
|
+
"SessionDep",
|
|
40
|
+
"SessionUser",
|
|
41
|
+
"clear_session_cookie",
|
|
42
|
+
"decode_session",
|
|
43
|
+
"encode_session",
|
|
44
|
+
"make_session",
|
|
45
|
+
"require_group",
|
|
46
|
+
"require_session",
|
|
47
|
+
"set_session_cookie",
|
|
48
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Router de autenticacion reutilizable: login propio + OAuth + refresh + logout.
|
|
2
|
+
|
|
3
|
+
La pagina HTML de `/login` la sirve la propia app (cada una con su branding);
|
|
4
|
+
este router solo expone los endpoints API y el OAuth callback.
|
|
5
|
+
|
|
6
|
+
Uso (en `src/main.py` de la app):
|
|
7
|
+
|
|
8
|
+
from fastapi.templating import Jinja2Templates
|
|
9
|
+
from datagrowth_common.auth.router import make_auth_router
|
|
10
|
+
|
|
11
|
+
auth_router = make_auth_router(post_login_redirect="/")
|
|
12
|
+
app.include_router(auth_router)
|
|
13
|
+
|
|
14
|
+
Endpoints expuestos:
|
|
15
|
+
POST /auth/password -> form email+password, set cookie
|
|
16
|
+
POST /auth/register -> sign-up (si AUTH_REGISTRATION=open)
|
|
17
|
+
POST /auth/magic-link/request -> sign_in_with_otp(email)
|
|
18
|
+
POST /auth/password/reset -> reset_password_for_email
|
|
19
|
+
GET /auth/oauth/{provider}/start -> redirige a Supabase OAuth
|
|
20
|
+
GET /auth/oauth/{provider}/callback -> exchange code -> set cookie
|
|
21
|
+
POST /auth/refresh -> refresca tokens si la cookie caduco
|
|
22
|
+
POST /logout -> borra cookie y redirige
|
|
23
|
+
GET /api/me -> SupabaseUser actual (json)
|
|
24
|
+
|
|
25
|
+
Requiere las envvars:
|
|
26
|
+
SUPABASE_URL, SUPABASE_KEY, SUPABASE_JWT_SECRET (+ optional AUTH_*).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from typing import Annotated, Any
|
|
32
|
+
|
|
33
|
+
from fastapi import APIRouter, Form, Query, Request, Response, status
|
|
34
|
+
from fastapi.responses import RedirectResponse
|
|
35
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
36
|
+
|
|
37
|
+
from ..api.errors import (
|
|
38
|
+
ApiResponse,
|
|
39
|
+
ErrorCode,
|
|
40
|
+
err_response,
|
|
41
|
+
ok_response,
|
|
42
|
+
)
|
|
43
|
+
from .session import (
|
|
44
|
+
SessionUserDep,
|
|
45
|
+
clear_session,
|
|
46
|
+
get_session_tokens,
|
|
47
|
+
refresh_session_tokens,
|
|
48
|
+
set_session,
|
|
49
|
+
)
|
|
50
|
+
from .supabase import SupabaseUser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _registration_open() -> bool:
|
|
54
|
+
return os.getenv("AUTH_REGISTRATION", "invite_only") == "open"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _magic_link_enabled() -> bool:
|
|
58
|
+
return os.getenv("AUTH_MAGIC_LINK", "false").lower() == "true"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _redirect_base() -> str:
|
|
62
|
+
"""URL publica de la app, para construir redirect_to del OAuth callback."""
|
|
63
|
+
return os.getenv("DG_APP_URL", "").rstrip("/")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _session_from_supabase(result: Any) -> dict[str, Any]:
|
|
67
|
+
session = getattr(result, "session", None)
|
|
68
|
+
if session is None:
|
|
69
|
+
return {}
|
|
70
|
+
return {
|
|
71
|
+
"access_token": getattr(session, "access_token", ""),
|
|
72
|
+
"refresh_token": getattr(session, "refresh_token", ""),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _RefreshBody(BaseModel):
|
|
77
|
+
pass # refresh lee de cookie, no de body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def make_auth_router(
|
|
81
|
+
*,
|
|
82
|
+
prefix: str = "",
|
|
83
|
+
post_login_redirect: str = "/",
|
|
84
|
+
post_logout_redirect: str = "/login",
|
|
85
|
+
oauth_callback_path_prefix: str = "/auth/oauth",
|
|
86
|
+
) -> APIRouter:
|
|
87
|
+
"""Construye el APIRouter de auth con redirects configurables.
|
|
88
|
+
|
|
89
|
+
El router NO sirve `/login` HTML — eso vive en la app (template propio).
|
|
90
|
+
"""
|
|
91
|
+
router = APIRouter(prefix=prefix, tags=["auth"])
|
|
92
|
+
|
|
93
|
+
# ── Password (Supabase email/password) ─────────────────────────────
|
|
94
|
+
|
|
95
|
+
@router.post("/auth/password", response_class=RedirectResponse)
|
|
96
|
+
async def auth_password(
|
|
97
|
+
email: Annotated[str, Form(min_length=3, max_length=254)],
|
|
98
|
+
password: Annotated[str, Form(min_length=8, max_length=200)],
|
|
99
|
+
) -> RedirectResponse:
|
|
100
|
+
try:
|
|
101
|
+
from ..supabase import get_supabase_client
|
|
102
|
+
client = get_supabase_client()
|
|
103
|
+
result = client.auth.sign_in_with_password(
|
|
104
|
+
{"email": email, "password": password},
|
|
105
|
+
)
|
|
106
|
+
except Exception:
|
|
107
|
+
return RedirectResponse("/login?error=invalid-credentials", status_code=303)
|
|
108
|
+
tokens = _session_from_supabase(result)
|
|
109
|
+
if not tokens.get("access_token"):
|
|
110
|
+
return RedirectResponse("/login?error=invalid-credentials", status_code=303)
|
|
111
|
+
redir = RedirectResponse(post_login_redirect, status_code=303)
|
|
112
|
+
set_session(
|
|
113
|
+
redir,
|
|
114
|
+
access_token=tokens["access_token"],
|
|
115
|
+
refresh_token=tokens.get("refresh_token"),
|
|
116
|
+
)
|
|
117
|
+
return redir
|
|
118
|
+
|
|
119
|
+
# ── Register (sign-up) ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
@router.post("/auth/register", response_model=ApiResponse)
|
|
122
|
+
async def auth_register(
|
|
123
|
+
email: Annotated[str, Form(min_length=3, max_length=254)],
|
|
124
|
+
password: Annotated[str, Form(min_length=8, max_length=200)],
|
|
125
|
+
name: Annotated[str, Form(min_length=1, max_length=120)] = "",
|
|
126
|
+
) -> ApiResponse:
|
|
127
|
+
if not _registration_open():
|
|
128
|
+
return err_response(ErrorCode.FORBIDDEN, "registration-closed")
|
|
129
|
+
try:
|
|
130
|
+
from ..supabase import get_supabase_client
|
|
131
|
+
client = get_supabase_client()
|
|
132
|
+
client.auth.sign_up({
|
|
133
|
+
"email": email, "password": password,
|
|
134
|
+
"options": {"data": {"name": name}} if name else {},
|
|
135
|
+
})
|
|
136
|
+
except Exception:
|
|
137
|
+
return err_response(ErrorCode.CONFLICT, "register-failed")
|
|
138
|
+
return ok_response({"created": True, "email": email})
|
|
139
|
+
|
|
140
|
+
# ── Magic link (OTP via email) ────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
@router.post("/auth/magic-link/request", response_model=ApiResponse)
|
|
143
|
+
async def auth_magic_link(
|
|
144
|
+
email: Annotated[str, Form(min_length=3, max_length=254)],
|
|
145
|
+
) -> ApiResponse:
|
|
146
|
+
if not _magic_link_enabled():
|
|
147
|
+
return err_response(ErrorCode.FORBIDDEN, "magic-link-disabled")
|
|
148
|
+
try:
|
|
149
|
+
from ..supabase import get_supabase_client
|
|
150
|
+
client = get_supabase_client()
|
|
151
|
+
client.auth.sign_in_with_otp({"email": email})
|
|
152
|
+
except Exception:
|
|
153
|
+
return err_response(ErrorCode.INTERNAL_ERROR, "magic-link-failed")
|
|
154
|
+
return ok_response({"sent": True})
|
|
155
|
+
|
|
156
|
+
# ── Password reset ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
@router.post("/auth/password/reset", response_model=ApiResponse)
|
|
159
|
+
async def auth_password_reset(
|
|
160
|
+
email: Annotated[str, Form(min_length=3, max_length=254)],
|
|
161
|
+
) -> ApiResponse:
|
|
162
|
+
try:
|
|
163
|
+
from ..supabase import get_supabase_client
|
|
164
|
+
client = get_supabase_client()
|
|
165
|
+
client.auth.reset_password_for_email(email)
|
|
166
|
+
except Exception:
|
|
167
|
+
return err_response(ErrorCode.INTERNAL_ERROR, "reset-failed")
|
|
168
|
+
return ok_response({"sent": True})
|
|
169
|
+
|
|
170
|
+
# ── OAuth (Microsoft/Google/etc. via Supabase) ───────────────────
|
|
171
|
+
|
|
172
|
+
@router.get(f"{oauth_callback_path_prefix}/{{provider}}/start")
|
|
173
|
+
async def auth_oauth_start(
|
|
174
|
+
provider: str, request: Request,
|
|
175
|
+
) -> RedirectResponse:
|
|
176
|
+
"""Redirige al consent de Supabase para el provider OAuth indicado."""
|
|
177
|
+
base = _redirect_base() or str(request.base_url).rstrip("/")
|
|
178
|
+
callback = f"{base}{oauth_callback_path_prefix}/{provider}/callback"
|
|
179
|
+
try:
|
|
180
|
+
from ..supabase import get_supabase_client
|
|
181
|
+
client = get_supabase_client()
|
|
182
|
+
result = client.auth.sign_in_with_oauth({
|
|
183
|
+
"provider": provider,
|
|
184
|
+
"options": {"redirect_to": callback},
|
|
185
|
+
})
|
|
186
|
+
url = getattr(result, "url", None)
|
|
187
|
+
except Exception:
|
|
188
|
+
return RedirectResponse(url="/login?error=oauth-start", status_code=303)
|
|
189
|
+
if not url:
|
|
190
|
+
return RedirectResponse(url="/login?error=oauth-start", status_code=303)
|
|
191
|
+
return RedirectResponse(url=str(url), status_code=303)
|
|
192
|
+
|
|
193
|
+
@router.get(f"{oauth_callback_path_prefix}/{{provider}}/callback")
|
|
194
|
+
async def auth_oauth_callback(
|
|
195
|
+
provider: str,
|
|
196
|
+
code: Annotated[str, Query(min_length=1, max_length=2048)],
|
|
197
|
+
) -> RedirectResponse:
|
|
198
|
+
"""Canjea el `?code=` por session JWT y guarda cookies."""
|
|
199
|
+
try:
|
|
200
|
+
from ..supabase import get_supabase_client
|
|
201
|
+
client = get_supabase_client()
|
|
202
|
+
result = client.auth.exchange_code_for_session({"auth_code": code})
|
|
203
|
+
except Exception:
|
|
204
|
+
return RedirectResponse(url="/login?error=oauth-exchange", status_code=303)
|
|
205
|
+
tokens = _session_from_supabase(result)
|
|
206
|
+
if not tokens.get("access_token"):
|
|
207
|
+
return RedirectResponse(url="/login?error=oauth-exchange", status_code=303)
|
|
208
|
+
redir = RedirectResponse(post_login_redirect, status_code=303)
|
|
209
|
+
set_session(
|
|
210
|
+
redir,
|
|
211
|
+
access_token=tokens["access_token"],
|
|
212
|
+
refresh_token=tokens.get("refresh_token"),
|
|
213
|
+
)
|
|
214
|
+
return redir
|
|
215
|
+
|
|
216
|
+
# ── Refresh ───────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
@router.post("/auth/refresh", response_model=ApiResponse)
|
|
219
|
+
async def auth_refresh(
|
|
220
|
+
request: Request, response: Response,
|
|
221
|
+
) -> ApiResponse:
|
|
222
|
+
tokens = get_session_tokens(request)
|
|
223
|
+
if tokens is None or not tokens.refresh_token:
|
|
224
|
+
return err_response(ErrorCode.UNAUTHORIZED, "no-refresh-token")
|
|
225
|
+
new_tokens, exc = await refresh_session_tokens(tokens.refresh_token)
|
|
226
|
+
if exc is not None or new_tokens is None:
|
|
227
|
+
return err_response(ErrorCode.UNAUTHORIZED, "refresh-failed")
|
|
228
|
+
set_session(
|
|
229
|
+
response,
|
|
230
|
+
access_token=new_tokens["access_token"],
|
|
231
|
+
refresh_token=new_tokens.get("refresh_token"),
|
|
232
|
+
)
|
|
233
|
+
return ok_response({"refreshed": True})
|
|
234
|
+
|
|
235
|
+
# ── Logout ────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
@router.post("/logout", response_class=RedirectResponse)
|
|
238
|
+
async def logout() -> RedirectResponse:
|
|
239
|
+
redir = RedirectResponse(post_logout_redirect, status_code=303)
|
|
240
|
+
clear_session(redir)
|
|
241
|
+
return redir
|
|
242
|
+
|
|
243
|
+
# ── Identity ──────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
@router.get("/api/me", response_model=ApiResponse[SupabaseUser])
|
|
246
|
+
async def me(user: SessionUserDep) -> ApiResponse[SupabaseUser]:
|
|
247
|
+
return ok_response(user)
|
|
248
|
+
|
|
249
|
+
return router
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Router por defecto con redirects estandar (post_login → "/").
|
|
253
|
+
auth_router = make_auth_router()
|