~bigbes/sr-ht-ecore

001f23a2bc68435fd3e9d5b7495824c6c6585072 — Eugene Blikh 9 days ago ed3a5af
login: the one decoder of the unified-login cookie

Six services decode sr.ht.unified-login.v1 independently, and each of them
makes the same five decisions: DecryptWithoutExpiration rather than a decrypt
with a service-side TTL, unmarshal into auth.AuthCookie, strip the leading '~',
treat every failure as anonymity rather than an error, and validate the name
before it reaches a path, a log line or a SQL parameter. Two of the six had
already dropped the last one.

The middleware is split into Optional and Required because that is where the
copies diverged: every donor folded its own gating policy into a single
middleware — never refuse (public clones), never 401 (visibility decided
downstream), always send the viewer to meta's login page — so one middleware
here would force most services to write the other half again.

ValidName ships as the default rather than being required: the grammar is
meta.sr.ht's and therefore instance-wide, and requiring a validator would put
the safe path behind an extra argument that the services validating nothing
today would go on not passing. WithValidator narrows it; a nil validator
restores the default, so "accept anything" has no spelling in the API.

Mirroring meta's profile into a service's own user row stays in each service:
that touches its own schema.
2 files changed, 793 insertions(+), 0 deletions(-)

A login/login.go
A login/login_test.go
A login/login.go => login/login.go +404 -0
@@ 0,0 1,404 @@
// Package login decodes the unified-login cookie of a self-hosted SourceHut
// instance into the username it names, and is the one copy of that decode for
// every custom service on it (compare, spec, dolt, cover, bench, tokens).
//
// There is exactly one session on the instance. meta.sr.ht sets one cookie,
// sr.ht.unified-login.v1, on the parent domain, sealed as a fernet token with
// the instance-wide [sr.ht] network-key — which is also why a custom service has
// to be served from under that shared domain: from anywhere else the cookie is
// simply never sent, every viewer looks anonymous, and on a service where every
// page needs an identity that means nobody can get in at all, because there is
// no second way in.
//
// One session, but six decoders. Each of the six services wrote its own, and
// each of them makes the same five decisions:
//
//  1. decrypt with crypto.DecryptWithoutExpiration and not crypto.Decrypt*;
//  2. unmarshal the payload into auth.AuthCookie and take the name;
//  3. strip the leading '~';
//  4. treat every failure as anonymity rather than as an error;
//  5. validate the name before it reaches path construction, a log line or a
//     SQL parameter.
//
// Five services deciding (1) or (5) independently is five chances for one of
// them to get it wrong invisibly — and two of the six donors had already dropped
// (5) entirely. That is the reason this is shared, and it is the same reason
// grants is: a rule on the security path that exists in six copies is a rule
// that holds in five.
//
// # Why DecryptWithoutExpiration
//
// The unified-login cookie carries no service-side TTL, and it must not be given
// one here. Its lifetime is the browser cookie's own Expires plus meta.sr.ht's
// ability to rotate the network key — both instance-wide facts. A service that
// added an expiry to the decrypt would log a viewer out of that one service on a
// schedule no sibling shares, and a viewer who is still logged in everywhere
// else does not read that as "I was logged out": they read it as "this service
// is broken". core-go's own (unexported) cookieAuth decrypts without expiration
// for the same reason, and the whole point of the shared decoder is that nobody
// has to rediscover this.
//
// A service-side TTL is right in exactly one place, and it is the opposite case:
// the short-lived Internal authorization a service seals for a service-to-service
// call, which is a credential this instance issues and can therefore bound.
//
// # Why every failure is anonymity
//
// No cookie, a forged or truncated one, a well-sealed payload that is not the
// JSON core-go writes, a payload with no name, a name that could not be a
// username — all of them return "". None returns an error, and none is logged.
//
// The reasons are indistinguishable to the viewer: every one of them means "log
// in again", and the browser has no way to act on the difference. Reporting them
// would turn a tab left open across a key rotation into a broken site rather
// than a logged-out one, and — on the services where public browsing and public
// clones must keep working without credentials — would break anonymous reads for
// everybody the moment anything about the cookie went wrong. Anonymous is the
// ordinary state of a first-time visitor; it is not a failure to be reported.
//
// They are not logged either, and that is deliberate: the cookie value is
// attacker-supplied, arrives on every request, and a warning per failed decrypt
// is a log-flood anybody on the internet can turn on.
//
// # The validator
//
// A decoded name is attacker-influenced text that goes straight on to be joined
// into a filesystem path, interpolated into a log line, put in a WHERE clause,
// and — on a miss — sent to meta.sr.ht inside a GraphQL query. The cheapest place
// to say "this could not be a username" is before any of those sees it.
//
// So this package ships a default rule, ValidName, rather than requiring one.
// The grammar is not a per-service policy: these are meta.sr.ht account names,
// the same on every service of the instance, so a per-service answer to "what
// may a username look like" is six answers to a question that has one. Requiring
// a validator would also put the safe path behind an extra argument, and the two
// donors that validate nothing at all are precisely the two that would go on
// passing nothing.
//
// WithValidator is for a service that wants to narrow further, or that already
// owns the rule (core.ValidateOwner) and wants one definition in its own tests
// as well as here. It cannot widen the rule to nothing: WithValidator(nil)
// restores the default, and there is deliberately no spelling of "accept
// anything" in this API.
//
// ValidName is a little wider than meta's registration grammar on purpose. meta
// is the authority on which names exist and this package is not; the job here is
// only to exclude what could not be a username *and* could hurt something
// downstream — a path separator, a NUL, a non-ASCII byte, a leading '-', "." and
// "..".
//
// # Optional and Required
//
// The split is the load-bearing decision in this package, and the reason the six
// copies diverged in the first place. Every donor wrote a middleware, and every
// donor folded its own gating policy into it: dolt's never refuses because
// public browsing and public clones have to work uncredentialed; compare's never
// 401s because git.sr.ht decides visibility downstream; tokens.sr.ht is the
// opposite and needs a viewer sent to meta's login page, since every page there
// is somebody's own token list. Those are three different policies over one
// decode — so shipping a single middleware here would just force two thirds of
// the services to write the other half again, which is how six copies happened.
//
// Optional resolves the identity and never refuses: it stores what it found,
// anonymous included, and calls the next handler. Required refuses through a
// deny handler the service supplies, so the refusal looks like the rest of that
// service's surface. deny is a handler and not a status code because the right
// refusal for a browser surface is usually a redirect to
// {meta}/login?return_to=..., and this package holds no configuration literal —
// it does not know meta's origin or the service's own, and chrome, which does,
// builds that URL.
//
// Install one or the other, not both: Required does everything Optional does.
//
// # What did not move here
//
// The other half of what the donors call "authn" stays in each service: turning
// the username into a local row — auth.LookupUser, the mirror of meta's profile
// into the service's own user table, the id every ownership row keys on. That
// touches each service's own schema, its own db.User, and its own answer for a
// user it has never seen, and a shared package should have no opinion about any
// of it. The line is exactly here: a name is instance-wide, a row is not.
//
// # Usage
//
//	r.Use(login.Optional())                       // public surface
//	r.Use(login.Required(s.redirectToLogin))      // surface where every page needs a viewer
//
//	username := login.FromContext(r.Context())    // "" is anonymous
//	page := svc.Page(r, "Title", username)
//
// crypto.InitCrypto must have run before any of this: the network key lives in
// that package's globals. It is not checked at request time, because there is
// nothing useful this package could do about it there — a process that skipped
// it decodes no cookie at all and every viewer is anonymous.
package login

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// CookieName is the unified-login cookie meta.sr.ht sets on the parent domain,
// and the only session any service on the instance has.
const CookieName = "sr.ht.unified-login.v1"

