package auth import ( "net/http" "strings" "time" ) // Authenticator turns a presented token into a principal. It is the ONLY place a principal is // created, which is what keeps the API portable to the desktop client: no endpoint can grow a // dependency on cookies if no endpoint ever sees one. type Authenticator struct { Sessions SessionStore IdleTTL time.Duration // Now is injectable so expiry is testable without sleeping. Now func() time.Time // Deny writes the 401 body. Injected because the error shape belongs to the API layer // (problem+json), and auth must not depend on it. Deny http.Handler } // Require rejects anything that does not carry a live session. func (a *Authenticator) Require(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, via, ok := present(r) // No store means no session can be proven, which is a denial and not a crash: the service // is allowed to run without a database (readiness says so), and a caller who presents a // token must get the same 401 as a caller who presents none. if !ok || a.Sessions == nil { a.Deny.ServeHTTP(w, r) return } now := a.now() digest := Digest(token) s, err := a.Sessions.Lookup(r.Context(), digest, now) if err != nil { // A store failure denies exactly like an unknown token: an authenticated caller is // what a store failure cannot prove, and a distinguishable answer is an oracle. a.Deny.ServeHTTP(w, r) return } // Slide the idle window only in its second half. Sliding on every request would turn every // read into a write, and the session table is on the hot path of every call. if a.IdleTTL > 0 && s.IdleExpiresAt.Sub(now) < a.IdleTTL/2 { _ = a.Sessions.Touch(r.Context(), digest, now, a.IdleTTL) } ctx := withPrincipal(r.Context(), Principal{UserID: s.UserID, Via: via}) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (a *Authenticator) now() time.Time { if a.Now != nil { return a.Now() } return time.Now() } // present extracts the token. Bearer wins over the cookie when both arrive: an explicit credential // beats an ambient one, and it keeps a stray cookie from deciding the CSRF path for an API client. func present(r *http.Request) (token string, via Presentation, ok bool) { if h := r.Header.Get("Authorization"); h != "" { scheme, value, found := strings.Cut(h, " ") if !found || !strings.EqualFold(scheme, "Bearer") || value == "" { return "", "", false } return value, ViaBearer, true } c, err := r.Cookie(CookieName) if err != nil || c.Value == "" { return "", "", false } return c.Value, ViaCookie, true }