textmachine/platform/internal/auth/middleware.go

124 lines
5.3 KiB
Go

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)
}
}
}
ctx := withPrincipal(r.Context(), Principal{UserID: s.UserID, Via: via})
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
}