Coverage for integrations / channels / oauth_api.py: 81.6%

147 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2OAuth click-through endpoints (PR O). 

3 

4Two routes mounted at ``/api/oauth/<channel_type>/...``: 

5 

6 POST /api/oauth/<channel_type>/start (Bearer auth required) 

7 Generates state + PKCE, builds the provider's authorize URL, 

8 returns it. Caller (Connect_Channel agent tool, web overlay, 

9 admin page) opens the URL in the browser. 

10 

11 GET /api/oauth/<channel_type>/callback (PUBLIC — provider redirects) 

12 Verifies state, exchanges the auth code for tokens, writes the 

13 channel binding via the existing register_channel path, returns 

14 an HTML page that posts ``oauth_complete`` to the opener and 

15 closes itself. 

16 

17Why a new blueprint instead of admin/api.py: 

18 

19 /callback must be reachable by the OAuth provider's user-agent — 

20 the user is mid-redirect from discord.com / accounts.google.com / 

21 facebook.com and isn't carrying our Bearer header. The admin 

22 blueprint's ``before_request`` hook would 401 every callback. A 

23 separate blueprint lets /start keep the same auth pattern while 

24 /callback uses state-token-based identity recovery. 

25 

26This file contains only the HTTP layer. All policy lives in: 

27 - ``integrations.channels.metadata`` — per-channel OAuth params 

28 - ``integrations.channels.security`` — OAuthStateManager + PKCE 

29 - ``integrations.channels.agent_tools`` — register_channel (binding write) 

30 

31So the OAuth flow is one populator into the same binding shape the 

32paste-form flow writes — no parallel infrastructure. 

33""" 

34 

35from __future__ import annotations 

36 

37import logging 

38import os 

39from typing import Dict, Any, Optional, Tuple 

40from urllib.parse import urlencode 

41 

42import requests 

43from flask import Blueprint, request, jsonify, g, current_app, Response 

44 

45from .metadata import ( 

46 CHANNEL_CATALOG, 

47 is_oauth_capable, 

48 is_oauth_configured, 

49 get_channel_metadata, 

50) 

51from .security import ( 

52 get_oauth_state_manager, 

53 generate_pkce_pair, 

54) 

55 

56logger = logging.getLogger(__name__) 

57 

58oauth_bp = Blueprint("channels_oauth", __name__, url_prefix="/api/oauth") 

59 

60 

61# ─────────────────────────────────────────────────────────────────── 

62# Helpers 

63# ─────────────────────────────────────────────────────────────────── 

64 

65def _public_base_url() -> str: 

66 """The externally-reachable URL the OAuth provider will redirect to. 

67 

68 Order: 

