[auth] hero_proxy as the single authentication authority — add password + email flows, retire hero_auth #63
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_proxy#63
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?
Problem
Authentication is fragmented across the stack — three overlapping systems, no single source of truth:
users → groups → roles → claimsRBAC, per-routeauth_mode, sessions, andX-Hero-User/X-Hero-Context/X-Hero-Claimsinjection. But it has no local password credential, no email-driven flows (verification, reset, self-registration), and no SMTP — it can only authenticate by delegating to an external IdP or by signature.password_hash,SessionStore,login(email,password),cm50_ui/pages/login.rs); freezone uses bespokeX-Proxy-*. Every app reinventing login is exactly what we're converging away from.Decision
Make hero_proxy the single authentication authority. Add local password auth and the email-driven flows to the proxy (reusing its existing user store, sessions, OIDC, and claim-resolution/injection), delegate the raw email send to hero_mail, and finish retiring hero_auth. Apps then get login for free — the proxy authenticates → opens a session → injects
X-Hero-*— so cm50's in-app auth and freezone'sX-Proxy-*both retire onto it (consistent with theX-Hero-*standardization in home#309).Email nuance: the proxy owns the auth flow + verification/reset token state but delegates delivery to hero_mail — keeps SMTP/provider churn out of the critical-path gateway.
Scope
In hero_proxy:
password/localauth_modealongsideoauth/signature.__Host-cookie).Migration (separate, tracked here):
X-Proxy-*) onto proxy-issued sessions +X-Hero-*.Caveat
The proxy is critical infra; centralizing auth raises its blast radius. Mitigations: delegate email transport (above), keep the local-password path optional per route, treat the auth modules as a well-tested unit. Net still the right call — a separate auth service re-introduces a second identity store + an extra hop, the opposite of the one-trunk/one-identity thesis.
References
X-Hero-*standardization), home#118 (hero_auth removal)hero_proxy/docs/claims.md, skillsherolib_openrpc_authorize/hero_claim_format/hero_contextdocs/2_blueprint/4_auth/in hero_skills (the authorization side)Plan to follow in a comment. Work lands on
development_timurbranches so existing apps (incl. the cm50 demo) keep working until cutover.Implementation plan
Phased so each lands as a self-contained, reviewable commit. All work on
development_timurbranches; existing apps (incl. the cm50 demo) keep working until an explicit cutover. I'll comment here as I commit each step.Phase 1 — credentials layer (additive, no behavior change)
credentialstable keyed onuser_id(Argon2 hash,email,email_verified, timestamps); migration alongside the existinguserstable.db:set_password,verify_password,get_credential_by_email,mark_email_verified. Add theargon2crate.Phase 2 — password login +
localauth_mode/auth/login(verify credential →create_oauth_sessionwithprovider_name="local"→ set__Host-hero_proxy_sessioncookie) and/auth/logout; reuses the existing session +resolve_claims_for_user+X-Hero-*injection unchanged.localin routeauth_mode(route enforcement inproxy.rs): valid session → proceed; missing → the login page instead of an OAuth redirect.auth.set_passwordRPC (self, or admin viaproxy.users.write);required_claim_for_methodentries for the new public/gated methods.Phase 3 — email flows via hero_mail
verification_tokenstable: purpose, token hash, expiry); delegates delivery to hero_mail over its JSON-RPC socket.auth.register(gated self-signup) → email verification;auth.request_password_reset→auth.reset_password.Phase 4 — login UI
Phase 5 — migrations (separate, gated on 1–4 green)
cm50_app:development_timur(current demo stays onmain/development).X-Proxy-*→X-Hero-*.Sequencing
Phases 1–2 first (the foundational password path), then 3–4 (email + UI), then 5 (migrations). Starting now on
development_timur.Phase 1 done — credentials data layer. Commit
edc8b77c3eondevelopment_timur(diff).credentialstable (1:1 withusers, Argon2id PHC hash, login email,email_verified), added as an idempotent migration so existing OAuth-only DBs gain it on upgrade — purely additive, no behavior change yet.ProxyDb::{set_password, verify_password, get_credential_by_email, mark_email_verified}+ private Argon2 hash/verify helpers (per-call random salt;verify_passwordreturns the sameNonefor unknown-email and wrong-password — no account oracle).argon2dep.cargo build+fmt+clippyclean.Next: Phase 2 —
/auth/login+/auth/logout(verify → create session → set__Host-cookie, reusing the existing session +X-Hero-*injection) and acceptinglocalin routeauth_mode.Phase 2 done — password login +
localauth_mode. Ondevelopment_timur.2a — endpoints (
6335f1d):GET /auth/loginserves a minimal sign-in form (polished UI is Phase 4);POST /auth/loginverifies the credential, opens a session keyed by the local username with providerlocal, and sets the same__Host-cookie the OAuth callback uses — so the existingvalidate_oauth_session+X-Hero-*injection honors it unchanged./auth/logoutreuses the provider-agnostic logout handler.resolve_caller's public allowlist (else a configuredauth_tokenwould 401 the login POST).safe_return_toclamps post-login redirects to same-origin paths (no open redirect); form values html-escaped; same response for unknown-email vs wrong-password.2b — route enforcement (
2fdf458):dispatch_domain_route+ the info-route guard now acceptauth_mode = "local": valid session → inject identity downstream (same path as oauth); no session → 302 to/auth/login?return_to=<path>.Tests:
safe_return_toallow/deny,html_escape, and bothlocalarms redirecting to login. 90 lib tests green; build/fmt/clippy clean. No change to existing oauth/signature/none/bearer behavior.Next: Phase 3 — email flows (verify / reset / gated registration) with delivery delegated to hero_mail.
Phase 3a done — verification-token layer. Commit
d847e51ondevelopment_timur.verification_tokenstable (idempotent migration) for email-verify + password-reset. Only the SHA-256 of the token is stored — a DB read never yields a usable token; the raw value lives only in the email link.ProxyDb::{create_verification_token, consume_verification_token}— single-use; consume DELETE scoped to(hash, purpose)so a wrong-purpose probe can't burn another purpose's token;{:+} secondsTTL stays a valid SQLite modifier for negatives.Heads-up that changes 3b:
hero_mailis currently a stub. Itsmail.mail.sendreturns canned JSON and ignores params — no Stalwart delivery is wired. So the remaining email flows will ship behind a pluggableMailSenderseam whose default impl logs the verification/reset link (fully usable for dev), with the realhero_maildelegation dropping in once hero_mail actually delivers. The proxy owns the flow + token state either way.One decision for @timur before 3b: self-registration policy — should
/auth/registerbe open (anyone can sign up, email-verify gates activation) or invite/admin-only (admins create users; password-reset + verify only)? That choice shapes whether 3b includes the register endpoint or just verify + reset.Confirmed: hero_mail is fully non-functional, and the proxy will own mail.
Checked
development,integration, andmainat their latest commits (all 2026-06-11): every RPC method — includingmail.mail.send— is a canned-JSON stub that ignores its params, and there is no SMTP/lettre/Stalwart-submit library anywhere in the repo or its Cargo deps. Delegating to it would silently drop every email. So we drop the hero_mail delegation: the proxy sends its own mail.Phase 3b-i done — proxy-native mail seam. Commit
ace8741ondevelopment_timur.mailmodule:MailSendertrait +SmtpMailSender(real SMTP vialettre) +LogMailSender(logs the link; default when SMTP unconfigured — dev/test flows still complete).SmtpConfig::from_dbreadssmtp_host/smtp_from/smtp_port/smtp_username/smtp_password/smtp_starttlsfrom the settings table;sender_from_dbpicks SMTP when host+from are set, else logging. Wired intoAppState.SmtpMailSenderdefensively installs the aws-lc-rsCryptoProviderto avoid the ambiguous-default rustls panic (cf. #60).Two caveats to watch: live SMTP-over-TLS can't be verified from here (needs a real server), and the new deps want a musl-CI build confirmation on this branch.
Still need your call on registration policy (blocks the register endpoint only): open self-signup vs invite/admin-only? Password-reset + email-verification flows are policy-independent and I can build them next regardless.
Phase 3 complete — the full local-auth backend is in place. On
development_timur.3b-ii — reset + verification (
52761f2):POST /auth/request-password-reset→ 1h token + emailed link; generic notice always (no account oracle).GET/POST /auth/reset→ password validated (≥8) before the single-use token is consumed (a weak password doesn't burn the link); thenset_password+ mark verified.GET /auth/verify?token=→ consume + mark verified →/auth/login?verified=1.3b-iii — registration, policy-gated (
ef912b9):registration_modesetting:open= self-signup; anything else (default) = closed/invite-only.GET/POST /auth/registercreates an unverified local account (same provisioning as the OAuth path) and emails a 24h verify link; closed by default.Phase 3 total: token store + SMTP/log mail seam + reset + verify + register. 101 lib tests green; fmt/clippy clean throughout.
cm50 will set
registration_mode=open.What remains: Phase 4 (polished login/register/reset UI — minimal functional forms exist now) and Phase 5 (migrations: retire hero_auth; move cm50 onto proxy auth on
cm50_app:development_timur; freezoneX-Proxy-*→X-Hero-*). Plus the two standing caveats: live SMTP-over-TLS verification and a musl-CI build check for the new deps.Verification. Full-workspace
cargo buildis clean with the new deps, and I added an end-to-end lifecycle test (5f6122a) that drives the real handlers through oneAppStatewith a capturingMailSender: register → read the verify link from the captured email → verify → login (session cookie issued) → request-reset → reset → old password rejected, new accepted. 102 hero_proxy_server tests green; fmt/clippy clean.⚠️ Pre-existing, unrelated:
cargo test --workspacecurrently fails to compilehero_proxy_examples/tests/integration.rs—connect()is annotatedResult<_, OpenRpcError>but the generatedconnect_socketnow returnsherolib_openrpc::error::RpcError(SDK error-type drift). This is present onorigin/development, not from the auth work;hero_proxy_serverbuilds + tests clean. Worth a one-line fix on development independently.Still open from before: live SMTP-over-TLS verification against a real server, and a musl-CI build confirmation for
lettre/rustlson this branch.Phase 3c — passwordless magic-link login (generic, for cm50 and any proxy-gated app). Commit
1940690.Exactly the flow you described: request a link by email → click it → session cookie set → signed in. Reuses everything already built (single-use token store, mail seam, the same
__Host-session cookie +X-Hero-*injection as password login).GET/POST /auth/magic-link— mints a 15-min single-usemagic_logintoken and emails the link; sends only when the address can sign in (existing account, orregistration_mode=openso the click provisions one); same generic notice always (no account oracle).GET /auth/magic?token=— consumes → finds (or, when open, auto-provisions a passwordless + verified) user → opens a session → sets the cookie → redirects.So the proxy now offers three sign-in modes generically: password, OAuth/OIDC, and magic link — any
auth_mode="local"route gets all of them. Next: the cm50 migration onto this (cm50_app:development_timur), and the hero_components auth-admin pane (pending coordination with @mik-tf, whose shell is actively changing).