// MaxUsernameLen bounds a decoded name. meta.sr.ht already bounds usernames at
// registration, so this is a sanity cap on what arrives in a cookie rather than
// the authority on the question — it is here so that a name whose length alone
// makes it absurd never reaches a query or a path.
const MaxUsernameLen = 64

// Message is what Required's default refusal says. A service that renders its
// own error page should pass its own deny handler and may still want this
// sentence, so that the six services answer the same one.
const Message = "You have to be logged in to view this page."

// ValidName reports whether name could be a meta.sr.ht account name: non-empty,
// at most MaxUsernameLen bytes, ASCII letters, digits and the three separators
// meta allows ('.', '_', '-'), not beginning with '-', and neither "." nor "..".
//
// It is the default validator, and it is deliberately conservative rather than
// exact. Being wider than meta's registration grammar costs nothing — meta
// decides which names exist, and a name that passes here but belongs to nobody
// simply fails to resolve — while being narrower would log real accounts out of
// every service at once. What it must catch is the other direction: '/' and '\'
// so that a name cannot escape a repository root through filepath.Join, control
// bytes and NUL so that it cannot forge a line in a log, non-ASCII so that two
// spellings of one name cannot compare unequal in Go and equal in Postgres, a
// leading '-' so that it cannot become a flag to something exec'd, and "." /
// ".." because those are directories rather than people.
//
// It is exported so that a service can build its own rule on top of it, and so
// that a service whose own core package owns the rule can assert the two agree.
func ValidName(name string) bool {
	if name == "" || len(name) > MaxUsernameLen {
		return false
	}
	if name == "." || name == ".." {
		return false
	}
	if name[0] == '-' {
		return false
	}
	for i := 0; i < len(name); i++ {
		if !isNameByte(name[i]) {
			return false
		}
	}
	return true
}

