hero_agent: replace hardcoded anonymous user with real auth identity #92

Closed
opened 2026-03-26 00:18:14 +00:00 by mik-tf · 3 comments
Owner

Context

Deferred from #45 (Phase 3: User Identity).

The OSIS storage migration is complete (v0.7.2-dev), but all operations use hardcoded "anonymous" as the user_id. This means:

  • All users share the same conversations, memories, audit log
  • No per-user data isolation
  • No scoping of OSIS queries by authenticated user

What needs to happen

  1. Extract real user from auth session — hero_agent routes currently default to "anonymous". Need middleware or helper to extract user identity from the auth token/session (hero_auth provides SSO sessions)
  2. Pass user_id through the stackroutes.rsagent.handle_message()osis_store.*() — the plumbing exists (user_sid parameter) but is always "anonymous"
  3. Scope OSIS querieslist_conversations(), list_memories(), get_stats() already filter by user_id — just needs real values
  4. Test multi-user isolation — create conversations as user A, verify user B cannot see them

Files to modify

File Change
hero_agent_server/src/routes.rs Extract user from auth session instead of hardcoding "anonymous"
hero_agent_server/src/main.rs Add auth middleware or session extraction
  • #45 — OSIS migration (completed)
  • #91 — Full SQLite removal (completed)

Signed-off-by: mik-tf

## Context Deferred from https://forge.ourworld.tf/lhumina_code/home/issues/45 (Phase 3: User Identity). The OSIS storage migration is complete (v0.7.2-dev), but all operations use hardcoded `"anonymous"` as the user_id. This means: - All users share the same conversations, memories, audit log - No per-user data isolation - No scoping of OSIS queries by authenticated user ## What needs to happen 1. **Extract real user from auth session** — hero_agent routes currently default to `"anonymous"`. Need middleware or helper to extract user identity from the auth token/session (hero_auth provides SSO sessions) 2. **Pass user_id through the stack** — `routes.rs` → `agent.handle_message()` → `osis_store.*()` — the plumbing exists (`user_sid` parameter) but is always `"anonymous"` 3. **Scope OSIS queries** — `list_conversations()`, `list_memories()`, `get_stats()` already filter by `user_id` — just needs real values 4. **Test multi-user isolation** — create conversations as user A, verify user B cannot see them ## Files to modify | File | Change | |------|--------| | `hero_agent_server/src/routes.rs` | Extract user from auth session instead of hardcoding `"anonymous"` | | `hero_agent_server/src/main.rs` | Add auth middleware or session extraction | ## Related - https://forge.ourworld.tf/lhumina_code/home/issues/45 — OSIS migration (completed) - https://forge.ourworld.tf/lhumina_code/home/issues/91 — Full SQLite removal (completed) Signed-off-by: mik-tf
Author
Owner

Analysis — complete auth flow mapping

Investigated the full identity flow across hero_auth, hero_os, hero_agent, and hero_proxy.

Current state: two disconnected auth systems

System Token type Storage Used by
hero_osis identity OSIS session token (challenge-response) localStorage hero_os WASM shell login
hero_auth HS256 JWT (Claims.sub = client_id UUID) returned in JSON body hero_auth_ui admin endpoints

The gap: The WASM shell authenticates via hero_osis identity service, gets a session token, but never sends it (or any identity) on subsequent API calls. hero_agent receives no auth header and hardcodes "anonymous" at 8 locations in routes.rs.

Where "anonymous" is hardcoded (hero_agent_server/src/routes.rs)

Line Handler Usage
265 stats() get_stats("anonymous")
298 messages() get_or_create_conversation("anonymous", "default")
315 messages() "from_user": "anonymous" in JSON
385 memories() list("anonymous")
502 chat() request.user_sid.unwrap_or_else "anonymous"
655 list_conversations() list_conversations("anonymous")
693 create_conversation() create_conversation("anonymous", title)
836 voice_chat() let user_sid = "anonymous" (hardcoded)

Fix options evaluated

Option A — Validate OSIS session token per request:

  • hero_agent calls hero_osis to validate the token on every API call
  • Pro: no WASM shell changes
  • Con: extra RPC round-trip per request, tightly couples hero_agent to hero_osis

