Move authentication to hero_proxy; hero_os boots in guest mode #118

Open
opened 2026-04-14 07:02:32 +00:00 by timur · 2 comments
Owner

Goal

Move user authentication out of hero_os (and out of hero_auth's HTML login page) and up to hero_proxy as the single edge-auth surface. Downstream services (hero_os, per-domain OSIS services, etc.) receive identity via HTTP headers already injected by hero_proxy — they don't run their own login flow.

Desired behavior

  1. Browser hits http://host:9997/ (or the configured public hero_proxy entrypoint).
  2. Unauthenticated: hero_proxy forwards to hero_os with no identity header. hero_os boots in guest mode — public content visible, plus a "Log in" button.
  3. Log in button: redirects to /login on hero_proxy, which kicks off the OIDC code flow against the configured provider.
  4. After auth, hero_proxy sets its session cookie and starts injecting X-Hero-User / X-Hero-Context / X-Hero-Claims / X-Proxy-User-Email on every forwarded request.
  5. hero_os reads those headers, transitions from guest → authenticated, and starts calling OSIS with the user's context.

Current state (2026-04)

hero_proxy — what already works

Per-route auth, stored in SQLite domain_routes table, managed via OpenRPC. Each DomainRoute has its own auth_mode, so strict vs no-auth is already a per-service decision — you don't need to build this.

Source: crates/hero_proxy_server/src/db.rs:21-40, crates/hero_proxy_server/src/proxy.rs:232-459.

auth_mode Missing credential → Headers injected on success
none forward silently; identity headers only when client IP matches user_networks X-Hero-User, X-Hero-Context (IP-matched only)
bearer 401 X-Proxy-Auth-Method: bearer
oauth 302 redirect to Google/GitHub X-Proxy-Auth-Method: oauth, X-Proxy-User-Email, X-Hero-User, X-Hero-Context, X-Hero-Claims
signature 401 X-Proxy-Auth-Method: signature, X-Proxy-User-Pubkey, X-Hero-User, X-Hero-Context, X-Hero-Claims

Claims resolution (authz.rs:20-97): users belong to groups, groups have roles, roles have claims. BFS on the group graph, filtered by (service, context), emitted as X-Hero-Claims: comma,sep,list. Per-user, not per-route.

Context (users.context column): single integer per user, injected as X-Hero-Context. No per-route override.

OAuth2 providers: Google and GitHub are hardcoded presets (crates/hero_proxy_server/src/oauth.rs). Session cookie valid 24h; spoof protection strips all incoming X-Hero-* / X-Proxy-* before forwarding.

hero_os — what it does today

  • hero_os_app/src/services/auth_service.rs — REST calls to GET /hero_auth/rpc/status, POST /hero_auth/rpc/login, POST /hero_auth/rpc/setup.
  • components/login_screen.rs — the email/password form. Blocks the desktop until login succeeds.
  • hero_auth owns the user DB, issues JWTs, exposes /sso-login for JWT exchange.

hero_auth — what it does today

  • hero_auth_server binds hero_auth/rpc.sock. Serves REST (/login, /setup, /sso-login, /admin/credentials, /token, /validate, HTML / dashboard) + OpenRPC on the same socket.
  • Stores users with password hashes; issues JWTs.

Gap

The model is 90% there. What's missing:

  1. auth_mode = "optional" doesn't exist yet. Current modes are all binary (allow or 401/redirect). For guest mode you need: attempt auth, inject identity on success, forward silently on failure. ≈30 lines in proxy.rs: mirror the oauth branch but skip oauth_redirect(...) and fall through.

  2. No explicit /login / /logout routes on hero_proxy. Today OAuth redirect only happens implicitly when a protected route is hit without a session. For an "optional" route, the user never gets redirected — they need a clickable /login?next=<path> URL to kick off auth on demand, and a /logout to clear the session.

  3. hero_os has no guest mode. It boots into login_screen.rs unconditionally. Needs a branch: if identity headers present → authenticated; else → public desktop with a "Log in" button that links to /login?next=<current-path>.

  4. hero_auth is not an OIDC provider. hero_proxy's OAuth code only understands Google + GitHub as providers. To let self-hosted users log in without an external Google account, hero_auth needs to speak the OpenID Connect wire protocol so hero_proxy can treat it as just another OIDC provider (same code path as Google). This also gets hero_auth SSO for free against any standards-compliant tool (kubectl, Grafana, Argo, …).

Proposed implementation (phased)

Phase 1 — Guest mode in hero_os (unblocks everything, no proxy changes yet)

  • Read X-Hero-User / X-Hero-Context / X-Hero-Claims in hero_os_ui (server side) on the initial HTML request; pass into the WASM app via a <meta> tag (or extend IslandContext seeding).
  • In hero_os_app, branch at boot: if identity present → current authenticated flow; else → public desktop + "Log in" button.
  • Login button: window.location = "/login?next=" + encodeURIComponent(location.pathname).
  • Keep components/login_screen.rs in the codebase — wire it behind a feature flag or a /local-login route for offline / native / dev use. Do not delete; it's a useful fallback and the UI looks nice.

Deliverable: hero_os loads in guest mode when served without identity headers. No proxy work yet; identity injection still comes from whatever's in front (hero_router today, hero_proxy later).

Phase 2 — hero_proxy: optional mode + /login + /logout

  • Add auth_mode = "optional" to the DomainRoute schema. Behavior: try to decode the session cookie; on hit, inject full identity headers; on miss, forward with no identity headers (and no redirect).
  • Add GET /login?next=<path> handler that reads next, runs the OAuth code flow, and on /oauth/callback sets the session cookie and 302s to next.
  • Add GET /logout handler that clears the session cookie and redirects to / (or ?next=).
  • Wire hero_os's DomainRoute to auth_mode = "optional".

Deliverable: guest + logged-in both work at the edge. Log in / log out are explicit, user-initiated.

Phase 3 — hero_auth as an OIDC provider

Why OIDC (OpenID Connect): it's the standard OAuth2-on-top-of-identity protocol. By implementing it, hero_auth becomes interoperable with every OIDC client in existence (hero_proxy, kubectl, Grafana, Argo CD, Kubernetes API server, etc.). No custom protocol; hero_proxy talks to hero_auth using the same code path it already uses for Google.

Endpoints hero_auth needs to add:

  • GET /.well-known/openid-configuration — discovery document. Lists the URLs below, supported scopes, supported response types, JWKS URI. Static JSON.
  • GET /authorize — user-facing. If no session, serves hero_auth's existing login form. On submit (or already-logged-in), 302s back to the caller's redirect_uri with ?code=<opaque>&state=<passthrough>.
  • POST /token — machine-facing. Client posts grant_type=authorization_code&code=<opaque>&client_id=…&client_secret=…&redirect_uri=…. Returns { access_token, id_token, refresh_token, expires_in }. id_token is a signed JWT (RS256) with sub, email, iss, aud, iat, exp.
  • GET /userinfo — bearer-token-authenticated. Returns { sub, email, name, ... }.
  • GET /.well-known/jwks.json — public keys for verifying id_token signatures.

Client registration: hero_auth needs a notion of "registered OAuth clients" — each with a client_id, client_secret, and allowed redirect_uris. hero_proxy gets one. The admin/credentials endpoint already stores admin client credentials — same idea, extended to N clients.

hero_proxy changes:

  • Generalize oauth.rs to read provider config (auth URL, token URL, userinfo URL, client_id/secret, scopes) from the DomainRoute / a providers table rather than hardcoded Google/GitHub constants.
  • Add hero_auth as a first-class preset: discovery URL points at the local hero_auth service (routed through hero_router if needed), scopes = openid email profile.

Deliverable: a user with a hero_auth password can sign in at hero_proxy without a Google account. Optional follow-up: any OIDC-speaking tool can authenticate against hero_auth.

Phase 4 — Retire hero_auth's public login page

Once Phase 3 is live and all user-facing login flows go through hero_proxy's /login:

  • hero_auth's HTML /login, /setup, /register pages become admin-only (gated behind auth_mode="bearer" or IP allowlist) — useful for bootstrapping and user management but not the daily login path.
  • hero_os auth_service.rs + login_screen.rs stay in the tree but are no longer invoked by the default boot flow.

Out of scope

  • Per-context authorization changes — the existing X-Hero-Context + X-Hero-Claims model from hero_context handles that. This issue only changes where identity is established.
  • Multi-tenancy / federated identity across multiple hero deployments (potential Phase 5 — cross-deployment SSO via OIDC).
  • Rewriting the hero_os login UI. Keep it.
  • hero_context skill — the 3-header security model (X-Hero-Context / X-Hero-Claims / X-Forwarded-Prefix).
  • hero_sockets skill — how services consume those headers.
  • hero_proxy/crates/hero_proxy_server/src/{db.rs, proxy.rs, oauth.rs, authz.rs} — current auth implementation.
  • hero_os_app/src/{services/auth_service.rs, components/login_screen.rs} — what gets superseded in Phase 4.
  • OpenID Connect Core 1.0 spec: https://openid.net/specs/openid-connect-core-1_0.html
## Goal Move user authentication out of hero_os (and out of hero_auth's HTML login page) and up to **hero_proxy** as the single edge-auth surface. Downstream services (hero_os, per-domain OSIS services, etc.) receive identity via HTTP headers already injected by hero_proxy — they don't run their own login flow. ## Desired behavior 1. Browser hits `http://host:9997/` (or the configured public hero_proxy entrypoint). 2. **Unauthenticated:** hero_proxy forwards to hero_os with no identity header. hero_os boots in **guest mode** — public content visible, plus a "Log in" button. 3. **Log in button:** redirects to `/login` on hero_proxy, which kicks off the OIDC code flow against the configured provider. 4. After auth, hero_proxy sets its session cookie and starts injecting `X-Hero-User` / `X-Hero-Context` / `X-Hero-Claims` / `X-Proxy-User-Email` on every forwarded request. 5. hero_os reads those headers, transitions from guest → authenticated, and starts calling OSIS with the user's context. ## Current state (2026-04) ### hero_proxy — what already works **Per-route auth, stored in SQLite `domain_routes` table**, managed via OpenRPC. Each `DomainRoute` has its own `auth_mode`, so **strict vs no-auth is already a per-service decision** — you don't need to build this. Source: `crates/hero_proxy_server/src/db.rs:21-40`, `crates/hero_proxy_server/src/proxy.rs:232-459`. | `auth_mode` | Missing credential → | Headers injected on success | |---|---|---| | `none` | forward silently; identity headers only when client IP matches `user_networks` | `X-Hero-User`, `X-Hero-Context` (IP-matched only) | | `bearer` | **401** | `X-Proxy-Auth-Method: bearer` | | `oauth` | **302 redirect** to Google/GitHub | `X-Proxy-Auth-Method: oauth`, `X-Proxy-User-Email`, `X-Hero-User`, `X-Hero-Context`, `X-Hero-Claims` | | `signature` | **401** | `X-Proxy-Auth-Method: signature`, `X-Proxy-User-Pubkey`, `X-Hero-User`, `X-Hero-Context`, `X-Hero-Claims` | **Claims resolution** (`authz.rs:20-97`): users belong to groups, groups have roles, roles have claims. BFS on the group graph, filtered by `(service, context)`, emitted as `X-Hero-Claims: comma,sep,list`. Per-user, not per-route. **Context** (`users.context` column): single integer per user, injected as `X-Hero-Context`. No per-route override. **OAuth2 providers**: Google and GitHub are hardcoded presets (`crates/hero_proxy_server/src/oauth.rs`). Session cookie valid 24h; spoof protection strips all incoming `X-Hero-*` / `X-Proxy-*` before forwarding. ### hero_os — what it does today - `hero_os_app/src/services/auth_service.rs` — REST calls to `GET /hero_auth/rpc/status`, `POST /hero_auth/rpc/login`, `POST /hero_auth/rpc/setup`. - `components/login_screen.rs` — the email/password form. Blocks the desktop until login succeeds. - hero_auth owns the user DB, issues JWTs, exposes `/sso-login` for JWT exchange. ### hero_auth — what it does today - `hero_auth_server` binds `hero_auth/rpc.sock`. Serves REST (`/login`, `/setup`, `/sso-login`, `/admin/credentials`, `/token`, `/validate`, HTML `/` dashboard) + OpenRPC on the same socket. - Stores users with password hashes; issues JWTs. ## Gap The model is **90% there**. What's missing: 1. **`auth_mode = "optional"` doesn't exist yet.** Current modes are all binary (allow or 401/redirect). For guest mode you need: *attempt auth, inject identity on success, forward silently on failure.* ≈30 lines in `proxy.rs`: mirror the `oauth` branch but skip `oauth_redirect(...)` and fall through. 2. **No explicit `/login` / `/logout` routes on hero_proxy.** Today OAuth redirect only happens implicitly when a protected route is hit without a session. For an "optional" route, the user never gets redirected — they need a clickable `/login?next=<path>` URL to kick off auth on demand, and a `/logout` to clear the session. 3. **hero_os has no guest mode.** It boots into `login_screen.rs` unconditionally. Needs a branch: if identity headers present → authenticated; else → public desktop with a "Log in" button that links to `/login?next=<current-path>`. 4. **hero_auth is not an OIDC provider.** hero_proxy's OAuth code only understands Google + GitHub as providers. To let self-hosted users log in without an external Google account, hero_auth needs to speak the OpenID Connect wire protocol so hero_proxy can treat it as just another OIDC provider (same code path as Google). This also gets hero_auth SSO for free against any standards-compliant tool (kubectl, Grafana, Argo, …). ## Proposed implementation (phased) ### Phase 1 — Guest mode in hero_os (unblocks everything, no proxy changes yet) - Read `X-Hero-User` / `X-Hero-Context` / `X-Hero-Claims` in `hero_os_ui` (server side) on the initial HTML request; pass into the WASM app via a `<meta>` tag (or extend `IslandContext` seeding). - In `hero_os_app`, branch at boot: if identity present → current authenticated flow; else → public desktop + "Log in" button. - Login button: `window.location = "/login?next=" + encodeURIComponent(location.pathname)`. - **Keep `components/login_screen.rs` in the codebase** — wire it behind a feature flag or a `/local-login` route for offline / native / dev use. Do not delete; it's a useful fallback and the UI looks nice. Deliverable: hero_os loads in guest mode when served without identity headers. No proxy work yet; identity injection still comes from whatever's in front (hero_router today, hero_proxy later). ### Phase 2 — hero_proxy: `optional` mode + `/login` + `/logout` - Add `auth_mode = "optional"` to the `DomainRoute` schema. Behavior: try to decode the session cookie; on hit, inject full identity headers; on miss, forward with no identity headers (and no redirect). - Add `GET /login?next=<path>` handler that reads `next`, runs the OAuth code flow, and on `/oauth/callback` sets the session cookie and `302`s to `next`. - Add `GET /logout` handler that clears the session cookie and redirects to `/` (or `?next=`). - Wire hero_os's DomainRoute to `auth_mode = "optional"`. Deliverable: guest + logged-in both work at the edge. Log in / log out are explicit, user-initiated. ### Phase 3 — hero_auth as an OIDC provider **Why OIDC (OpenID Connect):** it's the standard OAuth2-on-top-of-identity protocol. By implementing it, hero_auth becomes interoperable with every OIDC client in existence (hero_proxy, kubectl, Grafana, Argo CD, Kubernetes API server, etc.). No custom protocol; hero_proxy talks to hero_auth using the same code path it already uses for Google. **Endpoints hero_auth needs to add:** - `GET /.well-known/openid-configuration` — discovery document. Lists the URLs below, supported scopes, supported response types, JWKS URI. Static JSON. - `GET /authorize` — user-facing. If no session, serves hero_auth's existing login form. On submit (or already-logged-in), `302`s back to the caller's `redirect_uri` with `?code=<opaque>&state=<passthrough>`. - `POST /token` — machine-facing. Client posts `grant_type=authorization_code&code=<opaque>&client_id=…&client_secret=…&redirect_uri=…`. Returns `{ access_token, id_token, refresh_token, expires_in }`. `id_token` is a signed JWT (RS256) with `sub`, `email`, `iss`, `aud`, `iat`, `exp`. - `GET /userinfo` — bearer-token-authenticated. Returns `{ sub, email, name, ... }`. - `GET /.well-known/jwks.json` — public keys for verifying `id_token` signatures. **Client registration:** hero_auth needs a notion of "registered OAuth clients" — each with a `client_id`, `client_secret`, and allowed `redirect_uris`. hero_proxy gets one. The `admin/credentials` endpoint already stores admin client credentials — same idea, extended to N clients. **hero_proxy changes:** - Generalize `oauth.rs` to read provider config (auth URL, token URL, userinfo URL, client_id/secret, scopes) from the `DomainRoute` / a `providers` table rather than hardcoded Google/GitHub constants. - Add `hero_auth` as a first-class preset: discovery URL points at the local hero_auth service (routed through hero_router if needed), scopes = `openid email profile`. Deliverable: a user with a hero_auth password can sign in at hero_proxy without a Google account. Optional follow-up: any OIDC-speaking tool can authenticate against hero_auth. ### Phase 4 — Retire hero_auth's public login page Once Phase 3 is live and all user-facing login flows go through hero_proxy's `/login`: - hero_auth's HTML `/login`, `/setup`, `/register` pages become admin-only (gated behind `auth_mode="bearer"` or IP allowlist) — useful for bootstrapping and user management but not the daily login path. - hero_os `auth_service.rs` + `login_screen.rs` stay in the tree but are no longer invoked by the default boot flow. ## Out of scope - Per-context authorization changes — the existing `X-Hero-Context` + `X-Hero-Claims` model from `hero_context` handles that. This issue only changes *where* identity is established. - Multi-tenancy / federated identity across multiple hero deployments (potential Phase 5 — cross-deployment SSO via OIDC). - Rewriting the hero_os login UI. Keep it. ## Related - `hero_context` skill — the 3-header security model (`X-Hero-Context` / `X-Hero-Claims` / `X-Forwarded-Prefix`). - `hero_sockets` skill — how services consume those headers. - `hero_proxy/crates/hero_proxy_server/src/{db.rs, proxy.rs, oauth.rs, authz.rs}` — current auth implementation. - `hero_os_app/src/{services/auth_service.rs, components/login_screen.rs}` — what gets superseded in Phase 4. - OpenID Connect Core 1.0 spec: https://openid.net/specs/openid-connect-core-1_0.html
Author
Owner

Picking this up from the hero_proxy side. Scope I propose for the first PR (tracked in hero_proxy):

Phase 2 only — implementable without touching hero_os or hero_auth:

  1. GET /login?next=<path> — pick an OAuth provider, kick off code flow, redirect to next on callback.
  2. GET /logout — clear session cookie (+ delete session row), redirect to / (or ?next=).
  3. New auth_mode="optional" — forward identity headers when a valid session exists, strip + forward unauthenticated otherwise (no redirect).

Open questions before I start:

  • Provider selection for /login: with multiple enabled OAuth providers configured, should /login (a) render a tiny picker page, (b) take ?provider=<name> and default to the first enabled provider if omitted, or (c) require ?provider= explicitly? I'll default to (b) unless you say otherwise — matches the "one click to log in" intent.
  • Logout scope: just clear the hero_proxy session cookie + drop the session row. No upstream provider revocation (Google/GitHub don't support it cleanly). OK?
  • optional mode on missing session: inject x-proxy-auth-method: none and skip all x-hero-* headers (same as the current none path without an IP match). Downstream treats absent X-Hero-User as guest. OK?

Phase 1 (hero_os guest mode) and Phase 3 (hero_auth OIDC) will be separate PRs in their own repos. Proceeding with Phase 2 unless you push back on any of the above.

Picking this up from the hero_proxy side. Scope I propose for the first PR (tracked in `hero_proxy`): **Phase 2 only** — implementable without touching hero_os or hero_auth: 1. `GET /login?next=<path>` — pick an OAuth provider, kick off code flow, redirect to `next` on callback. 2. `GET /logout` — clear session cookie (+ delete session row), redirect to `/` (or `?next=`). 3. New `auth_mode="optional"` — forward identity headers when a valid session exists, strip + forward unauthenticated otherwise (no redirect). Open questions before I start: - **Provider selection for `/login`**: with multiple enabled OAuth providers configured, should `/login` (a) render a tiny picker page, (b) take `?provider=<name>` and default to the first enabled provider if omitted, or (c) require `?provider=` explicitly? I'll default to **(b)** unless you say otherwise — matches the "one click to log in" intent. - **Logout scope**: just clear the hero_proxy session cookie + drop the session row. No upstream provider revocation (Google/GitHub don't support it cleanly). OK? - **`optional` mode on missing session**: inject `x-proxy-auth-method: none` and skip all `x-hero-*` headers (same as the current `none` path without an IP match). Downstream treats absent `X-Hero-User` as guest. OK? Phase 1 (hero_os guest mode) and Phase 3 (hero_auth OIDC) will be separate PRs in their own repos. Proceeding with Phase 2 unless you push back on any of the above.
Author
Owner

Phase 2 implemented and pushed — proceeded with the defaults from my previous comment (provider picked by ?provider= or first enabled; logout clears cookie + session row only; optional emits x-proxy-auth-method: none with no X-Hero-* on missing session, still honors IP-based identity).

PR: lhumina_code/hero_proxy#25 — 3 files changed, +342/-62.

Shipped in this PR:

  • GET /login?next=<path>[&provider=<name>]
  • GET /logout?next=<path>
  • auth_mode="optional"
  • Open-redirect guard on ?next= (same-origin relative paths only)
  • Bearer-auth bypass for /login and /logout in the middleware
  • Shared apply_oauth_session helper between oauth and optional branches
  • 4 new unit tests (safe_next, url_decode, parse_query); clippy -D warnings clean

Still open on this issue:

  • Phase 1 — guest mode in hero_os (separate PR in hero_os)
  • Phase 3 — hero_auth as an OIDC-compatible provider in hero_proxy (separate PR in hero_auth + a small provider preset addition here)
  • Phase 4 — retire hero_auth's HTML login page (follows Phase 3)

Not closing the issue — parent stays open until all phases ship.

Phase 2 implemented and pushed — proceeded with the defaults from my previous comment (provider picked by `?provider=` or first enabled; logout clears cookie + session row only; `optional` emits `x-proxy-auth-method: none` with no `X-Hero-*` on missing session, still honors IP-based identity). **PR:** [lhumina_code/hero_proxy#25](https://forge.ourworld.tf/lhumina_code/hero_proxy/pulls/25) — 3 files changed, +342/-62. Shipped in this PR: - `GET /login?next=<path>[&provider=<name>]` - `GET /logout?next=<path>` - `auth_mode="optional"` - Open-redirect guard on `?next=` (same-origin relative paths only) - Bearer-auth bypass for `/login` and `/logout` in the middleware - Shared `apply_oauth_session` helper between `oauth` and `optional` branches - 4 new unit tests (`safe_next`, `url_decode`, `parse_query`); clippy `-D warnings` clean Still open on this issue: - **Phase 1** — guest mode in hero_os (separate PR in `hero_os`) - **Phase 3** — hero_auth as an OIDC-compatible provider in hero_proxy (separate PR in `hero_auth` + a small provider preset addition here) - **Phase 4** — retire hero_auth's HTML login page (follows Phase 3) Not closing the issue — parent stays open until all phases ship.
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#118
No description provided.