// isNameByte reports whether c may appear in an account name.
func isNameByte(c byte) bool {
	switch {
	case c >= 'a' && c <= 'z':
		return true
	case c >= 'A' && c <= 'Z':
		return true
	case c >= '0' && c <= '9':
		return true
	case c == '.' || c == '_' || c == '-':
		return true
	default:
		return false
	}
}

// Option adjusts how a cookie is decoded. The zero set of options is the one
// every service wants; see WithValidator for the only thing there is to vary.
type Option func(*options)

// options is the resolved configuration of one decode.
type options struct {
	valid func(string) bool
}

// WithValidator supplies the service's own username rule, replacing ValidName.
//
// Use it to narrow — a service that keeps a directory per owner may want a
// tighter character set than the shared one, and a service whose core package
// already owns the rule should pass that rule rather than keep two.
//
// A nil validator restores the default rather than switching validation off.
// That is not tidiness: "no validator" is the one setting that would quietly put
// a hostile cookie payload into a path, a log line and a query, and it should
// not be reachable by passing a zero value, a nil field or a func that a
// refactor stopped assigning.
func WithValidator(valid func(string) bool) Option {
	return func(o *options) {
		if valid == nil {
			return
		}
		o.valid = valid
	}
}

// resolve applies opts over the defaults. The middlewares call it once, when
// they are built, so that a per-request path never rebuilds configuration.
func resolve(opts []Option) options {
	o := options{valid: ValidName}
	for _, opt := range opts {
		if opt != nil {
			opt(&o)
		}
	}
	return o
}

// Username decodes a unified-login cookie value into the bare username it
// carries, or "" for anything that is not a usable identity.
//
// It takes the sealed value rather than a request because not every caller has
// one: an RPC that forwards the cookie, a hook, a test. UsernameFromRequest is
// this over an *http.Request.
//
// Every failure is "" and none is an error; see the package doc for why that is
// the whole error contract of this package.
func Username(value string, opts ...Option) string {
	return decode(value, resolve(opts))
}

// UsernameFromRequest is Username over the request's cookie, returning "" when
// the header is absent — a request without our cookie is a first-time visitor,
// which is the ordinary anonymous state and not a problem.
func UsernameFromRequest(r *http.Request, opts ...Option) string {
	return fromRequest(r, resolve(opts))
}

// decode is the decode itself, against options already resolved.
func decode(value string, o options) string {
	if value == "" {
		return ""
	}

	// DecryptWithoutExpiration, never a decrypt with a TTL: see the package doc.
	payload := crypto.DecryptWithoutExpiration([]byte(value))
	if payload == nil {
		// Forged, truncated, or sealed with a key this instance no longer holds.
		// Indistinguishable from here, and all three mean "log in again".
		return ""
	}

	var claims auth.AuthCookie
	if err := json.Unmarshal(payload, &claims); err != nil {
		// Well-sealed but not the JSON core-go writes.
		return ""
	}

	// Cookies carry the bare username; strip a leading '~' defensively in case
	// something upstream stored the canonical "~user" form. Only one, and only
	// at the front: "~~x" is not a name and must not be repaired into one.
	name := strings.TrimPrefix(claims.Name, "~")
	if !o.valid(name) {
		// A name that could not be a username is not an identity. Refusing it
		// here is what keeps a hostile payload out of path construction, log
		// lines and SQL parameters alike.
		return ""
	}
	return name
}

