agents/wordpress_agent/mindx_auth.py · 245 lines · 9.8 KB · Python source
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"]