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