Option B — JWT via hero_auth (recommended):

  • After WASM login, call hero_auth /sso-login with OSIS token to get JWT
  • Store JWT in localStorage, send as Authorization: Bearer on all fetch calls
  • hero_agent adds middleware using hero_auth extract_claims() to validate JWT and extract Claims.sub as user_id
  • Pro: stateless validation (verify HS256 signature locally with shared HERO_SECRET), standard Bearer auth pattern, no extra RPC calls
  • Con: requires WASM shell changes to store+send JWT

Option C — Proxy injects identity header:

  • Configure hero_proxy to validate OSIS session on path-prefix routes and inject X-Proxy-User-Email
  • hero_agent reads trusted header
  • Pro: no hero_agent auth logic
  • Con: proxy doesn't currently do this for path-prefix routes, only domain routes

Step 1: WASM shell — obtain + store JWT after login

File: hero_os/crates/hero_os_app/src/components/login_screen.rs

  • After successful authenticate_local() (line 363), call hero_auth /sso-login with the OSIS session token
  • Store returned JWT in localStorage alongside the OSIS token
  • On page reload / session restore, validate JWT expiry (1h) and refresh if needed

Step 2: WASM shell — send JWT on API calls

File: hero_os/crates/hero_os_app/src/ (AI bar, any fetch calls to hero_agent)

  • All fetch() calls to /hero_agent/* endpoints must include Authorization: Bearer header
  • This may require changes to hero_osis_sdk AiClient if that's the call path

Step 3: hero_agent — add JWT extraction middleware

File: hero_agent/crates/hero_agent_server/src/main.rs

  • Add axum middleware layer that calls extract_claims() on the Authorization header
  • Extract Claims.sub (client_id UUID) and inject into request extensions
  • Fallback: if no valid JWT, use "anonymous" (graceful degradation for API testing)

Step 4: hero_agent — replace "anonymous" with real identity

File: hero_agent/crates/hero_agent_server/src/routes.rs

  • Create helper: fn get_user_id(headers, state) -> String
  • Replace all 8 hardcoded "anonymous" with get_user_id() call
  • voice_chat() line 836 needs special attention (currently unconditional hardcode)

Step 5: Test multi-user isolation

  • Login as admin, create conversation, verify it exists
  • Login as different user, verify admin's conversation is NOT visible
  • Verify list_conversations, list_memories, get_stats all scope correctly

Dependencies

  • HERO_SECRET env var must be consistent across hero_auth and hero_agent (same signing key)
  • hero_auth must be running and accessible (already is in the container)
  • hero_auth /sso-login endpoint must accept hero_osis session tokens (it does — handlers.rs lines 24-133)

Repos touched

  • hero_os — WASM shell login + fetch headers (Steps 1-2)
  • hero_agent — middleware + route changes (Steps 3-4)
  • hero_services — tests (Step 5)

Signed-off-by: mik-tf

## Analysis — complete auth flow mapping Investigated the full identity flow across hero_auth, hero_os, hero_agent, and hero_proxy. ### Current state: two disconnected auth systems | System | Token type | Storage | Used by | |--------|-----------|---------|--------| | **hero_osis identity** | OSIS session token (challenge-response) | localStorage | hero_os WASM shell login | | **hero_auth** | HS256 JWT (Claims.sub = client_id UUID) | returned in JSON body | hero_auth_ui admin endpoints | **The gap:** The WASM shell authenticates via hero_osis identity service, gets a session token, but never sends it (or any identity) on subsequent API calls. hero_agent receives no auth header and hardcodes "anonymous" at 8 locations in routes.rs. ### Where "anonymous" is hardcoded (hero_agent_server/src/routes.rs) | Line | Handler | Usage | |------|---------|-------| | 265 | stats() | get_stats("anonymous") | | 298 | messages() | get_or_create_conversation("anonymous", "default") | | 315 | messages() | "from_user": "anonymous" in JSON | | 385 | memories() | list("anonymous") | | 502 | chat() | request.user_sid.unwrap_or_else "anonymous" | | 655 | list_conversations() | list_conversations("anonymous") | | 693 | create_conversation() | create_conversation("anonymous", title) | | 836 | voice_chat() | let user_sid = "anonymous" (hardcoded) | ### Fix options evaluated **Option A — Validate OSIS session token per request:** - hero_agent calls hero_osis to validate the token on every API call - Pro: no WASM shell changes - Con: extra RPC round-trip per request, tightly couples hero_agent to hero_osis **Option B — JWT via hero_auth (recommended):** - After WASM login, call hero_auth /sso-login with OSIS token to get JWT - Store JWT in localStorage, send as Authorization: Bearer on all fetch calls - hero_agent adds middleware using hero_auth extract_claims() to validate JWT and extract Claims.sub as user_id - Pro: stateless validation (verify HS256 signature locally with shared HERO_SECRET), standard Bearer auth pattern, no extra RPC calls - Con: requires WASM shell changes to store+send JWT **Option C — Proxy injects identity header:** - Configure hero_proxy to validate OSIS session on path-prefix routes and inject X-Proxy-User-Email - hero_agent reads trusted header - Pro: no hero_agent auth logic - Con: proxy doesn't currently do this for path-prefix routes, only domain routes ### Recommended plan (Option B) #### Step 1: WASM shell — obtain + store JWT after login **File:** hero_os/crates/hero_os_app/src/components/login_screen.rs - After successful authenticate_local() (line 363), call hero_auth /sso-login with the OSIS session token - Store returned JWT in localStorage alongside the OSIS token - On page reload / session restore, validate JWT expiry (1h) and refresh if needed #### Step 2: WASM shell — send JWT on API calls **File:** hero_os/crates/hero_os_app/src/ (AI bar, any fetch calls to hero_agent) - All fetch() calls to /hero_agent/* endpoints must include Authorization: Bearer header - This may require changes to hero_osis_sdk AiClient if that's the call path #### Step 3: hero_agent — add JWT extraction middleware **File:** hero_agent/crates/hero_agent_server/src/main.rs - Add axum middleware layer that calls extract_claims() on the Authorization header - Extract Claims.sub (client_id UUID) and inject into request extensions - Fallback: if no valid JWT, use "anonymous" (graceful degradation for API testing) #### Step 4: hero_agent — replace "anonymous" with real identity **File:** hero_agent/crates/hero_agent_server/src/routes.rs - Create helper: fn get_user_id(headers, state) -> String - Replace all 8 hardcoded "anonymous" with get_user_id() call - voice_chat() line 836 needs special attention (currently unconditional hardcode) #### Step 5: Test multi-user isolation - Login as admin, create conversation, verify it exists - Login as different user, verify admin's conversation is NOT visible - Verify list_conversations, list_memories, get_stats all scope correctly ### Dependencies - HERO_SECRET env var must be consistent across hero_auth and hero_agent (same signing key) - hero_auth must be running and accessible (already is in the container) - hero_auth /sso-login endpoint must accept hero_osis session tokens (it does — handlers.rs lines 24-133) ### Repos touched - hero_os — WASM shell login + fetch headers (Steps 1-2) - hero_agent — middleware + route changes (Steps 3-4) - hero_services — tests (Step 5) Signed-off-by: mik-tf
Author
Owner

Implementation Plan — JWT-based User Identity

Problem identified

Two disconnected auth systems exist:

  • hero_osis identity — WASM shell login (challenge-response → session token in localStorage)
  • hero_auth — JWT-based OAuth2 (HS256 signed with HERO_SECRET, stateless validation)

The WASM shell authenticates via hero_osis but never sends identity on API calls. hero_agent has no auth middleware — everything is "anonymous".

Critical design finding

hero_auth's /sso-login endpoint currently discards the OSIS session data after validation. It only checks success/failure, then returns the same admin JWT for every user. This defeats multi-user isolation — all users would get the same Claims.sub.

The fix: modify /sso-login to extract public_key from the validated OSIS session (which is the username in MVP), find-or-create a per-user hero_auth account, and return a JWT with that user's unique client_id as Claims.sub.

Architecture

Login Screen (OSIS challenge-response)
    ↓ success → OSIS session token in localStorage
    ↓ call /sso-login with OSIS token
    ↓ → per-user JWT in localStorage
    ↓
WASM shell / Islands (fetch calls to /hero_agent/*)
    ↓ attach Authorization: Bearer <jwt>
    ↓
hero_proxy (TCP → UDS, forwards Authorization header)
    ↓
hero_agent server (axum, Unix socket)
    ↓ middleware extracts Claims.sub → user_id
    ↓
routes.rs handlers
    ↓ read user_id from request extensions (fallback: "anonymous")

Step 1: Fix hero_auth /sso-login — per-user JWTs

Repo: hero_auth
File: src/handlers.rs (lines 21-133)

  • Parse authservice.validate_session JSON-RPC response to extract public_key from the OSIS Session
  • Use public_key to find-or-create a hero_auth user (not always the admin)
  • Return JWT with Claims.sub = that user's client_id
  • ~20 lines changed

Step 2: WASM shell — obtain + store JWT after login

Repo: hero_os
Files: auth_service.rs, storage.rs

  • After authenticate_local() succeeds and OSIS session token is stored, call hero_auth /sso-login with the OSIS token
  • Store JWT in localStorage under key hero_os_jwt
  • Add save_jwt() / load_jwt() / clear_jwt() helpers to storage.rs
  • Handle JWT expiry: on 401 response, try silent /sso-login refresh; if that fails, redirect to login

Step 3: Send JWT on all hero_agent API calls

Repo: hero_archipelagos
Files: ai_service.rs, voice.rs, input_area.rs, island.rs, books/mod.rs

All fetch calls to /hero_agent/* must include Authorization: Bearer <jwt> header:

  • Rust/WASM (gloo_net): ~6 call sites in ai_service.rs, 1 in voice.rs
  • Inline JavaScript: ~3 sites in input_area.rs, island.rs, books/mod.rs
  • JWT is read from localStorage at call time

Step 4: hero_agent — JWT extraction middleware

Repo: hero_agent
Files: Cargo.toml, main.rs, routes.rs

  • Add jsonwebtoken dependency
  • Create axum middleware:
    • Read Authorization: Bearer <token> header
    • Validate JWT using HERO_SECRET (HS256, issuer="hero_auth")
    • Extract Claims.sub → insert UserId(String) into request extensions
    • No JWT or invalid JWT → UserId("anonymous") (graceful degradation)
  • Apply middleware via .layer() on the router

Step 5: Replace "anonymous" with extracted user_id

Repo: hero_agent
File: routes.rs

Create fn get_user_id(extensions: &Extensions) -> String helper. Replace all 8 hardcoded locations:

  • Line 265: stats()get_stats("anonymous")
  • Line 298: messages()get_or_create_conversation("anonymous", ...)
  • Line 315: messages() — response body "anonymous"
  • Line 385: memories()memories.list("anonymous")
  • Line 502: chat()user_sid fallback
  • Line 655: list_conversations()list_conversations("anonymous")
  • Line 693: create_conversation()create_conversation("anonymous", ...)
  • Line 836: voice_chat()let user_sid = "anonymous"

Step 6: Testing

  • Existing smoke/integration tests keep working (graceful "anonymous" fallback when no JWT)
  • Add Playwright E2E test: login → create conversation → verify owned by user
  • Manual Hero Browser MCP verification post-deploy

Repos affected

Repo Scope Risk
hero_auth Fix /sso-login per-user JWT (~20 lines) Low
hero_os Store JWT after login, add storage helpers (~30 lines) Low
hero_archipelagos Add Authorization header to ~10 fetch sites Low
hero_agent JWT middleware + replace 8 "anonymous" hardcodes Medium

Not affected

  • hero_proxy — already forwards Authorization headers
  • hero_osis — no changes, consumed via existing JSON-RPC
  • hero_services — only test updates after code works

Key design decisions

  1. Stateless JWT validation — hero_agent validates locally with HERO_SECRET, no network call to hero_auth at request time
  2. Graceful degradation — no JWT = "anonymous", existing tests keep passing
  3. Identity only — no scope checking (read/write/admin) for now, just user identification
  4. Silent refresh — on 401, try /sso-login once with stored OSIS token before redirecting to login
  5. Build — requires make dist-clean-wasm (WASM shell + archipelago changes)

Signed-off-by: mik-tf

## Implementation Plan — JWT-based User Identity ### Problem identified Two disconnected auth systems exist: - **hero_osis identity** — WASM shell login (challenge-response → session token in localStorage) - **hero_auth** — JWT-based OAuth2 (HS256 signed with HERO_SECRET, stateless validation) The WASM shell authenticates via hero_osis but never sends identity on API calls. hero_agent has no auth middleware — everything is "anonymous". ### Critical design finding hero_auth's `/sso-login` endpoint currently **discards** the OSIS session data after validation. It only checks success/failure, then returns the **same admin JWT** for every user. This defeats multi-user isolation — all users would get the same `Claims.sub`. The fix: modify `/sso-login` to extract `public_key` from the validated OSIS session (which is the username in MVP), find-or-create a per-user hero_auth account, and return a JWT with that user's unique `client_id` as `Claims.sub`. ### Architecture ``` Login Screen (OSIS challenge-response) ↓ success → OSIS session token in localStorage ↓ call /sso-login with OSIS token ↓ → per-user JWT in localStorage ↓ WASM shell / Islands (fetch calls to /hero_agent/*) ↓ attach Authorization: Bearer <jwt> ↓ hero_proxy (TCP → UDS, forwards Authorization header) ↓ hero_agent server (axum, Unix socket) ↓ middleware extracts Claims.sub → user_id ↓ routes.rs handlers ↓ read user_id from request extensions (fallback: "anonymous") ``` ### Step 1: Fix hero_auth `/sso-login` — per-user JWTs **Repo:** `hero_auth` **File:** `src/handlers.rs` (lines 21-133) - Parse `authservice.validate_session` JSON-RPC response to extract `public_key` from the OSIS Session - Use `public_key` to find-or-create a hero_auth user (not always the admin) - Return JWT with `Claims.sub` = that user's `client_id` - ~20 lines changed ### Step 2: WASM shell — obtain + store JWT after login **Repo:** `hero_os` **Files:** `auth_service.rs`, `storage.rs` - After `authenticate_local()` succeeds and OSIS session token is stored, call hero_auth `/sso-login` with the OSIS token - Store JWT in localStorage under key `hero_os_jwt` - Add `save_jwt()` / `load_jwt()` / `clear_jwt()` helpers to `storage.rs` - Handle JWT expiry: on 401 response, try silent `/sso-login` refresh; if that fails, redirect to login ### Step 3: Send JWT on all hero_agent API calls **Repo:** `hero_archipelagos` **Files:** `ai_service.rs`, `voice.rs`, `input_area.rs`, `island.rs`, `books/mod.rs` All fetch calls to `/hero_agent/*` must include `Authorization: Bearer <jwt>` header: - **Rust/WASM (gloo_net):** ~6 call sites in `ai_service.rs`, 1 in `voice.rs` - **Inline JavaScript:** ~3 sites in `input_area.rs`, `island.rs`, `books/mod.rs` - JWT is read from localStorage at call time ### Step 4: hero_agent — JWT extraction middleware **Repo:** `hero_agent` **Files:** `Cargo.toml`, `main.rs`, `routes.rs` - Add `jsonwebtoken` dependency - Create axum middleware: - Read `Authorization: Bearer <token>` header - Validate JWT using `HERO_SECRET` (HS256, issuer="hero_auth") - Extract `Claims.sub` → insert `UserId(String)` into request extensions - No JWT or invalid JWT → `UserId("anonymous")` (graceful degradation) - Apply middleware via `.layer()` on the router ### Step 5: Replace "anonymous" with extracted user_id **Repo:** `hero_agent` **File:** `routes.rs` Create `fn get_user_id(extensions: &Extensions) -> String` helper. Replace all 8 hardcoded locations: - Line 265: `stats()` — `get_stats("anonymous")` - Line 298: `messages()` — `get_or_create_conversation("anonymous", ...)` - Line 315: `messages()` — response body `"anonymous"` - Line 385: `memories()` — `memories.list("anonymous")` - Line 502: `chat()` — `user_sid` fallback - Line 655: `list_conversations()` — `list_conversations("anonymous")` - Line 693: `create_conversation()` — `create_conversation("anonymous", ...)` - Line 836: `voice_chat()` — `let user_sid = "anonymous"` ### Step 6: Testing - Existing smoke/integration tests keep working (graceful "anonymous" fallback when no JWT) - Add Playwright E2E test: login → create conversation → verify owned by user - Manual Hero Browser MCP verification post-deploy ### Repos affected | Repo | Scope | Risk | |------|-------|------| | `hero_auth` | Fix `/sso-login` per-user JWT (~20 lines) | Low | | `hero_os` | Store JWT after login, add storage helpers (~30 lines) | Low | | `hero_archipelagos` | Add `Authorization` header to ~10 fetch sites | Low | | `hero_agent` | JWT middleware + replace 8 "anonymous" hardcodes | Medium | ### Not affected - **hero_proxy** — already forwards Authorization headers - **hero_osis** — no changes, consumed via existing JSON-RPC - **hero_services** — only test updates after code works ### Key design decisions 1. **Stateless JWT validation** — hero_agent validates locally with HERO_SECRET, no network call to hero_auth at request time 2. **Graceful degradation** — no JWT = "anonymous", existing tests keep passing 3. **Identity only** — no scope checking (read/write/admin) for now, just user identification 4. **Silent refresh** — on 401, try `/sso-login` once with stored OSIS token before redirecting to login 5. **Build** — requires `make dist-clean-wasm` (WASM shell + archipelago changes) Signed-off-by: mik-tf
Author
Owner

Implementation Complete

Changes pushed to development across 4 repos:

hero_auth/sso-login now extracts public_key from validated OSIS session, creates per-user accounts ({username}@sso.hero.local), returns user-specific JWT. First SSO user gets admin scope, subsequent users get write.

hero_os — After OSIS login, exchanges session token for JWT via /sso-login, stores in localStorage. Cleared on logout/expired sessions. Added auth_url() config helper and gloo-net dependency.

hero_archipelagos — All fetch calls to /hero_agent/* now include Authorization: Bearer <jwt> header. Covers Rust/WASM (gloo_net) and inline JS for chat, conversations, voice TTS, and transcription.

hero_agent — JWT middleware extracts Claims.sub as user identity (HS256, HERO_SECRET, issuer=hero_auth). Falls back to "anonymous" when no valid JWT present. All 8 hardcoded "anonymous" locations replaced.

Test results

  • Smoke: 116/124 pass (5 failures pre-existing infra issues)
  • Integration: 20/20 pass
  • Auth flow verified: OSIS login → SSO exchange → per-user JWT → multi-user data isolation
  • Graceful degradation confirmed: no JWT = anonymous fallback, existing tests unaffected

Signed-off-by: mik-tf

## Implementation Complete ### Changes pushed to `development` across 4 repos: **hero_auth** — `/sso-login` now extracts `public_key` from validated OSIS session, creates per-user accounts (`{username}@sso.hero.local`), returns user-specific JWT. First SSO user gets admin scope, subsequent users get write. **hero_os** — After OSIS login, exchanges session token for JWT via `/sso-login`, stores in localStorage. Cleared on logout/expired sessions. Added `auth_url()` config helper and `gloo-net` dependency. **hero_archipelagos** — All fetch calls to `/hero_agent/*` now include `Authorization: Bearer <jwt>` header. Covers Rust/WASM (gloo_net) and inline JS for chat, conversations, voice TTS, and transcription. **hero_agent** — JWT middleware extracts `Claims.sub` as user identity (HS256, HERO_SECRET, issuer=hero_auth). Falls back to "anonymous" when no valid JWT present. All 8 hardcoded "anonymous" locations replaced. ### Test results - Smoke: 116/124 pass (5 failures pre-existing infra issues) - Integration: 20/20 pass - Auth flow verified: OSIS login → SSO exchange → per-user JWT → multi-user data isolation - Graceful degradation confirmed: no JWT = anonymous fallback, existing tests unaffected Signed-off-by: mik-tf
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/home#92
No description provided.