oficina / meet Public

Integrate Home sign-in with current Meet calls and agent API

Preserve both browser identity linking and bearer-only agent routes. Verify existing principals across Home, calls, settings, API and signout.
Paul Campbell pushed by paul@keymail.dev 2ed8bcfa3deef8a2d9e8c8d6f3b5d49b248f1de8 parent a283c53parent 34fc367
18 files changed, +754 −17
  • Makefile +2 −0
  • cmd/meet/main.go +2 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +33 −1
  • go.mod +5 −1
  • go.sum +8 −2
  • internal/meet/app.go +32 −7
  • 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 +123 −0
  • internal/meet/home_release_test.go +77 −0
  • internal/meet/home_router_test.go +331 −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

CI passed — run details

diff --git a/Makefile b/Makefile
index 383fa5e..737bd0b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,5 @@
+export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git
+
APP := meet
# What `carlos ship -target` defaults to, and therefore what a release
diff --git a/cmd/meet/main.go b/cmd/meet/main.go
index 31a34c0..d598e07 100644
--- a/cmd/meet/main.go
+++ b/cmd/meet/main.go
@@ -13,6 +13,7 @@ import (
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/mail"
+ "amadan.net/oficina/home/connect"
"amadan.net/oficina/meet/internal/meet"
)
@@ -51,6 +52,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 b32eb49..9888270 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -868,6 +868,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
@@ -998,7 +1030,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..63dbe54 100644
--- a/go.mod
+++ b/go.mod
@@ -3,15 +3,19 @@ module amadan.net/oficina/meet
go 1.26.0
require (
- amadan.net/rastrillo/rastrillo v0.26.0
+ amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f
+ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
+ 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/estilo.git v0.0.0-20260909234655-dec3908b237c // 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..e5fab7f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,11 @@
-amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
-amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts=
+amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
+amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
+amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+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/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 a587440..d880201 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -31,6 +31,7 @@ type app struct {
store *store
logger *slog.Logger
auth *rauth.Auth
+ home *homeIntegration
linkBudget *budget
outsiderBudget *budget
hub *hub.Hub
@@ -382,6 +383,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
@@ -402,14 +406,29 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Group(a.apiRoutes)
r.Group(func(r chi.Router) {
- r.Use(csrf.Protect(rc.Origin))
-
- // Inside CSRF, outside the session: sign-in and /j/.
+ if a.home == nil {
+ r.Use(csrf.Protect(rc.Origin))
+ } else {
+ r.Use(a.home.routes.Middleware)
+ }
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.
@@ -426,6 +445,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Post("/m/{sqid}/takeover", a.takeover)
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/start", a.startCall)
r.Post("/meetings", a.createMeeting)
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
index db82a0b..d6ffc06 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 {
a.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) {
+ a.ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
a.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 a09b88b..742ca59 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"
"amadan.net/oficina/meet/internal/meet/relay"
@@ -22,6 +23,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
@@ -60,6 +62,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
@@ -154,5 +157,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 5a2b68f..9a989f5 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) {
+ a.ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
a.ErrorPage(w, r, http.StatusInternalServerError, "")
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go
index d9313bb..a764386 100644
--- a/internal/meet/handlers_meetings.go
+++ b/internal/meet/handlers_meetings.go
@@ -89,7 +89,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
}
render(w, "index", a.page(r, 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..28026d4
--- /dev/null
+++ b/internal/meet/home.go
@@ -0,0 +1,123 @@
+package meet
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "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
+ sessions *sessions.Sessions
+}
+
+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 {
+ return "", err
+ }
+ if !a.isMember(p) {
+ return "", sql.ErrNoRows
+ }
+ 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, sess}
+ 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.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(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", a.page(r, map[string]any{"Home": connect.Page(state, label)})); err != nil {
+ a.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_release_test.go b/internal/meet/home_release_test.go
new file mode 100644
index 0000000..b3a8da2
--- /dev/null
+++ b/internal/meet/home_release_test.go
@@ -0,0 +1,77 @@
+package meet
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestHomeConnectionKeepsCurrentCallAndAgentRoutes(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ started := f.do("POST", "/meetings/start", "")
+ if started.Code != http.StatusOK {
+ t.Fatalf("start call: %d", started.Code)
+ }
+ links := regexp.MustCompile(`href="(/m/[A-Za-z0-9]+/join)"`).FindStringSubmatch(started.Body.String())
+ if len(links) != 2 {
+ t.Fatal("missing holding page link")
+ }
+ target := links[1]
+ if !strings.HasPrefix(target, "/m/") {
+ t.Fatal("start call lost room destination")
+ }
+ if w := f.do("GET", target, ""); w.Code != 200 {
+ t.Fatalf("call page: %d", w.Code)
+ }
+ if w := f.do("GET", "/settings", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "alice@example.test") {
+ t.Fatalf("settings lost existing principal: %d", w.Code)
+ }
+ created := f.do("POST", "/settings/agents", "name=home-integration-test")
+ if created.Code != 200 {
+ t.Fatalf("create agent: %d", created.Code)
+ }
+ token := regexp.MustCompile(`value="(mt_[A-Za-z0-9_-]+)"`).FindStringSubmatch(created.Body.String())
+ if len(token) != 2 {
+ t.Fatal("missing once-shown agent token")
+ }
+ api := func(method, path, body, bearer string, cookies bool) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ r.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ if cookies {
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ return w
+ }
+ if w := api("GET", "/api/v1/meetings", "", token[1], false); w.Code != 200 {
+ t.Fatalf("bearer API with Home enabled: %d", w.Code)
+ }
+ if w := api("POST", "/api/v1/meetings", `{"title":"Agent call"}`, token[1], false); w.Code != 201 {
+ t.Fatalf("bearer mutation incorrectly requires browser proof: %d", w.Code)
+ }
+ if w := api("GET", "/api/v1/meetings", "", "", true); w.Code != 401 {
+ t.Fatalf("API accepted ambient Home cookie: %d", w.Code)
+ }
+ r := httptest.NewRequest("POST", homeTestOrigin+"/meetings/start", nil)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ if w.Code != 403 {
+ t.Fatalf("browser mutation lost CSRF protection: %d", w.Code)
+ }
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ if w := f.do("GET", target, ""); w.Code != 303 {
+ t.Fatalf("room survives signout: %d", w.Code)
+ }
+}
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go
new file mode 100644
index 0000000..4382892
--- /dev/null
+++ b/internal/meet/home_router_test.go
@@ -0,0 +1,331 @@
+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)
+ }
+}
+func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ homeRedirect(t, f.do("POST", "/signout", ""))
+ f.app.cfg.allow = allowlist{}
+ start := homeRedirect(t, f.do("GET", "/signin", ""))
+ if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 {
+ t.Fatal("removed member should be unknown, not unavailable", w.Code)
+ }
+}
+func TestHomeLookupDatabaseFailureKeepsSession(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ var before int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&before)
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE persons RENAME TO temporarily_unavailable_persons"); err != nil {
+ t.Fatal(err)
+ }
+ w := f.do("GET", "/", "")
+ if w.Code == 200 {
+ t.Fatal("database failure admitted")
+ }
+ var after int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&after)
+ if before != after {
+ t.Fatal("transient lookup failure revoked session")
+ }
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE temporarily_unavailable_persons RENAME TO persons"); err != nil {
+ t.Fatal(err)
+ }
+ if w = f.do("GET", "/", ""); w.Code != 200 {
+ t.Fatal("recovered lookup did not preserve session", w.Code)
+ }
+}
+
+func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ cookieName := f.app.home.sessions.CookieName()
+ saved := f.cookies[cookieName].Value
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil {
+ t.Fatal(err)
+ }
+ homeRedirect(t, f.do("GET", "/signin", ""))
+ if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved {
+ t.Fatal("temporary storage failure cleared valid cookie")
+ }
+ if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil {
+ t.Fatal(err)
+ }
+ start := homeRedirect(t, f.do("GET", "/signin", ""))
+ if f.cookies[cookieName] != nil {
+ t.Fatal("stale cookie not cleared")
+ }
+ target := homeRedirect(t, f.do("GET", start, ""))
+ if !strings.HasPrefix(target, homeTestIssuer+"/") {
+ t.Fatal("stale cookie prevented Home sign-in", target)
+ }
+}
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 2d2d4fb..0583fcb 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", "room", "settings", "agent_once"}
+var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "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 b02015b..b7626e4 100644
--- a/internal/meet/store.go
+++ b/internal/meet/store.go
@@ -579,3 +579,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 feb4ef9..85dfb4c 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>
<form method="post" action="/meetings/start"><button type="submit" rst-btn="primary lg">Start a call</button></form>
</section>