package auth import ( "errors" "log/slog" "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 // Cookies decides which cookie name the browser presents. Its zero value is the production // profile (__Host-). Cookies Cookies // 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 // Log receives store failures. Nil is allowed (tests), and then they are silent — which is // exactly the state PD-5 named as a defect, so production wiring passes a logger. Log *slog.Logger } // 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, a.Cookies.SessionName()) // 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. // The WIRE cannot tell the two apart; the LOG must, or an authentication outage looks // like a storm of ordinary 401s and nobody is paged (PD-5). if !errors.Is(err, ErrNoSession) { a.logf(r, "session lookup failed", err) } 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. // // The second condition is what makes the first one true. Touch clamps the new deadline with // least(now+IdleTTL, absolute_expires_at), so once the idle deadline has reached the absolute // ceiling it cannot move again — and "less than half a window left" then latches ON for the // whole last IdleTTL/2 of the session's life, turning every authenticated request into an // UPDATE on the session row and a Set-Cookie. Found by review. if a.IdleTTL > 0 && s.IdleExpiresAt.Sub(now) < a.IdleTTL/2 && s.IdleExpiresAt.Before(s.AbsoluteExpiresAt) { // A failed slide is not a failed request — the session is live either way — but it is // not nothing either: it means the session table is unwritable. if err := a.Sessions.Touch(r.Context(), digest, now, a.IdleTTL); err != nil { a.logf(r, "session touch failed", err) } else if via == ViaCookie { // The BROWSER's clock has to slide with the row's. Max-Age is written once, at login, // and nothing else re-issues the cookie: without this the cookie expires a fixed // idle-TTL after sign-in no matter how much the session was used, so a daily user is // signed out on schedule while their session row is still live, and the absolute // ceiling is never the thing that ends a session. A Bearer holder keeps its own token // and needs nothing. // // Capped at what is left of the ABSOLUTE window, which is the same rule the login // callback follows for the opposite reason: a cookie that outlives the session it // names turns every request after the ceiling into a 401 instead of a clean // signed-out state. if ttl := min(a.IdleTTL, s.AbsoluteExpiresAt.Sub(now)); ttl > 0 { a.Cookies.SetSession(w, token, ttl) } } } // The means to ASK AGAIN travels with every principal, not only with the long-lived routes: // "which route outlives its authentication" is not a fact this middleware can know, and a // route that grew a long life later would otherwise lose its revocation without anyone // editing this file. It costs one struct and asks nothing until a handler calls it. ctx := withPrincipal(r.Context(), Principal{ UserID: s.UserID, Via: via, life: SessionLife{sessions: a.Sessions, digest: digest, now: a.now}, }) next.ServeHTTP(w, r.WithContext(ctx)) }) } // logf reports a store failure. No token, no digest, no raw path: the request id correlates it. func (a *Authenticator) logf(r *http.Request, msg string, err error) { if a.Log == nil { return } a.Log.ErrorContext(r.Context(), msg, "err", err, "method", r.Method) } func (a *Authenticator) now() time.Time { if a.Now != nil { return a.Now() } return time.Now() } // Present extracts a token from a request without authenticating it. 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. // // Exported because the login flow needs the same reading to revoke the session a browser carried // into a sign-in, and a second copy of this is a second answer to "what is this request presenting". func Present(r *http.Request, cookieName string) (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 }