oficina / meet Public

auth: magic-link sign-in, persons, continue and welcome

rastrillo/auth behind Docs' gate with Slides' same-answer wrapper,
Authorize admitting allowlist or a live pending row, SignedInPath at
/continue, requirePerson and requireMember middleware, the template
tree with rastrillo's ui partials.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 595d6ae04cb754b91c2711de234aa1f59f67daca parent ef4ff05
14 files changed, +358 −29
  • internal/meet/app.go +28 −1
  • internal/meet/auth.go +172 −0
  • internal/meet/render.go +24 −23
  • internal/meet/templates/confirm.html +2 −0
  • internal/meet/templates/errors.html +6 −0
  • internal/meet/templates/index.html +2 −1
  • internal/meet/templates/join.html +2 −0
  • internal/meet/templates/layout.html +3 −1
  • internal/meet/templates/link_once.html +2 −0
  • internal/meet/templates/signin.html +13 −0
  • internal/meet/templates/welcome.html +6 −0
  • internal/meettest/harness_test.go +35 −0
  • internal/meettest/index_test.go +8 −3
  • internal/meettest/signin_test.go +55 −0
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 4d16443..eefe1b6 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -50,9 +50,36 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
st := &store{g: d.G, sealer: sealer, now: time.Now}
a := &app{db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger}
+ writer, err := d.G.DB()
+ if err != nil {
+ return nil, nil, err
+ }
+ authPlugin, err := a.newAuthPlugin(writer)
+ if err != nil {
+ return nil, nil, err
+ }
+
r := chi.NewRouter()
r.Use(csrf.Protect(rc.Origin))
- r.Get("/", a.index)
+
+ // Inside CSRF, outside the session: sign-in and, in Task 6, /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)
+
+ // Signed in, with a persons row.
+ r.Group(func(r chi.Router) {
+ r.Use(authPlugin.RequireSession)
+ r.Use(a.requirePerson)
+ r.Get("/continue", a.continueSignedIn)
+ r.Get("/welcome", a.welcome)
+ r.Group(func(r chi.Router) {
+ r.Use(a.requireMember)
+ r.Get("/", a.index)
+ })
+ })
mux := http.NewServeMux()
mux.Handle("GET /static/", assets.Handler())
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
new file mode 100644
index 0000000..fd824d4
--- /dev/null
+++ b/internal/meet/auth.go
@@ -0,0 +1,172 @@
+package meet
+
+import (
+ "context"
+ "database/sql"
+ "net/http"
+ "net/url"
+ "strings"
+
+ rauth "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+const signinPath = "/signin"
+const sentLocation = signinPath + "?sent=1"
+
+// newAuthPlugin wires rastrillo/auth. Authorize is Meet's one departure
+// from Docs (spec §3.2): an address is admitted at the emailed link when
+// it is on the allowlist, or when it holds an unexpired pending-join row
+// 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) {
+ return rauth.New(rauth.Config{
+ DB: writer,
+ Origin: a.cfg.Origin,
+ InstanceKey: a.cfg.instanceKey,
+ Mailer: a.cfg.mailer,
+ Authorize: a.authorize,
+ SigninPath: signinPath,
+ SignedInPath: "/continue",
+ Logger: a.logger,
+ })
+}
+
+func (a *app) authorize(address string) bool {
+ if a.cfg.allow.allows(address) {
+ return true
+ }
+ live, err := a.store.pendingLive(context.Background(), address)
+ if err != nil {
+ a.logger.Error("meet: authorize: pending lookup", "err", err)
+ return false
+ }
+ return live
+}
+
+// gateBegin sits in front of rastrillo's Begin. The guarantee: every
+// answer is byte-identical, 303 Location: /signin?sent=1, whatever the
+// address's standing, so the endpoint is not a membership oracle. Task 6
+// adds the pending-join exception in front of the allowlist check.
+func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ address := r.FormValue("address")
+ if !a.mayRequestLink(w, r, address) {
+ http.Redirect(w, r, sentLocation, http.StatusSeeOther)
+ return
+ }
+ if r.Form == nil {
+ r.Form = url.Values{}
+ }
+ r.Form.Set("force", "1")
+ next(&sameAnswer{ResponseWriter: w, app: a}, r)
+ }
+}
+
+// mayRequestLink decides whether Begin is reached. Task 6 extends it.
+func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
+ return a.cfg.allow.allows(address)
+}
+
+// sameAnswer rewrites rastrillo's own refusal redirects to the sent
+// location. It hooks WriteHeader because http.Redirect sets Location
+// and then calls WriteHeader, so the Location is visible in time.
+type sameAnswer struct {
+ http.ResponseWriter
+ app *app
+}
+
+func (s *sameAnswer) WriteHeader(status int) {
+ if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") {
+ s.app.logger.Warn("meet: sign-in refused; answering as if sent", "outcome", loc)
+ s.Header().Set("Location", sentLocation)
+ }
+ s.ResponseWriter.WriteHeader(status)
+}
+
+type personCtxKey struct{}
+
+// requirePerson runs behind RequireSession: it maps the session subject
+// (the verified address; sessions.UserID would return 0, false) to a
+// persons row, creating it on first sign-in.
+func (a *app) requirePerson(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s, ok := sessions.Current(r)
+ if !ok || s.Subject == "" {
+ a.logger.Error("meet: session with no subject reached requirePerson", "path", r.URL.Path)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ p, err := a.store.ensurePerson(r.Context(), s.Subject)
+ if err != nil {
+ a.logger.Error("meet: ensure person", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
+ })
+}
+
+func currentPerson(r *http.Request) (Person, bool) {
+ p, ok := r.Context().Value(personCtxKey{}).(Person)
+ return p, ok
+}
+
+// requireMember is spec §2.5's first row: current member standing.
+func (a *app) requireMember(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p, ok := currentPerson(r)
+ if !ok || !a.isMember(p) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (a *app) signin(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":
+ data["Error"] = "That does not look like an email address."
+ case "rate":
+ data["Error"] = "Too many sign-in attempts just now. Wait a few minutes and try again."
+ case "expired":
+ data["Error"] = "That sign-in link has expired. Ask for a new one."
+ case "":
+ default:
+ data["Error"] = "Sign-in did not work. Try again."
+ }
+ render(w, "signin", data)
+}
+
+// continueSignedIn is SignedInPath. Task 6 adds the pending-join branch
+// in front; this slice's first version routes members to the list and
+// everyone else to /welcome.
+func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
+ p, ok := currentPerson(r)
+ if !ok {
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if dest, handled := a.continuePending(w, r, p); handled {
+ http.Redirect(w, r, dest, http.StatusSeeOther)
+ return
+ }
+ if a.isMember(p) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, "/welcome", http.StatusSeeOther)
+}
+
+// continuePending is filled in by Task 6.
+func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
+ return "", false
+}
+
+func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
+ p, _ := currentPerson(r)
+ render(w, "welcome", map[string]any{"Email": p.Email})
+}
diff --git a/internal/meet/render.go b/internal/meet/render.go
index 3cd67c7..a964950 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -7,6 +7,7 @@ import (
"net/http"
"amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/ui"
"amadan.net/oficina/meet/internal/meet/icons"
)
@@ -14,37 +15,30 @@ import (
//go:embed templates static
var appFS embed.FS
-// assets fingerprints static/: {{asset "static/tokens.css"}} in a
-// template returns a content-hashed URL, served immutable-cacheable by
-// the handler App mounts.
var assets = rastrillo.NewAssets(appFS)
-// pages is one *template.Template per page, each combining layout.html
-// with that page's own file — kept separate so every page can define a
-// template named "content" without colliding with the others.
+var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"}
+
var pages = map[string]*template.Template{}
func init() {
- for _, name := range []string{"index"} {
- pages[name] = template.Must(template.New("layout").
- Funcs(template.FuncMap{
- "asset": assets.Path,
- // The app's own icon set (internal/meet/icons), scaffolded
- // by rastrillo new. iconAssets is whatever <head> markup the
- // chosen delivery needs — empty for the vendored-inline
- // default, so the layout calls it unconditionally and
- // switching delivery later needs no template edit.
- "icon": icons.Icon,
- "iconAssets": icons.Assets,
- }).
- ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+ for _, name := range pageNames {
+ pages[name] = buildPage(name)
}
}
-// render executes name's layout into a buffer before anything touches
-// the wire: a template error becomes a clean 500 instead of garbage
-// appended to a half-written page, and headers (flash cookies, say)
-// stay settable until the status line is written.
+// buildPage parses rastrillo's ui partials first, then the layout and
+// the page, so a page can call confirm-form, field, empty-state and the
+// rest by name with a dict.
+func buildPage(name string) *template.Template {
+ return template.Must(
+ template.Must(template.New("layout").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path}).
+ ParseFS(ui.Templates(), "*.html")).
+ ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+}
+
func render(w http.ResponseWriter, name string, data any) {
var buf bytes.Buffer
if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
@@ -53,3 +47,10 @@ func render(w http.ResponseWriter, name string, data any) {
}
buf.WriteTo(w)
}
+
+// ErrorPage renders the one error template with the status and the
+// reference rastrillo minted for the log line.
+func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ w.WriteHeader(status)
+ render(w, "errors", map[string]any{"Status": status, "Ref": ref})
+}
diff --git a/internal/meet/templates/confirm.html b/internal/meet/templates/confirm.html
new file mode 100644
index 0000000..8326cd1
--- /dev/null
+++ b/internal/meet/templates/confirm.html
@@ -0,0 +1,2 @@
+{{define "title"}}Meet{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/meet/templates/errors.html b/internal/meet/templates/errors.html
new file mode 100644
index 0000000..011d936
--- /dev/null
+++ b/internal/meet/templates/errors.html
@@ -0,0 +1,6 @@
+{{define "title"}}Something went wrong{{end}}
+{{define "content"}}
+{{template "page-header" dict "Title" (printf "%d" .Status)}}
+<p>That did not work.{{if .Ref}} Reference {{.Ref}}.{{end}}</p>
+<p><a href="/">Back to meetings</a></p>
+{{end}}
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html
index dd9abab..90975f3 100644
--- a/internal/meet/templates/index.html
+++ b/internal/meet/templates/index.html
@@ -1,3 +1,4 @@
+{{define "title"}}Meetings{{end}}
{{define "content"}}
-<h1>Hello, World — this is a rastrillo app.</h1>
+{{template "page-header" dict "Title" "Meetings"}}
{{end}}
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html
new file mode 100644
index 0000000..8326cd1
--- /dev/null
+++ b/internal/meet/templates/join.html
@@ -0,0 +1,2 @@
+{{define "title"}}Meet{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/meet/templates/layout.html b/internal/meet/templates/layout.html
index 783d0b0..d09f966 100644
--- a/internal/meet/templates/layout.html
+++ b/internal/meet/templates/layout.html
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{block "title" .}}Hello{{end}}</title>
+<title>{{block "title" .}}Meet{{end}}</title>
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
{{iconAssets}}
@@ -11,7 +11,9 @@
</head>
<body>
<main>
+<div rst-page>
{{template "content" .}}
+</div>
</main>
</body>
</html>
diff --git a/internal/meet/templates/link_once.html b/internal/meet/templates/link_once.html
new file mode 100644
index 0000000..8326cd1
--- /dev/null
+++ b/internal/meet/templates/link_once.html
@@ -0,0 +1,2 @@
+{{define "title"}}Meet{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/meet/templates/signin.html b/internal/meet/templates/signin.html
new file mode 100644
index 0000000..2388d88
--- /dev/null
+++ b/internal/meet/templates/signin.html
@@ -0,0 +1,13 @@
+{{define "title"}}Sign in{{end}}
+{{define "content"}}
+{{template "page-header" dict "Title" "Sign in to Meet"}}
+{{if .Sent}}
+<p rst-notice>If that address can sign in here, a link is on its way. Open it on this device to continue.</p>
+{{else}}
+{{if .Error}}<p rst-notice role="alert">{{.Error}}</p>{{end}}
+<form rst-form method="post" action="/signin">
+ {{template "field" dict "ID" "address" "Name" "address" "Label" "Email address" "Type" "email" "Required" true "Autocomplete" "email" "Autofocus" true}}
+ <div rst-form-actions><button type="submit" rst-btn="primary lg">Send me a sign-in link</button></div>
+</form>
+{{end}}
+{{end}}
diff --git a/internal/meet/templates/welcome.html b/internal/meet/templates/welcome.html
new file mode 100644
index 0000000..2be5df2
--- /dev/null
+++ b/internal/meet/templates/welcome.html
@@ -0,0 +1,6 @@
+{{define "title"}}Signed in{{end}}
+{{define "content"}}
+{{template "page-header" dict "Title" "You're signed in"}}
+<p>You're signed in as {{.Email}}. This team's Meet has no meetings for you. If someone sent you a meeting link, open it again now.</p>
+<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
+{{end}}
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go
index 03ce7bd..697ddb7 100644
--- a/internal/meettest/harness_test.go
+++ b/internal/meettest/harness_test.go
@@ -163,3 +163,38 @@ func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder
type nopMailer struct{}
func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil }
+
+// signIn asks for a magic link as address and follows it in this
+// client, leaving the session cookie behind. It returns the Location of
+// the post-sign-in redirect.
+func (c *client) signIn(address string) string {
+ c.t.Helper()
+ rec := c.post("/signin", url.Values{"address": {address}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ c.t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+ to, link := c.x.mail.last()
+ if to != address || link == "" {
+ c.t.Fatalf("no magic link mailed to %s (last to %q)", address, to)
+ }
+ return c.follow(link)
+}
+
+// follow opens an emailed link in this client and returns where it led.
+func (c *client) follow(link string) string {
+ c.t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ c.t.Fatal(err)
+ }
+ rec := c.get(u.RequestURI())
+ if rec.Code != http.StatusSeeOther {
+ c.t.Fatalf("GET %s: %d\n%s", u.RequestURI(), rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ rec = c.get(loc)
+ if rec.Code == http.StatusSeeOther {
+ return rec.Header().Get("Location")
+ }
+ return loc
+}
diff --git a/internal/meettest/index_test.go b/internal/meettest/index_test.go
index 8cf68c6..1b49c16 100644
--- a/internal/meettest/index_test.go
+++ b/internal/meettest/index_test.go
@@ -12,18 +12,23 @@ import (
var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`)
func TestIndexRenders(t *testing.T) {
- rec := get(t, newApp(t), "/")
+ c := newHarness(t, "paul@example.com").browser(t)
+ c.signIn("paul@example.com")
+ rec := c.get("/")
if rec.Code != http.StatusOK {
t.Fatalf("GET /: status %d, want 200", rec.Code)
}
- if !strings.Contains(rec.Body.String(), "this is a rastrillo app") {
+ if !strings.Contains(rec.Body.String(), "Meetings") {
t.Errorf("GET /: page is missing its heading:\n%s", rec.Body.String())
}
}
+// /signin renders the same layout as every other page and needs no
+// session, so it is what this test uses to find the fingerprinted
+// stylesheet link now that "/" requires one.
func TestIndexLinksFingerprintedStylesheet(t *testing.T) {
h := newApp(t)
- href := hashedStylesheet.FindString(get(t, h, "/").Body.String())
+ href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String())
if href == "" {
t.Fatal("index page links no fingerprinted stylesheet")
}
diff --git a/internal/meettest/signin_test.go b/internal/meettest/signin_test.go
new file mode 100644
index 0000000..7d2b131
--- /dev/null
+++ b/internal/meettest/signin_test.go
@@ -0,0 +1,55 @@
+package meettest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestMemberSignsInAndLandsOnTheList(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ if dest := c.signIn("paul@example.com"); dest != "/" {
+ t.Fatalf("member landed on %s, want /", dest)
+ }
+ if rec := c.get("/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Meetings") {
+ t.Fatalf("GET / after sign-in: %d", rec.Code)
+ }
+}
+
+func TestOutsiderWithoutALinkGetsNoMailAndTheSameAnswer(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ rec := c.post("/signin", url.Values{"address": {"stranger@example.com"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("refusal differs from success: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+ if x.mail.count() != 0 {
+ t.Fatal("mail was sent to a non-member with no link")
+ }
+}
+
+func TestSignedOutIsRedirectedToSignin(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ rec := x.browser(t).get("/")
+ if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/signin") {
+ t.Fatalf("GET / signed out: %d %s", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) {
+ // Development with no allowlist admits every address at sign-in but
+ // makes nobody a member; that is the shape the guest tests rely on.
+ x := newHarness(t)
+ c := x.browser(t)
+ if dest := c.signIn("anyone@example.com"); dest != "/welcome" {
+ t.Fatalf("non-member landed on %s, want /welcome", dest)
+ }
+ if rec := c.get("/"); rec.Code != http.StatusNotFound {
+ t.Fatalf("non-member GET /: %d, want 404", rec.Code)
+ }
+ if rec := c.get("/welcome"); !strings.Contains(rec.Body.String(), "anyone@example.com") {
+ t.Fatal("welcome page does not name the address")
+ }
+}