// fromRequest is UsernameFromRequest against options already resolved.
func fromRequest(r *http.Request, o options) string {
	cookie, err := r.Cookie(CookieName)
	if err != nil {
		return ""
	}
	return decode(cookie.Value, o)
}

// ctxKey is this package's private context key. A struct{} rather than an int
// so that no other package can collide with it even by accident.
type ctxKey struct{}

// NewContext returns a copy of ctx carrying username. Optional and Required call
// it; it is exported for the callers that resolved an identity some other way —
// an RPC that was handed a cookie value, a test that wants a request as if it
// had come through the middleware.
func NewContext(ctx context.Context, username string) context.Context {
	return context.WithValue(ctx, ctxKey{}, username)
}

// FromContext returns the username stored by Optional, Required or NewContext,
// and "" when there is none.
//
// "" means anonymous, and it also means "no middleware ran". They are the same
// answer on purpose: neither request has an identity this process can show, and
// distinguishing them would invite a handler to treat a missing middleware as a
// special case — which is a fail-open branch waiting to be written.
func FromContext(ctx context.Context) string {
	username, _ := ctx.Value(ctxKey{}).(string)
	return username
}

// Optional resolves the viewer and never refuses: it stores the identity —
// anonymous included — in the request context and calls next.
//
// This is the middleware for a surface where anonymous is a legitimate viewer:
// public repositories, public clones, anything a bookmark or a probe has to keep
// reaching. Handlers read the answer with FromContext and decide for themselves
// what an empty username may see.
//
// The value is stored even when it is empty, so that a handler behind this
// middleware always reads a resolved answer rather than "nobody looked yet".
//
// The returned value has the shape every net/http middleware chain expects,
// including chi's Use.
func Optional(opts ...Option) func(http.Handler) http.Handler {
	o := resolve(opts)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			username := fromRequest(r, o)
			next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), username)))
		})
	}
}

// Required resolves the viewer and refuses an anonymous one through deny.
//
// It is Optional for a surface where every page belongs to somebody — a token
// list, a settings page, a service whose whole UI is the viewer's own — and the
// gating is a parameter rather than a policy of this package precisely because
// the six donors each had a different one.
//
// deny renders the refusal. For a browser surface that is nearly always a 302 to
// {meta}/login?return_to=<this page>, which is why deny is a handler: the URL is
// built from configuration this package deliberately does not hold. A nil deny
// answers a plain 401 carrying Message — a usable floor so that a missing
// handler is not a nil dereference on the login path, not an invitation to leave
// it nil on a surface humans use.
//
// A request that gets through carries its identity in the context exactly as
// under Optional, so handlers behind either middleware read FromContext and
// nothing else. Install one or the other, not both.
func Required(deny http.HandlerFunc, opts ...Option) func(http.Handler) http.Handler {
	o := resolve(opts)
	if deny == nil {
		deny = denyPlain
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			username := fromRequest(r, o)
			if username == "" {
				deny(w, r)
				return
			}
			next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), username)))
		})
	}
}

// denyPlain is the refusal Required uses when the caller supplies none.
//
// 401 rather than 403: what is missing is a session, and the viewer's move is to
// log in. It carries no WWW-Authenticate header because there is no HTTP
// authentication scheme to name — the session is a cookie meta.sr.ht sets — and
// a browser must not be shown a basic-auth prompt for it.
func denyPlain(w http.ResponseWriter, _ *http.Request) {
	http.Error(w, Message, http.StatusUnauthorized)
}

A login/login_test.go => login/login_test.go +389 -0
@@ 0,0 1,389 @@
package login_test

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"

	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"
)

// TestMain installs the shared test keyset. Without it crypto.Encrypt and
// crypto.DecryptWithoutExpiration have no fernet key at all and every test here
// would be testing the anonymous path by accident.
func TestMain(m *testing.M) {
	ecoretest.InitCrypto()
	os.Exit(m.Run())
}

