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.
10 files changed,
+684
−26
cmd/docs/main.go+31 −4go.mod+1 −0internal/docs/app.go+99 −20internal/docs/app_test.go+4 −0internal/docs/auth.go+210 −0internal/docs/auth_test.go+171 −0internal/docs/list_test.go+42 −0internal/docs/render.go+15 −2internal/docs/testing.go+60 −0internal/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}} |