oficina / sheets Public

Connect existing Sheets accounts to Home through shared proofs

Paul Campbell pushed by paul@keymail.dev a0227cd3066fdeb89bbaabdbe5311c1d473be145 parent 20c2691
18 files changed, +619 −36
  • AGENTS.md +9 −3
  • cmd/sheets/main.go +4 −0
  • go.mod +4 −0
  • go.sum +20 −2
  • internal/sheets/app.go +18 −18
  • internal/sheets/auth.go +11 −1
  • internal/sheets/auth_test.go +1 −0
  • internal/sheets/home.go +150 −0
  • internal/sheets/home_navigation_test.go +100 −0
  • internal/sheets/home_routes.go +42 −0
  • internal/sheets/home_test.go +211 −0
  • internal/sheets/members.go +10 −0
  • internal/sheets/migrations.go +5 −3
  • internal/sheets/page.go +14 −1
  • internal/sheets/render.go +16 −8
  • internal/sheets/templates/home_link.html +1 −0
  • internal/sheets/templates/home_oauth.html +2 −0
  • internal/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>