// foreignKey stands in for the network key of another instance — or of this one
// before a rotation. It is a valid fernet key that is not ecoretest.NetworkKey,
// which is the only property the wrong-key test needs.
const foreignKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="

// seal mints a cookie value the way meta.sr.ht does: the auth.AuthCookie JSON,
// fernet-sealed with the instance network key.
func seal(t *testing.T, name string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	require.NoError(t, err)
	return string(crypto.Encrypt(payload))
}

// sealRaw seals an arbitrary payload, for the cases where the ciphertext is
// sound and what is inside it is not.
func sealRaw(t *testing.T, payload string) string {
	t.Helper()
	return string(crypto.Encrypt([]byte(payload)))
}

// sealWithForeignKey mints a well-formed fernet token under a key this instance
// does not hold — the shape a cookie has after meta rotates the network key,
// which is the one "invalid" cookie a legitimate viewer meets in practice.
//
// It swaps the process-global keyset and puts it back, so tests in this package
// must not run in parallel with it. The restore is exact rather than approximate
// because ecoretest's keys are constants.
func sealWithForeignKey(t *testing.T, name string) string {
	t.Helper()
	crypto.InitCrypto(ecoretest.Config("",
		ecoretest.Set("sr.ht", "network-key", foreignKey)))
	t.Cleanup(func() { crypto.InitCrypto(ecoretest.Config("")) })

	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	require.NoError(t, err)
	value := string(crypto.Encrypt(payload))

	// The token has to be a *good* one under the foreign key, or the test using
	// it would be re-testing "garbage" and the wrong-key path would go
	// unexercised.
	require.NotNil(t, crypto.DecryptWithoutExpiration([]byte(value)))

	crypto.InitCrypto(ecoretest.Config(""))
	require.Nil(t, crypto.DecryptWithoutExpiration([]byte(value)))
	return value
}

// request builds a GET carrying the given cookie; an empty name sets no cookie
// at all, which is what a first-time visitor looks like.
func request(name, value string) *http.Request {
	r := httptest.NewRequest(http.MethodGet, "/~bigbes/thing", nil)
	if name != "" {
		r.AddCookie(&http.Cookie{Name: name, Value: value})
	}
	return r
}

// countingHandler records how many times it ran and what identity it saw. Both
// halves matter: a middleware that refuses has to not call it at all, and one
// that admits has to hand it a resolved identity.
type countingHandler struct {
	calls    int
	username string
}

func (h *countingHandler) ServeHTTP(_ http.ResponseWriter, r *http.Request) {
	h.calls++
	h.username = login.FromContext(r.Context())
}

// ---------------------------------------------------------------------------
// Username
// ---------------------------------------------------------------------------

func TestUsernameDecodesASealedCookie(t *testing.T) {
	assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes")))
}

func TestUsernameStripsTheOwnerSigil(t *testing.T) {
	// meta writes the bare name, but the canonical form turns up in stored
	// values and in hand-written fixtures, and "~bigbes" is the same person.
	assert.Equal(t, "bigbes", login.Username(seal(t, "~bigbes")))
}

func TestUsernameIsAnonymousFor(t *testing.T) {
	tests := []struct {
		name  string
		value func(t *testing.T) string
	}{
		{
			name:  "an empty value",
			value: func(*testing.T) string { return "" },
		},
		{
			name:  "a value that is not a fernet token at all",
			value: func(*testing.T) string { return "not-a-token" },
		},
		{
			name: "a tampered ciphertext",
			value: func(t *testing.T) string {
				v := []byte(seal(t, "bigbes"))
				v[len(v)/2] ^= 'A' ^ 'B' // flip one byte in the middle
				return string(v)
			},
		},
		{
			name: "a truncated ciphertext",
			value: func(t *testing.T) string {
				v := seal(t, "bigbes")
				return v[:len(v)/2]
			},
		},
		{
			name:  "a cookie sealed with a key this instance no longer holds",
			value: func(t *testing.T) string { return sealWithForeignKey(t, "bigbes") },
		},
		{
			name:  "a well-sealed payload that is not JSON",
			value: func(t *testing.T) string { return sealRaw(t, "bigbes") },
		},
		{
			name:  "a payload carrying no name",
			value: func(t *testing.T) string { return sealRaw(t, `{"other":"bigbes"}`) },
		},
		{
			name:  "a payload whose name is only the sigil",
			value: func(t *testing.T) string { return seal(t, "~") },
		},
		{
			name:  "a name holding a path separator",
			value: func(t *testing.T) string { return seal(t, "../../etc/passwd") },
		},
		{
			name:  "a name that is the parent directory",
			value: func(t *testing.T) string { return seal(t, "..") },
		},
		{
			name:  "a name starting with a dash",
			value: func(t *testing.T) string { return seal(t, "-oProxyCommand") },
		},
		{
			name:  "a name holding a NUL",
			value: func(t *testing.T) string { return seal(t, "big\x00bes") },
		},
		{
			name:  "a name holding a newline",
			value: func(t *testing.T) string { return seal(t, "bigbes\nlevel=error") },
		},
		{
			name:  "a name outside ASCII",
			value: func(t *testing.T) string { return seal(t, "bigbés") },
		},
		{
			name: "a name longer than the cap",
			value: func(t *testing.T) string {
				return seal(t, strings.Repeat("a", login.MaxUsernameLen+1))
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, "", login.Username(tt.value(t)))
		})
	}
}

