Connect existing Sheets accounts to Home through shared proofs
18 files changed,
+619
−36
AGENTS.md+9 −3cmd/sheets/main.go+4 −0go.mod+4 −0go.sum+20 −2internal/sheets/app.go+18 −18internal/sheets/auth.go+11 −1internal/sheets/auth_test.go+1 −0internal/sheets/home.go+150 −0internal/sheets/home_navigation_test.go+100 −0internal/sheets/home_routes.go+42 −0internal/sheets/home_test.go+211 −0internal/sheets/members.go+10 −0internal/sheets/migrations.go+5 −3internal/sheets/page.go+14 −1internal/sheets/render.go+16 −8internal/sheets/templates/home_link.html+1 −0internal/sheets/templates/home_oauth.html+2 −0internal/sheets/templates/layout.html+1 −0
diff --git a/AGENTS.md b/AGENTS.md| index 59d3724..69e01da 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -132,9 +132,15 @@ relying on remembering one, make it a test instead. |
| one-connection writer pool deadlocks. |
| - Never bind a request body onto a GORM model. Explicit `map[string]any` |
| plus a `.Select` allowlist. |
| -- Read the viewer with `auth.From(r)` or `sessions.Current(r)`. **Never |
| - `sessions.UserID`** — it returns `(0, false)` for an email Subject, and |
| - dropping that `ok` scopes every query to uid 0. |
| +- Resolve the viewer through the app's `identity` and `actor` helpers. Local |
| + sessions retain verified addresses; opt-in Home sessions contain opaque Home |
| + subjects and must resolve through the shared connection to existing members. |
| + Never create members from Home subjects or infer links from matching addresses. |
| + Never use `sessions.UserID`: it does not resolve either identity shape. |
| +- Home links require independent fresh local and Home proofs plus confirmation. |
| + Recheck existing admission and sheet grants on every Home request. Member IDs |
| + are AUTOINCREMENT and must never be reassigned; any future deletion path must |
| + remove the member's `home_identity_links` rows in the same transaction. |
| - Migrations are additive-only. New code over an old DB is always safe. |
| Never delete data to update. |
| - **Cell values are IEEE doubles end to end.** Currency is a number format, |
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go| index ba72685..5401076 100644 |
| --- a/cmd/sheets/main.go |
| +++ b/cmd/sheets/main.go |
| @@ -102,6 +102,10 @@ func main() { |
| os.Exit(1) |
| } |
| app.UseAuth(signin) |
| + if err := app.ConfigureHome(authEnv.Home); err != nil { |
| + logger.Error("home configuration unavailable") |
| + os.Exit(1) |
| + } |
| // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| // router is a chi.Router (an http.Handler), so it mounts under one. |
diff --git a/go.mod b/go.mod| index 63406a6..e2fee5d 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -4,6 +4,9 @@ go 1.25.0 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 |
| + amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| github.com/xuri/excelize/v2 v2.11.0 |
| @@ -13,6 +16,7 @@ require ( |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
diff --git a/go.sum b/go.sum| index 189f1d3..159bdc8 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,17 +1,35 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg= |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8= |
| -github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| +amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 h1:eKlXkS4kkVxsFTDtGxnVj0+rfVc50alQLw8o6TeSRQM= |
| +amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= |
| +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= |
| +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= |
| +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= |
| +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= |
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= |
| github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= |
| +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= |
| +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= |
| +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= |
| +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= |
| +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index cbbe81f..71f84cb 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -5,6 +5,9 @@ package sheets |
| import ( |
| "net/http" |
| + "amadan.net/oficina/home/connect" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/jobs" |
| @@ -49,11 +52,14 @@ type Config struct { |
| // App holds the wiring. One per process. |
| type App struct { |
| - db *gorm.DB |
| - auth *auth.Auth |
| - sealer *seal.Sealer |
| - origin string |
| - admit func(string) bool |
| + home *connect.Bridge |
| + homeRoutes *connect.Routes |
| + homeSessions *sessions.Sessions |
| + db *gorm.DB |
| + auth *auth.Auth |
| + sealer *seal.Sealer |
| + origin string |
| + admit func(string) bool |
| // hub fans every committed edit out to every other open tab. One per |
| // process, because it holds the live subscriptions: a hub per |
| @@ -141,7 +147,9 @@ func (a *App) Router() http.Handler { |
| // a test App: a Protect("") would compare every Origin header |
| // against the empty string and refuse every POST, turning a missing |
| // test fixture into a wall of 403s that look like an auth bug. |
| - if a.origin != "" { |
| + if a.homeRoutes != nil { |
| + r.Use(a.homeRoutes.Middleware) |
| + } else if a.origin != "" { |
| r.Use(csrf.Protect(a.origin)) |
| } |
| @@ -159,7 +167,7 @@ func (a *App) Router() http.Handler { |
| // other path, which is what an operator hitting a wrong URL |
| // should see (TestIndexIsNotACatchAll pins this). |
| r.Get("/", a.handleIndex) |
| - r.Get("/signin", a.handleSignin) |
| + a.mountAuth(r) |
| // Fingerprinted and immutably cached, mounted at the FS's own path |
| // with no prefix stripped — rastrillo's Assets uses |
| // http.FileServerFS semantics, where the URL path is "/" plus the |
| @@ -175,17 +183,6 @@ func (a *App) Router() http.Handler { |
| // entry (spec §8.3). The token is the entire authorization. |
| r.Get("/l/{token}", a.handleLinkView) |
| - if a.auth != nil { |
| - // The four handlers rastrillo/auth expects an app to mount, at |
| - // the paths its own docs use. Verify reads ?token=; the path is |
| - // baked into every emailed link, so moving it later invalidates |
| - // links already in people's inboxes. |
| - r.Post("/signin", a.handleBegin) |
| - r.Get("/auth/callback", a.auth.Callback) |
| - r.Get("/auth/verify", a.auth.Verify) |
| - r.Post("/signout", a.auth.Signout) |
| - } |
| - |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireSession) |
| r.Get("/sheets", a.handleSheetList) |
| @@ -271,6 +268,9 @@ func (a *App) Router() http.Handler { |
| // refusal and a full magic-link round trip. Production cannot reach it |
| // — main exits before New if sign-in is not configured. |
| func (a *App) requireSession(next http.Handler) http.Handler { |
| + if a.home != nil { |
| + return a.homeRequire(next) |
| + } |
| if a.auth == nil { |
| return next |
| } |
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go| index 4d4303c..806dd83 100644 |
| --- a/internal/sheets/auth.go |
| +++ b/internal/sheets/auth.go |
| @@ -1,6 +1,7 @@ |
| package sheets |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "database/sql" |
| "errors" |
| "fmt" |
| @@ -16,6 +17,7 @@ import ( |
| // AuthEnv is everything sign-in needs from the environment, already |
| // validated. Building it is the only place these variable names appear. |
| type AuthEnv struct { |
| + Home *connect.Credentials |
| // Origin is the instance's external origin, scheme included. It is |
| // the base of every emailed link and the CSRF origin check, which |
| // makes a WRONG value the dangerous case rather than a missing one: |
| @@ -73,6 +75,7 @@ func loopback(origin string) bool { |
| // any address. Both serve pages and answer /healthz. |
| func LoadAuthEnv(logger *slog.Logger) (AuthEnv, error) { |
| env := AuthEnv{ |
| + Home: connect.CredentialsFromEnv("SHEETS"), |
| Origin: strings.TrimSpace(os.Getenv("SHEETS_ORIGIN")), |
| InstanceKey: strings.TrimSpace(os.Getenv("SHEETS_INSTANCE_KEY")), |
| Owners: splitOwners(os.Getenv("SHEETS_OWNERS")), |
| @@ -107,6 +110,9 @@ func LoadAuthEnv(logger *slog.Logger) (AuthEnv, error) { |
| logger.Warn("sign-in: no SMTP configured and origin is loopback — " + |
| "magic links will be LOGGED, NOT SENT") |
| } |
| + if _, err := connect.DecodeCredentials(env.Home, env.Origin); err != nil { |
| + return AuthEnv{}, errHome |
| + } |
| env.Mailer = mail.FromEnv("CARLOS", logger) |
| return env, nil |
| @@ -144,6 +150,10 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string |
| // is the version that says so. |
| return nil, errors.New("sign-in: refusing to wire auth with no admission gate") |
| } |
| + signedInPath := "/sheets" |
| + if env.Home != nil { |
| + signedInPath = connect.LocalCompletePath |
| + } |
| return auth.New(auth.Config{ |
| DB: db, |
| Origin: env.Origin, |
| @@ -155,7 +165,7 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string |
| // which is the anonymous entry page — so without this line |
| // every magic-link click arrives on a page that exists to |
| // offer sign-in, and has to be told it worked. |
| - SignedInPath: "/sheets", |
| + SignedInPath: signedInPath, |
| Subject: "Your Sheets sign-in link", |
| Logger: logger, |
| }) |
diff --git a/internal/sheets/auth_test.go b/internal/sheets/auth_test.go| index c3b31ba..775a114 100644 |
| --- a/internal/sheets/auth_test.go |
| +++ b/internal/sheets/auth_test.go |
| @@ -19,6 +19,7 @@ func setEnv(t *testing.T, kv map[string]string) { |
| t.Helper() |
| for _, k := range []string{ |
| "SHEETS_ORIGIN", "SHEETS_INSTANCE_KEY", "SHEETS_OWNERS", |
| + "SHEETS_HOME_ISSUER", "SHEETS_HOME_CONSUMER_ID", "SHEETS_HOME_KEYS_BASE64", "SHEETS_HOME_DEK_BASE64", |
| "CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM", |
| } { |
| t.Setenv(k, "") |
diff --git a/internal/sheets/home.go b/internal/sheets/home.go| new file mode 100644 |
| index 0000000..020a846 |
| --- /dev/null |
| +++ b/internal/sheets/home.go |
| @@ -0,0 +1,150 @@ |
| +package sheets |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +var errHome = errors.New("sheets: Home connection unavailable") |
| + |
| +// ConfigureHome runs after local auth is attached and before serving requests. |
| +func (a *App) ConfigureHome(raw *connect.Credentials) error { |
| + settings, err := connect.DecodeCredentials(raw, a.origin) |
| + if err != nil { |
| + return errHome |
| + } |
| + if settings == nil { |
| + return nil |
| + } |
| + if a.db == nil || a.auth == nil { |
| + return errHome |
| + } |
| + d, err := a.db.DB() |
| + if err != nil { |
| + return errHome |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"}) |
| + if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| + return errHome |
| + } |
| + read := func(r *http.Request) (*connect.Session, error) { |
| + s, ok := sess.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| + } |
| + render := func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + p := connect.Page(state, label) |
| + a.render(w, r, "home_link", p.Title, status, p) |
| + } |
| + bridge, err := connect.New(connect.Config{DB: d, Origin: a.origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Namespace: "oficina/sheets", Keys: settings.Keys, DEK: settings.DEK, SessionCookie: sess.CookieName(), ReadSession: read, |
| + MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| + return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| + }, |
| + LookupPerson: a.homePerson, |
| + LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + var id int64 |
| + address := strings.ToLower(strings.TrimSpace(s.Subject)) |
| + if err := d.QueryRowContext(ctx, "SELECT id FROM members WHERE address=?", address).Scan(&id); err != nil { |
| + return connect.Person{}, err |
| + } |
| + label, err := a.homePerson(ctx, d, id) |
| + return connect.Person{ID: id, Label: label}, err |
| + }, Render: render}) |
| + if err != nil { |
| + return errHome |
| + } |
| + routes, err := connect.NewRoutes(bridge, connect.RoutesConfig{LocalLandingPath: "/sheets", Namespace: "oficina/sheets", Origin: a.origin, Issuer: settings.Issuer, SessionCookie: sess.CookieName(), DEK: settings.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.handleSignin, Protect: csrf.Protect(a.origin), Render: render, FormPolicy: connect.FormPolicy(settings.Issuer)}) |
| + if err != nil { |
| + return errHome |
| + } |
| + a.home, a.homeRoutes, a.homeSessions = bridge, routes, sess |
| + return nil |
| +} |
| + |
| +// The same current admission rule as Admits, using the link transaction's q. |
| +func (a *App) homePerson(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + var address string |
| + if id <= 0 { |
| + return "", sql.ErrNoRows |
| + } |
| + if err := q.QueryRowContext(ctx, "SELECT address FROM members WHERE id=?", id).Scan(&address); err != nil { |
| + return "", err |
| + } |
| + if address == "" { |
| + return "", sql.ErrNoRows |
| + } |
| + if a.admit != nil && a.admit(address) { |
| + return address, nil |
| + } |
| + var count int |
| + err := q.QueryRowContext(ctx, `SELECT COUNT(*) FROM grants JOIN sheets ON sheets.id=grants.sheet_id WHERE grants.member_id=? AND sheets.deleted_at IS NULL AND sheets.filling_since IS NULL`, id).Scan(&count) |
| + if err != nil { |
| + return "", err |
| + } |
| + if count == 0 { |
| + return "", sql.ErrNoRows |
| + } |
| + return address, nil |
| +} |
| + |
| +func (a *App) homeRequire(next http.Handler) http.Handler { |
| + return a.homeSessions.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + session, ok := a.homeSessions.From(r) |
| + address, admitted := a.identity(r) |
| + if !ok || !admitted || !a.Admits(address) { |
| + if ok { |
| + a.homeSessions.SignOut(w, r) |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| + http.Redirect(w, r, "/signin?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| + } else { |
| + http.Error(w, "Sign in first.", http.StatusForbidden) |
| + } |
| + return |
| + } |
| + // Jobs use an already resolved local address so local and Home sessions have |
| + // the same owner. The bridge always rereads the actual cookie-backed session. |
| + session.Subject = address |
| + next.ServeHTTP(w, sessions.WithSession(r, session)) |
| + })) |
| +} |
| + |
| +func (a *App) homeLocalComplete(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.homeSessions.From(r) |
| + if !ok { |
| + a.homeRoutes.LocalComplete(w, r) |
| + return |
| + } |
| + _, fresh := connect.FreshLocal(connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, time.Now()) |
| + if !fresh || !a.Admits(s.Subject) { |
| + http.Error(w, "Sign in first.", http.StatusForbidden) |
| + return |
| + } |
| + // Only an independently verified local sign-in may create its local member. |
| + // Home assertions and lookup hooks never pass through this completion handler. |
| + if _, err := a.memberFor(s.Subject); err != nil { |
| + http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable) |
| + return |
| + } |
| + a.homeRoutes.LocalComplete(w, r) |
| +} |
| +func (a *App) homeLocalBegin(w http.ResponseWriter, r *http.Request) { |
| + connect.OAuthContinuation(a.origin, a.handleBegin, func(w http.ResponseWriter, r *http.Request, destination string) { |
| + p := connect.OAuthPage(destination) |
| + a.render(w, r, "home_oauth", p.Title, http.StatusOK, p) |
| + })(w, r) |
| +} |
diff --git a/internal/sheets/home_navigation_test.go b/internal/sheets/home_navigation_test.go| new file mode 100644 |
| index 0000000..782c1da |
| --- /dev/null |
| +++ b/internal/sheets/home_navigation_test.go |
| @@ -0,0 +1,100 @@ |
| +package sheets |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "context" |
| + "github.com/carlosframework/rastrillo" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestHomeLocalPluginRoundTripAndSignout(t *testing.T) { |
| + f := newHomeFixture(t) |
| + if w := f.request(t, "GET", "/account/home", nil, false); w.Code != 303 { |
| + t.Fatal(w.Code) |
| + } |
| + if w := f.request(t, "POST", "/signin", url.Values{"address": {signedInAddress}, "force": {"1"}}, false); w.Code != 303 { |
| + t.Fatal(w.Code) |
| + } |
| + u, err := url.Parse(f.app.testMailer(t).lastLink(t)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verify := f.request(t, "GET", u.RequestURI(), nil, false) |
| + if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath { |
| + t.Fatal("local callback", verify.Code, verify.Header().Get("Location")) |
| + } |
| + completed := f.request(t, "GET", connect.LocalCompletePath, nil, false) |
| + if completed.Code != 303 || completed.Header().Get("Location") != "/account/home" { |
| + t.Fatal("local completion", completed.Code, completed.Header().Get("Location")) |
| + } |
| + if page := f.request(t, "GET", "/account/home", nil, false); page.Code != 200 { |
| + t.Fatal("local account unavailable", page.Code) |
| + } |
| + var count int64 |
| + f.app.db.Model(&Member{}).Count(&count) |
| + if count != 1 { |
| + t.Fatal("local member missing", count) |
| + } |
| + f.app.db.Table("sheets").Count(&count) |
| + if count != 0 { |
| + t.Fatal("sign-in seeded content", count) |
| + } |
| + f.link(t, "round-trip") |
| + out := f.request(t, "POST", "/signout", nil, false) |
| + if out.Code != 303 || out.Header().Get("Location") != "/signed-out" { |
| + t.Fatal("signout", out.Code) |
| + } |
| + if page := f.request(t, "GET", "/signed-out", nil, false); page.Code != 200 || page.Header().Get("Location") != "" { |
| + t.Fatal("signout restarted Home") |
| + } |
| +} |
| +func TestHomeDeletedMemberIsNeverReassigned(t *testing.T) { |
| + f := newHomeFixture(t) |
| + old := f.local(t, signedInAddress) |
| + f.link(t, "deleted-person") |
| + if err := f.app.db.Exec("DELETE FROM members WHERE id=?", old.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + replacement, err := f.app.memberFor(signedInAddress) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if replacement.ID <= old.ID { |
| + t.Fatal("member IDs reused") |
| + } |
| + if _, err := f.app.home.Resolve(context.Background(), "deleted-person"); err == nil { |
| + t.Fatal("deleted account resolved to replacement") |
| + } |
| +} |
| +func TestHomeFrameworkPolicyAndDuplicateCookies(t *testing.T) { |
| + f := newHomeFixture(t) |
| + f.local(t, signedInAddress) |
| + mux := http.NewServeMux() |
| + mux.Handle("/", f.app.Router()) |
| + handler, close, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer close() |
| + r := httptest.NewRequest("GET", testOrigin+"/signin/local", nil) |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + got := w.Header().Get("Content-Security-Policy") |
| + if !strings.Contains(got, "form-action 'self' "+homeTestIssuer) || !strings.Contains(got, "frame-ancestors 'none'") { |
| + t.Fatal("Home policy lost", got) |
| + } |
| + r = httptest.NewRequest("GET", testOrigin+"/account/home", nil) |
| + for _, c := range f.jar { |
| + r.AddCookie(c) |
| + r.AddCookie(c) |
| + } |
| + w = httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != 400 { |
| + t.Fatal("duplicate session accepted", w.Code) |
| + } |
| +} |
diff --git a/internal/sheets/home_routes.go b/internal/sheets/home_routes.go| new file mode 100644 |
| index 0000000..880650f |
| --- /dev/null |
| +++ b/internal/sheets/home_routes.go |
| @@ -0,0 +1,42 @@ |
| +package sheets |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +func (a *App) mountAuth(r chi.Router) { |
| + if a.homeRoutes != nil { |
| + r.Get("/signin", a.homeRoutes.Signin) |
| + r.Get("/signin/local", a.homeRoutes.LocalGuard(a.handleSignin)) |
| + r.Get("/auth/start", a.home.Start) |
| + r.Post("/auth/assertion", a.home.Callback) |
| + r.Get("/auth/finish", a.home.Finish) |
| + r.Get(connect.LocalCompletePath, a.homeRoutes.LocalGuard(a.homeLocalComplete)) |
| + r.Get("/account/home", a.homeRoutes.Account) |
| + r.Post("/account/home", a.homeRoutes.LinkStart) |
| + r.Get("/account/home/confirm", a.home.ConfirmPage) |
| + r.Post("/account/home/confirm", a.homeRoutes.ConfirmPost) |
| + r.Get("/signed-out", a.homeRoutes.SignedOut) |
| + } else { |
| + r.Get("/signin", a.handleSignin) |
| + } |
| + if a.auth != nil { |
| + // The four handlers rastrillo/auth expects an app to mount, at |
| + // the paths its own docs use. Verify reads ?token=; the path is |
| + // baked into every emailed link, so moving it later invalidates |
| + // links already in people's inboxes. |
| + if a.homeRoutes != nil { |
| + r.Post("/signin", a.homeRoutes.LocalGuard(a.homeLocalBegin)) |
| + r.Get("/auth/callback", a.homeRoutes.LocalGuard(a.auth.Callback)) |
| + r.Get("/auth/verify", a.homeRoutes.LocalGuard(a.auth.Verify)) |
| + r.Post("/auth/verify", a.homeRoutes.LocalGuard(a.auth.Verify)) |
| + r.Post("/signout", a.homeRoutes.Signout) |
| + } else { |
| + r.Post("/signin", a.handleBegin) |
| + r.Get("/auth/callback", a.auth.Callback) |
| + r.Get("/auth/verify", a.auth.Verify) |
| + r.Post("/signout", a.auth.Signout) |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/home_test.go b/internal/sheets/home_test.go| new file mode 100644 |
| index 0000000..7e4b6e5 |
| --- /dev/null |
| +++ b/internal/sheets/home_test.go |
| @@ -0,0 +1,211 @@ |
| +package sheets |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const homeTestIssuer = "https://id.example.com" |
| + |
| +type homeFixture struct { |
| + app *App |
| + key *assertioncrypto.Keypair |
| + jar map[string]*http.Cookie |
| +} |
| + |
| +func newHomeFixture(t *testing.T) *homeFixture { |
| + t.Helper() |
| + app, _ := appWithOrigin(t, testOrigin) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + keys, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32))} |
| + d, err := app.db.DB() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + localAuth, err := NewAuth(d, AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: app.testMailer(t), Home: &cfg}, quietLogger(), app.Admits) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + app.UseAuth(localAuth) |
| + if err := app.ConfigureHome(&cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + return &homeFixture{app: app, key: key, jar: map[string]*http.Cookie{}} |
| +} |
| +func (f *homeFixture) request(t *testing.T, method, path string, form url.Values, issuer bool) *httptest.ResponseRecorder { |
| + t.Helper() |
| + body := "" |
| + if form != nil { |
| + body = form.Encode() |
| + } |
| + r := httptest.NewRequest(method, testOrigin+path, strings.NewReader(body)) |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + origin := testOrigin |
| + if issuer { |
| + origin = homeTestIssuer |
| + } |
| + r.Header.Set("Origin", origin) |
| + } |
| + if !issuer { |
| + for _, cookie := range f.jar { |
| + r.AddCookie(cookie) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.Router().ServeHTTP(w, r) |
| + for _, cookie := range w.Result().Cookies() { |
| + if cookie.MaxAge < 0 { |
| + delete(f.jar, cookie.Name) |
| + } else { |
| + f.jar[cookie.Name] = cookie |
| + } |
| + } |
| + return w |
| +} |
| +func (f *homeFixture) local(t *testing.T, address string) Member { |
| + t.Helper() |
| + m, err := f.app.memberFor(address) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := f.app.homeSessions.Mint(sessions.Session{Subject: address, Method: "magiclink"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.jar[f.app.homeSessions.CookieName()] = &http.Cookie{Name: f.app.homeSessions.CookieName(), Value: token} |
| + return m |
| +} |
| +func (f *homeFixture) exchange(t *testing.T, start *httptest.ResponseRecorder, subject string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + if start.Code != 303 { |
| + t.Fatal("start", start.Code, start.Body.String()) |
| + } |
| + target, err := url.Parse(start.Header().Get("Location")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if target.Host != "id.example.com" || target.Query().Get("consumer") != "sheets-preview" { |
| + t.Fatal("wrong Home destination", target.Host) |
| + } |
| + now := time.Now().Unix() |
| + id, _ := assertion.RandomID() |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: testOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 120, AuthTime: now - 10}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callback := f.request(t, "POST", "/auth/assertion", url.Values{"assertion": {token}}, true) |
| + if callback.Code != 303 { |
| + t.Fatal("callback", callback.Code, callback.Body.String()) |
| + } |
| + return f.request(t, "GET", callback.Header().Get("Location"), nil, false) |
| +} |
| +func (f *homeFixture) link(t *testing.T, subject string) { |
| + t.Helper() |
| + start := f.request(t, "POST", "/account/home", nil, false) |
| + if start.Code != 303 { |
| + t.Fatal("link", start.Code, start.Body.String()) |
| + } |
| + authStart := f.request(t, "GET", start.Header().Get("Location"), nil, false) |
| + target, _ := url.Parse(authStart.Header().Get("Location")) |
| + if target.Query().Get("max_age") != "300" { |
| + t.Fatal("link missing fresh Home proof") |
| + } |
| + finish := f.exchange(t, authStart, subject) |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| + t.Fatal("finish link", finish.Code, finish.Body.String()) |
| + } |
| + page := f.request(t, "GET", "/account/home/confirm", nil, false) |
| + if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm account connection") { |
| + t.Fatal("confirm page", page.Code, page.Body.String()) |
| + } |
| + confirmed := f.request(t, "POST", "/account/home/confirm", nil, false) |
| + if confirmed.Code != 303 { |
| + t.Fatal("confirm", confirmed.Code, confirmed.Body.String()) |
| + } |
| +} |
| +func TestHomeConnectionPreservesSheetsAndCurrentAdmission(t *testing.T) { |
| + f := newHomeFixture(t) |
| + member := f.local(t, signedInAddress) |
| + sheet, err := f.app.createSheet("Existing linked workbook", member) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.link(t, "opaque-home-subject") |
| + f.jar = map[string]*http.Cookie{} |
| + signin := f.request(t, "GET", "/signin?return_to=%2Fsheets", nil, false) |
| + start := f.request(t, "GET", signin.Header().Get("Location"), nil, false) |
| + finish := f.exchange(t, start, "opaque-home-subject") |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/sheets" { |
| + t.Fatal("ordinary sign-in", finish.Code, finish.Body.String()) |
| + } |
| + page := f.request(t, "GET", "/sheets", nil, false) |
| + if page.Code != 200 || !strings.Contains(page.Body.String(), "Existing linked workbook") || strings.Contains(page.Body.String(), "opaque-home-subject") { |
| + t.Fatal("existing sheet missing or subject exposed", page.Code) |
| + } |
| + var members int64 |
| + if err := f.app.db.Model(&Member{}).Count(&members).Error; err != nil || members != 1 { |
| + t.Fatal("Home created members", members, err) |
| + } |
| + r := httptest.NewRequest("GET", testOrigin+"/sheets", nil) |
| + for _, c := range f.jar { |
| + r.AddCookie(c) |
| + } |
| + actor, ok := f.app.actor(r) |
| + if !ok || actor.ID != member.ID { |
| + t.Fatal("owner changed") |
| + } |
| + if !f.app.mayWrite(sheet.ID, actor) { |
| + t.Fatal("existing grant changed") |
| + } |
| + f.app.admit = func(string) bool { return false } |
| + if err := f.app.db.Where("member_id = ?", member.ID).Delete(&Grant{}).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + denied := f.request(t, "GET", "/sheets", nil, false) |
| + if denied.Code != 303 || strings.Contains(denied.Body.String(), "Existing linked workbook") { |
| + t.Fatal("revoked member admitted", denied.Code) |
| + } |
| + if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok { |
| + t.Fatal("revoked session retained") |
| + } |
| +} |
| +func TestHomeUnknownAndMalformedFlowsNeverCreateMembers(t *testing.T) { |
| + f := newHomeFixture(t) |
| + start := f.request(t, "GET", "/auth/start?return_to=%2Fsheets", nil, false) |
| + finish := f.exchange(t, start, "unknown-subject") |
| + if finish.Code != 403 || !strings.Contains(finish.Body.String(), "/signin/local") { |
| + t.Fatal("unknown identity accepted", finish.Code) |
| + } |
| + var members int64 |
| + f.app.db.Model(&Member{}).Count(&members) |
| + if members != 0 { |
| + t.Fatal("Home created local identity", members) |
| + } |
| + if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok { |
| + t.Fatal("unknown identity minted session") |
| + } |
| + req := httptest.NewRequest("POST", testOrigin+"/account/home", nil) |
| + req.Header.Set("Origin", "https://evil.example") |
| + rec := httptest.NewRecorder() |
| + f.app.Router().ServeHTTP(rec, req) |
| + if rec.Code != 403 { |
| + t.Fatal("cross-origin link allowed", rec.Code) |
| + } |
| +} |
diff --git a/internal/sheets/members.go b/internal/sheets/members.go| index 13c2ac1..8ca6376 100644 |
| --- a/internal/sheets/members.go |
| +++ b/internal/sheets/members.go |
| @@ -82,6 +82,16 @@ func (a *App) memberFor(address string) (Member, error) { |
| // through requireSession, so a false here in production means the |
| // session went away between the middleware and the handler. |
| func (a *App) actor(r *http.Request) (Member, bool) { |
| + if a.home != nil { |
| + s, ok := a.homeSessions.From(r) |
| + if ok && s.Method == "home-assertion" { |
| + p, err := a.home.ResolveSession(r) |
| + if err != nil { |
| + return Member{}, false |
| + } |
| + return a.memberByID(uint64(p.ID)) |
| + } |
| + } |
| address, ok := a.identity(r) |
| if !ok { |
| return Member{}, false |
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go| index 69faa16..da34efc 100644 |
| --- a/internal/sheets/migrations.go |
| +++ b/internal/sheets/migrations.go |
| @@ -1,6 +1,8 @@ |
| package sheets |
| import ( |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| "embed" |
| "github.com/carlosframework/rastrillo/auth" |
| @@ -34,9 +36,9 @@ var Schema = migrate.MustFromFS(migrationsFS, "sheets") |
| // |
| // The order is not a preference. auth's backfill migration reads the |
| // sessions table, so sessions.Schema must apply first; migrate.Merge's |
| -// argument order IS apply order. Sheets' own Schema comes last because |
| -// it depends on neither. |
| -var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema) |
| +// argument order IS apply order. Sheets and optional Home protocol schemas |
| +// follow the local authentication schemas. |
| +var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| // Models lists every GORM model `rastrillo migration generate`/`check` |
| // diff Schema against — Sheet, Tab, Row, Col, Cell, Format and Member |
diff --git a/internal/sheets/page.go b/internal/sheets/page.go| index 3687ca8..945549d 100644 |
| --- a/internal/sheets/page.go |
| +++ b/internal/sheets/page.go |
| @@ -1,6 +1,9 @@ |
| package sheets |
| -import "net/http" |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "net/http" |
| +) |
| // The root page is the anonymous entry page, and nothing else. |
| // |
| @@ -35,6 +38,16 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { |
| // identity returns the signed-in address, if any. A nil auth (tests) |
| // simply has no session. |
| func (a *App) identity(r *http.Request) (string, bool) { |
| + if a.home != nil { |
| + s, ok := a.homeSessions.From(r) |
| + if !ok { |
| + return "", false |
| + } |
| + if s.Method == connect.HomeMethod { |
| + p, err := a.home.ResolveSession(r) |
| + return p.Label, err == nil |
| + } |
| + } |
| if a.auth == nil { |
| return "", false |
| } |
diff --git a/internal/sheets/render.go b/internal/sheets/render.go| index 27328ca..1afa1bf 100644 |
| --- a/internal/sheets/render.go |
| +++ b/internal/sheets/render.go |
| @@ -1,6 +1,7 @@ |
| package sheets |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| @@ -100,6 +101,7 @@ func parsePages() map[string]*template.Template { |
| } |
| t := template.Must(template.New(name).Funcs(funcs). |
| ParseFS(ui.Templates(), "*.html")) |
| + t = template.Must(t.ParseFS(connect.Templates(), "templates/*.html")) |
| t = template.Must(t.ParseFS(templatesFS, "templates/"+layoutFile, f)) |
| out[name] = t |
| } |
| @@ -114,7 +116,8 @@ func parsePages() map[string]*template.Template { |
| // every coloured cell. A page carrying it in its first byte cannot |
| // flash; one that asks for it can. |
| type PageData struct { |
| - Title string |
| + HomeLinkLabel string |
| + Title string |
| // Mode is the viewer's light/dark choice — light, dark or auto. |
| // ThemeAttr is what <html data-theme> gets, and is EMPTY for auto: |
| // rastrillo's toggle is two rules keyed on that attribute, and no |
| @@ -134,14 +137,19 @@ type PageData struct { |
| func (a *App) pageData(r *http.Request, title string, data any) PageData { |
| address, signedIn := a.identity(r) |
| mode := modeFrom(r) |
| + homeLabel := "" |
| + if a.home != nil { |
| + homeLabel = connect.English["menu_action"] |
| + } |
| return PageData{ |
| - Title: title, |
| - Mode: mode, |
| - ThemeAttr: themeAttr(mode), |
| - Address: address, |
| - SignedIn: signedIn, |
| - Path: r.URL.Path, |
| - Data: data, |
| + HomeLinkLabel: homeLabel, |
| + Title: title, |
| + Mode: mode, |
| + ThemeAttr: themeAttr(mode), |
| + Address: address, |
| + SignedIn: signedIn, |
| + Path: r.URL.Path, |
| + Data: data, |
| } |
| } |
diff --git a/internal/sheets/templates/home_link.html b/internal/sheets/templates/home_link.html| new file mode 100644 |
| index 0000000..db66ed9 |
| --- /dev/null |
| +++ b/internal/sheets/templates/home_link.html |
| @@ -0,0 +1 @@ |
| +{{define "content"}}{{template "home-connect" .Data}}{{end}} |
diff --git a/internal/sheets/templates/home_oauth.html b/internal/sheets/templates/home_oauth.html| new file mode 100644 |
| index 0000000..ad744a9 |
| --- /dev/null |
| +++ b/internal/sheets/templates/home_oauth.html |
| @@ -0,0 +1,2 @@ |
| +{{define "head"}}{{template "home-oauth-head" .Data}}{{end}} |
| +{{define "content"}}{{template "home-oauth" .Data}}{{end}} |
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html| index 9ab26a8..de3b604 100644 |
| --- a/internal/sheets/templates/layout.html |
| +++ b/internal/sheets/templates/layout.html |
| @@ -111,6 +111,7 @@ |
| <button rst-btn="ghost" type="submit" name="mode" value="dark"{{if eq .Mode "dark"}} aria-current="true"{{end}}>Dark</button> |
| <button rst-btn="ghost" type="submit" name="mode" value="auto"{{if eq .Mode "auto"}} aria-current="true"{{end}}>System</button> |
| </form> |
| +{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}} |
| <form method="post" action="/signout"> |
| <button rst-btn="ghost" type="submit">Sign out</button> |
| </form> |