// 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) }