func TestUsernameAcceptsTheNamesMetaIssues(t *testing.T) {
	for _, name := range []string{
		"bigbes",
		"a",
		"user_name",
		"user-name",
		"user.name",
		"CamelCase",
		"digits1234",
		strings.Repeat("a", login.MaxUsernameLen),
	} {
		t.Run(name, func(t *testing.T) {
			assert.Equal(t, name, login.Username(seal(t, name)))
		})
	}
}

// ---------------------------------------------------------------------------
// UsernameFromRequest
// ---------------------------------------------------------------------------

func TestUsernameFromRequest(t *testing.T) {
	t.Run("reads the unified-login cookie", func(t *testing.T) {
		r := request(login.CookieName, seal(t, "bigbes"))
		assert.Equal(t, "bigbes", login.UsernameFromRequest(r))
	})

	t.Run("is anonymous with no cookie at all", func(t *testing.T) {
		assert.Equal(t, "", login.UsernameFromRequest(request("", "")))
	})

	t.Run("ignores a cookie under another name", func(t *testing.T) {
		// A sound cookie under the wrong name is not our session: reading it
		// would make any cookie on the parent domain an identity.
		r := request("sr.ht.other", seal(t, "bigbes"))
		assert.Equal(t, "", login.UsernameFromRequest(r))
	})
}

// ---------------------------------------------------------------------------
// The validator
// ---------------------------------------------------------------------------

func TestWithValidator(t *testing.T) {
	onlyBigbes := func(name string) bool { return name == "bigbes" }

	t.Run("narrows the default rule", func(t *testing.T) {
		assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes"), login.WithValidator(onlyBigbes)))
		assert.Equal(t, "", login.Username(seal(t, "someone"), login.WithValidator(onlyBigbes)))
	})

	t.Run("still runs the decode before the rule", func(t *testing.T) {
		// A validator that accepts everything does not turn a broken cookie
		// into an identity: it only replaces the last of the five checks.
		everything := func(string) bool { return true }
		assert.Equal(t, "", login.Username("not-a-token", login.WithValidator(everything)))
	})

	t.Run("a nil validator restores the default rather than disabling it", func(t *testing.T) {
		assert.Equal(t, "", login.Username(seal(t, "../etc"), login.WithValidator(nil)))
		assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes"), login.WithValidator(nil)))
	})

	t.Run("applies to the request and middleware forms too", func(t *testing.T) {
		r := request(login.CookieName, seal(t, "someone"))
		assert.Equal(t, "", login.UsernameFromRequest(r, login.WithValidator(onlyBigbes)))

		next := &countingHandler{}
		login.Optional(login.WithValidator(onlyBigbes))(next).
			ServeHTTP(httptest.NewRecorder(), r)
		assert.Equal(t, 1, next.calls)
		assert.Equal(t, "", next.username)
	})
}

