Move authentication to hero_proxy; hero_os boots in guest mode #118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
http://host:9997/(or the configured public hero_proxy entrypoint)./loginon hero_proxy, which kicks off the OIDC code flow against the configured provider.X-Hero-User/X-Hero-Context/X-Hero-Claims/X-Proxy-User-Emailon every forwarded request.Current state (2026-04)
hero_proxy — what already works
Per-route auth, stored in SQLite
domain_routestable, managed via OpenRPC. EachDomainRoutehas its ownauth_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_modenoneuser_networksX-Hero-User,X-Hero-Context(IP-matched only)bearerX-Proxy-Auth-Method: beareroauthX-Proxy-Auth-Method: oauth,X-Proxy-User-Email,X-Hero-User,X-Hero-Context,X-Hero-ClaimssignatureX-Proxy-Auth-Method: signature,X-Proxy-User-Pubkey,X-Hero-User,X-Hero-Context,X-Hero-ClaimsClaims 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 asX-Hero-Claims: comma,sep,list. Per-user, not per-route.Context (
users.contextcolumn): single integer per user, injected asX-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 incomingX-Hero-*/X-Proxy-*before forwarding.hero_os — what it does today
hero_os_app/src/services/auth_service.rs— REST calls toGET /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./sso-loginfor JWT exchange.hero_auth — what it does today
hero_auth_serverbindshero_auth/rpc.sock. Serves REST (/login,/setup,/sso-login,/admin/credentials,/token,/validate, HTML/dashboard) + OpenRPC on the same socket.Gap
The model is 90% there. What's missing:
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 inproxy.rs: mirror theoauthbranch but skipoauth_redirect(...)and fall through.No explicit
/login//logoutroutes 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/logoutto clear the session.hero_os has no guest mode. It boots into
login_screen.rsunconditionally. Needs a branch: if identity headers present → authenticated; else → public desktop with a "Log in" button that links to/login?next=<current-path>.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)
X-Hero-User/X-Hero-Context/X-Hero-Claimsinhero_os_ui(server side) on the initial HTML request; pass into the WASM app via a<meta>tag (or extendIslandContextseeding).hero_os_app, branch at boot: if identity present → current authenticated flow; else → public desktop + "Log in" button.window.location = "/login?next=" + encodeURIComponent(location.pathname).components/login_screen.rsin the codebase — wire it behind a feature flag or a/local-loginroute 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:
optionalmode +/login+/logoutauth_mode = "optional"to theDomainRouteschema. Behavior: try to decode the session cookie; on hit, inject full identity headers; on miss, forward with no identity headers (and no redirect).GET /login?next=<path>handler that readsnext, runs the OAuth code flow, and on/oauth/callbacksets the session cookie and302s tonext.GET /logouthandler that clears the session cookie and redirects to/(or?next=).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'sredirect_uriwith?code=<opaque>&state=<passthrough>.POST /token— machine-facing. Client postsgrant_type=authorization_code&code=<opaque>&client_id=…&client_secret=…&redirect_uri=…. Returns{ access_token, id_token, refresh_token, expires_in }.id_tokenis a signed JWT (RS256) withsub,email,iss,aud,iat,exp.GET /userinfo— bearer-token-authenticated. Returns{ sub, email, name, ... }.GET /.well-known/jwks.json— public keys for verifyingid_tokensignatures.Client registration: hero_auth needs a notion of "registered OAuth clients" — each with a
client_id,client_secret, and allowedredirect_uris. hero_proxy gets one. Theadmin/credentialsendpoint already stores admin client credentials — same idea, extended to N clients.hero_proxy changes:
oauth.rsto read provider config (auth URL, token URL, userinfo URL, client_id/secret, scopes) from theDomainRoute/ aproviderstable rather than hardcoded Google/GitHub constants.hero_authas 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:/login,/setup,/registerpages become admin-only (gated behindauth_mode="bearer"or IP allowlist) — useful for bootstrapping and user management but not the daily login path.auth_service.rs+login_screen.rsstay in the tree but are no longer invoked by the default boot flow.Out of scope
X-Hero-Context+X-Hero-Claimsmodel fromhero_contexthandles that. This issue only changes where identity is established.Related
hero_contextskill — the 3-header security model (X-Hero-Context/X-Hero-Claims/X-Forwarded-Prefix).hero_socketsskill — 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.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:
GET /login?next=<path>— pick an OAuth provider, kick off code flow, redirect tonexton callback.GET /logout— clear session cookie (+ delete session row), redirect to/(or?next=).auth_mode="optional"— forward identity headers when a valid session exists, strip + forward unauthenticated otherwise (no redirect).Open questions before I start:
/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.optionalmode on missing session: injectx-proxy-auth-method: noneand skip allx-hero-*headers (same as the currentnonepath without an IP match). Downstream treats absentX-Hero-Useras 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.
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;optionalemitsx-proxy-auth-method: nonewith noX-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"?next=(same-origin relative paths only)/loginand/logoutin the middlewareapply_oauth_sessionhelper betweenoauthandoptionalbranchessafe_next,url_decode,parse_query); clippy-D warningscleanStill open on this issue:
hero_os)hero_auth+ a small provider preset addition here)Not closing the issue — parent stays open until all phases ship.