oficina / docs Public

app: sign in with a magic link

rastrillo's auth plugin, magic link only and no passkey second factor --
a passkey would enrol a credential whose relying party is this instance's
host, and a person in several teams would enrol several. Magic link
enrols nothing, so the one-relying-party ruling is untouched.

Per spec §8.1 this is the single-instance shape's real answer, not a
placeholder: a lone instance has no one-person-many-teams problem, which
is the problem the home exists to solve.

Every document route now sits behind rastrillo/auth's RequireSession.
InstanceKey and Mailer follow Origin's own fail-closed shape: empty only
defaults in development (a fixed dev key, and a logging Mailer that
writes the link to the log, clearly marked, for run-dev.sh and
TestSignIn); anywhere else, empty is a startup refusal -- a sign-in that
cannot send its link is not sign-in. GET /signin is this app's own page
(rastrillo/auth's own handlers never render one); POST /signin, GET
/auth/callback, GET /auth/verify and POST /signout are rastrillo/auth's.

testing.go gains App.TestSignIn(t, address), which mints a link via the
real POST /signin (force=1, skipping signin's classification network
call) and reads it back from an in-memory recordingMailer installed only
in development -- no real mail server involved. Existing tests that hit
a now-protected route (list_test.go, app_test.go) sign in first through
it.
Paul Campbell pushed by paul@keymail.dev 9cffc8e14c665979996ecf8716b3155fe7d7fefb parent 250856e
10 files changed, +684 −26
  • cmd/docs/main.go +31 −4
  • go.mod +1 −0
  • internal/docs/app.go +99 −20
  • internal/docs/app_test.go +4 −0
  • internal/docs/auth.go +210 −0
  • internal/docs/auth_test.go +171 −0
  • internal/docs/list_test.go +42 −0
  • internal/docs/render.go +15 −2
  • internal/docs/testing.go +60 −0
  • internal/docs/views/signin.html +51 −0
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 3809558..fbf0001 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -4,8 +4,10 @@ package main
import (
"log/slog"
"os"
+ "strings"
"github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/docs/internal/docs"
)
@@ -56,6 +58,29 @@ func main() {
origin = "http://" + opts.Addr
}
+ // $DOCS_INSTANCE_KEY seals rastrillo/auth's pending-link blob —
+ // docs.Config.InstanceKey's own doc comment. Left empty here when
+ // unset: docs.New refuses to start on an empty one outside
+ // development, the same fail-closed shape as an empty Origin.
+ instanceKey := os.Getenv("DOCS_INSTANCE_KEY")
+
+ // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the sibling
+ // $DOCS_SMTP_PORT/$DOCS_SMTP_USER/$DOCS_SMTP_PASS
+ // rastrillo/mail.FromEnv also reads) configure real magic-link
+ // delivery. Only built when both of the two values with no sane
+ // default are actually set — mail.FromEnv itself falls back to a
+ // silent warning-and-log Sender when they're missing, which is
+ // the wrong answer outside development, so that decision is left
+ // to docs.New (Config.Mailer's own doc comment): a nil Mailer
+ // here is what lets it refuse to start rather than silently
+ // logging a live credential in a real deployment's log.
+ var mailer mail.Sender
+ if host := strings.TrimSpace(os.Getenv("DOCS_SMTP_HOST")); host != "" {
+ if from := strings.TrimSpace(os.Getenv("DOCS_SMTP_FROM")); from != "" {
+ mailer = mail.FromEnv("DOCS", logger)
+ }
+ }
+
// New opens its own database handle (SKILL.md §1's "app that opens
// its own database"), so opts.DBPath is blanked below before Serve
// — otherwise Serve would open a second connection to the file New
@@ -68,10 +93,12 @@ func main() {
// out explicitly; running this binary with no arguments always
// gets "production", DevAuth false.
cfg := docs.Config{
- DBPath: opts.DBPath,
- Env: "production",
- Origin: origin,
- Logger: logger,
+ DBPath: opts.DBPath,
+ Env: "production",
+ Origin: origin,
+ InstanceKey: instanceKey,
+ Mailer: mailer,
+ Logger: logger,
}
var app *docs.App
if devAuth {
diff --git a/go.mod b/go.mod
index f0856ab..5c58304 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+ github.com/keymaildev/signin v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 57cec24..9765769 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -21,7 +21,9 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
"github.com/go-chi/chi/v5"
"github.com/carlosframework/docs/internal/core"
@@ -92,6 +94,36 @@ type Config struct {
// tried, and failed, to match it against anything real.
Origin string
+ // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC
+ // key — auth.Config.InstanceKey's own doc comment explains why an
+ // empty one is dangerous, not just inconvenient: it hashes to one
+ // fixed, publicly computable value, identical across every
+ // deployment that made the same mistake). Sourced from
+ // $DOCS_INSTANCE_KEY (cmd/docs/main.go), the same "read in main,
+ // validated in New" split Origin above uses.
+ //
+ // Empty is allowed only when Env is "development" (New falls back
+ // to a fixed, non-secret development value — see devInstanceKey in
+ // auth.go); with any other Env, empty is a startup refusal, the
+ // same fail-closed shape as Origin.
+ InstanceKey string
+
+ // Mailer delivers the magic-link email. Nil is fine only in
+ // development: New wraps rastrillo/mail's logging fallback so the
+ // link lands in the log, clearly marked, and TestSignIn (testing.go)
+ // can read it back — no mail server required for
+ // scripts/run-dev.sh or for tests. Outside development, nil is a
+ // startup refusal: a sign-in that cannot send its link is not
+ // sign-in, and silently falling back to logging a live credential
+ // in a real deployment's log is exactly the mistake that fallback
+ // exists to flag, not absorb. cmd/docs/main.go builds this from
+ // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the other
+ // $DOCS_SMTP_* vars rastrillo/mail.FromEnv reads) when both are
+ // set, and leaves it nil otherwise — New.buildMailer (auth.go) is
+ // what turns "nil outside development" into the refusal, the same
+ // split Origin/InstanceKey use.
+ Mailer mail.Sender
+
// Logger defaults to slog.Default().
Logger *slog.Logger
}
@@ -145,28 +177,30 @@ func validateOrigin(origin string) error {
// App is the CARLOS Docs rastrillo app.
type App struct {
- cfg Config
- db *db.DB
- mux *http.ServeMux
- logger *slog.Logger
- core *core.Runtime
- store *Store
+ cfg Config
+ db *db.DB
+ mux *http.ServeMux
+ logger *slog.Logger
+ core *core.Runtime
+ store *Store
+ mailRecorder *recordingMailer // nil outside development; see auth.go
}
// BootSchema is everything New applies at boot, in apply order.
-// migrate.Merge(sessions.Schema, ...) adds another set here once auth
-// does; for now it's just this app's own Schema (models.go).
-var BootSchema = migrate.Merge(Schema)
+// migrate.Merge's argument order is apply order (rastrillo SKILL.md
+// §2): sessions.Schema before auth.Schema — auth's own backfill
+// migration reads the sessions table — before this app's own Schema
+// (models.go).
+var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema)
// New opens the database, applies migrations and wires the router. It
// is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle,
// migrate.Apply once at boot, a chi router wrapped as an
// *http.ServeMux so httptest exercises the real router, csrf.Protect
-// mounted app-wide over POST /documents (SKILL.md §5). It does not
-// wire rastrillo's sessions package or a real identity plugin — there
-// is no signed-in route yet; spec §8.1's home issues the real identity
-// once it exists. devauth.go's guardDevAuth runs first, so a
-// misconfigured Config.DevAuth refuses before any of the above opens.
+// mounted app-wide (SKILL.md §5), rastrillo/auth (auth.go) guarding
+// every document route behind a real, per-person magic-link sign-in.
+// devauth.go's guardDevAuth runs first, so a misconfigured
+// Config.DevAuth refuses before any of the above opens.
func New(cfg Config) (*App, error) {
logger := cfg.Logger
if logger == nil {
@@ -230,6 +264,20 @@ func New(cfg Config) (*App, error) {
logger.Warn("docs: development-only identity ENABLED — refuses to start outside Env=development", "env", cfg.Env)
}
+ // Resolved before anything else opens — same fail-closed shape as
+ // Origin and DevAuth above: a deployment that cannot seal a
+ // pending link, or cannot send one, refuses to start rather than
+ // booting into a broken or (worse) silently-logging-credentials
+ // sign-in. See auth.go.
+ instanceKey, err := resolveInstanceKey(cfg)
+ if err != nil {
+ return nil, err
+ }
+ mailer, mailRecorder, err := buildMailer(cfg, logger)
+ if err != nil {
+ return nil, err
+ }
+
d, err := db.Open(cfg.DBPath, logger)
if err != nil {
return nil, fmt.Errorf("docs: open database: %w", err)
@@ -257,28 +305,59 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: build renderer: %w", err)
}
+ // auth.go: rastrillo's magic-link plugin, mounted per its own
+ // SKILL.md contract (POST /signin, GET /auth/callback, GET
+ // /auth/verify, POST /signout) — never a passkey second factor;
+ // see spec §8.1 and auth.go's own doc comment for why.
+ authPlugin, err := newAuthPlugin(cfg, d.Writer(), instanceKey, mailer, logger)
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, fmt.Errorf("docs: build auth: %w", err)
+ }
+
h := &handlers{render: render, logger: logger, store: store}
r := chi.NewRouter()
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
// cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
// Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
- // nothing to "/" or "/d/{sqid}" and everything to "/documents".
+ // nothing to "/" or "/d/{sqid}" and everything to "/documents",
+ // "/signin" and "/signout".
r.Use(csrf.Protect(cfg.Origin))
if cfg.DevAuth {
// Guarded above: this only ever mounts when guardDevAuth has
- // already confirmed Env == "development".
+ // already confirmed Env == "development". devAuthMiddleware
+ // stamps a fixed dev identity on the request context; it is
+ // not a session, and RequireSession below neither reads nor
+ // trusts it — see devauth.go and auth.go's own doc comments.
r.Use(devAuthMiddleware)
}
- r.Get("/", h.list)
- r.Post("/documents", h.create)
- r.Get("/d/{sqid}", h.show)
+ // Unauthenticated: the sign-in page and its four handlers, and the
+ // static assets that page (and the shell around it) needs to
+ // render at all.
+ r.Get("/signin", h.signin)
+ r.Post("/signin", authPlugin.Begin)
+ r.Get("/auth/callback", authPlugin.Callback)
+ r.Get("/auth/verify", authPlugin.Verify)
+ r.Post("/signout", authPlugin.Signout)
r.Handle("/static/*", render.assets.Handler())
+ // Everything else needs a real, signed-in person — rastrillo
+ // SKILL.md §5/§1's "signed-in routes... in a Group with
+ // sess.Require", here auth's own RequireSession. A signed-out
+ // GET/HEAD redirects to /signin; anything else 403s.
+ r.Group(func(r chi.Router) {
+ r.Use(authPlugin.RequireSession)
+ r.Get("/", h.list)
+ r.Post("/documents", h.create)
+ r.Get("/d/{sqid}", h.show)
+ })
+
mux := http.NewServeMux()
mux.Handle("/", r)
- return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store}, nil
+ return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store, mailRecorder: mailRecorder}, nil
}
// Store returns the app's document store — models.go's Document,
diff --git a/internal/docs/app_test.go b/internal/docs/app_test.go
index 9042c61..71b2a16 100644
--- a/internal/docs/app_test.go
+++ b/internal/docs/app_test.go
@@ -15,7 +15,11 @@ func TestRootRendersAPage(t *testing.T) {
if err != nil {
t.Fatalf("NewForTest: %v", err)
}
+ cookies := signInCookies(t, app, "reader@example.com")
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
new file mode 100644
index 0000000..ecb0416
--- /dev/null
+++ b/internal/docs/auth.go
@@ -0,0 +1,210 @@
+// Per spec §8.1 (reframed the day this task started): a single
+// instance signs people in itself, permanently, not as a stopgap for
+// a shared home that does not exist yet — a lone instance has no
+// "one person, many teams" problem, which is the problem a shared
+// home would exist to solve. It does so with rastrillo/auth's
+// turnkey magic-link plugin (SKILL.md §5, docs/site/magic-links.md
+// in the rastrillo module): an emailed, single-use link, no password,
+// no credential enrolled.
+//
+// Deliberately magic link only, never a passkey second factor:
+// passkey.Config.Origin derives its relying party ID from this
+// instance's own host, so a person signed into three team instances
+// would enrol three separate passkeys under this app — exactly the
+// per-team credential sprawl §8.1's ruling forbids. A magic link
+// enrols no credential at all, so it creates no relying party and
+// violates nothing. Task 1's brief is explicit that this is a design
+// ruling, not an oversight to "improve" on later.
+package docs
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/mail"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+// authSchema aliases rauth.Schema — auth's own migrations, merged into
+// BootSchema (app.go) ahead of this app's own Schema and after
+// sessions.Schema, per rauth's own doc comment: its backfill migration
+// reads the sessions table, so sessions.Schema must apply first.
+var authSchema = rauth.Schema
+
+// devInstanceKey is resolveInstanceKey's fallback, used only when Env
+// is "development" and Config.InstanceKey is empty (NewForTest's
+// case). It is not a secret: it is a fixed, public string, exactly as
+// dangerous — and exactly as acceptable — as rastrillo/auth's own
+// empty-key value would be, except spelled out here instead of left
+// implicit, and it never seals anything outside a throwaway
+// development database. resolveInstanceKey refuses to fall back to it
+// for any other Env.
+const devInstanceKey = "docs-development-instance-key-not-a-secret"
+
+// resolveInstanceKey applies Config.InstanceKey's own fail-closed
+// rule (see its doc comment on Config): non-empty always wins; empty
+// is fine only in development, where it defaults to devInstanceKey;
+// anywhere else it is a startup refusal, the same shape New already
+// uses for Origin and DevAuth.
+func resolveInstanceKey(cfg Config) (string, error) {
+ key := strings.TrimSpace(cfg.InstanceKey)
+ if key != "" {
+ return key, nil
+ }
+ if cfg.Env == "development" {
+ return devInstanceKey, nil
+ }
+ return "", fmt.Errorf("docs: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $DOCS_INSTANCE_KEY", cfg.Env, "development")
+}
+
+// buildMailer applies Config.Mailer's fail-closed rule (see its own
+// doc comment): an explicit Mailer always wins; nil is fine only in
+// development, where it falls back to rastrillo/mail's logging
+// sender; anywhere else nil is a startup refusal — a sign-in that
+// cannot send its link is not sign-in, so this is the same fail-closed
+// shape New already uses for Origin and DevAuth, not merely a warning.
+//
+// In development the chosen sender is always wrapped in a
+// *recordingMailer, whether or not Config.Mailer was already set:
+// that is what lets TestSignIn (testing.go) read back the last
+// mailed link without a real mail server, keeping
+// scripts/run-dev.sh a one-command affair. The returned
+// *recordingMailer is nil outside development — nothing there needs
+// it, and it holds the plaintext of the last link sent in memory,
+// which a real deployment has no reason to carry.
+func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) {
+ sender := cfg.Mailer
+ if sender == nil {
+ if cfg.Env != "development" {
+ return nil, nil, fmt.Errorf("docs: mail is not configured (set $DOCS_SMTP_HOST and $DOCS_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in", cfg.Env, "development")
+ }
+ // Loudly labelled by mail.Logged itself on every send — see
+ // its own doc comment; this is the dev/test-only fallback,
+ // never reached outside Env == "development".
+ sender = mail.Logged(logger)
+ }
+ if cfg.Env != "development" {
+ return sender, nil, nil
+ }
+ rec := &recordingMailer{next: sender}
+ return rec, rec, nil
+}
+
+// recordingMailer wraps a mail.Sender, remembering the most recently
+// sent message's body so TestSignIn (testing.go) can read the magic
+// link back out of it — the development/test convenience buildMailer
+// installs, never wired outside Env == "development". It still
+// forwards every Send to the wrapped sender, so the ordinary
+// dev-mode behavior (the link lands in the log, clearly marked) is
+// unchanged; this only adds a second, in-memory place to find it.
+type recordingMailer struct {
+ mu sync.Mutex
+ next mail.Sender
+ lastBody string
+}
+
+func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error {
+ m.mu.Lock()
+ m.lastBody = body
+ m.mu.Unlock()
+ return m.next.Send(ctx, to, subject, body)
+}
+
+// magicLinkPattern finds the emailed link inside signin's default mail
+// body ("Open this link to sign in:\n\n<link>\n\n..." —
+// keymaildev/signin's flow.go). Anchored to the app's own callback
+// path so it cannot mistake some other URL the body might someday
+// carry for the sign-in link itself.
+var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
+
+// newAuthPlugin wires rastrillo/auth per its SKILL.md contract: one
+// *Auth built at boot (New — SKILL.md's own warning against building
+// a Flow per request, since the rate limiter's budget lives on the
+// value), Origin as both the emailed link's base and the cookie
+// decision, InstanceKey and Mailer already resolved and fail-closed
+// by resolveInstanceKey/buildMailer above. Authorize is left nil —
+// every verified address is admitted; Task 2 layers ownership on this
+// hook, not this task. SecondFactor is left nil on purpose: no
+// passkey step-up, ever, for the reason this file's own package
+// comment gives.
+func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.Sender, logger *slog.Logger) (*rauth.Auth, error) {
+ return rauth.New(rauth.Config{
+ DB: writer,
+ Origin: cfg.Origin,
+ InstanceKey: instanceKey,
+ Mailer: mailer,
+ Logger: logger,
+ })
+}
+
+// signinPage is GET /signin's view data. rauth.Auth's own handlers
+// (Begin, Callback, Verify) never render a page themselves — SKILL.md
+// §5/magic-links.md: "the sign-in page stays yours" — they report
+// outcomes by redirecting back here with a query this handler reads.
+type signinPage struct {
+ Title string
+ Body string
+ AddressLabel string
+ SubmitLabel string
+ Sent string // non-empty: a link was emailed — show it
+ ErrorMessage string // non-empty: something went wrong — show it
+}
+
+// signin serves GET /signin — the form itself, and the landing spot
+// for every outcome rauth.Auth's handlers redirect back to
+// (?sent=1, ?err=rate|address|expired|keymail|1, ?force=1). One
+// error message reads all the "something is wrong, try again" shapes:
+// rauth.Auth already refuses to be more specific than that between
+// "wrong" and "unknown" (Verify's own doc comment — telling those
+// apart would be an oracle), so there is nothing this page could add
+// by drawing finer distinctions of its own on Begin's outcomes either.
+func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ data := signinPage{
+ Title: rastrillo.T(r, "docs.signin.title"),
+ Body: rastrillo.T(r, "docs.signin.body"),
+ AddressLabel: rastrillo.T(r, "docs.signin.address.label"),
+ SubmitLabel: rastrillo.T(r, "docs.signin.submit"),
+ }
+ if q.Get("sent") == "1" {
+ data.Sent = rastrillo.T(r, "docs.signin.sent")
+ }
+ switch q.Get("err") {
+ case "":
+ // nothing to report
+ case "rate":
+ data.ErrorMessage = rastrillo.T(r, "docs.signin.error.rate")
+ case "address":
+ data.ErrorMessage = rastrillo.T(r, "docs.signin.error.address")
+ case "expired":
+ data.ErrorMessage = rastrillo.T(r, "docs.signin.error.expired")
+ default:
+ data.ErrorMessage = rastrillo.T(r, "docs.error.generic")
+ }
+ if err := h.render.page(w, r, "signin", data); err != nil {
+ h.logger.Error("docs: render signin", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ }
+}
+
+// linkPath extracts the request-target (path plus query, no
+// scheme/host) from an emailed magic link — what
+// httptest.NewRequest, and a real browser following a same-origin
+// link, both just need. It exists so TestSignIn (testing.go) does not
+// have to know the emailed link is absolute.
+func linkPath(link string) (string, error) {
+ u, err := url.Parse(link)
+ if err != nil {
+ return "", fmt.Errorf("docs: parse magic link %q: %w", link, err)
+ }
+ return u.RequestURI(), nil
+}
diff --git a/internal/docs/auth_test.go b/internal/docs/auth_test.go
new file mode 100644
index 0000000..54eacfa
--- /dev/null
+++ b/internal/docs/auth_test.go
@@ -0,0 +1,171 @@
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// TestSignInRequiredForEverything is the brief's own literal test:
+// every document route now sits behind rastrillo/auth's
+// RequireSession, so a signed-out request must redirect to sign in
+// rather than reach the handler.
+func TestSignInRequiredForEverything(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/", "/d/anything"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
+ t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code)
+ }
+ }
+}
+
+// TestMagicLinkSignsSomebodyIn is the brief's own literal test:
+// TestSignIn mints a link without sending real mail, and following it
+// must establish a real session (a cookie), not merely 200.
+func TestMagicLinkSignsSomebodyIn(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := app.TestSignIn(t, "reader@example.com")
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+ // Following the link must establish a session.
+ req := httptest.NewRequest(http.MethodGet, link, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if len(rec.Result().Cookies()) == 0 {
+ t.Fatal("following the magic link set no session cookie")
+ }
+}
+
+// TestMagicLinkThenSignedInRouteWorks proves the session the previous
+// test only checked for the shape of is actually usable: the cookie
+// following the link sets must let a subsequent request reach a
+// RequireSession route — the whole point of Task 1, not just "a
+// cookie was set".
+func TestMagicLinkThenSignedInRouteWorks(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := app.TestSignIn(t, "reader@example.com")
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+
+ verifyReq := httptest.NewRequest(http.MethodGet, link, nil)
+ verifyRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(verifyRec, verifyReq)
+ cookies := verifyRec.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Fatal("following the magic link set no session cookie")
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET / signed in: status %d, want 200", rec.Code)
+ }
+}
+
+// TestSignInPageRendersSignedOut proves the sign-in form itself is
+// reachable signed out — it has to be, or nobody could ever sign in.
+func TestSignInPageRendersSignedOut(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/signin", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /signin: status %d, want 200", rec.Code)
+ }
+}
+
+// TestSignOutEndsTheSession proves POST /signout is real revocation,
+// not merely clearing a cookie the client could ignore: the session
+// must stop working for a RequireSession route even if a client
+// somehow kept presenting the old cookie value.
+func TestSignOutEndsTheSession(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := app.TestSignIn(t, "reader@example.com")
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+ verifyReq := httptest.NewRequest(http.MethodGet, link, nil)
+ verifyRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(verifyRec, verifyReq)
+ cookies := verifyRec.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Fatal("following the magic link set no session cookie")
+ }
+
+ signoutReq := httptest.NewRequest(http.MethodPost, "/signout", nil)
+ signoutReq.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range cookies {
+ signoutReq.AddCookie(c)
+ }
+ signoutRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(signoutRec, signoutReq)
+ if signoutRec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /signout: status %d, want %d", signoutRec.Code, http.StatusSeeOther)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
+ t.Errorf("GET / with a signed-out session's old cookie: status %d, want a redirect to sign-in", rec.Code)
+ }
+}
+
+// TestNewRefusesMissingInstanceKeyOutsideDevelopment is the same
+// fail-closed shape as Origin's own startup refusal (app_test.go):
+// InstanceKey seals auth's pending-link blob, and an empty one is
+// dangerous, not merely inconvenient (rauth.Config.InstanceKey's own
+// doc comment) — so it must refuse to start rather than default
+// outside development.
+func TestNewRefusesMissingInstanceKeyOutsideDevelopment(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ Env: "production",
+ Origin: "https://docs.example.com",
+ })
+ if err == nil {
+ t.Fatal("New must refuse a missing InstanceKey outside development")
+ }
+}
+
+// TestNewRefusesMissingMailOutsideDevelopment: a sign-in that cannot
+// send its link is not sign-in — the same fail-closed shape as the
+// origin check, per the brief.
+func TestNewRefusesMissingMailOutsideDevelopment(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ Env: "production",
+ Origin: "https://docs.example.com",
+ InstanceKey: "some-real-key",
+ })
+ if err == nil {
+ t.Fatal("New must refuse to start outside development with no mail configured")
+ }
+}
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 9236f21..d3671ab 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -30,14 +30,41 @@ var shellClassVocab = map[string]bool{
"page": true,
}
+// signInCookies drives a real sign-in through app.TestSignIn
+// (docs.App, testing.go) and the magic link it mints, returning the
+// cookies that established session sets — every document route now
+// sits behind rastrillo/auth's RequireSession (auth.go), so any test
+// below that exercises "/", "/documents" or "/d/{sqid}" needs a
+// signed-in request to reach the handler at all, not just a
+// same-origin one.
+func signInCookies(t *testing.T, app *docs.App, address string) []*http.Cookie {
+ t.Helper()
+ link, err := app.TestSignIn(t, address)
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, link, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ cookies := rec.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Fatal("following the magic link set no session cookie")
+ }
+ return cookies
+}
+
func TestListShowsDocuments(t *testing.T) {
app, _ := docs.NewForTest(t)
ctx := context.Background()
if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
t.Fatal(err)
}
+ cookies := signInCookies(t, app, "reader@example.com")
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
@@ -57,7 +84,11 @@ func TestListShowsDocuments(t *testing.T) {
func TestEmptyListSaysSo(t *testing.T) {
app, _ := docs.NewForTest(t)
+ cookies := signInCookies(t, app, "reader@example.com")
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -81,11 +112,15 @@ func TestEmptyListSaysSo(t *testing.T) {
// protection.
func TestCreateThenRead(t *testing.T) {
app, _ := docs.NewForTest(t)
+ cookies := signInCookies(t, app, "reader@example.com")
form := strings.NewReader("title=Minutes")
req := httptest.NewRequest(http.MethodPost, "/documents", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", docs.DefaultOrigin)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
@@ -98,6 +133,9 @@ func TestCreateThenRead(t *testing.T) {
}
req2 := httptest.NewRequest(http.MethodGet, loc, nil)
+ for _, c := range cookies {
+ req2.AddCookie(c)
+ }
rec2 := httptest.NewRecorder()
app.Handler().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
@@ -140,7 +178,11 @@ func TestCreateWrongOriginRejected(t *testing.T) {
func TestUnknownDocument404s(t *testing.T) {
app, _ := docs.NewForTest(t)
+ cookies := signInCookies(t, app, "reader@example.com")
req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
diff --git a/internal/docs/render.go b/internal/docs/render.go
index c353638..8d67a75 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -68,6 +68,18 @@ var Catalog = rastrillo.Catalog{
// this app writes, not the caller's own input, so it is a catalog
// key rather than a hardcoded default.
"docs.document.untitled": "Untitled document",
+
+ // docs.signin.* — GET /signin (auth.go's signin handler): the
+ // form itself, and every outcome rastrillo/auth's own handlers
+ // redirect back here with (?sent=1, ?err=rate|address|expired|1).
+ "docs.signin.title": "Sign in",
+ "docs.signin.body": "Enter your email address and we'll send you a link to sign in.",
+ "docs.signin.address.label": "Email address",
+ "docs.signin.submit": "Send link",
+ "docs.signin.sent": "Check your email for a link to sign in.",
+ "docs.signin.error.rate": "Too many attempts. Wait a few minutes and try again.",
+ "docs.signin.error.address": "That doesn't look like a valid email address.",
+ "docs.signin.error.expired": "That link has expired or was already used. Enter your email below for a new one.",
}
// Locales and DefaultLocale declare this app's locale set for
@@ -92,8 +104,9 @@ var viewsFS embed.FS
// pageTemplates maps a page name to its file under views/.
var pageTemplates = map[string]string{
- "list": "views/list.html",
- "show": "views/show.html",
+ "list": "views/list.html",
+ "show": "views/show.html",
+ "signin": "views/signin.html",
}
// renderer owns the app's parsed templates and its fingerprinted
diff --git a/internal/docs/testing.go b/internal/docs/testing.go
index ad91d27..c6e052f 100644
--- a/internal/docs/testing.go
+++ b/internal/docs/testing.go
@@ -1,8 +1,13 @@
package docs
import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -35,6 +40,61 @@ func NewForTest(t *testing.T) (*App, error) {
return app, nil
}
+// TestSignIn mints a magic link for address and returns its path
+// (e.g. "/auth/verify?token=..."), suitable for
+// httptest.NewRequest(http.MethodGet, link, nil) directly. It never
+// sends real mail: it POSTs /signin with force=1 — the escape hatch
+// auth.Begin's own doc comment describes ("skips classification and
+// goes straight to the magic link") — so no outbound classification
+// call ever happens, and it reads the link back out of the app's
+// *recordingMailer (auth.go), installed only in development
+// (NewForTest's Env), rather than through any real transport. It is
+// test scaffolding, not a shortcut available in production: an App
+// built with a non-development Env has no recordingMailer and this
+// returns an error.
+//
+// Every later task's tests that need a signed-in request build on
+// this — get its shape right (a path, ready for httptest.NewRequest)
+// and address (a plain email string) is all that changes.
+func (a *App) TestSignIn(t *testing.T, address string) (string, error) {
+ t.Helper()
+
+ if a.mailRecorder == nil {
+ return "", fmt.Errorf("docs: TestSignIn requires Env %q; app was built with Env %q", "development", a.cfg.Env)
+ }
+
+ form := url.Values{"address": {address}, "force": {"1"}}
+ req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ // auth.Begin CSRF-checks this itself (its own doc comment: an
+ // unauthenticated endpoint that sends mail and makes outbound
+ // calls decided by the submitted address must never accept a
+ // cross-site form) and csrf.Protect guards it again app-wide;
+ // Sec-Fetch-Site: same-origin is exactly what a real same-origin
+ // browser POST sends and satisfies both the same way
+ // list_test.go's TestCreateThenRead does for POST /documents.
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+
+ rec := httptest.NewRecorder()
+ a.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ return "", fmt.Errorf("docs: TestSignIn: POST /signin: status %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if loc := rec.Header().Get("Location"); !strings.Contains(loc, "sent=1") {
+ return "", fmt.Errorf("docs: TestSignIn: POST /signin redirected to %q, want a %q outcome", loc, "?sent=1")
+ }
+
+ a.mailRecorder.mu.Lock()
+ body := a.mailRecorder.lastBody
+ a.mailRecorder.mu.Unlock()
+
+ link := magicLinkPattern.FindString(body)
+ if link == "" {
+ return "", fmt.Errorf("docs: TestSignIn: no magic link found in the mailed body for %q", address)
+ }
+ return linkPath(link)
+}
+
// ReadFixture loads fixtures/data/<name>.update.bin — a real Yjs
// update produced by browser yjs (fixtures/generate.mjs), never
// hand-edited. Not in a _test.go file for the same reason as
diff --git a/internal/docs/views/signin.html b/internal/docs/views/signin.html
new file mode 100644
index 0000000..8517e0e
--- /dev/null
+++ b/internal/docs/views/signin.html
@@ -0,0 +1,51 @@
+{{/* signin — GET /signin (auth.go's signin handler). This is the
+ page rastrillo/auth's own handlers never render themselves
+ (magic-links.md: "the sign-in page stays yours") — Begin,
+ Callback and Verify report every outcome by redirecting back
+ here with a query this page reads: ?sent=1 once a link has been
+ emailed, ?err=rate|address|expired|1 when something went wrong.
+ auth.go's signin handler resolves those into Sent/ErrorMessage
+ already-translated strings; this template only decides whether
+ to show them.
+
+ The form itself POSTs straight to rastrillo/auth's own Begin
+ (mounted at POST /signin, app.go) — there is no handler of this
+ app's own in that path at all. force=1: this app never offers a
+ passkey or keymail ceremony (spec §8.1's ruling — see auth.go's
+ package comment), so there is nothing for a person to choose
+ between; force always sends the plain magic link, skipping
+ signin's classification network call outright rather than
+ silently paying its latency for a decision this app never acts
+ on.
+
+ Writes the bare rst-* attribute grammar throughout, same
+ convention as list.html/show.html and the same test
+ (render_test.go's TestOurTemplatesUseTheAttributeGrammar) holds
+ it to. The "callout" partial is rastrillo's own (ui/partials);
+ invoking it here embeds none of its own markup into this file's
+ bytes at all — only the {{template}} call does, which is exactly
+ what that test walks. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Title}}</h1>
+ </div>
+</header>
+{{if .Sent}}
+{{template "callout" dict "Body" .Sent "Tone" "positive"}}
+{{end}}
+{{if .ErrorMessage}}
+{{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}}
+{{end}}
+<div rst-box>
+ <div rst-box-head>
+ <p>{{.Body}}</p>
+ </div>
+ <form method="post" action="/signin">
+ <input type="hidden" name="force" value="1">
+ <input rst-input type="email" name="address" placeholder="{{.AddressLabel}}" aria-label="{{.AddressLabel}}" autocomplete="email" required>
+ <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button>
+ </form>
+</div>
+{{end}}