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>
14 files changed,
+358
−29
internal/meet/app.go+28 −1internal/meet/auth.go+172 −0internal/meet/render.go+24 −23internal/meet/templates/confirm.html+2 −0internal/meet/templates/errors.html+6 −0internal/meet/templates/index.html+2 −1internal/meet/templates/join.html+2 −0internal/meet/templates/layout.html+3 −1internal/meet/templates/link_once.html+2 −0internal/meet/templates/signin.html+13 −0internal/meet/templates/welcome.html+6 −0internal/meettest/harness_test.go+35 −0internal/meettest/index_test.go+8 −3internal/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") |
| + } |
| +} |