Servicio de autenticacion centralizado para todas las aplicaciones de Pampling. Gestiona usuarios, sesiones JWT (RS256) y registro de aplicaciones cliente. Proporciona SSO via cookies de dominio .pampl.ing y login federado con Microsoft Entra ID (tenant de Pampling).
| Campo | Valor |
|---|---|
| URL | https://login.pampl.ing |
| Repositorio | git@bitbucket.org:pampling/pampling-login.git |
| App UUID (Coolify) | p14hynlmrwbu19olodlc4nz5 |
| DB UUID (Coolify) | geo38ko9nozvmckdbdxi96qk |
| Base de datos | login_db (usuario: login_user) |
| Puerto DB local | 5433 |
| Network alias Docker | pampling-login |
Todas las apps estan en subdominios *.pampl.ing. El sistema usa cookies de dominio para SSO:
analytics.pampl.ingpampling_token validalogin.pampl.ing?redirect=https://analytics.pampl.ingpampling_token en .pampl.ing (HttpOnly, Secure, SameSite=Lax)*.pampl.inglogin.pampl.ing/.well-known/jwks.jsonEl modulo de integracion usa dos URLs distintas:
| Uso | URL | Motivo |
|---|---|---|
| Redirects al navegador | https://login.pampl.ing |
El navegador del usuario necesita la URL publica |
| Fetch JWKS (servidor) | http://pampling-login:8000 |
Dentro de Docker, los contenedores no resuelven el dominio externo |
La URL publica esta hardcodeada en el modulo. La URL interna se configura con la env var PAMPLING_LOGIN_INTERNAL.
| Token | Tipo | Duracion | Almacenamiento |
|---|---|---|---|
| Access token | JWT RS256 | 1 hora | Cookie pampling_token |
| Refresh token | Opaco (random) | 30 dias | Cookie pampling_refresh, hash SHA-256 en DB |
{
"sub": "42",
"role": "user",
"email": "juan@pampling.com",
"display_name": "Juan Perez",
"username": "juan",
"apps": ["incidencias", "vault"],
"iat": 1717000000,
"exp": 1717003600
}
El campo apps contiene los slugs de las apps a las que el usuario puede acceder. Se calcula como union de:
allowed_roles incluye el rol del usuario (acceso por rol)user_applications (acceso individual)Ademas del login local (usuario+contrasena), la pagina de login ofrece un boton "Entrar con Microsoft" que autentica contra el tenant de Pampling en Entra ID (razon social "Republica Grafica SL", single-tenant, solo cuentas @pampling.com).
https://login.pampl.ing/api/auth/microsoft/callbackUser.Read (Microsoft Graph, delegado)login.pampl.ing.GET /api/auth/microsoft/login construye el state firmado, guarda un nonce en cookie y redirige a https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize.GET /api/auth/microsoft/callback?code=...&state=....oid, email y name del id_token, y resuelve el usuario Pampling:
microsoft_oid (id inmutable del tenant).microsoft_oid).user.| Variable | Descripcion |
|---|---|
MICROSOFT_CLIENT_ID |
Application (client) ID de la App registration |
MICROSOFT_TENANT_ID |
Directory (tenant) ID |
MICROSOFT_CLIENT_SECRET |
Client secret Value (unico momento de captura) |
MICROSOFT_REDIRECT_URI |
(opcional) por defecto la de produccion |
MICROSOFT_REQUIRED_DOMAIN |
(opcional) dominio permitido, por defecto pampling.com |
Sin esas variables el boton devuelve 503 y el login local sigue funcionando.
No hay auto-registro publico. El endpoint POST /api/auth/register y la pagina /register fueron retirados. Las dos unicas vias para tener cuenta son:
@pampling.com no existe todavia en users, se crea automaticamente con rol user. Es la via canonica para todo el equipo interno./admin → Usuarios → Nuevo (POST /api/users). Uso tipico: cuentas de tienda o invitados que no tienen cuenta en el tenant de Microsoft de Pampling.Cualquier Claude que este desarrollando una app nueva para Pampling puede pedir el alta desde su propia sesion, sin necesidad de humano intermediario. La solicitud queda pendiente de aprobacion pero el sistema devuelve ya toda la configuracion necesaria para preparar la app.
POST /api/apps/requests con los datos de la app y el email del responsable humano.request_token, las env vars listas para pegar y la URL de descarga del modulo de integracion. El Claude puede seguir desarrollando la app.ADMIN_NOTIFICATION_EMAIL via Resend + tarea en Nexo./admin → Solicitudes y aprueba o rechaza (con motivo).applications con el slug y client_secret ya generados. El login empieza a validar sesiones para esa app.POST https://login.pampl.ing/api/apps/requests
Content-Type: application/json
{
"name": "Mi App",
"slug": "mi-app",
"domain": "https://mi-app.pampl.ing",
"description": "Para que sirve la app",
"allowed_roles": "user,admin,viewer",
"requester_email": "responsable@pampling.com"
}
Validaciones:
requester_email debe pertenecer al dominio @pampling.com (403 en caso contrario).slug en kebab-case [a-z0-9-]{2,50} (400 si invalido).domain URL http(s)://... (400 si invalido).slug y domain no pueden colisionar con una app existente ni con otra solicitud pendiente (409).Respuesta (201 Created):
{
"status": "pending",
"request_id": 42,
"request_token": "opaco-para-consulta-de-estado",
"message": "Solicitud recibida. Un administrador la revisara...",
"config": {
"env_vars": {
"PAMPLING_APP_SLUG": "mi-app",
"PAMPLING_LOGIN_URL": "https://login.pampl.ing"
},
"integration": {
"download_url": "https://login.pampl.ing/integration/pampling_auth.py",
"target_path": "backend/pampling_auth.py",
"requirements_add": ["pyjwt[crypto]>=2.8.0"]
},
"notes": "..."
}
}
GET https://login.pampl.ing/api/apps/requests/{id}/status?token=<request_token>
Devuelve status, rejection_reason, reviewed_at. Solo el poseedor del request_token puede consultarlo.
| Metodo | Path | Descripcion |
|---|---|---|
GET |
/api/apps/requests?status_filter=pending\|approved\|rejected |
Listado |
GET |
/api/apps/requests/{id} |
Detalle |
POST |
/api/apps/requests/{id}/approve |
Aprobar → crea fila en applications |
POST |
/api/apps/requests/{id}/reject |
Rechazar, body {"reason": "..."} |
Pestana Solicitudes en /admin, con:
Se envia email desde noreply@mail.pampl.ing en tres eventos:
| Evento | Destinatario | Contenido |
|---|---|---|
| Nueva solicitud | ADMIN_NOTIFICATION_EMAIL |
Datos de la solicitud + enlace al panel |
| Aprobada | Responsable humano (requester_email) |
Confirmacion + recordatorio de env vars |
| Rechazada | Responsable humano | Motivo del rechazo |
Env vars requeridas:
| Variable | Descripcion |
|---|---|
RESEND_API_KEY |
Team-level en Coolify ({{team.RESEND_API_KEY}}) |
EMAIL_FROM |
Team-level ({{team.EMAIL_FROM}}) |
ADMIN_NOTIFICATION_EMAIL |
Destinatario del aviso de solicitud nueva |
pampling-login soporta dos mecanismos combinables para dar acceso a apps:
Cada aplicacion tiene una columna allowed_roles (CSV) con los roles a los que da acceso por defecto. Ejemplo:
| App | allowed_roles | Significado |
|---|---|---|
incidencias |
user,admin,viewer,tienda |
Cualquier usuario con esos roles entra |
dashboard |
admin |
Solo admins |
vault |
user,admin |
Users y admins |
Util cuando un grupo entero de usuarios debe tener acceso a una app (ej: "todos los tienda pueden usar incidencias").
Tabla user_applications(user_id, app_id, granted_at, granted_by) con asignaciones explicitas. Se gestiona desde el panel admin → Usuarios → Apps.
Util para conceder acceso a una app concreta a un usuario puntual sin cambiarle el rol.
Un usuario tiene acceso a una app si cualquiera de los dos mecanismos da acceso. En el panel admin, al gestionar apps de un usuario, las concedidas por rol aparecen con etiqueta "por rol" (no desmarcables) y las individuales con etiqueta "individual".
| Metodo | Path | Descripcion |
|---|---|---|
GET |
/.well-known/jwks.json |
Clave publica JWKS |
GET |
/health |
Health check |
POST |
/api/auth/login |
Login local (username + password) |
POST |
/api/auth/logout |
Logout (revoca refresh) |
POST |
/api/auth/refresh |
Renovar tokens |
GET |
/api/auth/verify |
Verificar token |
GET |
/api/auth/me |
Perfil del usuario |
GET |
/api/auth/microsoft/login |
Redirige a Entra ID |
GET |
/api/auth/microsoft/callback |
Callback OIDC, emite JWT Pampling |
POST |
/api/apps/requests |
Solicitar alta de app nueva |
GET |
/api/apps/requests/{id}/status?token= |
Consultar estado de solicitud |
GET |
/integration/pampling_auth.py |
Modulo de integracion (estatico) |
| Metodo | Path | Descripcion |
|---|---|---|
GET/POST/PATCH/DELETE |
/api/users/* |
CRUD usuarios |
GET |
/api/users/{id}/applications |
Apps del usuario (con flag granted individual y by_role) |
POST |
/api/users/{id}/applications |
Conceder app individual (body: {app_id}) |
DELETE |
/api/users/{id}/applications/{app_id} |
Revocar app individual |
GET/POST/PATCH/DELETE |
/api/apps/* |
CRUD aplicaciones |
POST |
/api/apps/{id}/rotate-secret |
Rotar client secret |
GET |
/api/apps/requests |
Listar solicitudes de alta |
GET |
/api/apps/requests/{id} |
Detalle de una solicitud |
POST |
/api/apps/requests/{id}/approve |
Aprobar (crea la app real) |
POST |
/api/apps/requests/{id}/reject |
Rechazar con motivo |
GET |
/api/audit |
Log de auditoria (filtrable) |
Accesible en https://login.pampl.ing/admin (requiere rol admin):
login_ms, request_app, approve_app_request, reject_app_request, grant_app, revoke_app).| Tabla | Descripcion |
|---|---|
users |
Usuarios (id, email, username, password_hash, display_name, role, is_active, microsoft_oid) |
applications |
Apps registradas (id, name, slug, domain, is_active, client_secret, allowed_roles) |
user_applications |
Permisos individuales (user_id, app_id, granted_at, granted_by) |
application_requests |
Solicitudes publicas pendientes de aprobacion (ver detalle abajo) |
refresh_tokens |
Tokens de refresco (user_id, token_hash, expires_at, revoked) |
audit_log |
Log de auditoria (user_id, action, app_id, ip_address, detail) |
users.microsoft_oidColumna nullable con el oid (object id) del usuario en el tenant de Microsoft. Es un identificador inmutable dentro del tenant. Se rellena la primera vez que el usuario entra por Microsoft. Usuarios creados por auto-registro anterior o por admin manual la tienen a NULL hasta que hagan login con Microsoft.
application_requests| Columna | Descripcion |
|---|---|
id |
PK |
name, slug, domain, description, allowed_roles |
Datos de la app propuesta |
requester_email |
Email del responsable humano (@pampling.com) |
request_token |
Token opaco para que el cliente consulte estado |
client_secret_pregen |
Client secret pregenerado; se usa al crear la app real al aprobar |
status |
pending / approved / rejected |
rejection_reason |
Motivo si rejected |
application_id |
FK a applications cuando se aprueba |
reviewed_by, reviewed_at |
Auditoria de la aprobacion/rechazo |
created_at |
Cuando llego la solicitud |
| Rol | Descripcion |
|---|---|
admin |
Acceso completo al panel de administracion |
user |
Usuario estandar autenticado (rol por defecto al entrar por Microsoft) |
viewer |
Solo lectura |
tienda |
Personal de tienda - por defecto solo acceso a pampling-incidencias |
pendiente |
Rol legado del auto-registro (ya no se crean nuevos); sin acceso a ninguna app |
Los roles especificos de cada app (ej:
requester,ai_teamen pampling-requests) se gestionan dentro de cada app, no en pampling-login.
El modulo pampling_auth.py se sirve como archivo estatico desde el propio login:
https://login.pampl.ing/integration/pampling_auth.py
Publico, sin autenticacion. Cualquier Claude o script puede bajarlo con curl y colocarlo en backend/pampling_auth.py de su app. Ya no hace falta acceso al repositorio privado de Bitbucket.
Alternativa: si estas trabajando dentro del repositorio de pampling-login, tambien esta en integration/pampling_auth.py.
"""
Pampling Auth - Modulo de integracion para apps cliente.
Copiar este archivo a backend/ de cada app que use pampling-login.
Requiere: pyjwt[crypto]>=2.8.0
Env vars:
PAMPLING_LOGIN_INTERNAL - URL interna para JWKS (Docker: http://pampling-login:8000)
PAMPLING_APP_SLUG - Slug de esta app en pampling-login (recomendado). Si se define,
el acceso se valida contra el campo 'apps' del JWT (rol + permisos
individuales). Ej: "incidencias"
PAMPLING_ALLOWED_ROLES - [Legacy] Roles permitidos en esta app. Fallback si no hay APP_SLUG.
Ej: "user,admin,viewer"
Uso en routers FastAPI:
from backend.pampling_auth import get_current_user, require_role
from fastapi import Depends
@router.get("/api/protected")
def protected(user: dict = Depends(get_current_user)):
...
@router.get("/api/admin-only")
def admin_only(user: dict = Depends(require_role("admin"))):
...
# Proteger un router completo:
router = APIRouter(prefix="/api/datos", dependencies=[Depends(get_current_user)])
"""
import os
from fastapi import HTTPException, Request, status
from jwt import PyJWKClient, decode, ExpiredSignatureError, InvalidTokenError
# URL publica - para redirects al navegador (siempre el dominio externo)
LOGIN_URL_PUBLIC = "https://login.pampl.ing"
# URL interna - para fetch JWKS servidor-a-servidor (en Docker: http://pampling-login:8000)
LOGIN_URL_INTERNAL = os.environ.get("PAMPLING_LOGIN_INTERNAL", LOGIN_URL_PUBLIC)
JWKS_URL = f"{LOGIN_URL_INTERNAL}/.well-known/jwks.json"
# Modo recomendado: valida contra campo 'apps' del JWT (rol + permisos individuales)
APP_SLUG = os.environ.get("PAMPLING_APP_SLUG", "").strip()
# Modo legacy: valida solo rol contra lista estatica
ALLOWED_ROLES = os.environ.get("PAMPLING_ALLOWED_ROLES", "")
_jwks_client = PyJWKClient(JWKS_URL, lifespan=86400)
_allowed_roles = {r.strip() for r in ALLOWED_ROLES.split(",") if r.strip()} if ALLOWED_ROLES else None
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
return request.cookies.get("pampling_token")
def _raise_unauth(request: Request, detail: str = "No autenticado"):
accept = request.headers.get("accept", "")
if "text/html" in accept:
redirect_url = str(request.url)
raise HTTPException(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
headers={"Location": f"{LOGIN_URL_PUBLIC}?redirect={redirect_url}"},
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
def get_current_user(request: Request) -> dict:
token = _extract_token(request)
if not token:
_raise_unauth(request)
try:
signing_key = _jwks_client.get_signing_key_from_jwt(token)
payload = decode(token, signing_key.key, algorithms=["RS256"])
except ExpiredSignatureError:
_raise_unauth(request, "Token expirado")
except InvalidTokenError:
_raise_unauth(request, "Token invalido")
user = {
"id": int(payload["sub"]),
"role": payload.get("role", "user"),
"email": payload.get("email", ""),
"display_name": payload.get("display_name", ""),
"username": payload.get("username", ""),
"apps": payload.get("apps", []),
}
# Modo recomendado: comprobar que el slug de esta app esta en el token.apps
if APP_SLUG:
if APP_SLUG not in user["apps"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes acceso a esta aplicacion. Solicita acceso a un administrador.",
)
# Modo legacy: solo valida rol (para apps que aun no han migrado)
elif _allowed_roles and user["role"] not in _allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes acceso a esta aplicacion",
)
return user
def require_role(*roles):
def checker(request: Request) -> dict:
user = get_current_user(request)
if user["role"] not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sin permisos")
return user
return checker
Integrar una app existente requiere 4 pasos:
Desde la app cliente:
curl -o backend/pampling_auth.py https://login.pampl.ing/integration/pampling_auth.py
O, si trabajas dentro del repo de pampling-login: cp integration/pampling_auth.py /ruta/a/mi-app/backend/pampling_auth.py.
En requirements.txt de la app:
pyjwt[crypto]>=2.8.0
En cada router que necesite autenticacion:
from backend.pampling_auth import get_current_user, require_role
from fastapi import Depends
# Ruta protegida (cualquier usuario autenticado con acceso a esta app)
@router.get("/api/datos")
def get_datos(user: dict = Depends(get_current_user)):
# user = {"id": 1, "role": "admin", "email": "...", "display_name": "...", "username": "...", "apps": [...]}
return {"data": "..."}
# Ruta con rol especifico
@router.get("/api/admin-only")
def admin_only(user: dict = Depends(require_role("admin"))):
return {"data": "..."}
Para proteger un router completo (todos sus endpoints):
from backend.pampling_auth import get_current_user
router = APIRouter(
prefix="/api/datos",
dependencies=[Depends(get_current_user)] # protege todos los endpoints
)
Recomendado (nuevo modelo con permisos individuales):
PAMPLING_LOGIN_INTERNAL=http://pampling-login:8000
PAMPLING_APP_SLUG=mi-app-slug
PAMPLING_APP_SLUG debe coincidir con el slug registrado en pampling-login para esta app.apps del JWT → soporta acceso por rol Y acceso individual.Legacy (solo roles, sin permisos individuales):
PAMPLING_LOGIN_INTERNAL=http://pampling-login:8000
PAMPLING_ALLOWED_ROLES=user,admin,viewer
PAMPLING_APP_SLUG tiene prioridad sobre PAMPLING_ALLOWED_ROLES.Migracion: si tu app ya usa
PAMPLING_ALLOWED_ROLES, puedes migrar anadiendoPAMPLING_APP_SLUG(se activa el nuevo modelo) y opcionalmente quitarPAMPLING_ALLOWED_ROLESmas tarde.
Si estas desarrollando una app desde cero, no hace falta que un humano te registre la app en el panel — puedes pedir el alta tu mismo desde tu propia sesion:
curl -X POST https://login.pampl.ing/api/apps/requests \
-H "Content-Type: application/json" \
-d '{
"name": "Mi App Nueva",
"slug": "mi-app-nueva",
"domain": "https://mi-app-nueva.pampl.ing",
"description": "Que hace mi app",
"requester_email": "responsable@pampling.com"
}'
La respuesta te devuelve las env vars listas para pegar y la URL de descarga del modulo. Configura la app normalmente y sigue desarrollando; el login empezara a validar sesiones cuando un admin apruebe la solicitud (recibiras email al requester_email).
| Tipo de request | Sin autenticacion |
|---|---|
Browser (Accept: text/html) |
Redirect 307 a login.pampl.ing?redirect=<url_actual> |
API (Accept: application/json) |
HTTP 401 JSON |
El modulo busca el JWT en este orden:
Authorization: Bearer <token>pampling_tokenRecomendacion: crear un wrapper api() reutilizable que gestione el 401 globalmente:
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json", ...opts.headers },
...opts,
});
if (res.status === 401) {
window.location.href = `https://login.pampl.ing?redirect=${encodeURIComponent(window.location.origin)}`;
throw new Error("AUTH_REDIRECT");
}
if (res.status === 403) {
const data = await res.json().catch(() => ({}));
alert(data.detail || "No tienes acceso a esta aplicacion");
throw new Error("NO_ACCESS");
}
if (!res.ok) throw new Error(await res.text());
return res.json();
}
Asi no hace falta comprobar el 401/403 en cada fetch individual.
Para apps que ya tienen auth propio (ej: pampling-requests):
from backend.auth import get_current_user, require_role por from backend.pampling_auth import get_current_user, require_role.login.pampl.ing.user es compatible: {id, role, email, display_name, username, apps}.Could not translate host name "login.pampl.ing" to address: Name or service not known
Causa: la app intenta resolver login.pampl.ing dentro de Docker. Ese dominio lo gestiona Traefik desde fuera de la red Docker.
Solucion: asegurarse de que:
PAMPLING_LOGIN_INTERNAL=http://pampling-login:8000 esta definida en Coolify.pampling-login (ya configurado en custom_docker_run_options).Desde cualquier contenedor en la red coolify, esta URL debe ser accesible:
http://pampling-login:8000/.well-known/jwks.json
Causa: el usuario esta autenticado pero no tiene esta app en su JWT (apps[]).
Solucion: desde el panel admin → Usuarios → boton "Apps" del usuario → marcar la app. O cambiarle el rol si esa app ya incluye ese rol en allowed_roles. El usuario necesita cerrar sesion y volver a entrar (o esperar al refresh) para que el nuevo JWT incluya la app.
Verificar que pyjwt[crypto]>=2.8.0 esta en requirements.txt. Sin la dependencia cryptography, PyJWT no puede verificar tokens RS256.
Causa: el tenant tiene restricciones sobre user consent y la app requiere admin consent para los permisos User.Read / openid / profile.
Solucion: un administrador del tenant debe entrar en Entra ID → App registration "Pampling Login" → API permissions → Grant admin consent for Republica Grafica SL. Tras eso los usuarios entran sin ver la pantalla de consentimiento.
Causa: se ha superado el rate limit de 5 solicitudes/hora por IP en POST /api/apps/requests.
Solucion: esperar una hora o solicitar desde otra IP. Si ocurre con frecuencia legitima, revisar el limite en backend/routers/app_requests.py.
git push origin main
curl -s -X POST -H 'Authorization: Bearer <COOLIFY_TOKEN>' -d '{}' \
http://192.168.1.10:8000/api/v1/applications/p14hynlmrwbu19olodlc4nz5/restart
Cuando el cambio toca requirements.txt (nuevas dependencias) usar /deploy?force=false en vez de /restart para forzar rebuild:
curl -s -X POST -H 'Authorization: Bearer <COOLIFY_TOKEN>' \
"http://192.168.1.10:8000/api/v1/deploy?uuid=p14hynlmrwbu19olodlc4nz5&force=false"
← Deploy · Arquitectura →