eb8dbf66a1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.3 KiB
Python
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()
|