Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions AUTH0_SETUP.md
Original file line number Diff line number Diff line change
@@ -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": "<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."
27 changes: 27 additions & 0 deletions backend/agents/historical_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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(
Expand Down
45 changes: 36 additions & 9 deletions backend/agents/orchestrator/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
),
Expand Down Expand Up @@ -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"],
),
)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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']})."
)
Expand Down
Loading