69 1. HARTOS_PUBLIC_URL env (operator-set, e.g. https://hartos.example.com) 

70 2. request.url_root (works for localhost / direct access; will fail 

71 for cloud-hosted providers since they can't reach 127.0.0.1) 

72 """ 

73 return (os.environ.get('HARTOS_PUBLIC_URL') or '').rstrip('/') \ 

74 or request.url_root.rstrip('/') 

75 

76 

77def _redirect_uri(channel_type: str) -> str: 

78 return f"{_public_base_url()}/api/oauth/{channel_type}/callback" 

79 

80 

81def _client_credentials(channel_type: str) -> Tuple[Optional[str], Optional[str]]: 

82 """Read the operator-supplied OAuth app credentials from env. 

83 

84 Single source of truth: ``HARTOS_OAUTH_CLIENT_<TYPE>`` and 

85 ``HARTOS_OAUTH_SECRET_<TYPE>``. When unset, ``is_oauth_configured`` 

86 returns False and Connect_Channel falls back to paste-form. 

87 """ 

88 upper = channel_type.upper() 

89 return ( 

90 os.environ.get(f'HARTOS_OAUTH_CLIENT_{upper}'), 

91 os.environ.get(f'HARTOS_OAUTH_SECRET_{upper}'), 

92 ) 

93 

94 

95def _require_user() -> Optional[Tuple[Response, int]]: 

96 """Mini auth gate for /start. Mirrors admin_bp.before_request but 

97 scoped to this blueprint so /callback can stay public. 

98 """ 

99 from integrations.social.auth import _get_user_from_token 

100 auth_header = request.headers.get('Authorization', '') 

101 if not auth_header.startswith('Bearer '): 

102 return jsonify({'success': False, 'error': 'Authentication required'}), 401 

103 token = auth_header[7:] 

104 user, db = _get_user_from_token(token) 

105 if user is None: 

106 if db: 

107 db.close() 

108 return jsonify({'success': False, 'error': 'Invalid token'}), 401 

109 g.user = user 

110 g.user_id = int(user.id) 

111 g.db = db 

112 return None 

113 

114 

115@oauth_bp.teardown_request 

116def _oauth_teardown(exc): 

117 db = getattr(g, 'db', None) 

118 if db: 

119 try: 

120 if exc: 

121 db.rollback() 

122 else: 

123 db.commit() 

124 finally: 

125 db.close() 

126 

127 

128# ─────────────────────────────────────────────────────────────────── 

129# Authorize-URL builder (single source of truth) 

130# ─────────────────────────────────────────────────────────────────── 

131# 

132# Two callers consume this: 

133# 1. POST /api/oauth/<channel_type>/start (HTTP layer below). 

134# 2. hart_intelligence_entry._handle_connect_channel_tool — 

135# the agent emits ``oauth_link`` directly without going through 

136# HTTP. Both must produce identical URLs (identical params, 

137# identical state-store entries) — keeping the builder in one 

138# place is the structural fix for that invariant. 

139# 

140# Returns ``(authorize_url, state)`` so callers can echo state back to 

141# the client if they need correlation. 

142 

143def build_authorize_url( 

144 user_id: int, 

145 channel_type: str, 

146 return_to: str = '', 

147) -> Tuple[str, str]: 

148 """Build the provider's authorize URL with state + PKCE. 

149 

150 Caller-side preconditions (NOT re-checked here so the helper stays 

151 pure): channel_type must be OAuth-capable AND OAuth-configured. 

152 Use ``is_oauth_capable`` / ``is_oauth_configured`` before calling. 

153 

154 Side effect: generates and stores a state record in the global 

155 OAuthStateManager. The returned URL embeds that state token. 

156 """ 

157 meta = get_channel_metadata(channel_type) or {} 

158 client_id, _ = _client_credentials(channel_type) 

159 redirect_uri = _redirect_uri(channel_type) 

160 

161 # PKCE for providers that require it (Google, Microsoft, Twitter v2). 

162 code_verifier = None 

163 code_challenge = None 

164 if meta.get('oauth_uses_pkce'): 

165 code_verifier, code_challenge = generate_pkce_pair() 

166 

167 state = get_oauth_state_manager().generate_state( 

168 user_id=user_id, 

169 channel_type=channel_type, 

170 code_verifier=code_verifier, 

171 return_to=return_to, 

172 ) 

173 

174 params: Dict[str, Any] = { 

175 'client_id': client_id, 

176 'redirect_uri': redirect_uri, 

177 'response_type': 'code', 

178 'scope': meta.get('oauth_scopes') or '', 

179 'state': state, 

180 } 

181 if code_challenge: 

182 params['code_challenge'] = code_challenge 

183 params['code_challenge_method'] = 'S256' 

184 

185 # Per-provider extras (Discord permissions, Google access_type, etc). 

186 for k, v in (meta.get('oauth_extra_params') or {}).items(): 

187 params[k] = v 

188 

189 authorize_url = f"{meta['oauth_authorize_url']}?{urlencode(params)}" 

190 logger.info( 

191 "OAuth authorize URL built: user=%s channel=%s redirect=%s pkce=%s", 

192 user_id, channel_type, redirect_uri, bool(code_verifier), 

193 ) 

194 return authorize_url, state 

195 

196 

197# ─────────────────────────────────────────────────────────────────── 

198# /api/oauth/<channel_type>/start 

199# ─────────────────────────────────────────────────────────────────── 

200 

201@oauth_bp.route("/<channel_type>/start", methods=["POST"]) 

202def oauth_start(channel_type: str): 

203 """Build the provider's authorize URL with state + PKCE. 

204 

205 Body (optional): {"return_to": "<deep-link>"} — the deep link the 

206 callback's close-page should ping back so the agent overlay can 

207 dismiss itself. Stored alongside the state record; not validated 

208 here (validated by the client when it consumes the postMessage). 

209 """ 

210 auth_err = _require_user() 

211 if auth_err is not None: 

212 return auth_err 

213 

214 channel_type = (channel_type or '').lower().strip() 

215 if not is_oauth_capable(channel_type): 

216 return jsonify({ 

217 'success': False, 

218 'error': f'Channel {channel_type!r} is not OAuth-capable.', 

219 }), 400 

220 if not is_oauth_configured(channel_type): 

221 return jsonify({ 

222 'success': False, 

223 'error': ( 

224 f'OAuth click-through is not configured for {channel_type}. ' 

225 f'Set HARTOS_OAUTH_CLIENT_{channel_type.upper()} and ' 

226 f'HARTOS_OAUTH_SECRET_{channel_type.upper()} env vars, or ' 

227 f'use the paste-token flow.' 

228 ), 

229 }), 400 

230 

231 body = request.get_json(silent=True) or {} 

232 return_to = body.get('return_to') or '' 

233 authorize_url, state = build_authorize_url( 

234 user_id=g.user_id, 

235 channel_type=channel_type, 

236 return_to=return_to, 

237 ) 

238 return jsonify({ 

239 'success': True, 

240 'authorize_url': authorize_url, 

241 'redirect_uri': _redirect_uri(channel_type), 

242 'state': state, # exposed so client can correlate; provider 

243 # echoes it back, we re-validate. 

244 }) 

245 

246 

247# ─────────────────────────────────────────────────────────────────── 

248# /api/oauth/<channel_type>/callback (PUBLIC) 

249# ─────────────────────────────────────────────────────────────────── 

250 

251def _exchange_code( 

252 channel_type: str, 

253 code: str, 

254 code_verifier: Optional[str], 

255) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: 

256 """POST to the provider's token endpoint, return (token_dict, error). 

257 

258 The token_dict shape varies per provider — Slack v2 nests bot creds 

259 under ``bot``; Google flat-returns ``{access_token, refresh_token}``; 

260 Meta returns ``{access_token, expires_in}``. Caller maps fields via 

261 ``oauth_token_response_map``. 

262 """ 

263 meta = get_channel_metadata(channel_type) or {} 

264 client_id, client_secret = _client_credentials(channel_type) 

265 redirect_uri = _redirect_uri(channel_type) 

266 

267 data = { 

268 'grant_type': 'authorization_code', 

269 'code': code, 

270 'redirect_uri': redirect_uri, 

271 'client_id': client_id, 

272 'client_secret': client_secret, 

273 } 

274 if code_verifier: 

275 data['code_verifier'] = code_verifier 

276 

277 headers = {'Accept': 'application/json'} 

278 try: 

279 resp = requests.post( 

280 meta['oauth_token_url'], 

281 data=data, 

282 headers=headers, 

283 timeout=15, 

284 ) 

285 except requests.RequestException as e: 

286 return None, f'Token exchange request failed: {e}' 

287 

288 try: 

289 token = resp.json() 

290 except ValueError: 

291 return None, f'Token exchange returned non-JSON (status {resp.status_code}): {resp.text[:200]}' 

292 

293 if resp.status_code >= 400 or token.get('error'): 

294 return None, ( 

295 f'Token exchange rejected (status {resp.status_code}): ' 

296 f'{token.get("error_description") or token.get("error") or token}' 

297 ) 

298 return token, None 

299 

300 

301def _walk(d: Dict[str, Any], dotted: str) -> Any: 

302 """Resolve a dotted path like ``bot.access_token`` against ``d``.""" 

303 cur: Any = d 

304 for part in dotted.split('.'): 

305 if not isinstance(cur, dict): 

306 return None 

307 cur = cur.get(part) 

308 return cur 

309 

310 

311def _close_page_html(channel_type: str, ok: bool, message: str) -> str: 

312 """HTML the OAuth provider's redirect lands on. Three dismissal 

313 paths so the same page works for web popups, RN WebView popups, 

314 and mobile system browsers: 

315 

316 1. ``window.opener.postMessage`` — web (popup window). Skipped 

317 when there is no opener (mobile system-browser case) so we 

318 don't trigger an unnecessary "Open in Hevolve?" prompt on 

319 desktop browsers that aren't popups. 

320 2. ``window.location = 'hevolve://oauth-complete?…'`` — mobile. 

321 deepLinkService picks the URI up and emits 

322 ``DeviceEventEmitter('onAgentOAuthComplete')`` to dismiss the 

323 OAuthLinkCard. 

324 3. ``window.close()`` — fallback for both surfaces. 

325 

326 Escaping: 

327 - HTML body uses ``html.escape`` so the user-visible message 

328 can't break out of <p>. 

329 - JS payload is built via ``json.dumps`` so no string injected 

330 into ``message`` (including ``</script>`` or backslashes) can 

331 break out of the JSON literal in the script tag. 

332 - Deep-link query is built via ``urlencode`` so ``&``/``=``/`#`` 

333 in the message don't corrupt the param parser on the RN side. 

334 """ 

335 import html as _html 

336 import json as _json 

337 payload_obj = { 

338 'type': 'oauth_complete', 

339 'channel_type': channel_type, 

340 'ok': bool(ok), 

341 'message': message or '', 

342 } 

343 # ensure_ascii=True keeps the JSON safe in HTML (no </script> 

344 # surrogate escapes needed); the </ catastrophe is handled by the 

345 # extra replace below. 

346 payload_js = _json.dumps(payload_obj, ensure_ascii=True).replace( 

347 '</', '<\\/' 

348 ) 

349 deep_link_qs = urlencode({ 

350 'channel_type': channel_type, 

351 'ok': 'true' if ok else 'false', 

352 'message': message or '', 

353 }) 

354 deep_link = f"hevolve://oauth-complete?{deep_link_qs}" 

355 body_msg = _html.escape(message or '') 

356 return f'''<!doctype html> 

357<html><head><meta charset="utf-8"><title>Channel connected</title> 

358<style>body{{font:14px system-ui;margin:48px;text-align:center}} 

359.ok{{color:#1b8a3a}}.err{{color:#c0392b}}</style></head> 

360<body> 

361<h2 class="{ 'ok' if ok else 'err' }">{ 'Connected.' if ok else 'Connection failed.' }</h2> 

362<p>{body_msg}</p> 

363<p><small>This window will close automatically.</small></p> 

364<script> 

365try {{ 

366 if (window.opener) {{ 

367 window.opener.postMessage({payload_js}, "*"); 

368 }} else {{ 

369 window.location = {_json.dumps(deep_link)}; 

370 }} 

371}} catch(e){{}} 

372setTimeout(function(){{ try{{window.close();}}catch(e){{}} }}, 1500); 

373</script> 

374</body></html>''' 

375 

376 

377@oauth_bp.route("/<channel_type>/callback", methods=["GET"]) 

378def oauth_callback(channel_type: str): 

379 """Provider-redirect target. No Bearer auth — state token is the 

380 proof of identity (single-use, 10-min TTL, replay-protected). 

381 """ 

382 channel_type = (channel_type or '').lower().strip() 

383 

384 code = request.args.get('code') 

385 state = request.args.get('state') 

386 error = request.args.get('error') 

387 

388 if error: 

389 return Response( 

390 _close_page_html(channel_type, False, f'Provider error: {error}'), 

391 mimetype='text/html', status=400, 

392 ) 

393 

394 if not code or not state: 

395 return Response( 

396 _close_page_html(channel_type, False, 'Missing code or state.'), 

397 mimetype='text/html', status=400, 

398 ) 

399 

400 ctx = get_oauth_state_manager().verify_state(state) 

401 if ctx is None: 

402 return Response( 

403 _close_page_html(channel_type, False, 'Invalid or expired state token.'), 

404 mimetype='text/html', status=400, 

405 ) 

406 if ctx['channel_type'] != channel_type: 

407 # State token was for a different channel — possible CSRF. 

408 return Response( 

409 _close_page_html(channel_type, False, 'State / channel mismatch.'), 

410 mimetype='text/html', status=400, 

411 ) 

412 

413 token, err = _exchange_code(channel_type, code, ctx.get('code_verifier')) 

414 if token is None: 

415 return Response( 

416 _close_page_html(channel_type, False, err or 'Token exchange failed.'), 

417 mimetype='text/html', status=502, 

418 ) 

419 

420 # Map provider response → our binding-config shape via the metadata 

421 # response map. Empty map (e.g. LINE) → bounce to paste form; 

422 # caller's overlay will receive ok=true but message indicates next step. 

423 meta = get_channel_metadata(channel_type) or {} 

424 response_map = meta.get('oauth_token_response_map') or {} 

425 config: Dict[str, Any] = {} 

426 for src_path, dst_key in response_map.items(): 

427 val = _walk(token, src_path) 

428 if val is not None: 

429 config[dst_key] = val 

430 

431 if not config and response_map: 

432 # Mapped fields all came back null — shouldn't happen on a 2xx 

433 # response; treat as failure so we don't write an empty binding. 

434 return Response( 

435 _close_page_html(channel_type, False, 'Provider response missing expected token fields.'), 

436 mimetype='text/html', status=502, 

437 ) 

438 

439 # Write binding via the existing register_channel path — same single 

440 # populator the paste form goes through. No parallel write path. 

441 try: 

442 from integrations.channels.agent_tools import build_channel_tool_closures 

443 tool_ctx = {'user_id': ctx['user_id'], 'prompt_id': None} 

444 tools = build_channel_tool_closures(tool_ctx) or [] 

445 register_fn = next( 

446 (t[2] for t in tools 

447 if isinstance(t, tuple) and len(t) >= 3 and t[0] == 'register_channel'), 

448 None, 

449 ) 

450 if register_fn is None: 

451 return Response( 

452 _close_page_html(channel_type, False, 'Channel registration is unavailable.'), 

453 mimetype='text/html', status=500, 

454 ) 

455 import json as _json 

456 result = register_fn(channel_type, _json.dumps(config)) 

457 ok = isinstance(result, str) and 'registered and enabled' in result 

458 return Response( 

459 _close_page_html(channel_type, ok, result if isinstance(result, str) else 'Connected.'), 

460 mimetype='text/html', 

461 status=200 if ok else 502, 

462 ) 

463 except Exception as e: 

464 logger.exception("OAuth callback register_channel failed") 

465 return Response( 

466 _close_page_html(channel_type, False, f'Registration error: {e}'), 

467 mimetype='text/html', status=500, 

468 )