app: a development-only identity, guarded so it cannot ship
Spec §8.1 puts sign-in at the home's credential origin and the home does not exist yet. This refuses to start outside development rather than silently authenticating everyone, and it is deleted when assertion verification lands. - devauth.go: guardDevAuth is the single choke point every path to the fixed dev identity passes through (New itself, not just NewWithDevAuth), so DevAuth=true with any Env other than exactly "development" refuses with a named reason. Fail-closed: New already defaults an empty Env to "production" before the guard runs, and there is no allowlist to fall out of loosely, so a typo refuses identically to a deliberate "production". A loud slog.Warn fires on every boot where it does activate. - cmd/docs/main.go: wires this behind an explicit -dev-auth flag, stripped out of os.Args before rastrillo.Resolve parses argv (its own flag.FlagSet errors on unknown flags otherwise). No flags means Env "production", DevAuth false — unchanged from before this task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
4 files changed,
+239
−13
cmd/docs/main.go+44 −4internal/docs/app.go+34 −9internal/docs/devauth.go+81 −0internal/docs/devauth_test.go+80 −0
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 187b208..ad9f739 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -10,9 +10,34 @@ import ( |
| "github.com/carlosframework/docs/internal/docs" |
| ) |
| +// extractDevAuthFlag pulls a bare "-dev-auth"/"--dev-auth" token out |
| +// of args, returning whether it was present and everything else |
| +// unchanged (order preserved). rastrillo.Resolve parses os.Args[1:] |
| +// itself, with its own flag.FlagSet that knows only -socket/-addr/-db |
| +// and errors on anything else — so -dev-auth must be stripped out of |
| +// os.Args before Resolve ever sees it, or Resolve fails to start |
| +// outright rather than merely refusing dev auth. This is presence-only |
| +// (no "-dev-auth=false"): the deliberately narrow, explicit shape the |
| +// brief asks for — no config default, no env var, nothing a systemd |
| +// unit's boilerplate could set without someone typing this literal |
| +// flag. |
| +func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| + for _, a := range args { |
| + if a == "-dev-auth" || a == "--dev-auth" { |
| + enabled = true |
| + continue |
| + } |
| + rest = append(rest, a) |
| + } |
| + return enabled, rest |
| +} |
| + |
| func main() { |
| logger := slog.Default() |
| + devAuth, rest := extractDevAuthFlag(os.Args[1:]) |
| + os.Args = append(os.Args[:1], rest...) |
| + |
| // Resolve applies the platform's activation argv (-socket/-addr/-db, |
| // or LISTEN_FDS/$STATE_DIRECTORY under the platform) — never |
| // hand-rolled, per SKILL.md §1. |
| @@ -43,14 +68,29 @@ func main() { |
| // — otherwise Serve would open a second connection to the file New |
| // already owns. |
| // |
| - // Env is "production" until Task 5's devauth.go lands and this |
| - // reads a flag instead. |
| - app, err := docs.New(docs.Config{ |
| + // Env is "production" unless -dev-auth was given on the command |
| + // line, in which case NewWithDevAuth both sets Env to |
| + // "development" and asks for the development-only identity — the |
| + // only way any of this ever turns on is someone spelling that flag |
| + // 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, |
| - }) |
| + } |
| + var app *docs.App |
| + if devAuth { |
| + // NewWithDevAuth itself forces DevAuth true; Env must be set |
| + // to "development" here too — its guard checks both, and |
| + // leaving Env at "production" would make -dev-auth refuse to |
| + // start rather than silently doing nothing. |
| + cfg.Env = "development" |
| + app, err = docs.NewWithDevAuth(cfg) |
| + } else { |
| + app, err = docs.New(cfg) |
| + } |
| if err != nil { |
| logger.Error("docs: new", "err", err) |
| os.Exit(1) |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index c9c8499..f7b0a14 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -31,15 +31,17 @@ type Config struct { |
| DBPath string |
| // Env is "development" or "production" ("" defaults to |
| - // "production"). A later task's development-only identity |
| - // (devauth.go) refuses to start unless Env is "development" — see |
| - // DevAuth. |
| + // "production"). devauth.go's development-only identity refuses to |
| + // start unless Env is exactly "development" — see DevAuth. |
| Env string |
| - // DevAuth enables that development-only identity. New itself does |
| - // nothing with it yet; the field exists here — rather than being |
| - // added when devauth.go lands — so NewForTest, which four later |
| - // tasks' tests already depend on, never needs a signature change. |
| + // DevAuth enables devauth.go's fixed development identity — |
| + // New's guardDevAuth refuses to start unless Env is exactly |
| + // "development" too. Prefer NewWithDevAuth (devauth.go) over |
| + // setting this by hand; it exists as its own Config field, rather |
| + // than being threaded through only that constructor, so |
| + // NewForTest — which four later tasks' tests depend on — never |
| + // needed a signature change once devauth.go landed. |
| DevAuth bool |
| // Origin is this app's own origin (scheme://host[:port], no path) |
| @@ -87,8 +89,10 @@ var BootSchema = migrate.Merge(Schema) |
| // 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 sessions or an identity plugin — there is no signed-in route |
| -// yet; that lands with Task 5. |
| +// 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. |
| func New(cfg Config) (*App, error) { |
| logger := cfg.Logger |
| if logger == nil { |
| @@ -101,6 +105,22 @@ func New(cfg Config) (*App, error) { |
| cfg.Origin = DefaultOrigin |
| } |
| + // Checked before anything else opens — including the database — |
| + // so a misconfigured DevAuth refuses before New has any side |
| + // effect to unwind. See devauth.go: this is the single choke |
| + // point every path to the development-only identity passes |
| + // through, NewWithDevAuth and NewForTest included. |
| + if err := guardDevAuth(cfg); err != nil { |
| + return nil, err |
| + } |
| + if cfg.DevAuth { |
| + // Loud on purpose: an operator watching boot logs should |
| + // never be able to miss this. It does not make the guard |
| + // above any less necessary — logging is a tripwire, not a |
| + // control. |
| + logger.Warn("docs: development-only identity ENABLED — refuses to start outside Env=development", "env", cfg.Env) |
| + } |
| + |
| d, err := db.Open(cfg.DBPath, logger) |
| if err != nil { |
| return nil, fmt.Errorf("docs: open database: %w", err) |
| @@ -136,6 +156,11 @@ func New(cfg Config) (*App, error) { |
| // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does |
| // nothing to "/" or "/d/{sqid}" and everything to "/documents". |
| r.Use(csrf.Protect(cfg.Origin)) |
| + if cfg.DevAuth { |
| + // Guarded above: this only ever mounts when guardDevAuth has |
| + // already confirmed Env == "development". |
| + r.Use(devAuthMiddleware) |
| + } |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
| r.Get("/d/{sqid}", h.show) |
diff --git a/internal/docs/devauth.go b/internal/docs/devauth.go| new file mode 100644 |
| index 0000000..65a6694 |
| --- /dev/null |
| +++ b/internal/docs/devauth.go |
| @@ -0,0 +1,81 @@ |
| +// This file exists only because spec §8.1's home — the shared |
| +// credential origin that issues a signed identity assertion — does |
| +// not exist yet; it hands every request one fixed development |
| +// identity purely so the app is reachable in a browser, and it is |
| +// deleted the day this app verifies a real signed assertion instead. |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| +) |
| + |
| +// devSubject is the one identity devauth ever grants. Fixed rather |
| +// than configurable: a real identity is exactly what this stands in |
| +// for, so giving it knobs would just be building a worse version of |
| +// the thing it replaces. |
| +const devSubject = "dev" |
| + |
| +// DevIdentity is what devAuthMiddleware attaches to a request's |
| +// context — the shape spec §8.1's verified assertion will eventually |
| +// fill in for real. |
| +type DevIdentity struct { |
| + Subject string |
| +} |
| + |
| +type devIdentityKey struct{} |
| + |
| +// CurrentDevIdentity returns the identity devAuthMiddleware attached |
| +// to r, if any. Nothing in this app requires a signed-in viewer yet, |
| +// so no handler calls this today — it exists so a later task can ask |
| +// "is anyone signed in" without reaching into this file's internals, |
| +// and it goes away with the rest of this file once §8.1 lands. |
| +func CurrentDevIdentity(r *http.Request) (DevIdentity, bool) { |
| + id, ok := r.Context().Value(devIdentityKey{}).(DevIdentity) |
| + return id, ok |
| +} |
| + |
| +// devAuthMiddleware stamps every request with the fixed development |
| +// identity. New wires this in only after guardDevAuth has approved |
| +// cfg — see New and NewWithDevAuth below. |
| +func devAuthMiddleware(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + ctx := context.WithValue(r.Context(), devIdentityKey{}, DevIdentity{Subject: devSubject}) |
| + next.ServeHTTP(w, r.WithContext(ctx)) |
| + }) |
| +} |
| + |
| +// guardDevAuth is the single choke point every path to the |
| +// development identity must pass through — New calls it directly, so |
| +// it applies whether an app is built via NewWithDevAuth, via New |
| +// itself with DevAuth set by hand, or via NewForTest. |
| +// |
| +// Fail closed: cfg.DevAuth false is always fine (there is nothing to |
| +// guard against), but cfg.DevAuth true demands cfg.Env be exactly |
| +// "development" — no allowlist of "development-like" strings to fall |
| +// out of, so an unrecognised value, a typo, or empty (New defaults "" |
| +// to "production" before this runs) all refuse identically to a |
| +// deliberate "production". There is exactly one string that is ever |
| +// permitted. |
| +func guardDevAuth(cfg Config) error { |
| + if !cfg.DevAuth { |
| + return nil |
| + } |
| + if cfg.Env != "development" { |
| + return fmt.Errorf("docs: DevAuth requires Env %q to start development-only, insecure identity; got Env %q — refusing to start", "development", cfg.Env) |
| + } |
| + return nil |
| +} |
| + |
| +// NewWithDevAuth is New, named for the one call site that means to |
| +// opt into the development-only identity: cmd/docs/main.go's explicit |
| +// -dev-auth flag. It is not a second code path — it forces |
| +// cfg.DevAuth true and calls New unchanged, so the guard that |
| +// actually enforces "development only" (guardDevAuth, run inside New) |
| +// lives in exactly one place no matter which name a caller reaches it |
| +// through. |
| +func NewWithDevAuth(cfg Config) (*App, error) { |
| + cfg.DevAuth = true |
| + return New(cfg) |
| +} |
diff --git a/internal/docs/devauth_test.go b/internal/docs/devauth_test.go| new file mode 100644 |
| index 0000000..0d30ca4 |
| --- /dev/null |
| +++ b/internal/docs/devauth_test.go |
| @@ -0,0 +1,80 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// TestDevAuthRefusesOutsideDevelopment is the plan's own literal test: |
| +// the guard is the point of this task — a build without the dev flag |
| +// must refuse to start rather than silently authenticating everyone. |
| +func TestDevAuthRefusesOutsideDevelopment(t *testing.T) { |
| + _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"}) |
| + if err == nil { |
| + t.Fatal("dev auth must refuse to start outside development") |
| + } |
| + if !strings.Contains(err.Error(), "development") { |
| + t.Errorf("error should say why: %v", err) |
| + } |
| +} |
| + |
| +// TestDevAuthGivesAnIdentityInDevelopment is the plan's own literal |
| +// test: NewForTest fixes Env at "development" with DevAuth enabled, so |
| +// a request must not come back unauthorized. |
| +func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) { |
| + app, err := docs.NewForTest(t) // NewForTest sets Env: development |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code == http.StatusUnauthorized { |
| + t.Error("dev auth should provide an identity in development") |
| + } |
| +} |
| + |
| +// TestDevAuthRefusesEmptyEnv proves the fail-closed default: an empty |
| +// Env must not be treated as "development-like". New defaults "" to |
| +// "production" before the guard runs, so this exercises that the |
| +// default itself is refused, not just an explicit "production". |
| +func TestDevAuthRefusesEmptyEnv(t *testing.T) { |
| + _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: ""}) |
| + if err == nil { |
| + t.Fatal("dev auth must refuse to start with an empty Env") |
| + } |
| + if !strings.Contains(err.Error(), "development") { |
| + t.Errorf("error should say why: %v", err) |
| + } |
| +} |
| + |
| +// TestDevAuthRefusesTypoedEnv proves there is no allowlist to fall out |
| +// of loosely — only the exact string "development" is ever permitted, |
| +// so a near-miss (a plausible env-var typo) refuses exactly like a |
| +// deliberate "production" rather than being treated as close enough. |
| +func TestDevAuthRefusesTypoedEnv(t *testing.T) { |
| + _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "developement"}) |
| + if err == nil { |
| + t.Fatal("dev auth must refuse to start with a typo'd Env") |
| + } |
| + if !strings.Contains(err.Error(), "development") { |
| + t.Errorf("error should say why: %v", err) |
| + } |
| +} |
| + |
| +// TestDevAuthRefusesViaNewDirectly proves the guard is a single choke |
| +// point: calling New itself with DevAuth set by hand, bypassing |
| +// NewWithDevAuth entirely, refuses exactly the same way. |
| +func TestDevAuthRefusesViaNewDirectly(t *testing.T) { |
| + _, err := docs.New(docs.Config{DevAuth: true, Env: "production"}) |
| + if err == nil { |
| + t.Fatal("New itself must refuse DevAuth outside development, not just NewWithDevAuth") |
| + } |
| + if !strings.Contains(err.Error(), "development") { |
| + t.Errorf("error should say why: %v", err) |
| + } |
| +} |