oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

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.
15package home
16
17import (
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.
40type 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.
74func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
75 return NewWithOrigins(secrets, d, logger, Origins{Origin, directoryOrigin})
76}
77
78func 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.
149func (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