diff --git a/AUTH0_SETUP.md b/AUTH0_SETUP.md new file mode 100644 index 0000000..6689439 --- /dev/null +++ b/AUTH0_SETUP.md @@ -0,0 +1,125 @@ +# Auth0 for AI Agents — StandIn Setup + +This guide wires StandIn to **Auth0 for AI Agents** — Auth0's product line built specifically for autonomous agent security, separate from generic Universal Login. It targets the LA Hacks 2026 *Best Use of Auth0 AI Agents* prize category. + +## What's wired + +| Auth0 AI primitive | StandIn surface | File | +|---|---|---| +| **Token Vault** — per-user OAuth tokens for 3rd-party APIs | `status_agent` Slack calls use the requesting user's Slack token instead of a shared bot token | [`backend/agents/status_agent/agent.py`](backend/agents/status_agent/agent.py) | +| **CIBA** — push approval to user's phone | `perform_action` initiates a CIBA push when a `send_slack` / `send_email` / `schedule_meeting` action hits the approval gate; `/approvals/ciba-poll` auto-executes on approve | [`backend/agents/perform_action/agent.py`](backend/agents/perform_action/agent.py) | +| **FGA** — fine-grained authz on RAG | `historical_agent` runs a batch_check against the FGA store and drops any document the requesting user is not authorized to read, before synthesis | [`backend/agents/historical_agent/agent.py`](backend/agents/historical_agent/agent.py) | + +The shared client is [`backend/auth0_ai.py`](backend/auth0_ai.py). All three primitives are **no-op when env vars are missing** — the project still runs locally without Auth0 configured. + +## .env additions + +```bash +# Required — Management API M2M client +AUTH0_DOMAIN=standin.us.auth0.com +AUTH0_CLIENT_ID=... +AUTH0_CLIENT_SECRET=... +AUTH0_AUDIENCE=https://standin.us.auth0.com/api/v2/ + +# Optional — separate CIBA-enabled client (falls back to AUTH0_CLIENT_ID) +AUTH0_CIBA_CLIENT_ID=... +AUTH0_CIBA_CLIENT_SECRET=... +AUTH0_CIBA_BINDING_MSG=Approve StandIn agent action + +# Optional — Auth0 FGA (for historical_agent RAG filtering) +AUTH0_FGA_API_URL=https://api.us1.fga.dev +AUTH0_FGA_STORE_ID=01H... +AUTH0_FGA_MODEL_ID=01H... +AUTH0_FGA_CLIENT_ID=... +AUTH0_FGA_CLIENT_SECRET=... +``` + +## Auth0 Dashboard — one-time setup + +### 1. Create the tenant + +1. Sign up at [auth0.com](https://auth0.com/ai) — pick the AI Agents trial. +2. Create a tenant (e.g. `standin`) in the **US** region. + +### 2. Management API M2M app (Token Vault reads) + +1. **Applications → Create Application** → "Machine to Machine". +2. Authorize for the **Auth0 Management API** with these scopes: + - `read:users` + - `read:user_idp_tokens` +3. Copy `Client ID` and `Client Secret` → `AUTH0_CLIENT_ID` / `AUTH0_CLIENT_SECRET`. + +### 3. Federated connections (Token Vault sources) + +For each provider you want StandIn agents to act on behalf of: + +- **Slack** — Authentication → Social → Slack. Use connection name `slack-oauth`. Enable "Sync user profile attributes at each login" + "Store IdP tokens". Required scopes: `chat:write`, `channels:read`, `search:read`. +- **Google** — Social → Google. Connection name `google-oauth2`. Scopes: `https://www.googleapis.com/auth/calendar`, `https://www.googleapis.com/auth/gmail.send`. +- **Atlassian** — Social → Custom OAuth2. Connection name `atlassian`. Scopes: `read:jira-work`, `write:jira-work`. + +Users connect these by signing into StandIn through Auth0; the IdP tokens land in `identities[].access_token` on the user record, which `auth0_ai.get_federated_token()` retrieves. + +### 4. CIBA configuration (phone approvals) + +1. **Tenant Settings → Advanced** → enable "Client-Initiated Backchannel Authentication (CIBA)". +2. **Applications → Create Application** → "Regular Web Application" (or reuse the M2M app). +3. On that app: **Settings → Advanced → Grant Types** → enable **CIBA**. +4. **Authentication → MFA → Push Notifications** → install Guardian SDK, enable for the tenant. +5. Each demo user must enroll the **Auth0 Guardian** mobile app (App Store / Play Store) and link it to the Auth0 tenant. +6. Copy that app's `Client ID` / `Client Secret` → `AUTH0_CIBA_CLIENT_ID` / `AUTH0_CIBA_CLIENT_SECRET`. + +### 5. FGA store (optional — for RAG filtering) + +1. Go to [dashboard.fga.dev](https://dashboard.fga.dev) and create a store. +2. Paste this authorization model: + + ```dsl + model + schema 1.1 + + type user + + type role + relations + define member: [user] + + type document + relations + define viewer: [user, role#member] + ``` + +3. Add tuples per seed doc, e.g.: + ``` + user:alice@centerfield.com viewer document:doc_seed_5 + role:engineering#member viewer document:doc_seed_3 + ``` +4. **Settings → Authorized Clients → Create** an FGA M2M client. Copy `client_id` / `client_secret`. +5. Set `AUTH0_FGA_STORE_ID`, `AUTH0_FGA_MODEL_ID`, `AUTH0_FGA_CLIENT_ID`, `AUTH0_FGA_CLIENT_SECRET`. + +## Verifying + +1. Restart `python backend/main.py`. +2. Check the Auth0 chip in the StandIn HealthBar (top-right). When configured, the dot turns orange and `TV` / `CIBA` / `FGA` light up. +3. Hit the status endpoint directly: + ``` + curl http://localhost:8008/auth0/status + ``` +4. Token Vault: trigger a brief — status_agent log should print `Auth0 Token Vault hit | user=… | slack token applied` and `slack_token=token_vault`. +5. CIBA: trigger an action that hits the approval gate (e.g. an escalation suggesting `send_slack`). Your phone receives the Guardian push. Then poll: + ``` + curl -X POST http://localhost:8008/approvals/ciba-poll \ + -H 'Content-Type: application/json' \ + -d '{"action_id": ""}' + ``` + On `state: approved` the action auto-executes — no dashboard click required. +6. FGA: ask the historical agent something with `user_email` set; log shows `Auth0 FGA dropped N/M docs`. + +## Demo narrative + +> "StandIn agents act on behalf of real humans. Three Auth0 AI surfaces make that safe in production: +> +> 1. **Token Vault** — every user's brief uses *their* Slack identity. Service-account secrets aren't shared between agents and aren't visible in any agent's environment. +> 2. **CIBA** — before any outbound action, the user gets a push notification on their phone. No agent ever sends a Slack message or schedules a meeting without explicit human consent on a second device. +> 3. **FGA** — the historical agent only retrieves documents the user can read, enforced at query time — not after the fact in synthesis. +> +> No shared secrets. No agent over-privilege. No silent automation." diff --git a/backend/agents/historical_agent/agent.py b/backend/agents/historical_agent/agent.py index 450993b..f9b0871 100644 --- a/backend/agents/historical_agent/agent.py +++ b/backend/agents/historical_agent/agent.py @@ -18,6 +18,10 @@ load_dotenv() from models import RAGRequest, RAGResponse +try: + import auth0_ai +except Exception: # pragma: no cover — Auth0 module is optional + auth0_ai = None # type: ignore[assignment] try: from services.calendar_service import list_events as list_calendar_events except Exception: @@ -487,6 +491,28 @@ async def _handle_rag_inner(ctx: Context, sender: str, msg: RAGRequest): else: ctx.logger.info("No documents matched — synthesising with no context") + # ── Auth0 FGA — drop docs the caller is not authorized to read ─────── + fga_filtered_count: Optional[int] = None + if ( + auth0_ai is not None + and auth0_ai.fga_configured() + and msg.user_email + and docs + ): + candidate_ids = [d.get("id", "") for d in docs if d.get("id")] + try: + allowed = set(auth0_ai.fga_filter_doc_ids(msg.user_email, candidate_ids)) + before = len(docs) + docs = [d for d in docs if d.get("id", "") in allowed] + fga_filtered_count = before - len(docs) + if fga_filtered_count > 0: + ctx.logger.info( + f"Auth0 FGA dropped {fga_filtered_count}/{before} docs " + f"for user={msg.user_email}" + ) + except Exception as exc: + ctx.logger.warning(f"FGA filter failed (passing all docs): {exc}") + # ── Synthesis ───────────────────────────────────────────────────────── answer, confidence = await _synthesize(msg.question, docs, retrieval_method) source_ids = [d.get("id", "?") for d in docs] @@ -498,6 +524,7 @@ async def _handle_rag_inner(ctx: Context, sender: str, msg: RAGRequest): source_ids=source_ids, confidence=confidence, retrieval_method=retrieval_method, + fga_filtered=fga_filtered_count, ) ctx.logger.info( diff --git a/backend/agents/orchestrator/agent.py b/backend/agents/orchestrator/agent.py index 34dc7c6..63f48e5 100644 --- a/backend/agents/orchestrator/agent.py +++ b/backend/agents/orchestrator/agent.py @@ -197,6 +197,12 @@ def _build_demo_personas() -> dict[str, dict]: DEMO_PERSONAS: dict[str, dict] = _build_demo_personas() DEMO_PERSONA_ID = os.getenv("ORCHESTRATOR_DEMO_USER_ID", "user_alice").strip() DEMO_PERSONA = DEMO_PERSONAS.get(DEMO_PERSONA_ID) or next(iter(DEMO_PERSONAS.values())) +# Override the persona's email with AUTH0_DEMO_EMAIL so CIBA pushes land on the +# device enrolled for that Auth0 user. Without this, owner=alice@standin.ai which +# has no Auth0 user → CIBA silently no-ops and falls back to "no approval". +_AUTH0_DEMO_EMAIL = os.getenv("AUTH0_DEMO_EMAIL", "").strip() +if _AUTH0_DEMO_EMAIL: + DEMO_PERSONA = {**DEMO_PERSONA, "email": _AUTH0_DEMO_EMAIL} TEAM_ALIASES = { "engineering": "Engineering", @@ -844,7 +850,7 @@ async def _dispatch_followup_create_jira( title=payload["summary"], summary=description, team=ctx_data.get("team") or "Engineering", - owner=persona["id"], + owner=persona.get("email") or persona["id"], owner_name=persona["name"], risk="high", ), @@ -1062,7 +1068,7 @@ async def handle_message(ctx: Context, sender: str, msg: ChatMessage): title=base_payload.get("title") or base_payload.get("summary") or "", summary=base_payload.get("description") or user_text, team=(schedule_meta or {}).get("team") or "", - owner=DEMO_PERSONA["id"], + owner=DEMO_PERSONA.get("email") or DEMO_PERSONA["id"], owner_name=DEMO_PERSONA["name"], ), ) @@ -1146,25 +1152,46 @@ async def handle_history_response(ctx: Context, sender: str, msg: RAGResponse): @orchestrator.on_message(ActionResponse) async def handle_action_response(ctx: Context, sender: str, msg: ActionResponse): import time - rtt = int((time.monotonic() - _request_sent_at.pop(msg.request_id, time.monotonic())) * 1000) + is_pending_approval = (msg.result or "").lower().startswith("pending_approval") + is_ciba_terminal = (msg.result or "").lower().startswith("ciba_") + # Keep timer for the eventual final response — only pop on terminal. + if is_pending_approval: + rtt = int((time.monotonic() - _request_sent_at.get(msg.request_id, time.monotonic())) * 1000) + else: + rtt = int((time.monotonic() - _request_sent_at.pop(msg.request_id, time.monotonic())) * 1000) ctx.logger.info( f"← ActionResponse | id={msg.request_id} | rtt={rtt}ms | type={msg.action_type} | " - f"success={msg.success} | stub={msg.stub} | action_id={msg.action_id}" + f"success={msg.success} | stub={msg.stub} | action_id={msg.action_id} | " + f"pending_approval={is_pending_approval}" ) - pending = pending_requests.pop(msg.request_id, None) + # On the initial pending_approval response, keep pending_requests so the + # follow-up (after phone approval) can still find the original chat sender. + if is_pending_approval: + pending = pending_requests.get(msg.request_id) + else: + pending = pending_requests.pop(msg.request_id, None) if not pending: return reply_text = _format_action_response(msg) - if msg.action_type in {"send_slack", "draft_slack"} and msg.success: + if msg.action_type in {"send_slack", "draft_slack"}: payload = pending.get("action_payload") or {} channel = _friendly_channel_name(str(payload.get("channel", ""))) - reply_text = f"Message has been sent in {channel}." + if is_pending_approval: + reply_text = ( + f"Approval requested — I sent a push to your phone. " + f"Once you approve, the message will post to {channel}." + ) + elif is_ciba_terminal and not msg.success: + state = (msg.result or "").split("ciba_", 1)[-1] or "rejected" + reply_text = f"Phone approval {state} — message was not sent." + elif msg.success: + reply_text = f"Message has been sent in {channel}." # For schedule_meeting, reply only with scheduling confirmation. # Do not auto-prompt Jira creation. - if msg.action_type == "schedule_meeting" and msg.success: + if msg.action_type == "schedule_meeting" and msg.success and not is_pending_approval: meta = pending.get("schedule_meta") or {} # Pull calendar event identifiers from the success result if perform_action # surfaced them (currently included in the result string). @@ -1202,7 +1229,7 @@ async def handle_action_response(ctx: Context, sender: str, msg: ActionResponse) if calendar_link: reply_text += f"\nCalendar link: {calendar_link}" - if msg.action_type == "create_jira" and msg.success: + if msg.action_type == "create_jira" and msg.success and not is_pending_approval: reply_text += ( f"\n\nTicket created on behalf of {DEMO_PERSONA['name']} ({DEMO_PERSONA['team']})." ) diff --git a/backend/agents/perform_action/agent.py b/backend/agents/perform_action/agent.py index 49883c0..1ebcc18 100644 --- a/backend/agents/perform_action/agent.py +++ b/backend/agents/perform_action/agent.py @@ -31,6 +31,11 @@ RejectRequest, RejectResponse, ) +try: + import auth0_ai +except Exception: # pragma: no cover — Auth0 module is optional + auth0_ai = None # type: ignore[assignment] + try: from schemas.action_payloads import normalize_action_payload except Exception: @@ -72,9 +77,20 @@ def normalize_action_payload(action_type: str, payload: dict, context: dict): # --------------------------------------------------------------------------- _SEED = os.getenv("PERFORM_ACTION_SEED", "perform_action_standin_seed_v1") _PORT = int(os.getenv("PERFORM_ACTION_PORT", "8008")) -_MONGODB_URI = os.getenv("MONGODB_URI", "") +_MONGODB_URI = os.getenv("MONGODB_URI", "") +_GEMINI_KEY = os.getenv("GEMINI_API_KEY", "") +_GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") _LOGGER = logging.getLogger("perform_action") +try: + from google import genai as _genai + from google.genai import types as _gtypes + _gemini_client = _genai.Client(api_key=_GEMINI_KEY) if _GEMINI_KEY else None +except Exception: + _genai = None # type: ignore + _gtypes = None # type: ignore + _gemini_client = None + def _ensure_event_loop() -> None: """Python 3.14 no longer provides an implicit main-thread loop.""" @@ -109,8 +125,13 @@ def _get_db(): return db -# Approval gating disabled: all actions execute immediately. -_APPROVAL_REQUIRED: set[str] = set() +# Approval gating: actions in this set require human approval before executing. +# Set ENABLE_APPROVAL_GATE=0 to disable for fully autonomous demos. +_APPROVAL_REQUIRED: set[str] = ( + {"send_slack", "send_email", "schedule_meeting"} + if os.getenv("ENABLE_APPROVAL_GATE", "1") != "0" + else set() +) def _save_pending_approval( @@ -120,6 +141,8 @@ def _save_pending_approval( owner: str = "", owner_name: str = "", ticket_status: str = "in_review", risk: str = "medium", stub: bool = True, escalation: dict | None = None, + auth0_ciba: dict | None = None, + chat_origin: dict | None = None, ) -> None: """Persist an action to pending_approvals collection.""" if not _MONGODB_URI: @@ -143,6 +166,8 @@ def _save_pending_approval( "risk": risk, "stub": stub, "escalation": escalation or {}, + "auth0_ciba": auth0_ciba or {}, + "chat_origin": chat_origin or {}, }) except Exception: pass @@ -674,6 +699,69 @@ async def _handle_action_inner(ctx: Context, sender: str, msg: ActionRequest): await ctx.send(sender, response) return + # ── Approval gate ────────────────────────────────────────────────────── + if msg.action_type in _APPROVAL_REQUIRED and _MONGODB_URI: + # ── Auth0 CIBA — push approval to user's phone (best-effort) ────── + ciba_meta: dict = {} + if auth0_ai and auth0_ai.ciba_configured() and msg.owner: + binding = ( + f"{msg.action_type.replace('_', ' ').title()}: " + f"{(msg.title or msg.summary or '')[:48]}" + ) or "StandIn agent action" + try: + auth_req_id = auth0_ai.ciba_initiate( + user_email=msg.owner, + binding_message=binding, + ) + if auth_req_id: + ciba_meta = { + "auth_req_id": auth_req_id, + "user": msg.owner, + "binding_message": binding, + "initiated_at": datetime.now(UTC).isoformat(), + "state": "pending", + } + ctx.logger.info( + f"CIBA push sent | user={msg.owner} | action_id={action_id} | " + f"auth_req_id={auth_req_id[:12]}…" + ) + except Exception as exc: + ctx.logger.warning(f"CIBA initiate failed: {exc}") + + _save_pending_approval( + action_id, msg.action_type, normalized_payload, priority, + requested_by=sender, + title=msg.title or "", + summary=msg.summary or msg.context or "", + team=msg.team or "", + owner=msg.owner or "", + owner_name=msg.owner_name or "", + ticket_status=msg.ticket_status or "in_review", + risk=msg.risk or "medium", + stub=True, + auth0_ciba=ciba_meta, + chat_origin={"reply_to": sender, "request_id": msg.request_id}, + ) + ctx.logger.info( + f"Action queued for approval | type={msg.action_type} | id={action_id}" + ) + result_msg = ( + f"pending_approval — human must approve before this action executes. " + f"Call POST /approvals/approve with action_id={action_id}" + ) + if ciba_meta: + result_msg += " (Auth0 CIBA push also sent to user's phone)" + response = ActionResponse( + request_id=msg.request_id, + action_type=msg.action_type, + success=True, + action_id=action_id, + result=result_msg, + stub=False, + ) + await ctx.send(sender, response) + return + # ── Immediate execution ──────────────────────────────────────────────── exec_payload = normalized_payload @@ -882,6 +970,321 @@ async def reject_action(ctx: Context, req: RejectRequest) -> RejectResponse: return RejectResponse(action_id=req.action_id, rejected=False) +# --------------------------------------------------------------------------- +# Auth0 CIBA — poll the user's phone-approval state, auto-execute on approve +# --------------------------------------------------------------------------- + +class _CibaPollRequest(Model): + action_id: str + + +class _CibaPollResponse(Model): + action_id: str + state: str # "pending" | "approved" | "denied" | "expired" | "error" | "no_ciba" + auth_req_id: Optional[str] = None + binding_message: Optional[str] = None + user: Optional[str] = None + executed: bool = False + result: Optional[str] = None + success: Optional[bool] = None + + +class _Auth0StatusResponse(Model): + configured: bool + domain: Optional[str] = None + token_vault: bool = False + ciba: bool = False + fga: bool = False + + +@agent.on_rest_get("/auth0/status", _Auth0StatusResponse) +async def auth0_status(ctx: Context) -> _Auth0StatusResponse: + """Surface Auth0 AI integration state to the dashboard.""" + if auth0_ai is None: + return _Auth0StatusResponse(configured=False) + s = auth0_ai.status_summary() + return _Auth0StatusResponse( + configured = bool(s.get("configured")), + domain = s.get("domain"), + token_vault = bool(s.get("token_vault")), + ciba = bool(s.get("ciba")), + fga = bool(s.get("fga")), + ) + + +@agent.on_rest_post("/approvals/ciba-poll", _CibaPollRequest, _CibaPollResponse) +async def approvals_ciba_poll(ctx: Context, req: _CibaPollRequest) -> _CibaPollResponse: + """ + Poll Auth0 CIBA for an action's phone-approval state. On `approved`, the + handler executes the action immediately — no second click needed in the UI. + """ + if auth0_ai is None or not auth0_ai.ciba_configured(): + return _CibaPollResponse(action_id=req.action_id, state="no_ciba") + if not _MONGODB_URI: + return _CibaPollResponse(action_id=req.action_id, state="error", + result="MONGODB_URI not configured") + + try: + db = _get_db() + doc = db["pending_approvals"].find_one({"action_id": req.action_id}) + if not doc: + return _CibaPollResponse(action_id=req.action_id, state="error", + result="Action not found") + + meta = dict(doc.get("auth0_ciba") or {}) + auth_req_id = meta.get("auth_req_id") + if not auth_req_id: + return _CibaPollResponse(action_id=req.action_id, state="no_ciba") + + # Already resolved — short-circuit + if doc.get("status") in ("approved", "rejected"): + return _CibaPollResponse( + action_id = req.action_id, + state = "approved" if doc["status"] == "approved" else "denied", + auth_req_id = auth_req_id, + binding_message = meta.get("binding_message"), + user = meta.get("user"), + executed = doc["status"] == "approved", + result = doc.get("result"), + success = doc["status"] == "approved", + ) + + state, _id_token = auth0_ai.ciba_poll(auth_req_id) + meta["state"] = state + meta["polled_at"] = datetime.now(UTC).isoformat() + db["pending_approvals"].update_one( + {"action_id": req.action_id}, {"$set": {"auth0_ciba": meta}}, + ) + + ctx.logger.info( + f"CIBA poll | id={req.action_id} | state={state} | " + f"user={meta.get('user')}" + ) + + if state != "approved": + return _CibaPollResponse( + action_id = req.action_id, + state = state, + auth_req_id = auth_req_id, + binding_message = meta.get("binding_message"), + user = meta.get("user"), + ) + + # ── Approved on phone — execute the action immediately ───────────── + action_type = doc["action_type"] + payload = dict(doc.get("payload") or {}) + priority = doc.get("priority", "normal") + handler = _ACTIONS.get(action_type) + + if action_type == "send_slack" and not (payload.get("user_id") or "").strip(): + if (doc.get("owner") or "").strip(): + payload["user_id"] = doc["owner"].strip() + + ok, payload, validation_error = normalize_action_payload( + action_type, payload, + {"owner": doc.get("owner"), "title": doc.get("title"), + "summary": doc.get("summary"), "context": "", "priority": priority}, + ) + if handler is None or not ok: + err = validation_error or f"No handler for '{action_type}'" + _mark_approval_done(req.action_id, approved=False, result=err) + return _CibaPollResponse( + action_id=req.action_id, state="error", + auth_req_id=auth_req_id, user=meta.get("user"), + result=err, success=False, + ) + + success, result, _ = await handler(req.action_id, payload, priority) + _mark_approval_done(req.action_id, approved=success, result=result) + _log_action(req.action_id, action_type, payload, success, result, stub=False) + + if success: + _emit_notification( + kind="action.executed", + title=f"{action_type.replace('_', ' ').title()} delivered (Auth0 CIBA)", + body=(doc.get("title") or result or "")[:300], + severity="success", + action_id=req.action_id, + team=doc.get("team"), owner=doc.get("owner"), + ) + + return _CibaPollResponse( + action_id = req.action_id, + state = "approved", + auth_req_id = auth_req_id, + binding_message = meta.get("binding_message"), + user = meta.get("user"), + executed = True, + result = result, + success = success, + ) + except Exception as exc: + ctx.logger.error(f"approvals_ciba_poll failed: {exc}") + return _CibaPollResponse(action_id=req.action_id, state="error", result=str(exc)) + + +# --------------------------------------------------------------------------- +# Background CIBA poller — auto-execute actions when user approves on phone +# --------------------------------------------------------------------------- + +_CIBA_POLL_INTERVAL = float(os.getenv("CIBA_BG_POLL_INTERVAL_SECONDS", "5")) + + +@agent.on_interval(period=_CIBA_POLL_INTERVAL) +async def _ciba_background_poller(ctx: Context) -> None: + """Scan pending_approvals for CIBA-pending actions, poll Auth0, execute on approve.""" + if auth0_ai is None or not auth0_ai.ciba_configured(): + return + if not _MONGODB_URI: + return + # Only poll recent pending approvals — auth_req_ids expire (typically + # ~10 min) and stale docs from earlier runs would spam state=error. + max_age_minutes = float(os.getenv("CIBA_MAX_AGE_MINUTES", "10")) + cutoff_iso = (datetime.now(UTC) - timedelta(minutes=max_age_minutes)).isoformat() + try: + db = _get_db() + cursor = db["pending_approvals"].find( + { + "status": "pending", + "auth0_ciba.auth_req_id": {"$exists": True, "$ne": ""}, + "created_at": {"$gte": cutoff_iso}, + }, + {"action_id": 1, "action_type": 1, "payload": 1, "priority": 1, + "owner": 1, "title": 1, "summary": 1, "team": 1, "auth0_ciba": 1, + "chat_origin": 1}, + ).limit(20) + except Exception as exc: + ctx.logger.error(f"CIBA bg poll: db scan failed: {exc}") + return + + # Sweep older still-pending CIBA rows so they don't sit forever. + try: + db["pending_approvals"].update_many( + { + "status": "pending", + "auth0_ciba.auth_req_id": {"$exists": True, "$ne": ""}, + "created_at": {"$lt": cutoff_iso}, + }, + {"$set": {"status": "expired", "result": "ciba_expired", + "resolved_at": datetime.now(UTC).isoformat()}}, + ) + except Exception: + pass + + for doc in cursor: + action_id = doc.get("action_id") + meta = dict(doc.get("auth0_ciba") or {}) + auth_req_id = meta.get("auth_req_id") + if not action_id or not auth_req_id: + continue + try: + state, _id_token = auth0_ai.ciba_poll(auth_req_id) + except Exception as exc: + ctx.logger.warning(f"CIBA bg poll: poll failed | id={action_id} | {exc}") + continue + + meta["state"] = state + meta["polled_at"] = datetime.now(UTC).isoformat() + try: + db["pending_approvals"].update_one( + {"action_id": action_id}, {"$set": {"auth0_ciba": meta}}, + ) + except Exception: + pass + + if state == "pending": + continue + + ctx.logger.info(f"CIBA bg poll | id={action_id} | state={state}") + + chat_origin = dict(doc.get("chat_origin") or {}) + reply_to = chat_origin.get("reply_to") + chat_req_id = chat_origin.get("request_id") + + if state in ("denied", "expired", "error"): + _mark_approval_done(action_id, approved=False, result=f"ciba_{state}") + _emit_notification( + kind="action.rejected", + title=f"{str(doc.get('action_type','')).replace('_',' ').title()} {state} on phone", + body=(doc.get("title") or "")[:300], + severity="warning", + action_id=action_id, + team=doc.get("team"), owner=doc.get("owner"), + ) + if reply_to and chat_req_id: + try: + await ctx.send(reply_to, ActionResponse( + request_id=chat_req_id, + action_type=doc.get("action_type", ""), + success=False, + action_id=action_id, + result=f"ciba_{state}", + stub=False, + )) + except Exception as exc: + ctx.logger.warning(f"CIBA bg poll: follow-up send failed | {exc}") + continue + + if state != "approved": + continue + + # Approved — execute the action. + action_type = doc.get("action_type", "") + payload = dict(doc.get("payload") or {}) + priority = doc.get("priority", "normal") + handler = _ACTIONS.get(action_type) + + if action_type == "send_slack" and not (payload.get("user_id") or "").strip(): + if (doc.get("owner") or "").strip(): + payload["user_id"] = doc["owner"].strip() + + ok, payload, validation_error = normalize_action_payload( + action_type, payload, + {"owner": doc.get("owner"), "title": doc.get("title"), + "summary": doc.get("summary"), "context": "", "priority": priority}, + ) + if handler is None or not ok: + err = validation_error or f"No handler for '{action_type}'" + _mark_approval_done(action_id, approved=False, result=err) + ctx.logger.error(f"CIBA bg poll: cannot execute | id={action_id} | {err}") + continue + + try: + success, result, _ = await handler(action_id, payload, priority) + except Exception as exc: + success, result = False, f"handler error: {exc}" + + _mark_approval_done(action_id, approved=success, result=result) + _log_action(action_id, action_type, payload, success, result, stub=False) + ctx.logger.info( + f"CIBA bg poll: executed | id={action_id} | type={action_type} | " + f"success={success} | result={(result or '')[:300]}" + ) + + if success: + _emit_notification( + kind="action.executed", + title=f"{action_type.replace('_', ' ').title()} delivered (Auth0 CIBA)", + body=(doc.get("title") or result or "")[:300], + severity="success", + action_id=action_id, + team=doc.get("team"), owner=doc.get("owner"), + ) + + if reply_to and chat_req_id: + try: + await ctx.send(reply_to, ActionResponse( + request_id=chat_req_id, + action_type=action_type, + success=success, + action_id=action_id, + result=result, + stub=False, + )) + except Exception as exc: + ctx.logger.warning(f"CIBA bg poll: follow-up send failed | {exc}") + + # --------------------------------------------------------------------------- # Dashboard graph endpoint # --------------------------------------------------------------------------- @@ -1240,16 +1643,204 @@ def _peer_for_team(team: str) -> str: return "peer_engineering" -def _conversation_script(action_type: str, title: str, owner: str, team: str, summary: str) -> list[dict]: +# --------------------------------------------------------------------------- +# Gemini-powered conversation generator +# --------------------------------------------------------------------------- + +_CONV_AGENT_PERSONAS = { + "orchestrator": "You are StandIn's Orchestrator — a concise, decisive coordinator who routes work, proposes resolutions, and keeps the conversation moving. You never waffle.", + "status_agent": "You are StandIn's Status Agent — a data-grounded analyst. You answer factually about current role status, blockers, and confidence. Short, direct, no speculation.", + "historical_agent": "You are StandIn's Historical Agent — an institutional-memory specialist. You cite prior incidents, resolution patterns, and time-to-unblock from the knowledge base.", + "perform_action": "You are StandIn's Perform Action agent — an executor. You confirm tool calls, report outcomes, and link results to evidence passports. Terse, operational.", + "peer_engineering": "You represent Engineering's StandIn agent — a peer orchestrator in another org. You speak for the Engineering team's current blockers, owners, and availability.", + "peer_design": "You represent Design's StandIn agent. You speak for the Design team's readiness, dependencies, and capacity. You're collaborative but guard scope creep.", + "peer_gtm": "You represent GTM's StandIn agent. You speak for the GTM team's launch readiness and timeline pressure. Focused on go/no-go criteria.", + "peer_product": "You represent Product's StandIn agent. You coordinate between teams and hold the launch decision authority.", +} + +_CONV_SKELETONS: dict[str, list[dict]] = { + "send_slack": [ + {"from": "orchestrator", "to": "{peer}", "kind": "handshake"}, + {"from": "{peer}", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "status_agent", "kind": "delegate"}, + {"from": "status_agent", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "{peer}", "kind": "decision"}, + {"from": "orchestrator", "to": "perform_action","kind": "delegate"}, + {"from": "perform_action","to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action","to": "orchestrator", "kind": "completed"}, + ], + "draft_slack": [ + {"from": "orchestrator", "to": "{peer}", "kind": "handshake"}, + {"from": "{peer}", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "perform_action","kind": "delegate"}, + {"from": "perform_action","to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action","to": "orchestrator", "kind": "completed"}, + ], + "schedule_meeting": [ + {"from": "orchestrator", "to": "peer_engineering", "kind": "handshake"}, + {"from": "peer_engineering", "to": "orchestrator", "kind": "handshake"}, + {"from": "orchestrator", "to": "peer_design", "kind": "handshake"}, + {"from": "peer_design", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "historical_agent", "kind": "delegate"}, + {"from": "historical_agent", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "peer_engineering", "kind": "decision"}, + {"from": "peer_engineering", "to": "orchestrator", "kind": "decision"}, + {"from": "peer_design", "to": "orchestrator", "kind": "decision"}, + {"from": "orchestrator", "to": "perform_action", "kind": "delegate"}, + {"from": "perform_action", "to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action", "to": "orchestrator", "kind": "completed"}, + ], + "send_email": [ + {"from": "orchestrator", "to": "{peer}", "kind": "handshake"}, + {"from": "{peer}", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "perform_action","kind": "decision"}, + {"from": "perform_action","to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action","to": "orchestrator", "kind": "completed"}, + ], + "create_jira": [ + {"from": "orchestrator", "to": "{peer}", "kind": "handshake"}, + {"from": "{peer}", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "perform_action","kind": "decision"}, + {"from": "perform_action","to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action","to": "orchestrator", "kind": "completed"}, + ], + "update_jira_status": [ + {"from": "orchestrator", "to": "{peer}", "kind": "handshake"}, + {"from": "{peer}", "to": "orchestrator", "kind": "finding"}, + {"from": "orchestrator", "to": "perform_action","kind": "decision"}, + {"from": "perform_action","to": "orchestrator", "kind": "tool_call"}, + {"from": "perform_action","to": "orchestrator", "kind": "completed"}, + ], +} + +_CONV_KIND_HINTS = { + "handshake": "Opening the A2A channel, establishing shared context or confirming readiness.", + "finding": "Reporting a concrete fact, status, or data point relevant to the decision.", + "delegate": "Handing off a sub-task to a specialist agent with clear scope.", + "decision": "Proposing or confirming an action, resolution, or next step.", + "tool_call": "Reporting the exact tool invoked and its parameters (terse, technical).", + "completed": "Confirming the action completed and linking to the evidence passport or audit trail.", +} + + +async def _gemini_fill_conversation( + skeleton: list[dict], + context: dict, +) -> list[dict]: + """ + Ask Gemini to fill in `content` for each step in the skeleton. + Returns the completed steps list. Raises on failure — caller catches and falls back. """ - Returns an ordered list of messages: {from, to, kind, content, delay_ms}. + if not _gemini_client: + raise RuntimeError("Gemini not configured") + + personas_used = sorted({s["from"] for s in skeleton} | {s["to"] for s in skeleton}) + personas_block = "\n".join( + f" {pid}: {_CONV_AGENT_PERSONAS[pid]}" + for pid in personas_used + if pid in _CONV_AGENT_PERSONAS + ) + + skeleton_block = json.dumps([ + {"step": i + 1, "from": s["from"], "to": s["to"], "kind": s["kind"], + "kind_hint": _CONV_KIND_HINTS.get(s["kind"], "")} + for i, s in enumerate(skeleton) + ], indent=2) + + prompt = f"""You are generating a realistic agent-to-agent (A2A) conversation for StandIn, an AI agent network that coordinates workplace actions without human meetings. + +## Action context +- action_type: {context['action_type']} +- title: {context['title']} +- owner: {context['owner']} +- team: {context['team']} +- summary: {context['summary']} +- risk: {context.get('risk', 'medium')} + +## Agent personas (stay in character for each agent) +{personas_block} + +## Conversation skeleton +Fill in the `content` field for each step. The structure is fixed — only generate content. +Constraints: +- Each message must be 1-3 sentences max. No padding, no pleasantries. +- Content must be contextually grounded in the action above (reference owner, title, team where natural). +- tool_call steps: use format "tool.method → params" (e.g. "slack.post_message → @derek.vasquez #launch-alpha") +- completed steps: confirm outcome and mention evidence passport or action log. +- Be specific — avoid generic filler like "understood" or "acknowledged" alone. +- Output ONLY a JSON array matching this schema exactly: + +{skeleton_block} + +Return ONLY a JSON array of objects with keys: step, from, to, kind, content, delay_ms. +delay_ms should be between 600 and 1400, varying naturally. +No markdown, no explanation, no wrapper object — just the raw JSON array.""" + + resp = await _gemini_client.aio.models.generate_content( + model=_GEMINI_MODEL, + contents=prompt, + config=_gtypes.GenerateContentConfig( + temperature=0.7, + response_mime_type="application/json", + ), + ) + raw = resp.text.strip() + # Strip markdown fences if present + if raw.startswith("```"): + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + steps = json.loads(raw.strip()) + if not isinstance(steps, list) or len(steps) != len(skeleton): + raise ValueError(f"Gemini returned {len(steps)} steps, expected {len(skeleton)}") + # Merge generated content back onto skeleton (preserve from/to/kind) + result = [] + for skel, gen in zip(skeleton, steps): + result.append({ + "from": skel["from"], + "to": skel["to"], + "kind": skel["kind"], + "content": str(gen.get("content", "")).strip(), + "delay_ms": max(400, min(int(gen.get("delay_ms", 900)), 1600)), + }) + return result + - Models a cross-org agent-to-agent (A2A) negotiation: our user's - orchestrator opens a channel with the *counterparty user's* StandIn - orchestrator (e.g. Engineering's StandIn, Design's StandIn). Internal - helpers (status / historical) chime in only when our orchestrator needs - grounding, so the dialog reads as peer-to-peer. +async def _build_conversation_script( + action_type: str, title: str, owner: str, team: str, summary: str, + risk: str = "medium", +) -> list[dict]: """ + Primary entry-point. Tries Gemini first; falls back to deterministic script. + """ + primary_peer = _peer_for_team(team) + skel_key = action_type if action_type in _CONV_SKELETONS else "send_slack" + skeleton = [ + {k: (v.replace("{peer}", primary_peer) if isinstance(v, str) else v) + for k, v in step.items()} + for step in _CONV_SKELETONS[skel_key] + ] + ctx_data = { + "action_type": action_type, + "title": title or action_type.replace("_", " ").title(), + "owner": owner or "owner", + "team": team or "team", + "summary": (summary or "")[:400], + "risk": risk, + } + try: + steps = await _gemini_fill_conversation(skeleton, ctx_data) + _LOGGER.info( + f"Conversation script generated by Gemini | action={action_type} | steps={len(steps)}" + ) + return steps + except Exception as exc: + _LOGGER.warning(f"Gemini conversation generation failed ({exc}) — using fallback") + return _conversation_script_fallback(action_type, title, owner, team, summary) + + +def _conversation_script_fallback(action_type: str, title: str, owner: str, team: str, summary: str) -> list[dict]: + """Deterministic fallback — returned only when Gemini is unavailable or fails.""" owner_label = owner or "owner" team_label = team or "team" title_label = title or action_type.replace("_", " ").title() @@ -1509,7 +2100,14 @@ async def start_conversation(ctx: Context, req: _ConvStartReq) -> _ConvStartResp except Exception as exc: ctx.logger.warning(f"start_conversation lookup failed: {exc}") - script = _conversation_script(action_type, title, owner, team, summary) + risk = "medium" + try: + doc2 = _get_db()["pending_approvals"].find_one({"action_id": req.action_id}, {"risk": 1}) + risk = (doc2 or {}).get("risk", "medium") or "medium" + except Exception: + pass + + script = await _build_conversation_script(action_type, title, owner, team, summary, risk) participants = sorted({s["from"] for s in script} | {s["to"] for s in script}) conversation_id = f"conv-{uuid.uuid4().hex[:10]}" @@ -1529,9 +2127,10 @@ async def start_conversation(ctx: Context, req: _ConvStartReq) -> _ConvStartResp ) asyncio.create_task(_run_conversation(conversation_id, script, req.action_id)) + source = "gemini" if _gemini_client else "fallback" ctx.logger.info( f"Conversation started | id={conversation_id} | action={req.action_id} | " - f"type={action_type} | steps={len(script)}" + f"type={action_type} | steps={len(script)} | script_source={source}" ) return _ConvStartResp( conversation_id=conversation_id, action_id=req.action_id, status="running", diff --git a/backend/agents/status_agent/agent.py b/backend/agents/status_agent/agent.py index c12803e..350a59e 100644 --- a/backend/agents/status_agent/agent.py +++ b/backend/agents/status_agent/agent.py @@ -7,6 +7,7 @@ import sys import uuid from datetime import datetime, timedelta, UTC +from typing import Optional from dotenv import load_dotenv from uagents import Agent, Context, Model @@ -44,6 +45,22 @@ _SLACK_USER_TOKEN = os.getenv("SLACK_USER_TOKEN", "") _LOGGER = logging.getLogger("status_agent") +try: + import auth0_ai +except Exception: # pragma: no cover — Auth0 module is optional + auth0_ai = None # type: ignore[assignment] + +# Per-request override populated by handle_full_brief before _gather_role_data fires. +# Used by _slack_*_search to pull a per-user Slack token from Auth0 Token Vault +# instead of the shared bot token. Reset to None at the end of each request. +_request_slack_token: Optional[str] = None +_request_token_source: str = "env" # "env" | "token_vault" + + +def _slack_bot_token_for_request() -> str: + """Returns the active Slack token: per-user (Token Vault) if set, else env.""" + return _request_slack_token or _SLACK_BOT_TOKEN + def _ensure_event_loop() -> None: """Python 3.14 no longer provides an implicit main-thread loop.""" @@ -366,7 +383,7 @@ def _slack_list_channels() -> dict[str, str]: ) req = _ureq.Request( f"https://slack.com/api/conversations.list?{params}", - headers={"Authorization": f"Bearer {_SLACK_BOT_TOKEN}"}, + headers={"Authorization": f"Bearer {_slack_bot_token_for_request()}"}, ) with _ureq.urlopen(req, timeout=8) as r: data = json.loads(r.read().decode()) @@ -441,7 +458,7 @@ def _slack_history_search(queries: list[str], limit: int) -> list[dict]: params = urllib.parse.urlencode({"channel": ch_id, "limit": "100"}) req = _ureq.Request( f"https://slack.com/api/conversations.history?{params}", - headers={"Authorization": f"Bearer {_SLACK_BOT_TOKEN}"}, + headers={"Authorization": f"Bearer {_slack_bot_token_for_request()}"}, ) with _ureq.urlopen(req, timeout=6) as r: ch_data = json.loads(r.read().decode()) @@ -1570,6 +1587,23 @@ async def _run_brief_pipeline(ctx: Context, msg: FullBriefRequest) -> FullBriefR f"topic='{msg.topic}' | user={msg.user_email}" ) + # ── Auth0 Token Vault — pull per-user Slack token if available ─────── + global _request_slack_token, _request_token_source + _request_slack_token = None + _request_token_source = "env" + if auth0_ai is not None and auth0_ai.is_configured() and msg.user_email: + try: + tok = auth0_ai.get_federated_token(msg.user_email, "slack-oauth") + if tok: + _request_slack_token = tok + _request_token_source = "token_vault" + ctx.logger.info( + f"Auth0 Token Vault hit | user={msg.user_email} | " + f"slack token applied for this request" + ) + except Exception as exc: + ctx.logger.warning(f"Token Vault lookup failed: {exc}") + # ── Load prior brief for delta detection (non-blocking) ────────────── last_brief = _load_last_brief(msg.user_email) previous_roles: dict = {} @@ -1778,8 +1812,12 @@ async def _run_brief_pipeline(ctx: Context, msg: FullBriefRequest) -> FullBriefR f"(p1={t1_ms}ms gather, p2={t2_ms}ms synthesis, p3={t3_ms}ms contradict) | " f"roles={statuses} | contradictions={len(brief.contradictions)} | " f"passports={len(passports)} | escalation={brief.escalation_required} | " - f"deltas={len(deltas)} | mode={mode}" + f"deltas={len(deltas)} | mode={mode} | slack_token={_request_token_source}" ) + + # Reset Token Vault override so the next request starts clean + _request_slack_token = None + _request_token_source = "env" return brief diff --git a/backend/auth0_ai.py b/backend/auth0_ai.py new file mode 100644 index 0000000..c5f8ece --- /dev/null +++ b/backend/auth0_ai.py @@ -0,0 +1,417 @@ +""" +auth0_ai.py — Auth0 for AI Agents integration for StandIn. + +Three primitives wired into the agent network: + + 1. Token Vault — per-user OAuth tokens for 3rd-party APIs (Slack, Google, Atlassian). + status_agent uses this so the brief reflects what *the user* + can see, not what a shared service account can see. + + 2. CIBA (Async Authz) — pushes "approve this action" prompts to the user's phone + before perform_action executes send_slack / send_email / + schedule_meeting. Replaces the dashboard-only approval gate. + + 3. FGA (Fine-Grained Authz) — filters historical_agent's RAG corpus to only + documents the requesting user is authorized to read. + +DESIGN: every function has a graceful no-op fallback when AUTH0_DOMAIN is unset, +so the project still runs locally without an Auth0 tenant. `is_configured()` +tells callers whether the live integration is active. + +ENV VARS (set in project-root .env): + AUTH0_DOMAIN tenant host, e.g. "standin.us.auth0.com" + AUTH0_CLIENT_ID M2M app client id + AUTH0_CLIENT_SECRET M2M app client secret + AUTH0_AUDIENCE usually "https:///api/v2/" + + AUTH0_CIBA_CLIENT_ID (optional) separate CIBA-enabled client; falls + back to AUTH0_CLIENT_ID if unset + AUTH0_CIBA_CLIENT_SECRET (optional) likewise + AUTH0_CIBA_BINDING_MSG default human-readable string shown in the push + + AUTH0_FGA_API_URL e.g. "https://api.us1.fga.dev" + AUTH0_FGA_STORE_ID FGA store id + AUTH0_FGA_MODEL_ID (optional) authorization model id + AUTH0_FGA_CLIENT_ID FGA M2M client (separate credential pair) + AUTH0_FGA_CLIENT_SECRET FGA M2M secret + +References: + Token Vault: https://auth0.com/ai/docs/call-others-apis-on-users-behalf + CIBA: https://auth0.com/ai/docs/async-user-confirmation + FGA: https://auth0.com/ai/docs/authorization-for-rag +""" +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.parse +import urllib.request +from typing import Optional + +log = logging.getLogger("auth0_ai") + +# ── Config ───────────────────────────────────────────────────────────────────── + +_DOMAIN = os.getenv("AUTH0_DOMAIN", "").strip().rstrip("/") +_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID", "").strip() +_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET", "").strip() +_AUDIENCE = os.getenv("AUTH0_AUDIENCE", "").strip() or ( + f"https://{_DOMAIN}/api/v2/" if _DOMAIN else "" +) + +_CIBA_CLIENT_ID = os.getenv("AUTH0_CIBA_CLIENT_ID", "").strip() or _CLIENT_ID +_CIBA_CLIENT_SECRET = os.getenv("AUTH0_CIBA_CLIENT_SECRET", "").strip() or _CLIENT_SECRET +_CIBA_BINDING_MSG = os.getenv("AUTH0_CIBA_BINDING_MSG", "Approve StandIn agent action") + +_FGA_API_URL = os.getenv("AUTH0_FGA_API_URL", "").strip().rstrip("/") +_FGA_STORE_ID = os.getenv("AUTH0_FGA_STORE_ID", "").strip() +_FGA_MODEL_ID = os.getenv("AUTH0_FGA_MODEL_ID", "").strip() +_FGA_CLIENT_ID = os.getenv("AUTH0_FGA_CLIENT_ID", "").strip() +_FGA_CLIENT_SECRET = os.getenv("AUTH0_FGA_CLIENT_SECRET", "").strip() + +_REQUEST_TIMEOUT = 8.0 + +# ── M2M token cache ──────────────────────────────────────────────────────────── + +_token_cache: dict[str, tuple[str, float]] = {} # cache_key -> (access_token, expires_at) + + +def is_configured() -> bool: + """True when Auth0 base credentials are present. Other features layered on top.""" + return bool(_DOMAIN and _CLIENT_ID and _CLIENT_SECRET) + + +def ciba_configured() -> bool: + return is_configured() and bool(_CIBA_CLIENT_ID and _CIBA_CLIENT_SECRET) + + +def fga_configured() -> bool: + return bool(_FGA_API_URL and _FGA_STORE_ID and _FGA_CLIENT_ID and _FGA_CLIENT_SECRET) + + +# ── HTTP helpers ─────────────────────────────────────────────────────────────── + +def _http( + url: str, + method: str = "GET", + headers: Optional[dict] = None, + body: Optional[dict] = None, + form: Optional[dict] = None, +) -> tuple[int, dict | str]: + """Tiny urllib wrapper. Returns (status, parsed_json_or_text).""" + h = {"Accept": "application/json"} + if headers: + h.update(headers) + + data: bytes | None = None + if form is not None: + data = urllib.parse.urlencode(form).encode("utf-8") + h["Content-Type"] = "application/x-www-form-urlencoded" + elif body is not None: + data = json.dumps(body).encode("utf-8") + h["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, method=method, headers=h) + try: + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + raw = resp.read().decode("utf-8") or "{}" + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as exc: + try: + return exc.code, json.loads(exc.read().decode("utf-8") or "{}") + except Exception: + return exc.code, str(exc) + except Exception as exc: + return 0, str(exc) + + +def _m2m_token( + cache_key: str, + domain: str, + client_id: str, + client_secret: str, + audience: str, +) -> Optional[str]: + cached = _token_cache.get(cache_key) + if cached and cached[1] > time.time() + 30: + return cached[0] + + status, payload = _http( + f"https://{domain}/oauth/token", + method="POST", + body={ + "client_id": client_id, + "client_secret": client_secret, + "audience": audience, + "grant_type": "client_credentials", + }, + ) + if status != 200 or not isinstance(payload, dict): + log.warning("Auth0 M2M token fetch failed | status=%s | payload=%s", status, payload) + return None + + token = payload.get("access_token") + expires = time.time() + float(payload.get("expires_in", 3600)) + if not token: + return None + _token_cache[cache_key] = (token, expires) + return token + + +def _management_token() -> Optional[str]: + if not is_configured(): + return None + return _m2m_token("mgmt", _DOMAIN, _CLIENT_ID, _CLIENT_SECRET, _AUDIENCE) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. Token Vault — federated 3rd-party access tokens per user +# ═══════════════════════════════════════════════════════════════════════════════ + +def get_federated_token(user_email: str, connection: str) -> Optional[str]: + """ + Retrieve the user's federated 3rd-party access token from Auth0's Token Vault. + + `connection` is the Auth0 connection name, e.g.: + "slack-oauth", "google-oauth2", "atlassian" + + Returns None if Auth0 isn't configured, the user isn't found, or hasn't + connected that provider — caller should fall back to env-var token. + """ + if not (is_configured() and user_email and connection): + return None + + mgmt = _management_token() + if not mgmt: + return None + + qs = urllib.parse.urlencode({ + "q": f'email:"{user_email}"', + "search_engine": "v3", + "fields": "user_id,identities", + }) + status, users = _http( + f"https://{_DOMAIN}/api/v2/users?{qs}", + headers={"Authorization": f"Bearer {mgmt}"}, + ) + if status != 200 or not isinstance(users, list) or not users: + log.info("Token Vault: no Auth0 user for %s (status=%s)", user_email, status) + return None + + user_id = users[0].get("user_id") + if not user_id: + return None + + status, payload = _http( + f"https://{_DOMAIN}/api/v2/users/{urllib.parse.quote(user_id)}", + headers={"Authorization": f"Bearer {mgmt}"}, + ) + if status != 200 or not isinstance(payload, dict): + return None + + for ident in payload.get("identities", []): + if ident.get("connection") == connection or ident.get("provider") == connection: + tok = ident.get("access_token") + if tok: + log.info("Token Vault hit | user=%s | connection=%s", user_email, connection) + return tok + + log.info("Token Vault miss | user=%s | connection=%s", user_email, connection) + return None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. CIBA — push-based async user authorization +# ═══════════════════════════════════════════════════════════════════════════════ + +def _resolve_user_id(user_email: str) -> Optional[str]: + """Resolve email -> Auth0 user_id (e.g. 'google-oauth2|123...'). None if not found.""" + mgmt = _management_token() + if not mgmt: + return None + qs = urllib.parse.urlencode({ + "q": f'email:"{user_email}"', + "search_engine": "v3", + "fields": "user_id,identities,last_login", + }) + status, users = _http( + f"https://{_DOMAIN}/api/v2/users?{qs}", + headers={"Authorization": f"Bearer {mgmt}"}, + ) + if status != 200 or not isinstance(users, list) or not users: + return None + # Prefer the most recently logged-in user when multiple exist (e.g. social + db) + users.sort(key=lambda u: u.get("last_login") or "", reverse=True) + return users[0].get("user_id") + + +def ciba_initiate( + user_email: str, + binding_message: Optional[str] = None, + scope: str = "openid", +) -> Optional[str]: + """ + Initiate a CIBA (Client-Initiated Backchannel Authentication) flow. + + Returns auth_req_id which the caller polls via ciba_poll(). The user sees + a push notification in the Auth0 Guardian app with `binding_message`. + + Returns None when CIBA isn't configured — caller should fall back to the + existing manual approval REST endpoint. + """ + if not ciba_configured(): + return None + + user_id = _resolve_user_id(user_email) + if not user_id: + log.warning("CIBA initiate: no Auth0 user for email=%s", user_email) + return None + + msg = (binding_message or _CIBA_BINDING_MSG)[:64] # spec caps at 64 + status, payload = _http( + f"https://{_DOMAIN}/bc-authorize", + method="POST", + form={ + "client_id": _CIBA_CLIENT_ID, + "client_secret": _CIBA_CLIENT_SECRET, + "binding_message": msg, + "scope": scope, + "login_hint": json.dumps({ + "format": "iss_sub", + "iss": f"https://{_DOMAIN}/", + "sub": user_id, + }), + }, + ) + if status not in (200, 201) or not isinstance(payload, dict): + log.warning("CIBA initiate failed | status=%s | payload=%s", status, payload) + return None + return payload.get("auth_req_id") + + +def ciba_poll(auth_req_id: str) -> tuple[str, Optional[str]]: + """ + Poll CIBA token endpoint. Returns (state, access_token). + state in {"approved", "pending", "denied", "expired", "error"} + """ + if not ciba_configured() or not auth_req_id: + return "error", None + + status, payload = _http( + f"https://{_DOMAIN}/oauth/token", + method="POST", + form={ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": auth_req_id, + "client_id": _CIBA_CLIENT_ID, + "client_secret": _CIBA_CLIENT_SECRET, + }, + ) + if status == 200 and isinstance(payload, dict) and payload.get("access_token"): + return "approved", payload["access_token"] + + err = payload.get("error") if isinstance(payload, dict) else None + if err == "authorization_pending": + return "pending", None + if err == "slow_down": + return "pending", None + if err == "access_denied": + return "denied", None + if err == "expired_token": + return "expired", None + return "error", None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. FGA — fine-grained authz for RAG retrieval +# ═══════════════════════════════════════════════════════════════════════════════ + +def _fga_token() -> Optional[str]: + if not fga_configured(): + return None + audience = "https://api.us1.fga.dev/" if "us1" in _FGA_API_URL else f"{_FGA_API_URL}/" + return _m2m_token( + "fga", "fga.us.auth0.com", _FGA_CLIENT_ID, _FGA_CLIENT_SECRET, audience, + ) + + +def fga_filter_doc_ids(user_email: str, doc_ids: list[str]) -> list[str]: + """ + Given a list of candidate doc IDs, return only those the user can `viewer`. + + Uses FGA's batch_check (single round-trip). On failure or no config, + returns the original list (fail-open — appropriate for hackathon demo; + production should fail-closed). + + Expected FGA model: + type user + type document + relations + define viewer: [user, role#member] + type role + relations + define member: [user] + """ + if not (fga_configured() and user_email and doc_ids): + return doc_ids + + tok = _fga_token() + if not tok: + return doc_ids + + checks = [ + { + "tuple_key": { + "user": f"user:{user_email}", + "relation": "viewer", + "object": f"document:{doc_id}", + }, + "correlation_id": doc_id, + } + for doc_id in doc_ids + ] + body: dict = {"checks": checks} + if _FGA_MODEL_ID: + body["authorization_model_id"] = _FGA_MODEL_ID + + status, payload = _http( + f"{_FGA_API_URL}/stores/{_FGA_STORE_ID}/batch-check", + method="POST", + headers={"Authorization": f"Bearer {tok}"}, + body=body, + ) + if status != 200 or not isinstance(payload, dict): + log.warning("FGA batch_check failed | status=%s — passing all docs through", status) + return doc_ids + + allowed: list[str] = [] + for r in payload.get("result", []): + if r.get("allowed"): + cid = r.get("correlation_id") + if cid: + allowed.append(cid) + + log.info( + "FGA filter | user=%s | input=%d | allowed=%d", + user_email, len(doc_ids), len(allowed), + ) + return allowed + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Status helper for /health and the dashboard +# ═══════════════════════════════════════════════════════════════════════════════ + +def status_summary() -> dict: + return { + "configured": is_configured(), + "domain": _DOMAIN if is_configured() else None, + "token_vault": is_configured(), + "ciba": ciba_configured(), + "fga": fga_configured(), + } diff --git a/backend/models.py b/backend/models.py index 3afe434..7942ad1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -178,6 +178,7 @@ class RAGRequest(Model): question: str role_filter: Optional[str] = None # "Engineering" | "Design" | etc. top_k: Optional[int] = 5 + user_email: Optional[str] = None # caller's identity — used for Auth0 FGA filtering class RAGResponse(Model): @@ -187,6 +188,7 @@ class RAGResponse(Model): source_ids: List[str] confidence: float retrieval_method: str # "vector_search" | "keyword" | "no_results" + fga_filtered: Optional[int] = None # count of docs dropped by Auth0 FGA (None = FGA not applied) class GX10RedactedSource(Model): diff --git a/backend/test_auth0.py b/backend/test_auth0.py new file mode 100644 index 0000000..d07e675 --- /dev/null +++ b/backend/test_auth0.py @@ -0,0 +1,65 @@ +""" +Quick Auth0 AI integration smoke test. +Run from project root: + python backend/test_auth0.py [user@email.com] +""" +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from dotenv import load_dotenv +load_dotenv() + +import auth0_ai + +EMAIL = sys.argv[1] if len(sys.argv) > 1 else "mboroshilov@centerfield.com" + +print("\n=== Auth0 AI Smoke Test ===\n") +s = auth0_ai.status_summary() +for k, v in s.items(): + mark = "OK" if v else "--" + print(f" {mark} {k}: {v}") + +print() + +# ── Token Vault ─────────────────────────────────────────────────────────────── +print("--- Token Vault ---") +tok = auth0_ai.get_federated_token(EMAIL, "slack-oauth") +if tok: + print(f" OK Slack token retrieved for {EMAIL}: {tok[:20]}...") +else: + print(f" -- No Slack token for {EMAIL}.") + print(f" → User must sign in via Auth0 with Slack connected") + print(f" → Or create user in Auth0 dashboard + connect Slack social") + +print() + +# ── CIBA ───────────────────────────────────────────────────────────────────── +print("--- CIBA ---") +if not auth0_ai.ciba_configured(): + print(" -- CIBA not configured — set AUTH0_CIBA_CLIENT_ID in .env") +else: + print(f" Initiating CIBA push to {EMAIL}...") + req_id = auth0_ai.ciba_initiate(EMAIL, "StandIn smoke test — approve me") + if req_id: + print(f" OK auth_req_id = {req_id}") + print(f" Polling once (expect 'pending' unless you approve on phone)...") + state, _ = auth0_ai.ciba_poll(req_id) + print(f" State: {state}") + else: + print(" -- CIBA initiate returned None — check CIBA grant is enabled on the app") + +print() + +# ── FGA ─────────────────────────────────────────────────────────────────────── +print("--- FGA ---") +if not auth0_ai.fga_configured(): + print(" -- FGA not configured — set AUTH0_FGA_* vars in .env") +else: + test_ids = ["doc_seed_1", "doc_seed_2", "doc_seed_3"] + allowed = auth0_ai.fga_filter_doc_ids(EMAIL, test_ids) + print(f" Input: {test_ids}") + print(f" Allowed: {allowed}") + +print() +print("Done.\n") diff --git a/backend/test_ciba_e2e.py b/backend/test_ciba_e2e.py new file mode 100644 index 0000000..651181e --- /dev/null +++ b/backend/test_ciba_e2e.py @@ -0,0 +1,100 @@ +""" +End-to-end CIBA test through the perform_action agent. + +Spins up perform_action + a tiny test client in one Bureau, sends a +send_slack ActionRequest with owner=mika.borosh@gmail.com, watches for +the CIBA push, and polls until you approve on phone (or it times out). + +Run from project root: + .venv\\Scripts\\activate + python backend/test_ciba_e2e.py + +Phone should buzz within ~3 seconds. Tap Allow → script prints "approved". +""" +from __future__ import annotations + +import asyncio +import json +import os +import sys +import uuid +from datetime import UTC, datetime + +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) + +from dotenv import load_dotenv +load_dotenv() + +from uagents import Agent, Bureau, Context, Protocol +from uagents.setup import fund_agent_if_low + +from agents.perform_action.agent import agent as perform_action_agent +from models import ActionRequest, ActionResponse + + +TEST_OWNER_EMAIL = os.getenv("TEST_OWNER_EMAIL", "mika.borosh@gmail.com") +PORT = 8123 + +client = Agent( + name="ciba_test_client", + seed="ciba_test_client_seed_v1", + port=PORT, + endpoint=[f"http://localhost:{PORT}/submit"], +) + +_done = asyncio.Event() + + +@client.on_event("startup") +async def fire(ctx: Context): + req = ActionRequest( + request_id=f"ciba-test-{uuid.uuid4().hex[:8]}", + action_type="send_slack", + payload=json.dumps({ + "text": "[CIBA TEST] StandIn approval gate test message", + "channel": "general", + }), + owner=TEST_OWNER_EMAIL, + owner_name="Mika", + title="CIBA E2E test", + summary="Verify push lands on phone via perform_action → auth0_ai", + priority="normal", + risk="medium", + ) + ctx.logger.info(f">>> Sending ActionRequest to perform_action | owner={TEST_OWNER_EMAIL}") + ctx.logger.info(">>> CHECK YOUR PHONE — Guardian push should arrive within ~3 sec") + await ctx.send(perform_action_agent.address, req) + + +@client.on_message(ActionResponse) +async def on_response(ctx: Context, sender: str, msg: ActionResponse): + ctx.logger.info("=" * 60) + ctx.logger.info(f"RESPONSE | success={msg.success} | action_id={msg.action_id}") + ctx.logger.info(f" status={getattr(msg, 'status', None)}") + ctx.logger.info(f" error={msg.error}") + if hasattr(msg, "auth0_ciba"): + ctx.logger.info(f" auth0_ciba={msg.auth0_ciba}") + ctx.logger.info("=" * 60) + _done.set() + + +async def main(): + bureau = Bureau() + bureau.add(perform_action_agent) + bureau.add(client) + + task = asyncio.create_task(bureau.run_async()) + try: + await asyncio.wait_for(_done.wait(), timeout=180) + except asyncio.TimeoutError: + print("TIMEOUT — no ActionResponse within 180s") + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/test_orchestrator.py b/backend/test_orchestrator.py index 20fe7c6..7797a79 100644 --- a/backend/test_orchestrator.py +++ b/backend/test_orchestrator.py @@ -47,7 +47,7 @@ def _info(self) -> AgentInfo: from uagents import Context, Protocol TEST_BUREAU_PORT = int(os.getenv("TEST_BUREAU_PORT", "8100")) -TEST_TIMEOUT_SECONDS = int(os.getenv("TEST_TIMEOUT_SECONDS", "60")) +TEST_TIMEOUT_SECONDS = int(os.getenv("TEST_TIMEOUT_SECONDS", "300")) client = Agent( name="standin_test_client", @@ -96,10 +96,16 @@ async def handle_ack(ctx: Context, sender: str, msg: ChatAcknowledgement) -> Non @client_proto.on_message(ChatMessage) async def handle_reply(ctx: Context, sender: str, msg: ChatMessage) -> None: global _completed - _completed = True + text = msg.text() print("\n=== Orchestrator Reply ===\n", flush=True) - print(msg.text(), flush=True) + print(text, flush=True) print("\n==========================\n", flush=True) + # If this was a CIBA pending-approval notice, keep the bureau alive so the + # background poller can fire after the user taps approve on their phone. + if "Approval requested" in text or "push to your phone" in text.lower(): + ctx.logger.info("Awaiting phone approval — bureau will stay up for the follow-up reply.") + return + _completed = True await _shutdown(0) diff --git a/frontend/app.jsx b/frontend/app.jsx index 1331c27..f07c9b9 100644 --- a/frontend/app.jsx +++ b/frontend/app.jsx @@ -177,6 +177,164 @@ function NotificationBell() { ); } +// ── Auth0 helpers ──────────────────────────────────────────────────────────── + +function _decodeJwtPayload(token) { + try { + const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(b64)); + } catch (_) { return null; } +} + +function _readAuth0User() { + try { return JSON.parse(localStorage.getItem('auth0_user') || 'null'); } catch (_) { return null; } +} + +function _writeAuth0User(u) { + if (u) localStorage.setItem('auth0_user', JSON.stringify(u)); + else localStorage.removeItem('auth0_user'); +} + +// Parse id_token from hash on page load (implicit flow callback) +(function _handleAuth0Callback() { + const hash = window.location.hash.slice(1); + if (!hash.includes('id_token=')) return; + const params = Object.fromEntries(hash.split('&').map(p => p.split('='))); + const idToken = params['id_token']; + if (!idToken) return; + const payload = _decodeJwtPayload(idToken); + if (payload?.email) { + _writeAuth0User({ email: payload.email, sub: payload.sub, name: payload.name || payload.email }); + window.history.replaceState({}, document.title, window.location.pathname); + } +})(); + +function Auth0Chip() { + const [status, setStatus] = useState(window.MOCK_API.getAuth0Status()); + const [cfg, setCfg] = useState(null); // {domain, client_id} + const [user, setUser] = useState(_readAuth0User); + const [open, setOpen] = useState(false); + + useEffect(() => window.MOCK_API.onAuth0Status(setStatus), []); + + // Fetch public SPA config from serve.py + useEffect(() => { + fetch('/auth0-config') + .then(r => r.json()) + .then(d => { if (d.domain && d.client_id) setCfg(d); }) + .catch(() => {}); + }, []); + + function login() { + if (!cfg) return; + const nonce = Math.random().toString(36).slice(2); + const params = new URLSearchParams({ + response_type: 'id_token', + client_id: cfg.client_id, + redirect_uri: window.location.origin, + scope: 'openid email profile', + nonce, + }); + window.location.href = `https://${cfg.domain}/authorize?${params}`; + } + + function logout() { + _writeAuth0User(null); + setUser(null); + if (cfg) { + window.location.href = + `https://${cfg.domain}/v2/logout?client_id=${cfg.client_id}&returnTo=${encodeURIComponent(window.location.origin)}`; + } + } + + // Expose logged-in email to mock-api so brief calls include it + useEffect(() => { + window.__auth0User = user; + if (user?.email) window.MOCK_API.setAuth0UserEmail?.(user.email); + }, [user]); + + const live = status.configured; + const canLogin = !!cfg?.client_id; + const features = [ + { id: 'tv', on: status.token_vault, short: 'TV', label: 'Token Vault', + desc: 'Status agent uses each user\'s own Slack/Jira tokens, not a shared service account.' }, + { id: 'ciba', on: status.ciba, short: 'CIBA', label: 'Async Authz (CIBA)', + desc: 'Approval requests pushed to the user\'s phone before perform_action sends Slack/email.' }, + { id: 'fga', on: status.fga, short: 'FGA', label: 'Fine-Grained Authz', + desc: 'Historical agent only retrieves documents the user is authorized to read.' }, + ]; + + return ( +
+ + {open && ( +
+
+ Auth0 for AI Agents + + {live ? `Live · ${status.domain}` : 'Not configured'} + +
+ + {/* User identity */} +
+ {user ? ( +
+ + + {user.email} + + +
+ ) : ( +
+ No user signed in + +
+ )} +
+ +
    + {features.map(f => ( +
  • + {f.label} + {f.on ? 'ENABLED' : 'off'} + {f.desc} +
  • + ))} +
+ {!canLogin && live && ( +
+ Create a Single Page Application in the Auth0 dashboard, + then add AUTH0_SPA_CLIENT_ID=<client_id> to .env + and restart serve.py. +
+ )} +
+ )} +
+ ); +} + function HealthBar({ route, setRoute, counts, onBackToLanding }) { useEffect(() => { const t = setInterval(() => window.MOCK_API.healthAgents(), 8000); @@ -236,6 +394,7 @@ function HealthBar({ route, setRoute, counts, onBackToLanding }) { ))}
+ {statusClass !== 'demo' && (
{ _flashTool(_toolKey(agent, tool)); } + // ── Auth0 AI ────────────────────────────────────────────────────────── + let _auth0UserEmail = null; + + // ── Auth0 AI status — Token Vault / CIBA / FGA ───────────────────────── + let _auth0Status = { configured: false, token_vault: false, ciba: false, fga: false, domain: null }; + let _auth0Listeners = new Set(); + async function _refreshAuth0Status() { + try { + const data = await _get(`${BASE.perform}/auth0/status`); + _auth0Status = { + configured: !!data.configured, + domain: data.domain || null, + token_vault: !!data.token_vault, + ciba: !!data.ciba, + fga: !!data.fga, + }; + } catch (_) { /* keep last known */ } + for (const fn of _auth0Listeners) { try { fn(_auth0Status); } catch (_) {} } + } + _refreshAuth0Status(); + setInterval(_refreshAuth0Status, 15000); + + async function _cibaPoll(actionId) { + try { + return await _post(`${BASE.perform}/approvals/ciba-poll`, { action_id: actionId }); + } catch (_) { + return { action_id: actionId, state: 'error' }; + } + } + // ── Public API ────────────────────────────────────────────────────────── return { TEAMS, + getAuth0Status: () => ({ ..._auth0Status }), + onAuth0Status: (fn) => { _auth0Listeners.add(fn); fn({ ..._auth0Status }); return () => _auth0Listeners.delete(fn); }, + refreshAuth0Status: _refreshAuth0Status, + cibaPoll: _cibaPoll, listUsers: () => _users.slice(), listEdges: () => _edges.slice(), @@ -537,9 +571,15 @@ window.MOCK_API = (() => { try { await _post(`${BASE.perform}/notifications/mark_read`, { all: true }); } catch (_) {} }, + // ── Auth0 user identity (set by Auth0Chip on login) ────────────────── + setAuth0UserEmail: (email) => { _auth0UserEmail = email || null; }, + getAuth0UserEmail: () => _auth0UserEmail, + // ── Brief & RAG HTTP endpoints ──────────────────────────────────────── fetchBrief: async (topic, userEmail = 'demo@standin.ai') => { + // Prefer the Auth0-authenticated identity when available + const effectiveEmail = _auth0UserEmail || userEmail; // Step 0: user → orch _liveTrace = { scenario: 'status', step: 0, tools: [] }; // Step 1: orch → status (FullBriefRequest) @@ -551,7 +591,7 @@ window.MOCK_API = (() => { // Step 4: contradict + passports const t4 = setTimeout(() => { if (_liveTrace?.scenario === 'status') _liveTrace = { scenario: 'status', step: 4, tools: ['status.contradict', 'status.passports'] }; }, 5000); try { - const result = await _postLong(`${BASE.status}/brief`, { topic, user_email: userEmail }); + const result = await _postLong(`${BASE.status}/brief`, { topic, user_email: effectiveEmail }); clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); // Step 5: status → orch (FullBriefResponse) _liveTrace = { scenario: 'status', step: 5, tools: [] }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a8f010..2f6dd28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,7 +43,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1169,7 +1168,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1415,7 +1413,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1456,7 +1453,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1608,7 +1604,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/frontend/serve.py b/frontend/serve.py index 1c65f18..925347f 100644 --- a/frontend/serve.py +++ b/frontend/serve.py @@ -13,11 +13,15 @@ /api/status/* → http://localhost:8007/* (Status Agent) /api/history/* → http://localhost:8009/* (Historical Agent) """ +import json import mimetypes import os import sys from pathlib import Path +from dotenv import load_dotenv +load_dotenv(Path(__file__).parent.parent / ".env") + try: from aiohttp import web, ClientSession, ClientConnectorError, ClientTimeout except ImportError: @@ -115,8 +119,21 @@ async def handle_static(request: web.Request) -> web.Response: return web.FileResponse(path, headers=headers) +async def handle_auth0_config(request: web.Request) -> web.Response: + """Expose Auth0 public config to the SPA — no secrets, only public values.""" + cfg = { + "domain": os.getenv("AUTH0_DOMAIN", ""), + "client_id": os.getenv("AUTH0_SPA_CLIENT_ID", ""), + "audience": os.getenv("AUTH0_AUDIENCE", ""), + } + return web.Response( + text=json.dumps(cfg), content_type="application/json", headers=CORS, + ) + + def build_app() -> web.Application: app = web.Application() + app.router.add_route("GET", "/auth0-config", handle_auth0_config) app.router.add_route("*", "/api/{tail:.*}", handle_api) app.router.add_route("GET", "/{tail:.*}", handle_static) return app diff --git a/frontend/styles.css b/frontend/styles.css index 9acaa1a..cb65667 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -2400,6 +2400,110 @@ button:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } .resolve-panel { width: 100%; } } +/* ── Auth0 AI chip ────────────────────────────────────────────────────── */ + +.auth0-chip-wrap { + position: relative; + display: inline-flex; + margin-right: 10px; +} +.auth0-chip { + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 10px; border-radius: 999px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.10); + font-size: 11px; font-weight: 600; letter-spacing: 0.04em; + color: rgba(255,255,255,0.86); cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +.auth0-chip:hover { background: rgba(255,255,255,0.07); } +.auth0-chip .auth0-dot { + width: 7px; height: 7px; border-radius: 50%; + background: #6b7280; box-shadow: 0 0 0 0 rgba(0,0,0,0); +} +.auth0-chip.live { border-color: rgba(255, 122, 89, 0.45); color: #ffb39c; } +.auth0-chip.live .auth0-dot { background: #ff7a59; box-shadow: 0 0 8px rgba(255,122,89,0.6); } +.auth0-chip.off { opacity: 0.65; } +.auth0-label { letter-spacing: 0.06em; } +.auth0-feats { display: inline-flex; gap: 4px; margin-left: 2px; } +.auth0-feat { + font-size: 9.5px; font-weight: 700; padding: 1px 5px; + border-radius: 4px; letter-spacing: 0.06em; +} +.auth0-feat.on { background: rgba(255,122,89,0.18); color: #ffb39c; } +.auth0-feat.off { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.45); } + +.auth0-detail { + position: fixed; top: 52px; right: 16px; + width: 360px; z-index: 1100; + background: #1c1d22; border: 1px solid rgba(255,255,255,0.10); + border-radius: 12px; padding: 14px 14px 10px; + box-shadow: 0 20px 50px rgba(0,0,0,0.55); + animation: notifPop 140ms ease-out; +} +.auth0-detail-head { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 10px; +} +.auth0-detail-head strong { color: #fff; font-size: 13px; } +.auth0-state { + font-size: 10px; padding: 2px 7px; border-radius: 4px; + letter-spacing: 0.05em; font-weight: 700; +} +.auth0-state.live { background: rgba(255,122,89,0.22); color: #ffb39c; } +.auth0-state.off { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.55); } + +.auth0-feat-list { list-style: none; margin: 0; padding: 0; } +.auth0-feat-list li { + display: grid; + grid-template-columns: 1fr auto; + grid-row-gap: 4px; + padding: 8px 0; border-top: 1px solid rgba(255,255,255,0.06); +} +.auth0-feat-list li:first-child { border-top: 0; } +.auth0-feat-name { font-size: 12px; font-weight: 600; color: #f4f4f6; } +.auth0-feat-state { + font-size: 9.5px; font-weight: 700; letter-spacing: 0.06em; + padding: 1px 6px; border-radius: 4px; +} +.auth0-feat-list li.on .auth0-feat-state { background: rgba(255,122,89,0.22); color: #ffb39c; } +.auth0-feat-list li.off .auth0-feat-state { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.45); } +.auth0-feat-desc { + grid-column: 1 / span 2; + font-size: 11px; line-height: 1.4; color: rgba(255,255,255,0.62); +} +.auth0-hint { + margin-top: 10px; padding: 8px 10px; + background: rgba(255,255,255,0.04); border-radius: 6px; + font-size: 11px; color: rgba(255,255,255,0.66); line-height: 1.5; +} +.auth0-hint code { + background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 3px; + font-size: 10.5px; color: #f0d3c5; +} +.auth0-user-badge { + font-size: 10px; color: #ffb39c; font-weight: 700; + background: rgba(255,122,89,0.15); padding: 1px 6px; border-radius: 4px; +} +.auth0-identity { + padding: 8px 0 10px; + border-bottom: 1px solid rgba(255,255,255,0.06); + margin-bottom: 6px; +} +.auth0-identity-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.auth0-identity-email { font-size: 12px; color: #ffb39c; display: flex; align-items: center; gap: 6px; } +.auth0-identity-dot { width: 7px; height: 7px; border-radius: 50%; background: #ff7a59; display: inline-block; } +.auth0-identity-anon { font-size: 12px; color: rgba(255,255,255,0.55); } +.auth0-btn-sm { + font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 6px; cursor: pointer; + border: 1px solid; letter-spacing: 0.04em; transition: opacity 120ms; +} +.auth0-btn-login { background: #ff7a59; border-color: #ff7a59; color: #fff; } +.auth0-btn-login:hover { opacity: 0.85; } +.auth0-btn-login.disabled { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.12); color: rgba(255,255,255,0.45); cursor: default; } +.auth0-btn-logout { background: transparent; border-color: rgba(255,255,255,0.15); color: rgba(255,255,255,0.65); } +.auth0-btn-logout:hover { border-color: rgba(255,255,255,0.3); color: #fff; } + /* ── Notifications: bell, dropdown, toast ─────────────────────────────── */ .notif-bell-wrap {