agents/wordpress_agent/mindx_auth.py · 245 lines · 9.8 KB · Python source
agents/wordpress_agent/mindx_auth.py · 245 lines · 9.8 KBrawGitHub
1# SPDX-License-Identifier: Apache-2.0
2"""mindX-side client for the *mindx-publish-auth* WordPress plugin.
3 
4The plugin exposes three endpoints under ``/wp-json/mindx/v1/auth/``:
5 
6  GET  /challenge                → one-time challenge text + id
7  POST /verify  {id, address, sig} → short-lived HS256 JWT
8  GET  /whoami  (Bearer)         → debug helper
9 
10This module drives that protocol with the vault-held wordpress.agent
11wallet as the signing identity. The credential surface on the wire is
12*only* the signature — no passwords, no Application Passwords, no
13secrets in env vars.
14 
15Falls back gracefully when the plugin is not installed (challenge
16endpoint returns 404): caller can then opt into Basic Auth with an
17Application Password if available, or fail soft.
18 
19Public surface
20--------------
21 
22    auth_client = MindXAuthClient(base_url="https://rage.pythai.net")
23    token = await auth_client.get_token()                   # JWT, cached until ~near-exp
24    headers = await auth_client.bearer_headers()            # {"Authorization": "Bearer ..."}
25    is_available = await auth_client.plugin_present()       # bool, cached
26"""
27from __future__ import annotations
28 
29import asyncio
30import logging
31import time
32from dataclasses import dataclass
33from typing import Optional
34 
35import httpx
36 
37from .vault_creds import load_wp_settings_from_vault, sign_with_agent_wallet
38 
39logger = logging.getLogger("wordpress_agent.mindx_auth")
40 
41# Browser-shaped UA so Hostinger's WAF doesn't 403 the request.
42_DEFAULT_UA = (
43    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
44    "(KHTML, like Gecko) Chrome/120 Safari/537.36 mindX-wordpress-agent/0.4"
45)
46 
47 
48@dataclass
49class _CachedToken:
50    token: str
51    expires_at: float    # unix seconds
52    user_id: int
53 
54    def expiring_soon(self, skew: float = 60.0) -> bool:
55        """True if the token expires within ``skew`` seconds."""
56        return time.time() + skew >= self.expires_at
57 
58 
59class MindXAuthClient:
60    """Async client for the mindx-publish-auth REST endpoints.
61 
62    Thread-safe under a single asyncio loop. Tokens are cached in
63    memory only — re-instantiating the client forces a fresh
64    challenge/verify round-trip.
65    """
66 
67    PATH_CHALLENGE = "/wp-json/mindx/v1/auth/challenge"
68    PATH_VERIFY    = "/wp-json/mindx/v1/auth/verify"
69    PATH_WHOAMI    = "/wp-json/mindx/v1/auth/whoami"
70    PATH_DIAGNOSE  = "/wp-json/mindx/v1/auth/diagnose"
71 
72    def __init__(
73        self,
74        base_url: Optional[str] = None,
75        *,
76        timeout_s: float = 30.0,
77        user_agent: str = _DEFAULT_UA,
78    ):
79        # If base_url is not provided, derive it from the wordpress.agent
80        # vault entry — same source of truth as the rest of the wp.agent.
81        self._explicit_base_url = base_url
82        self._timeout = timeout_s
83        self._ua = user_agent
84        self._token: Optional[_CachedToken] = None
85        self._lock = asyncio.Lock()
86        self._plugin_present: Optional[bool] = None    # cached after first probe
87 
88    # ─── Public API ─────────────────────────────────────────────
89 
90    async def get_token(self, *, force: bool = False) -> Optional[str]:
91        """Return a valid JWT for the wordpress.agent wallet. Performs
92        a challenge/verify round-trip if no token is cached or the
93        cached one is expiring within 60s. Returns ``None`` on any
94        failure (logged); caller decides on the fallback."""
95        async with self._lock:
96            if not force and self._token and not self._token.expiring_soon():
97                return self._token.token
98            token = await self._mint_token()
99            if token is None:
100                return None
101            self._token = token
102            return token.token
103 
104    async def bearer_headers(self) -> dict:
105        """Convenience: returns ``{"Authorization": "Bearer <jwt>"}``
106        ready to pass to httpx. Empty dict on failure."""
107        tok = await self.get_token()
108        if tok is None:
109            return {}
110        return {"Authorization": f"Bearer {tok}"}
111 
112    async def plugin_present(self) -> bool:
113        """True if the plugin's /diagnose endpoint responds with the
114        expected shape. Cached after the first probe (good for the
115        lifetime of the client). Used to detect "plugin not installed"
116        so callers can fall back to Basic Auth where appropriate."""
117        if self._plugin_present is not None:
118            return self._plugin_present
119        base = await self._base_url()
120        if base is None:
121            self._plugin_present = False
122            return False
123        try:
124            async with httpx.AsyncClient(
125                timeout=self._timeout, headers={"User-Agent": self._ua}
126            ) as c:
127                r = await c.get(base + self.PATH_DIAGNOSE)
128            self._plugin_present = (
129                r.status_code == 200
130                and r.headers.get("content-type", "").startswith("application/json")
131                and "plugin_version" in r.json()
132            )
133        except Exception as e:   # pragma: no cover — defensive
134            logger.warning(f"mindx_auth: plugin probe failed: {e}")
135            self._plugin_present = False
136        return self._plugin_present
137 
138    # ─── Internals ──────────────────────────────────────────────
139 
140    async def _base_url(self) -> Optional[str]:
141        if self._explicit_base_url:
142            return self._explicit_base_url.rstrip("/")
143        try:
144            settings = load_wp_settings_from_vault()
145        except Exception as e:
146            logger.warning(f"mindx_auth: vault settings load failed: {e}")
147            return None
148        if settings is None:
149            return None
150        return str(settings.base_url).rstrip("/")
151 
152    async def _mint_token(self) -> Optional[_CachedToken]:
153        base = await self._base_url()
154        if base is None:
155            logger.warning("mindx_auth: no base_url available (vault unconfigured)")
156            return None
157        # Probe the vault for wordpress.agent:pk BEFORE making any HTTP
158        # request. If the vault doesn't have the wallet provisioned, we
159        # can't sign anything, so issuing the challenge would be wasted.
160        # This also keeps the no-vault test path silent (no unexpected
161        # GETs to /auth/challenge that would trip pytest_httpx assertions).
162        probe = sign_with_agent_wallet("__mindx_auth_probe__")
163        if probe is None:
164            logger.info(
165                "mindx_auth: wordpress.agent wallet not in vault; "
166                "skipping challenge round-trip"
167            )
168            return None
169        try:
170            async with httpx.AsyncClient(
171                timeout=self._timeout, headers={"User-Agent": self._ua}
172            ) as c:
173                # 1. Fetch a challenge.
174                r = await c.get(base + self.PATH_CHALLENGE)
175                if r.status_code == 404:
176                    logger.warning(
177                        "mindx_auth: /wp-json/mindx/v1/auth/challenge returned 404 — "
178                        "mindx-publish-auth plugin is not installed or not activated"
179                    )
180                    self._plugin_present = False
181                    return None
182                if r.status_code != 200:
183                    logger.warning(
184                        f"mindx_auth: challenge fetch returned {r.status_code}: "
185                        f"{r.text[:200]!r}"
186                    )
187                    return None
188                chal = r.json()
189                challenge_id = chal.get("challenge_id")
190                message      = chal.get("message")
191                if not challenge_id or not message:
192                    logger.warning(
193                        f"mindx_auth: challenge response missing fields: {chal!r}"
194                    )
195                    return None
196 
197                # 2. Sign with the vault-held wordpress.agent wallet.
198                sig_result = sign_with_agent_wallet(message)
199                if sig_result is None:
200                    logger.warning(
201                        "mindx_auth: sign_with_agent_wallet returned None — "
202                        "wordpress.agent:pk is not in the vault"
203                    )
204                    return None
205                signature, address = sig_result
206 
207                # 3. Post (challenge_id, address, signature) to /verify.
208                r2 = await c.post(
209                    base + self.PATH_VERIFY,
210                    json={
211                        "challenge_id": challenge_id,
212                        "address": address,
213                        "signature": signature,
214                    },
215                )
216                if r2.status_code != 200:
217                    body = r2.text[:300]
218                    logger.warning(
219                        f"mindx_auth: verify returned {r2.status_code}: {body!r}"
220                    )
221                    return None
222                body = r2.json()
223                tok = body.get("token")
224                exp = float(body.get("expires_at") or 0)
225                uid = int(body.get("user_id") or 0)
226                if not tok or not exp:
227                    logger.warning(
228                        f"mindx_auth: verify response missing token/expires: {body!r}"
229                    )
230                    return None
231                logger.info(
232                    f"mindx_auth: JWT minted for user_id={uid}, valid for "
233                    f"{int(exp - time.time())}s"
234                )
235                return _CachedToken(token=tok, expires_at=exp, user_id=uid)
236 
237        except httpx.HTTPError as e:
238            logger.warning(f"mindx_auth: transport error: {e}")
239            return None
240        except Exception as e:   # pragma: no cover — defensive
241            logger.warning(f"mindx_auth: unexpected error: {e}")
242            return None
243 
244 
245__all__ = ["MindXAuthClient"]

All DocumentsBook of mindXAPI Reference