diff --git a/login/login.go b/login/login.go new file mode 100644 index 0000000000000000000000000000000000000000..ec303aa00f68c20163e53127293c19fca3eea0fa --- /dev/null +++ b/login/login.go @@ -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=, 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) +} diff --git a/login/login_test.go b/login/login_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f2d70ac9744c96b963509dbafa2d675f07e60253 --- /dev/null +++ b/login/login_test.go @@ -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(), ""))) +}