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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2OAuth click-through endpoints (PR O).
4Two routes mounted at ``/api/oauth/<channel_type>/...``:
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.
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.
17Why a new blueprint instead of admin/api.py:
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.
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)
31So the OAuth flow is one populator into the same binding shape the
32paste-form flow writes — no parallel infrastructure.
33"""
35from __future__ import annotations
37import logging
38import os
39from typing import Dict, Any, Optional, Tuple
40from urllib.parse import urlencode
42import requests
43from flask import Blueprint, request, jsonify, g, current_app, Response
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)
56logger = logging.getLogger(__name__)
58oauth_bp = Blueprint("channels_oauth", __name__, url_prefix="/api/oauth")
61# ───────────────────────────────────────────────────────────────────
62# Helpers
63# ───────────────────────────────────────────────────────────────────
65def _public_base_url() -> str:
66 """The externally-reachable URL the OAuth provider will redirect to.
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('/')
77def _redirect_uri(channel_type: str) -> str:
78 return f"{_public_base_url()}/api/oauth/{channel_type}/callback"
81def _client_credentials(channel_type: str) -> Tuple[Optional[str], Optional[str]]:
82 """Read the operator-supplied OAuth app credentials from env.
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 )
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
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()
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.
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.
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.
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)
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()
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 )
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'
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
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
197# ───────────────────────────────────────────────────────────────────
198# /api/oauth/<channel_type>/start
199# ───────────────────────────────────────────────────────────────────
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.
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
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
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 })
247# ───────────────────────────────────────────────────────────────────
248# /api/oauth/<channel_type>/callback (PUBLIC)
249# ───────────────────────────────────────────────────────────────────
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).
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)
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
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}'
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]}'
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
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
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:
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.
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>'''
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()
384 code = request.args.get('code')
385 state = request.args.get('state')
386 error = request.args.get('error')
388 if error:
389 return Response(
390 _close_page_html(channel_type, False, f'Provider error: {error}'),
391 mimetype='text/html', status=400,
392 )
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 )
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 )
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 )
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
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 )
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 )