func TestValidName(t *testing.T) {
	assert.True(t, login.ValidName("bigbes"))
	assert.False(t, login.ValidName(""))
	assert.False(t, login.ValidName("."))
	assert.False(t, login.ValidName(".."))
	assert.False(t, login.ValidName("-lead"))
	assert.False(t, login.ValidName("a/b"))
	assert.False(t, login.ValidName(`a\b`))
	assert.False(t, login.ValidName("~bigbes")) // the sigil is stripped before this runs
	assert.False(t, login.ValidName(strings.Repeat("a", login.MaxUsernameLen+1)))
}

// ---------------------------------------------------------------------------
// Optional / Required
// ---------------------------------------------------------------------------

func TestOptional(t *testing.T) {
	t.Run("carries the identity to the handler", func(t *testing.T) {
		next := &countingHandler{}
		w := httptest.NewRecorder()
		login.Optional()(next).ServeHTTP(w, request(login.CookieName, seal(t, "bigbes")))

		assert.Equal(t, 1, next.calls)
		assert.Equal(t, "bigbes", next.username)
		assert.Equal(t, http.StatusOK, w.Code)
	})

	t.Run("lets an anonymous request through", func(t *testing.T) {
		// The whole point of Optional: public browsing and public clones must
		// keep working with no credential at all.
		next := &countingHandler{}
		w := httptest.NewRecorder()
		login.Optional()(next).ServeHTTP(w, request("", ""))

		assert.Equal(t, 1, next.calls)
		assert.Equal(t, "", next.username)
		assert.Equal(t, http.StatusOK, w.Code)
	})

	t.Run("lets an unreadable cookie through as anonymous", func(t *testing.T) {
		next := &countingHandler{}
		login.Optional()(next).ServeHTTP(httptest.NewRecorder(),
			request(login.CookieName, sealWithForeignKey(t, "bigbes")))

		assert.Equal(t, 1, next.calls)
		assert.Equal(t, "", next.username)
	})
}

func TestRequired(t *testing.T) {
	t.Run("calls deny exactly once and never the handler", func(t *testing.T) {
		denials := 0
		deny := func(w http.ResponseWriter, _ *http.Request) {
			denials++
			http.Error(w, "go and log in", http.StatusFound)
		}

		next := &countingHandler{}
		w := httptest.NewRecorder()
		login.Required(deny)(next).ServeHTTP(w, request("", ""))

		assert.Equal(t, 1, denials)
		assert.Equal(t, 0, next.calls)
		assert.Equal(t, http.StatusFound, w.Code)
	})

	t.Run("refuses a cookie the validator rejects", func(t *testing.T) {
		denials := 0
		next := &countingHandler{}
		login.Required(func(http.ResponseWriter, *http.Request) { denials++ })(next).
			ServeHTTP(httptest.NewRecorder(), request(login.CookieName, seal(t, "../etc")))

		assert.Equal(t, 1, denials)
		assert.Equal(t, 0, next.calls)
	})

	t.Run("passes an authenticated request through with its identity", func(t *testing.T) {
		denials := 0
		next := &countingHandler{}
		w := httptest.NewRecorder()
		login.Required(func(http.ResponseWriter, *http.Request) { denials++ })(next).
			ServeHTTP(w, request(login.CookieName, seal(t, "bigbes")))

		assert.Equal(t, 0, denials)
		assert.Equal(t, 1, next.calls)
		assert.Equal(t, "bigbes", next.username)
		assert.Equal(t, http.StatusOK, w.Code)
	})

	t.Run("a nil deny answers a plain 401 instead of panicking", func(t *testing.T) {
		next := &countingHandler{}
		w := httptest.NewRecorder()
		login.Required(nil)(next).ServeHTTP(w, request("", ""))

		assert.Equal(t, 0, next.calls)
		assert.Equal(t, http.StatusUnauthorized, w.Code)
		assert.Contains(t, w.Body.String(), login.Message)
	})
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

func TestContextRoundTrip(t *testing.T) {
	ctx := login.NewContext(context.Background(), "bigbes")
	assert.Equal(t, "bigbes", login.FromContext(ctx))

	// A context nothing stored an identity in reads as anonymous, which is the
	// same answer as an anonymous viewer on purpose.
	assert.Equal(t, "", login.FromContext(context.Background()))

	// An anonymous identity is stored and read back as "" rather than as a
	// missing value, so a handler behind Optional never has to tell the two
	// apart.
	assert.Equal(t, "", login.FromContext(login.NewContext(context.Background(), "")))
}