@@ 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)
+}
@@ 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(), "")))
+}