| 1 | // Package home is the Oficina suite home app: identity, directory, |
| 2 | // provisioning glue shared by every instance in the suite. See |
| 3 | // AGENTS.md (binding) and |
| 4 | // docs/superpowers/specs/2026-08-30-home-design.md (the design) for |
| 5 | // what this service is and, just as importantly, is not. |
| 6 | // |
| 7 | // §13 step 2 is built: hand-rolled sign-in on keymaildev/signin with the |
| 8 | // Keymail upgrade, passkeys as an optional second factor with recovery |
| 9 | // codes, sealed link state, the person ref and the blind index. The |
| 10 | // front page is still a placeholder (page.go). |
| 11 | // |
| 12 | // Explicit credential and directory roles now exchange browser-bound assertions. |
| 13 | // A directory session establishes identity only. Membership-backed navigation |
| 14 | // does not authorize access to application content. |
| 15 | package home |
| 16 | |
| 17 | import ( |
| 18 | "context" |
| 19 | "errors" |
| 20 | "log/slog" |
| 21 | "net/http" |
| 22 | "strings" |
| 23 | "time" |
| 24 | |
| 25 | "amadan.net/oficina/estilo.git/theme" |
| 26 | "amadan.net/oficina/home/handoff" |
| 27 | "amadan.net/rastrillo/rastrillo/csrf" |
| 28 | "amadan.net/rastrillo/rastrillo/db" |
| 29 | "amadan.net/rastrillo/rastrillo/passkey" |
| 30 | "amadan.net/rastrillo/rastrillo/sessions" |
| 31 | "github.com/go-chi/chi/v5" |
| 32 | "github.com/keymaildev/signin" |
| 33 | ) |
| 34 | |
| 35 | // App holds the wiring. One per process. |
| 36 | // |
| 37 | // It holds the validated Secrets because every value this app persists |
| 38 | // is sealed on the way in, and the database handle because that is what |
| 39 | // it is sealed into. Nothing here reaches for a raw key. |
| 40 | type App struct { |
| 41 | runtime *Runtime |
| 42 | directory *directoryFeatures |
| 43 | consumer *handoff.Consumer |
| 44 | origins Origins |
| 45 | credentialHost string |
| 46 | directoryHost string |
| 47 | secrets Secrets |
| 48 | db *db.DB |
| 49 | links *linkStore |
| 50 | logger *slog.Logger |
| 51 | renderer *renderer |
| 52 | flow *signin.Flow |
| 53 | sessions *sessions.Sessions |
| 54 | passkeys *passkey.Handlers |
| 55 | |
| 56 | // passkeyCfg is the config handed to passkey.New. Retained because |
| 57 | // passkey.Handlers keeps its own copy unexported, and the RP-ID |
| 58 | // guard has to read what the app configured rather than a literal. |
| 59 | passkeyCfg passkey.Config |
| 60 | } |
| 61 | |
| 62 | // New builds an App on validated secrets, and refuses anything less. |
| 63 | // |
| 64 | // Secrets is a value, so a zero one always exists and can always be |
| 65 | // passed; an earlier version of this comment claimed there was "no |
| 66 | // zero-value escape hatch", which was false — New(Secrets{}) compiled |
| 67 | // and ran, and an App with a nil Sealer would persist addresses in the |
| 68 | // clear, the exact failure this step exists to prevent. The claim is now |
| 69 | // true because it is checked rather than asserted. |
| 70 | // |
| 71 | // The checks duplicate LoadSecrets deliberately. LoadSecrets guards the |
| 72 | // boot path; this guards every other way an App can be constructed, |
| 73 | // which is every test and every future caller. |
| 74 | func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| 75 | return NewWithOrigins(secrets, d, logger, Origins{Origin, directoryOrigin}) |
| 76 | } |
| 77 | |
| 78 | func NewWithOrigins(secrets Secrets, d *db.DB, logger *slog.Logger, origins Origins) (*App, error) { |
| 79 | if err := origins.validate(); err != nil { |
| 80 | return nil, err |
| 81 | } |
| 82 | if logger == nil { |
| 83 | logger = slog.Default() |
| 84 | } |
| 85 | switch { |
| 86 | case d == nil: |
| 87 | return nil, errors.New("home: no database") |
| 88 | case secrets.Sealer == nil: |
| 89 | return nil, errors.New("home: no Sealer, so nothing would be sealed at rest") |
| 90 | case len(secrets.Pepper) == 0: |
| 91 | return nil, errors.New("home: no pepper, so no address could be found again") |
| 92 | case secrets.SigninKey == ([32]byte{}): |
| 93 | return nil, errors.New("home: signing key is the zero value, which signin.Flow refuses") |
| 94 | case secrets.Mailer == nil: |
| 95 | return nil, errors.New("home: no mailer, so no sign-in link could be sent") |
| 96 | } |
| 97 | links := &linkStore{db: d, sealer: secrets.Sealer, logger: logger} |
| 98 | |
| 99 | rend, err := newRenderer() |
| 100 | if err != nil { |
| 101 | return nil, err |
| 102 | } |
| 103 | |
| 104 | // Exactly one Sessions per process, per the package's own |
| 105 | // instruction. TTL and SigninPath are named rather than defaulted so |
| 106 | // a reader of this file knows what they are without opening |
| 107 | // rastrillo. |
| 108 | sess, err := sessions.New(sessions.Config{ |
| 109 | DB: d.Writer(), |
| 110 | Origin: origins.Credential, |
| 111 | TTL: 30 * 24 * time.Hour, |
| 112 | SigninPath: "/signin", |
| 113 | Logger: logger, |
| 114 | }) |
| 115 | if err != nil { |
| 116 | return nil, err |
| 117 | } |
| 118 | |
| 119 | a := &App{ |
| 120 | origins: origins, |
| 121 | credentialHost: strings.TrimPrefix(origins.Credential, "https://"), |
| 122 | directoryHost: strings.TrimPrefix(origins.Directory, "https://"), |
| 123 | secrets: secrets, |
| 124 | db: d, |
| 125 | links: links, |
| 126 | logger: logger, |
| 127 | renderer: rend, |
| 128 | flow: newFlow(secrets, links, origins.Credential), |
| 129 | sessions: sess, |
| 130 | } |
| 131 | |
| 132 | if a.passkeys, err = newPasskeys(a); err != nil { |
| 133 | return nil, err |
| 134 | } |
| 135 | |
| 136 | // Sweep at boot as well as on each put. A process that put a link |
| 137 | // and then restarted before the next one leaves that row behind |
| 138 | // forever otherwise, and this app is not expected to restart often. |
| 139 | // Logged rather than fatal: expired sealed rows are untidy, not |
| 140 | // unsafe, and refusing to boot over housekeeping is the wrong trade. |
| 141 | if err := a.links.sweep(context.Background(), time.Now()); err != nil { |
| 142 | logger.Warn("sweeping expired sign-in links at boot", "err", err) |
| 143 | } |
| 144 | |
| 145 | return a, nil |
| 146 | } |
| 147 | |
| 148 | // Router returns the mounted handler. |
| 149 | func (a *App) Router() http.Handler { |
| 150 | if a.runtime != nil && a.runtime.role == "directory" { |
| 151 | return a.directoryRouter() |
| 152 | } |
| 153 | r := chi.NewRouter() |
| 154 | |
| 155 | r.Use(a.serveKnownHost) |
| 156 | // estilo's error page for a browser, plain text for anything else |
| 157 | // (chrome.go). Unknown hosts never reach these: serveKnownHost answers |
| 158 | // them first, with an empty 404. |
| 159 | r.NotFound(a.notFound) |
| 160 | r.MethodNotAllowed(a.methodNotAllowed) |
| 161 | a.mountCredits(r) |
| 162 | |
| 163 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| 164 | if strings.ToLower(r.Host) == a.credentialHost { |
| 165 | w.Header().Set("Location", a.origins.Directory+"/") |
| 166 | w.WriteHeader(http.StatusSeeOther) |
| 167 | return |
| 168 | } |
| 169 | a.handleIndex(w, r) |
| 170 | }) |
| 171 | // This /healthz is NOT what answers in a deployed instance: |
| 172 | // rastrillo.Serve registers its own GET /healthz on the outer mux |
| 173 | // (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that |
| 174 | // exact pattern over this router's "/" mount, so the outer one wins |
| 175 | // every time in production. It exists here so App's own router is |
| 176 | // testable in isolation (see TestHealthz) and as the seam later |
| 177 | // routes mount alongside — mirrors Sheets' internal/sheets/app.go. |
| 178 | r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { |
| 179 | w.WriteHeader(http.StatusOK) |
| 180 | }) |
| 181 | |
| 182 | if a.runtime != nil && a.runtime.role == "credential" { |
| 183 | // Outside csrf.Protect on purpose, with its own exact origin |
| 184 | // check: a consumer's sign-out arrives same-site or cross-site, |
| 185 | // never same-origin. GET only renders the "Finish signing out" |
| 186 | // page. See signoutPath. |
| 187 | r.With(a.credentialOnly).Post(signoutPath, a.handleCredentialSignout) |
| 188 | r.With(a.credentialOnly).Get(signoutPath, a.handleCredentialSignoutPage) |
| 189 | } |
| 190 | |
| 191 | r.Group(func(r chi.Router) { |
| 192 | r.Use(a.credentialOnly) |
| 193 | // POST /signin is an unauthenticated endpoint that sends mail and makes |
| 194 | // outbound DNS and HTTPS calls decided by the submitted address — a |
| 195 | // blind-SSRF surface by design — and csrf.Protect is pure origin |
| 196 | // evidence (Sec-Fetch-Site, then Origin, then Referer) that never |
| 197 | // consults a session, so it works before anybody is signed in. |
| 198 | r.Use(csrf.Protect(a.origins.Credential)) |
| 199 | if a.runtime != nil && a.runtime.role == "credential" { |
| 200 | r.Get("/authorize", a.handleAuthorize) |
| 201 | r.With(a.sessions.Require).Get("/authorize/resume", a.handleAssertion) |
| 202 | r.Get("/static/assertion.js", serveAsset(staticAsset("assertion.js"), "text/javascript; charset=utf-8")) |
| 203 | } |
| 204 | r.Get("/signin", a.handleSigninForm) |
| 205 | r.Post("/signin", a.handleSigninBegin) |
| 206 | r.Get("/signin/sent", a.handleSigninSent) |
| 207 | // GET renders, POST redeems. See handleVerifyForm for why. |
| 208 | r.Get(linkPath, a.handleVerifyForm) |
| 209 | r.Post(linkPath, a.handleVerify) |
| 210 | r.Get(callbackPath, a.handleCallback) |
| 211 | |
| 212 | // The second factor. The ceremony endpoints are JSON POSTs driven by |
| 213 | // static/passkey.js; the recovery POST is a plain form and works |
| 214 | // with script off, because recovery is exactly the moment WebAuthn |
| 215 | // is not working. |
| 216 | r.Get(confirmPath, a.handlePasskeyConfirm) |
| 217 | r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin) |
| 218 | r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish) |
| 219 | r.Post("/passkey/signin/begin", a.passkeys.SignInBegin) |
| 220 | r.Post("/passkey/signin/finish", a.passkeys.SignInFinish) |
| 221 | r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery) |
| 222 | // Enrolling changes who can get in, and there is no operator reset, |
| 223 | // so it wants a recent session rather than merely a valid one — see |
| 224 | // enrolFreshness for the lockout this closes. |
| 225 | r.Group(func(r chi.Router) { |
| 226 | r.Use(a.sessions.RequireFresh(enrolFreshness)) |
| 227 | r.Get("/passkey/enrol", a.handlePasskeyEnrol) |
| 228 | r.Post("/passkey/register/begin", a.passkeys.RegisterBegin) |
| 229 | r.Post("/passkey/register/finish", a.passkeys.RegisterFinish) |
| 230 | }) |
| 231 | |
| 232 | // The codes page shows ten codes once, so it wants a recent session |
| 233 | // rather than merely a valid one. |
| 234 | r.Group(func(r chi.Router) { |
| 235 | r.Use(a.sessions.RequireFresh(codesFreshness)) |
| 236 | r.Get("/passkey/codes", a.handleRecoveryCodesPage) |
| 237 | r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes) |
| 238 | }) |
| 239 | |
| 240 | // Stable, unfingerprinted paths: Task 7's scriptless allowlist has |
| 241 | // to name them, and a rule that cannot name its subject is not a |
| 242 | // rule. |
| 243 | r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8")) |
| 244 | r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8")) |
| 245 | }) |
| 246 | |
| 247 | // Vendored from rastrillo's ui package, not linked from it: an app |
| 248 | // owns its stylesheet from the moment it lands, and |
| 249 | // `rastrillo doctor --fix` is what reconciles it on an upgrade. |
| 250 | mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| 251 | mountCSS(r, "/static/theme.css", theme.CSS()) |
| 252 | mountBrand(r) |
| 253 | mountCalma(r) |
| 254 | mountSite(r) |
| 255 | |
| 256 | return r |
| 257 | } |
| 258 | |