oficina / meet Public

Connect existing Meet members to Home while preserving invited guest sign-in

Paul Campbell pushed by paul@keymail.dev 088f1193e5aeff55f8f83a0811d66b69e5248a8d parent 19ccf42
16 files changed, +607 −13
  • cmd/meet/main.go +2 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +33 −1
  • go.mod +4 −1
  • go.sum +8 −0
  • internal/meet/app.go +32 −5
  • internal/meet/auth.go +12 −3
  • internal/meet/config.go +7 −0
  • internal/meet/handlers_join.go +7 −1
  • internal/meet/handlers_meetings.go +1 −1
  • internal/meet/home.go +118 −0
  • internal/meet/home_router_test.go +269 −0
  • internal/meet/home_security_test.go +92 −0
  • internal/meet/render.go +5 −1
  • internal/meet/store.go +14 −0
  • internal/meet/templates/home.html +2 −0
  • internal/meet/templates/index.html +1 −0
diff --git a/cmd/meet/main.go b/cmd/meet/main.go
index a2f1ef6..4f41d62 100644
--- a/cmd/meet/main.go
+++ b/cmd/meet/main.go
@@ -11,6 +11,7 @@ import (
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/mail"
+ "amadan.net/oficina/home/connect"
"amadan.net/oficina/meet/internal/meet"
)
@@ -49,6 +50,7 @@ func main() {
mailer := newMailer(logger)
cfg := meet.Config{
+ Home: connect.CredentialsFromEnv("MEET"),
Env: *env,
Origin: os.Getenv("MEET_ORIGIN"),
InstanceKey: os.Getenv("MEET_INSTANCE_KEY"),
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 7c9558b..a6f6330 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -865,6 +865,38 @@ outside `-env development`. This file already lives in Docs, Calendar
and Slides; Meet's is the fourth copy, and the extraction task Slides
recorded stands. Meet adds none.
+### 7.1.1 Optional Home sign-in (2026-09-10)
+
+The operator can enable Home with all four `MEET_HOME_ISSUER`,
+`MEET_HOME_CONSUMER_ID`, `MEET_HOME_KEYS_BASE64` and
+`MEET_HOME_DEK_BASE64` settings. Missing all four keeps standalone
+sign-in; partial settings refuse startup. The Home key is separate
+from the meeting-title key.
+
+Home links an existing member after fresh independent local sign-in,
+a fresh Home proof and explicit confirmation. It never creates a person,
+matches a Home email claim or grants membership. Both protected routes
+and the public meeting-link handler resolve Home subjects to the existing
+person ID, checking current membership. Existing meeting actors remain
+`person:<id>`. Person IDs are AUTOINCREMENT and cannot be recycled;
+any future account-deletion operation must also delete its Home links in
+the same transaction.
+
+`GET /signin` starts Home unless the browser has a pending guest-join
+cookie or is returning a local sign-in result. `GET /signin/local` is
+independent local sign-in. Its verification finishes at
+`/auth/local/complete`, then `/continue`, preserving the pending-join
+rules above. An explicit account-link continuation takes precedence.
+Guests still verify locally and cannot access `/account/home`.
+
+Shared `home/connect` owns `/auth/start`, `POST /auth/assertion`,
+`/auth/finish`, `/signed-out` and the GET/POST account-link routes
+`/account/home` and `/account/home/confirm`. Only the exact signed
+assertion callback bypasses ordinary CSRF protection; its issuer and
+proof are checked by the shared protocol. CSP preserves the framework's
+other directives and adds only the configured Home issuer to form-action.
+All account-link screens use the shared templates and work without scripts.
+
### 7.2 Actors
Every write records an actor: `person:<id>`, or `agent:<id>` for the
@@ -995,7 +1027,7 @@ are drafts for that review, not approved copy.
Recurring meetings. Calendar integration. Recording. Attendance
records. A relay of Meet's own. Rooms above four. Persisted chat.
-Reactions. Dial-in. Captions. Home integration. Multi-host signalling.
+Reactions. Dial-in. Captions. Multi-host signalling.
Push-to-talk. Virtual backgrounds. A native client and its sign-in
(§8.4). Denying a knock (a guest who is not
admitted simply waits or leaves). Each is named so the next session
diff --git a/go.mod b/go.mod
index a20abe5..a540d33 100644
--- a/go.mod
+++ b/go.mod
@@ -3,15 +3,18 @@ module amadan.net/oficina/meet
go 1.26.0
require (
- amadan.net/rastrillo/rastrillo v0.26.0
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/go-chi/chi/v5 v5.3.2
golang.org/x/crypto v0.56.0
gorm.io/gorm v1.31.2
)
require (
+ amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 // indirect
+ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
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 c8aae48..f55613a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,13 @@
+amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE=
+amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
+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.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 5419246..2cb0e8f 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -26,6 +26,7 @@ type app struct {
store *store
logger *slog.Logger
auth *rauth.Auth
+ home *homeIntegration
linkBudget *budget
outsiderBudget *budget
}
@@ -186,6 +187,9 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
return nil, nil, err
}
a.auth = authPlugin
+ if err := a.wireHome(); err != nil {
+ return nil, nil, err
+ }
// Bounded, not open-ended: a boot sweep runs on the caller's ctx but
// must still finish, so an app never fails to start (or hangs a
@@ -195,14 +199,31 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
cancel()
r := chi.NewRouter()
- r.Use(csrf.Protect(rc.Origin))
+ if a.home == nil {
+ r.Use(csrf.Protect(rc.Origin))
+ } else {
+ r.Use(a.home.routes.Middleware)
+ }
// Inside CSRF, outside the session: sign-in and /j/.
r.Get(signinPath, a.signin)
- r.Post(signinPath, a.gateBegin(authPlugin.Begin))
- r.Get("/auth/callback", authPlugin.Callback)
- r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
+ if a.home == nil {
+ r.Post(signinPath, a.gateBegin(authPlugin.Begin))
+ r.Get("/auth/callback", authPlugin.Callback)
+ r.Get("/auth/verify", authPlugin.Verify)
+ r.Post("/signout", authPlugin.Signout)
+ } else {
+ r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin)))
+ r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback))
+ r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify))
+ r.Post("/signout", a.home.routes.Signout)
+ r.Get("/signin/local", a.localSignin)
+ r.Get("/signed-out", a.home.routes.SignedOut)
+ r.Get("/auth/start", a.home.bridge.Start)
+ r.Post("/auth/assertion", a.home.bridge.Callback)
+ r.Get("/auth/finish", a.home.bridge.Finish)
+ r.Get("/auth/local/complete", a.home.routes.LocalComplete)
+ }
r.Get("/j/{token}", a.followLink)
// Signed in, with a persons row.
@@ -214,6 +235,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Get("/m/{sqid}/join", a.holding)
r.Group(func(r chi.Router) {
r.Use(a.requireMember)
+ if a.home != nil {
+ r.Get("/account/home", a.home.routes.Account)
+ r.Post("/account/home", a.home.routes.LinkStart)
+ r.Get("/account/home/confirm", a.home.bridge.ConfirmPage)
+ r.Post("/account/home/confirm", a.home.routes.ConfirmPost)
+ }
r.Get("/", a.index)
r.Post("/meetings", a.createMeeting)
r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
index d412ef2..95c7830 100644
--- a/internal/meet/auth.go
+++ b/internal/meet/auth.go
@@ -3,6 +3,7 @@ package meet
import (
"context"
"database/sql"
+ "errors"
"net/http"
"net/url"
"strings"
@@ -20,6 +21,10 @@ const sentLocation = signinPath + "?sent=1"
// whose link is still live. The row was written before the mail went
// out, so the click is admitted in whichever browser it lands.
func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
+ landing := "/continue"
+ if a.cfg.home != nil {
+ landing = "/auth/local/complete"
+ }
return rauth.New(rauth.Config{
DB: writer,
Origin: a.cfg.Origin,
@@ -27,7 +32,7 @@ func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
Mailer: a.cfg.mailer,
Authorize: a.authorize,
SigninPath: signinPath,
- SignedInPath: "/continue",
+ SignedInPath: landing,
Logger: a.logger,
})
}
@@ -152,7 +157,11 @@ func (a *app) requirePerson(next http.Handler) http.Handler {
ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- p, err := a.store.ensurePerson(r.Context(), s.Subject)
+ p, err := a.personForSession(r.Context(), s)
+ if errors.Is(err, errHomeIdentity) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
ErrorPage(w, r, http.StatusInternalServerError, "")
@@ -179,7 +188,7 @@ func (a *app) requireMember(next http.Handler) http.Handler {
})
}
-func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"Sent": r.URL.Query().Get("sent") == "1"}
switch r.URL.Query().Get("err") {
case "address":
diff --git a/internal/meet/config.go b/internal/meet/config.go
index c1e7c20..e111c84 100644
--- a/internal/meet/config.go
+++ b/internal/meet/config.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "amadan.net/oficina/home/connect"
"amadan.net/rastrillo/rastrillo/mail"
)
@@ -20,6 +21,7 @@ const EnvDevelopment = "development"
// secret or a boundary is a startup refusal when it is missing outside
// development, per spec §7.1.
type Config struct {
+ Home *connect.Credentials
// Env is "development" or anything else (treated as production).
Env string
// Origin is the app's external origin, scheme included
@@ -57,6 +59,7 @@ var devDEK = make([]byte, 32)
// resolved is Config after every refusal has been checked and every
// default applied.
type resolved struct {
+ home *connect.DecodedCredentials
Config
instanceKey string
dek []byte
@@ -136,5 +139,9 @@ func resolve(cfg Config) (resolved, error) {
}
out.zone = loc
out.Zone = zone
+ out.home, err = connect.DecodeCredentials(cfg.Home, out.Origin)
+ if err != nil {
+ return out, err
+ }
return out, nil
}
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go
index 738b7cd..afa6e8e 100644
--- a/internal/meet/handlers_join.go
+++ b/internal/meet/handlers_join.go
@@ -1,6 +1,8 @@
package meet
import (
+ "amadan.net/rastrillo/rastrillo/sessions"
+ "errors"
"net/http"
"github.com/go-chi/chi/v5"
@@ -26,7 +28,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, signinPath, http.StatusSeeOther)
return
}
- p, err := a.store.ensurePerson(r.Context(), id.Address)
+ p, err := a.personForSession(r.Context(), sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At})
+ if errors.Is(err, errHomeIdentity) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
ErrorPage(w, r, http.StatusInternalServerError, "")
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go
index f06fdba..e8f7d80 100644
--- a/internal/meet/handlers_meetings.go
+++ b/internal/meet/handlers_meetings.go
@@ -64,7 +64,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
}
render(w, "index", map[string]any{
"Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
- "TitleError": titleErr, "StartError": startErr,
+ "TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r),
})
}
diff --git a/internal/meet/home.go b/internal/meet/home.go
new file mode 100644
index 0000000..1f8d3c0
--- /dev/null
+++ b/internal/meet/home.go
@@ -0,0 +1,118 @@
+package meet
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/connect/linkstore"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+var errHomeIdentity = errors.New("meet: Home identity refused")
+
+type homeIntegration struct {
+ bridge *connect.Bridge
+ routes *connect.Routes
+}
+
+func (a *app) wireHome() error {
+ h := a.cfg.home
+ if h == nil {
+ return nil
+ }
+ if _, err := migrate.Apply(context.Background(), a.db, migrate.Merge(migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))); err != nil {
+ return errHomeIdentity
+ }
+ sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger})
+ if err != nil || sess.CookieName() != a.auth.SessionCookie() {
+ return errHomeIdentity
+ }
+ 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
+ }
+ b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/meet", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.DEK, 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})
+ },
+ LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) {
+ if s.Method != "magiclink" && s.Method != "keymail" {
+ return connect.Person{}, errHomeIdentity
+ }
+ p, err := a.store.existingHomePerson(ctx, s.Subject)
+ if err != nil || !a.isMember(p) {
+ return connect.Person{}, errHomeIdentity
+ }
+ return connect.Person{ID: p.ID, Label: p.Email}, nil
+ },
+ LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
+ p, err := a.store.homePersonByID(ctx, q, id)
+ if err != nil || !a.isMember(p) {
+ return "", errHomeIdentity
+ }
+ return p.Email, nil
+ }, Render: a.renderHome})
+ if err != nil {
+ return errHomeIdentity
+ }
+ routes, err := connect.NewRoutes(b, connect.RoutesConfig{LocalLandingPath: "/continue", Namespace: "oficina/meet", Origin: a.cfg.Origin, Issuer: h.Issuer, SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome})
+ if err != nil {
+ return errHomeIdentity
+ }
+ a.home = &homeIntegration{b, routes}
+ return nil
+}
+
+func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) {
+ if s.Method == connect.HomeMethod {
+ if a.home == nil {
+ return Person{}, errHomeIdentity
+ }
+ linked, err := a.home.bridge.Resolve(ctx, s.Subject)
+ if err != nil {
+ return Person{}, errHomeIdentity
+ }
+ p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID)
+ if err != nil || !a.isMember(p) {
+ return Person{}, errHomeIdentity
+ }
+ return p, nil
+ }
+ if s.Method != "magiclink" && s.Method != "keymail" {
+ return Person{}, errHomeIdentity
+ }
+ return a.store.ensurePerson(ctx, s.Subject)
+}
+func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+ if a.home != nil {
+ if _, pending := a.readJoinCookie(r); !pending {
+ a.home.routes.Signin(w, r)
+ return
+ }
+ }
+ a.localSignin(w, r)
+}
+func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
+ var body bytes.Buffer
+ if err := pages["home"].ExecuteTemplate(&body, "layout", map[string]any{"Home": connect.Page(state, label)}); err != nil {
+ ErrorPage(w, r, 500, "")
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _, _ = w.Write(body.Bytes())
+}
+func (a *app) showHomeConnection(r *http.Request) bool {
+ s, ok := sessions.Current(r)
+ return a.home != nil && ok && (s.Method == "magiclink" || s.Method == "keymail")
+}
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go
new file mode 100644
index 0000000..b7b2e81
--- /dev/null
+++ b/internal/meet/home_router_test.go
@@ -0,0 +1,269 @@
+package meet
+
+import (
+ "amadan.net/oficina/home/connect"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "amadan.net/rastrillo/rastrillo/db"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+)
+
+const homeTestOrigin = "https://meet.example.test"
+const homeTestIssuer = "https://id.example.test"
+
+type homeTestMail struct{ body string }
+
+func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil }
+
+type homeRouterFixture struct {
+ app *app
+ handler http.Handler
+ key *assertioncrypto.Keypair
+ mail *homeTestMail
+ cookies map[string]*http.Cookie
+}
+
+func newHomeRouterFixture(t *testing.T) *homeRouterFixture {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "meet.db"), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(key.SignPub())
+ id := hex.EncodeToString(sum[:])
+ raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())})
+ mail := &homeTestMail{}
+ mux, a, err := build(t.Context(), d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "meet-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}}
+}
+
+func homeKeyID(key *assertioncrypto.Keypair) string {
+ sum := sha256.Sum256(key.SignPub())
+ return hex.EncodeToString(sum[:])
+}
+func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ if method == "POST" {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", homeTestOrigin)
+ if path == "/auth/assertion" {
+ r.Header.Set("Origin", homeTestIssuer)
+ }
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ for _, c := range w.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(f.cookies, c.Name)
+ } else {
+ f.cookies[c.Name] = c
+ }
+ }
+ return w
+}
+func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
+ t.Helper()
+ if w.Code != 303 {
+ t.Fatalf("redirect status %d: %s", w.Code, w.Body.String())
+ }
+ return w.Header().Get("Location")
+}
+func (f *homeRouterFixture) local(t *testing.T) {
+ t.Helper()
+ homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1"))
+ link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
+ u, err := url.Parse(link)
+ if err != nil || link == "" {
+ t.Fatal("no captured local verification link")
+ }
+ landing := homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ if landing != "/auth/local/complete" {
+ t.Fatal("wrong local landing", landing)
+ }
+ landing = homeRedirect(t, f.do("GET", landing, ""))
+ if landing != "/continue" {
+ t.Fatal("lost local continue", landing)
+ }
+ homeRedirect(t, f.do("GET", landing, ""))
+}
+func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder {
+ t.Helper()
+ target := homeRedirect(t, f.do("GET", start, ""))
+ u, err := url.Parse(target)
+ if err != nil || u.Query().Get("consumer") != "meet-test" {
+ t.Fatal("bad issuer redirect")
+ }
+ nonce := u.Query().Get("nonce")
+ now := time.Now().Unix()
+ jti, err := assertion.RandomID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: homeKeyID(f.key), ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode())
+ finish := homeRedirect(t, callback)
+ return f.do("GET", finish, "")
+}
+func (f *homeRouterFixture) link(t *testing.T) {
+ f.local(t)
+ if w := f.do("GET", "/account/home", ""); w.Code != 200 {
+ t.Fatal("account", w.Code)
+ }
+ start := homeRedirect(t, f.do("POST", "/account/home", ""))
+ if next := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); next != "/account/home/confirm" {
+ t.Fatal("confirmation", next)
+ }
+ homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ start = homeRedirect(t, f.do("GET", "/signin", ""))
+ homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
+}
+func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ created := f.do("POST", "/meetings", "title=Existing+private+meeting")
+ if created.Code != 200 && created.Code != 303 {
+ t.Fatal(created.Code, created.Body.String())
+ }
+ var before string
+ if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil {
+ t.Fatal(err)
+ }
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ f.link(t)
+ w := f.do("GET", "/", "")
+ if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") {
+ t.Fatal("lost existing meeting", w.Code, w.Body.String())
+ }
+ var after string
+ f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&after)
+ if after != before {
+ t.Fatal("changed attribution")
+ }
+ var count int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&count)
+ if count != 1 {
+ t.Fatal("Home created person", count)
+ }
+ f.app.cfg.allow = allowlist{}
+ if w = f.do("GET", "/", ""); w.Code != 404 {
+ t.Fatal("removed member accepted", w.Code)
+ }
+}
+func TestUnknownHomeDoesNotCreatePerson(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ start := homeRedirect(t, f.do("GET", "/signin", ""))
+ w := f.exchange(t, start, "unknown-home-person")
+ if w.Code != 403 {
+ t.Fatal(w.Code)
+ }
+ var n int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n)
+ if n != 0 {
+ t.Fatal("Home allocated person", n)
+ }
+}
+func TestHomeSessionFailsWhenDisabled(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ f.app.home = nil
+ if w := f.do("GET", "/", ""); w.Code != 404 {
+ t.Fatal("disabled accepted", w.Code)
+ }
+}
+func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ made := f.do("POST", "/meetings", "title=Guest+meeting")
+ token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String())
+ sqid := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(made.Body.String())
+ if token == nil || sqid == nil {
+ t.Fatal("missing meeting links")
+ }
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ path := homeRedirect(t, f.do("GET", "/j/"+token[1], ""))
+ if path != "/signin" {
+ t.Fatal(path)
+ }
+ if w := f.do("GET", path, ""); w.Code != 200 {
+ t.Fatal("guest redirected to Home", w.Code)
+ }
+ homeRedirect(t, f.do("POST", "/signin", "address=guest%40example.test"))
+ link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
+ u, _ := url.Parse(link)
+ landing := homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ if landing != "/auth/local/complete" {
+ t.Fatal(landing)
+ }
+ landing = homeRedirect(t, f.do("GET", landing, ""))
+ if landing != "/continue" {
+ t.Fatal("lost pending guest landing", landing)
+ }
+ landing = homeRedirect(t, f.do("GET", landing, ""))
+ if landing != "/m/"+sqid[1]+"/join" {
+ t.Fatal("lost grant", landing)
+ }
+ if w := f.do("GET", landing, ""); w.Code != 200 {
+ t.Fatal("holding", w.Code)
+ }
+ for _, path := range []string{"/", "/account/home"} {
+ if w := f.do("GET", path, ""); w.Code != 404 {
+ t.Fatal("guest gained member privilege", path, w.Code)
+ }
+ }
+}
+func TestHomePublicJoinResolvesExistingMember(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ made := f.do("POST", "/meetings", "title=Home+meeting")
+ token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String())
+ if token == nil {
+ t.Fatal("missing link")
+ }
+ if w := f.do("GET", "/j/"+token[1], ""); w.Code != 303 {
+ t.Fatal("Home member follow", w.Code)
+ }
+ var n int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n)
+ if n != 1 {
+ t.Fatal("opaque Home subject became person", n)
+ }
+ f.app.cfg.allow = allowlist{}
+ if w := f.do("GET", "/j/"+token[1], ""); w.Code != 404 {
+ t.Fatal("removed member gained guest grant", w.Code)
+ }
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM guest_grants").Scan(&n)
+ if n != 0 {
+ t.Fatal("removed Home member acquired grant", n)
+ }
+}
diff --git a/internal/meet/home_security_test.go b/internal/meet/home_security_test.go
new file mode 100644
index 0000000..55afff7
--- /dev/null
+++ b/internal/meet/home_security_test.go
@@ -0,0 +1,92 @@
+package meet
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if closeBaseline != nil {
+ defer closeBaseline()
+ }
+ rec := httptest.NewRecorder()
+ baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil))
+ baseline := rec.Header().Get("Content-Security-Policy")
+ want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1)
+ if want == baseline {
+ t.Fatal("framework form-action changed")
+ }
+ f := newHomeRouterFixture(t)
+ wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if done != nil {
+ defer done()
+ }
+ for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} {
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil))
+ if got := rec.Header().Get("Content-Security-Policy"); got != want {
+ t.Fatalf("policy drift at %s: %q", path, got)
+ }
+ }
+}
+func TestHomeMutationOriginsAndDuplicateCookies(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ for _, path := range []string{"/signin", "/account/home", "/account/home/confirm", "/signout", "/meetings"} {
+ r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader("address=alice%40example.test"))
+ r.Header.Set("Origin", "https://other.example.test")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ if w.Code != 403 {
+ t.Fatal("cross-origin accepted", path, w.Code)
+ }
+ }
+ r := httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ if w.Code != 400 {
+ t.Fatal("duplicate cookies accepted", w.Code)
+ }
+}
+func TestHomeCredentialsFailClosedWhenPartial(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ cfg := f.app.cfg.Config
+ for _, name := range []string{"issuer", "consumer", "keys", "dek"} {
+ broken := *cfg.Home
+ switch name {
+ case "issuer":
+ broken.Issuer = ""
+ case "consumer":
+ broken.ConsumerID = ""
+ case "keys":
+ broken.KeysBase64 = ""
+ case "dek":
+ broken.DEKBase64 = ""
+ }
+ cfg.Home = &broken
+ if _, err := resolve(cfg); err == nil {
+ t.Fatal("partial accepted", name)
+ }
+ cfg = f.app.cfg.Config
+ }
+}
diff --git a/internal/meet/render.go b/internal/meet/render.go
index a964950..255c256 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -1,6 +1,7 @@
package meet
import (
+ "amadan.net/oficina/home/connect"
"bytes"
"embed"
"html/template"
@@ -17,13 +18,16 @@ var appFS embed.FS
var assets = rastrillo.NewAssets(appFS)
-var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"}
+var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "home"}
var pages = map[string]*template.Template{}
func init() {
for _, name := range pageNames {
pages[name] = buildPage(name)
+ if name == "home" {
+ pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html"))
+ }
}
}
diff --git a/internal/meet/store.go b/internal/meet/store.go
index f6833a8..b462e52 100644
--- a/internal/meet/store.go
+++ b/internal/meet/store.go
@@ -470,3 +470,17 @@ func (s *store) rastrilloTextExpiry(ctx context.Context, table string) (time.Tim
}
return t, nil
}
+
+// Identity rows use AUTOINCREMENT and are never recycled for another person.
+func (s *store) existingHomePerson(ctx context.Context, address string) (Person, error) {
+ var p Person
+ err := s.g.WithContext(ctx).Where("email = ?", normalizeAddress(address)).Take(&p).Error
+ return p, err
+}
+func (s *store) homePersonByID(ctx context.Context, q interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}, id int64) (Person, error) {
+ var p Person
+ err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name)
+ return p, err
+}
diff --git a/internal/meet/templates/home.html b/internal/meet/templates/home.html
new file mode 100644
index 0000000..4dafa74
--- /dev/null
+++ b/internal/meet/templates/home.html
@@ -0,0 +1,2 @@
+{{define "title"}}{{.Home.Title}} · Meet{{end}}
+{{define "content"}}{{template "home-connect" .Home}}{{end}}
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html
index 755d96f..ddc95dd 100644
--- a/internal/meet/templates/index.html
+++ b/internal/meet/templates/index.html
@@ -1,6 +1,7 @@
{{define "title"}}Meetings{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Meetings"}}
+{{if .HomeEnabled}}<p><a href="/account/home" rst-btn="ghost">Connect Home account</a></p>{{end}}
<section>
<h2>New meeting</h2>
<form rst-form method="post" action="/meetings">