Files
fn_registry/python/functions/infra/hoppscotch_login.py
T
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00

180 lines
6.3 KiB
Python

"""Login headless contra un Hoppscotch self-hosted via magic link.
Reproduce el flujo de magic link de Hoppscotch sin navegador, leyendo el
correo de verificacion desde una instancia Mailpit de pruebas:
1. POST /v1/auth/signin -> deviceIdentifier
2. GET mailpit messages -> ultimo correo "Sign in" para ese email
3. GET mailpit message/{id} -> extrae el token (?token=...) del cuerpo
4. POST /v1/auth/verify -> Set-Cookie access_token + refresh_token
Devuelve los JWT de sesion (access_token / refresh_token). El access_token es
el que las mutations GraphQL protegidas por GqlAuthGuard esperan en la cookie
`access_token` (no en el header Authorization).
"""
import re
import requests
# El correo de Hoppscotch incluye un enlace con ?token=<jwt>. El token es un
# JWT (3 segmentos base64url separados por puntos), asi que aceptamos letras,
# digitos, guion, guion bajo y punto.
_TOKEN_RE = re.compile(r"token=([A-Za-z0-9_\-.]+)")
def hoppscotch_login(
email: str,
*,
backend_url: str = "http://localhost:3170",
mailpit_url: str = "http://localhost:8025",
timeout_s: float = 15.0,
) -> dict:
"""Obtiene un JWT de sesion de Hoppscotch via magic link (headless).
Args:
email: correo del usuario que inicia sesion. Debe poder recibir el
correo de verificacion en la instancia Mailpit indicada.
backend_url: base del backend Hoppscotch (sin barra final). El endpoint
REST de auth cuelga de ``{backend_url}/v1/auth/...``.
mailpit_url: base de la API de Mailpit donde aterriza el correo de
verificacion (sin barra final).
timeout_s: timeout por request HTTP en segundos.
Returns:
Dict. En exito:
``{"status": "ok", "access_token": str, "refresh_token": str,
"email": str}``. En error (signin no 201, no llega correo, token no
encontrado, verify no 200, o fallo de transporte):
``{"status": "error", "error": str}``.
"""
session = requests.Session()
try:
# 1) Signin: pide el magic link. Respuesta 201 con deviceIdentifier.
signin = session.post(
f"{backend_url}/v1/auth/signin",
json={"email": email},
timeout=timeout_s,
)
if signin.status_code != 201:
return {
"status": "error",
"error": (
f"signin returned {signin.status_code} "
f"(expected 201): {signin.text[:200]}"
),
}
try:
device_identifier = signin.json().get("deviceIdentifier")
except ValueError:
device_identifier = None
if not device_identifier:
return {
"status": "error",
"error": "signin response missing deviceIdentifier",
}
# 2) Localiza el correo de verificacion mas reciente para este email.
messages = session.get(
f"{mailpit_url}/api/v1/messages",
params={"limit": 5},
timeout=timeout_s,
)
if messages.status_code != 200:
return {
"status": "error",
"error": (
f"mailpit messages returned {messages.status_code} "
"(expected 200)"
),
}
try:
inbox = messages.json().get("messages") or []
except ValueError:
return {
"status": "error",
"error": "mailpit messages response is not valid JSON",
}
message_id = None
for msg in inbox:
recipients = msg.get("To") or []
to_match = any(
(addr.get("Address") or "").lower() == email.lower()
for addr in recipients
)
subject = msg.get("Subject") or ""
if to_match and "Sign in" in subject:
message_id = msg.get("ID")
break
if not message_id:
return {
"status": "error",
"error": f"no 'Sign in' email found for {email} in mailpit",
}
# 3) Descarga el cuerpo del correo y extrae el token.
message = session.get(
f"{mailpit_url}/api/v1/message/{message_id}",
timeout=timeout_s,
)
if message.status_code != 200:
return {
"status": "error",
"error": (
f"mailpit message returned {message.status_code} "
"(expected 200)"
),
}
try:
body = message.json()
except ValueError:
return {
"status": "error",
"error": "mailpit message response is not valid JSON",
}
haystack = f"{body.get('Text') or ''}\n{body.get('HTML') or ''}"
token_match = _TOKEN_RE.search(haystack)
if not token_match:
return {
"status": "error",
"error": "magic-link token not found in verification email",
}
token = token_match.group(1)
# 4) Verify: canjea el token + deviceIdentifier por las cookies de
# sesion (access_token / refresh_token).
verify = session.post(
f"{backend_url}/v1/auth/verify",
json={"token": token, "deviceIdentifier": device_identifier},
timeout=timeout_s,
)
if verify.status_code != 200:
return {
"status": "error",
"error": (
f"verify returned {verify.status_code} "
f"(expected 200): {verify.text[:200]}"
),
}
access_token = session.cookies.get("access_token")
refresh_token = session.cookies.get("refresh_token")
if not access_token:
return {
"status": "error",
"error": "verify succeeded but no access_token cookie was set",
}
return {
"status": "ok",
"access_token": access_token,
"refresh_token": refresh_token,
"email": email,
}
except requests.RequestException as exc:
return {"status": "error", "error": f"transport error: {exc}"}
finally:
session.close()