oficina / meet Public

join: /j/, the pending-join cookie, ruling 12 and /continue

Following a live link sets a signed cookie and redirects to sign-in;
the gate lets a non-member address through only with that cookie,
within two budgets, and writes the pending row before Begin so the
emailed link admits it anywhere; /continue re-checks the link and
mints the grant in one transaction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f189d0f3ff9c7519e69a8ceb6744bb60effb2f3f parent ea7c80d
9 files changed, +465 −12
  • internal/meet/app.go +17 −7
  • internal/meet/auth.go +53 −4
  • internal/meet/budget.go +40 −0
  • internal/meet/budget_test.go +23 −0
  • internal/meet/handlers_join.go +43 −0
  • internal/meet/joincookie.go +76 −0
  • internal/meet/joincookie_test.go +39 −0
  • internal/meet/store.go +34 −1
  • internal/meettest/join_test.go +140 −0
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 22dca09..3ec24e8 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
+ rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/csrf"
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/migrate"
@@ -17,11 +18,14 @@ import (
// app holds what every handler needs.
type app struct {
- db *db.DB
- cfg resolved
- sealer *seal.Sealer
- store *store
- logger *slog.Logger
+ db *db.DB
+ cfg resolved
+ sealer *seal.Sealer
+ store *store
+ logger *slog.Logger
+ auth *rauth.Auth
+ linkBudget *budget
+ outsiderBudget *budget
}
// SetClockForTest replaces the store's clock; the harness uses it to
@@ -48,7 +52,11 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
"skipped", migrated.Skipped, "adopted", migrated.Adopted)
st := &store{g: d.G, sealer: sealer, now: time.Now}
- a := &app{db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger}
+ a := &app{
+ db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger,
+ linkBudget: newBudget(5, 15*time.Minute),
+ outsiderBudget: newBudget(10, 15*time.Minute),
+ }
writer, err := d.G.DB()
if err != nil {
@@ -58,16 +66,18 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
if err != nil {
return nil, nil, err
}
+ a.auth = authPlugin
r := chi.NewRouter()
r.Use(csrf.Protect(rc.Origin))
- // Inside CSRF, outside the session: sign-in and, in Task 6, /j/.
+ // 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)
+ r.Get("/j/{token}", a.followLink)
// Signed in, with a persons row.
r.Group(func(r chi.Router) {
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
index cf80a13..7635394 100644
--- a/internal/meet/auth.go
+++ b/internal/meet/auth.go
@@ -64,9 +64,42 @@ func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
}
}
-// mayRequestLink decides whether Begin is reached. Task 6 extends it.
+// mayRequestLink decides whether Begin is reached (ruling 12). A member
+// address always may. Any other address may only when the browser
+// carries a valid pending-join cookie for a live link, within two
+// budgets counted before Begin: five attempts per link and ten across
+// all links per fifteen minutes. When it may, a pending_joins row is
+// written first, for the member too (it carries their destination).
func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
- return a.cfg.allow.allows(address)
+ address = normalizeAddress(address)
+ member := a.cfg.allow.allows(address)
+ ck, hasCookie := a.readJoinCookie(r)
+ if !hasCookie {
+ return member
+ }
+ link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash)
+ if err != nil {
+ a.logger.Error("meet: link for cookie", "err", err)
+ return member
+ }
+ if !live {
+ return member
+ }
+ if !member {
+ now := a.store.now()
+ if !a.outsiderBudget.allow("all", now) || !a.linkBudget.allow(ck.TokenHash, now) {
+ a.logger.Warn("meet: outsider sign-in attempts over budget", "link", ck.TokenHash[:8])
+ return false
+ }
+ }
+ if address == "" {
+ return member
+ }
+ if err := a.store.writePending(r.Context(), address, link); err != nil {
+ a.logger.Error("meet: write pending join", "err", err)
+ return member
+ }
+ return true
}
// rastrillo's own Begin also same-origin-checks the request and
@@ -165,9 +198,25 @@ func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/welcome", http.StatusSeeOther)
}
-// continuePending is filled in by Task 6.
+// continuePending is /continue's first branch (spec §3.2): the most
+// recent unexpired pending row for this person, re-checked against its
+// link and meeting in one transaction with the grant, then deleted.
func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
- return "", false
+ a.clearJoinCookie(w)
+ pend, ok, err := a.store.latestPending(r.Context(), p.Email)
+ if err != nil {
+ a.logger.Error("meet: latest pending", "err", err)
+ return "", false
+ }
+ if !ok {
+ return "", false
+ }
+ dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p))
+ if err != nil {
+ a.logger.Error("meet: consume pending", "err", err)
+ return "", false
+ }
+ return dest, dest != ""
}
func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/meet/budget.go b/internal/meet/budget.go
new file mode 100644
index 0000000..746eb0c
--- /dev/null
+++ b/internal/meet/budget.go
@@ -0,0 +1,40 @@
+package meet
+
+import (
+ "sync"
+ "time"
+)
+
+// budget is a sliding-window counter: at most limit events per key per
+// window. It counts attempts, refused or not, because the point is to
+// keep outsider traffic away from rastrillo's shared limiter (spec
+// §3.2), and an attempt that reaches Begin is what consumes that.
+type budget struct {
+ mu sync.Mutex
+ limit int
+ window time.Duration
+ hits map[string][]time.Time
+}
+
+func newBudget(limit int, window time.Duration) *budget {
+ return &budget{limit: limit, window: window, hits: map[string][]time.Time{}}
+}
+
+// allow records an attempt and reports whether it was within budget.
+func (b *budget) allow(key string, now time.Time) bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ cut := now.Add(-b.window)
+ kept := b.hits[key][:0]
+ for _, t := range b.hits[key] {
+ if t.After(cut) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= b.limit {
+ b.hits[key] = kept
+ return false
+ }
+ b.hits[key] = append(kept, now)
+ return true
+}
diff --git a/internal/meet/budget_test.go b/internal/meet/budget_test.go
new file mode 100644
index 0000000..5ccd538
--- /dev/null
+++ b/internal/meet/budget_test.go
@@ -0,0 +1,23 @@
+package meet
+
+import (
+ "testing"
+ "time"
+)
+
+func TestBudgetSlidesItsWindow(t *testing.T) {
+ b := newBudget(2, time.Minute)
+ now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
+ if !b.allow("k", now) || !b.allow("k", now) {
+ t.Fatal("first two refused")
+ }
+ if b.allow("k", now) {
+ t.Fatal("third allowed")
+ }
+ if !b.allow("other", now) {
+ t.Fatal("other key refused")
+ }
+ if !b.allow("k", now.Add(61*time.Second)) {
+ t.Fatal("refused after the window slid")
+ }
+}
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go
new file mode 100644
index 0000000..80bcb2c
--- /dev/null
+++ b/internal/meet/handlers_join.go
@@ -0,0 +1,43 @@
+package meet
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// followLink is GET /j/{token}: one 404 for unknown, revoked, ended or
+// expired; otherwise one of three outcomes (spec §3.2). It is mounted
+// outside the session group because the person is often not signed in.
+func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
+ link, m, live, err := a.store.liveLink(r.Context(), chi.URLParam(r, "token"))
+ if err != nil {
+ a.logger.Error("meet: follow link", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if !live {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ id, signedIn := a.auth.SessionFrom(r)
+ if !signedIn {
+ a.setJoinCookie(w, link.TokenHash)
+ http.Redirect(w, r, signinPath, http.StatusSeeOther)
+ return
+ }
+ p, err := a.store.ensurePerson(r.Context(), id.Address)
+ if err != nil {
+ a.logger.Error("meet: ensure person", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if !a.isMember(p) {
+ if _, err := a.store.mintGrant(r.Context(), m, p.ID); err != nil {
+ a.logger.Error("meet: mint grant", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ }
+ http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
+}
diff --git a/internal/meet/joincookie.go b/internal/meet/joincookie.go
new file mode 100644
index 0000000..3000c6b
--- /dev/null
+++ b/internal/meet/joincookie.go
@@ -0,0 +1,76 @@
+package meet
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const joinCookieName = "meet_join"
+
+// joinCookie tells POST /signin which live link this browser followed.
+// It carries the token's hash, not the token, so a cookie leak is not a
+// link leak, and it is signed under the instance key so it cannot be
+// forged. Its only job is that one hand-off (spec §3.2).
+type joinCookie struct {
+ TokenHash string
+ Expires time.Time
+}
+
+func (a *app) joinMAC(payload string) string {
+ mac := hmac.New(sha256.New, []byte("meet/join\x00"+a.cfg.instanceKey))
+ mac.Write([]byte(payload))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func (a *app) setJoinCookie(w http.ResponseWriter, tokenHash string) {
+ exp := a.store.now().Add(pendingLife)
+ payload := tokenHash + "|" + strconv.FormatInt(exp.Unix(), 10)
+ value := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.joinMAC(payload)
+ http.SetCookie(w, &http.Cookie{
+ Name: joinCookieName, Value: value, Path: "/", HttpOnly: true,
+ Secure: strings.HasPrefix(a.cfg.Origin, "https://"), SameSite: http.SameSiteLaxMode,
+ MaxAge: int(pendingLife.Seconds()),
+ })
+}
+
+func (a *app) clearJoinCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: joinCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+}
+
+func (a *app) readJoinCookie(r *http.Request) (joinCookie, bool) {
+ ck, err := r.Cookie(joinCookieName)
+ if err != nil {
+ return joinCookie{}, false
+ }
+ parts := strings.SplitN(ck.Value, ".", 2)
+ if len(parts) != 2 {
+ return joinCookie{}, false
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return joinCookie{}, false
+ }
+ payload := string(raw)
+ if !hmac.Equal([]byte(a.joinMAC(payload)), []byte(parts[1])) {
+ return joinCookie{}, false
+ }
+ hash, expStr, ok := strings.Cut(payload, "|")
+ if !ok || len(hash) != 64 {
+ return joinCookie{}, false
+ }
+ exp, err := strconv.ParseInt(expStr, 10, 64)
+ if err != nil {
+ return joinCookie{}, false
+ }
+ c := joinCookie{TokenHash: hash, Expires: time.Unix(exp, 0)}
+ if !c.Expires.After(a.store.now()) {
+ return joinCookie{}, false
+ }
+ return c, true
+}
diff --git a/internal/meet/joincookie_test.go b/internal/meet/joincookie_test.go
new file mode 100644
index 0000000..32dc66b
--- /dev/null
+++ b/internal/meet/joincookie_test.go
@@ -0,0 +1,39 @@
+package meet
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func cookieApp(now time.Time) *app {
+ return &app{cfg: resolved{instanceKey: "k", Config: Config{Origin: "http://app.test"}}, store: &store{now: func() time.Time { return now }}}
+}
+
+func TestJoinCookieRoundTripsAndRejectsTampering(t *testing.T) {
+ now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
+ a := cookieApp(now)
+ rec := httptest.NewRecorder()
+ hash := strings.Repeat("ab", 32)
+ a.setJoinCookie(rec, hash)
+ ck := rec.Result().Cookies()[0]
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(ck)
+ got, ok := a.readJoinCookie(req)
+ if !ok || got.TokenHash != hash {
+ t.Fatalf("round trip failed: %+v %v", got, ok)
+ }
+ bad := *ck
+ bad.Value = strings.Replace(bad.Value, ".", "x.", 1)
+ req2 := httptest.NewRequest(http.MethodGet, "/", nil)
+ req2.AddCookie(&bad)
+ if _, ok := a.readJoinCookie(req2); ok {
+ t.Fatal("tampered cookie accepted")
+ }
+ later := cookieApp(now.Add(pendingLife + time.Second))
+ if _, ok := later.readJoinCookie(req); ok {
+ t.Fatal("expired cookie accepted")
+ }
+}
diff --git a/internal/meet/store.go b/internal/meet/store.go
index 0c8904c..e033444 100644
--- a/internal/meet/store.go
+++ b/internal/meet/store.go
@@ -144,8 +144,12 @@ func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meetin
if token == "" {
return MeetingLink{}, Meeting{}, false, nil
}
+ return s.liveLinkByHash(ctx, hashLinkToken(token))
+}
+
+func (s *store) liveLinkByHash(ctx context.Context, hash string) (MeetingLink, Meeting, bool, error) {
var l MeetingLink
- err := s.g.WithContext(ctx).Where("token_hash = ?", hashLinkToken(token)).Take(&l).Error
+ err := s.g.WithContext(ctx).Where("token_hash = ?", hash).Take(&l).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return MeetingLink{}, Meeting{}, false, nil
}
@@ -289,3 +293,32 @@ func (s *store) deletePending(ctx context.Context, address string) error {
}
return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error
}
+
+// consumePending re-checks the pending row's link and meeting as
+// GET /j/{token} would, mints the grant for a non-member, and deletes
+// every pending row for the address, all in one transaction. It returns
+// the holding-screen path, or "" when the re-check failed.
+func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) {
+ dest := ""
+ err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ ts := &store{g: tx, sealer: s.sealer, now: s.now}
+ if err := ts.deletePending(ctx, p.Email); err != nil {
+ return err
+ }
+ if !pend.ExpiresAt.After(s.now()) {
+ return nil
+ }
+ _, m, live, err := ts.liveLinkByID(ctx, pend.LinkID)
+ if err != nil || !live {
+ return err
+ }
+ if !member {
+ if _, err := ts.mintGrant(ctx, m, p.ID); err != nil {
+ return err
+ }
+ }
+ dest = "/m/" + m.Sqid + "/join"
+ return nil
+ })
+ return dest, err
+}
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go
new file mode 100644
index 0000000..474d9a7
--- /dev/null
+++ b/internal/meettest/join_test.go
@@ -0,0 +1,140 @@
+package meettest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func memberWithMeeting(t *testing.T) (*harness, string, string) {
+ t.Helper()
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ sqid, tok := createMeeting(t, c, "Board meeting", "")
+ return x, sqid, tok
+}
+
+func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ rec := x.browser(t).get("/j/" + strings.Repeat("A", 43))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown: %d", rec.Code)
+ }
+}
+
+func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ g := x.browser(t)
+ rec := g.get("/j/" + tok)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" {
+ t.Fatalf("follow signed out: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+ if _, ok := g.cookies["meet_join"]; !ok {
+ t.Fatal("no pending-join cookie")
+ }
+ if dest := g.signIn("guest@example.com"); dest != "/m/"+sqid+"/join" {
+ t.Fatalf("outsider landed on %s", dest)
+ }
+ if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK {
+ t.Fatalf("holding page as guest: %d", rec.Code)
+ }
+ if rec := g.get("/"); rec.Code != http.StatusNotFound {
+ t.Fatal("guest can see the meeting list")
+ }
+}
+
+func TestEmailedLinkWorksInAnotherBrowser(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ phone := x.browser(t)
+ phone.get("/j/" + tok)
+ rec := phone.post("/signin", url.Values{"address": {"guest@example.com"}})
+ if rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatal("outsider with cookie refused")
+ }
+ _, link := x.mail.last()
+ laptop := x.browser(t) // no cookie here
+ if dest := laptop.follow(link); dest != "/m/"+sqid+"/join" {
+ t.Fatalf("other-browser click landed on %s", dest)
+ }
+}
+
+func TestLinkReplacedBetweenMailAndClickAdmitsNobody(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.post("/signin", url.Values{"address": {"guest@example.com"}})
+ _, link := x.mail.last()
+ member := x.browser(t)
+ member.signIn("paul@example.com")
+ member.post("/meetings/"+sqid+"/links", nil)
+ u, _ := url.Parse(link)
+ rec := g.get(u.RequestURI())
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("outsider's stale click: %d, want 403 from Authorize", rec.Code)
+ }
+ if rec := g.get("/m/" + sqid + "/join"); rec.Code == http.StatusOK {
+ t.Fatal("a grant was minted through a replaced link")
+ }
+}
+
+func TestSignedOutMemberFollowsALinkAndLandsOnItsHoldingPage(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ m := x.browser(t)
+ m.get("/j/" + tok)
+ if dest := m.signIn("paul@example.com"); dest != "/m/"+sqid+"/join" {
+ t.Fatalf("member landed on %s", dest)
+ }
+}
+
+func TestOutsiderWithoutCookieStillGetsNoMail(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ before := x.mail.count()
+ x.browser(t).post("/signin", url.Values{"address": {"guest@example.com"}})
+ if x.mail.count() != before {
+ t.Fatal("mail sent to an outsider with no link")
+ }
+}
+
+func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) {
+ x, _, tok := memberWithMeeting(t)
+ before := x.mail.count()
+ for i := 0; i < 7; i++ {
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.post("/signin", url.Values{"address": {"g" + string(rune('a'+i)) + "@example.com"}})
+ }
+ if got := x.mail.count() - before; got != 5 {
+ t.Fatalf("mails through one link: %d, want 5", got)
+ }
+ x.advance(16 * time.Minute)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ var toks []string
+ for i := 0; i < 3; i++ {
+ _, t2 := createMeeting(t, c, "m", "")
+ toks = append(toks, t2)
+ }
+ before = x.mail.count()
+ for i := 0; i < 12; i++ {
+ g := x.browser(t)
+ g.get("/j/" + toks[i%3])
+ g.post("/signin", url.Values{"address": {"h" + string(rune('a'+i)) + "@example.com"}})
+ }
+ if got := x.mail.count() - before; got != 10 {
+ t.Fatalf("mails across links: %d, want 10", got)
+ }
+}
+
+func TestExpiredGrantIsRefusedEntry(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("guest@example.com")
+ x.advance(2*time.Hour + time.Minute)
+ if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
+ t.Fatalf("expired guest at the holding page: %d, want 404", rec.Code)
+ }
+}