// Package auth answers one question for every API request: who is asking? // // Until now Petal ran as a single hardcoded user and every query passed // db.LocalUserID directly. That made the identity of the caller a compile-time // constant scattered across ~35 call sites — nothing a real login could ever // replace without touching all of them. This package moves that identity into // the request context, resolved once by [Middleware], so handlers read the // current user instead of naming one. // // The identity itself still comes from [StaticResolver] today, which returns // the same local user for everyone. Swapping in Authentik later means writing // one Resolver (validate the session cookie → user id) and changing the single // line in main.go that constructs it. No handler changes. package auth import ( "context" "net/http" "gitea.parodia.dev/drwily/petal/internal/httputil" ) // ctxKey is unexported so no other package can plant a user id in the context // without going through [WithUser]. type ctxKey struct{} // WithUser returns a copy of ctx carrying userID as the authenticated caller. // Handlers never call this; [Middleware] does, and tests use it to build a // request that looks authenticated. func WithUser(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, ctxKey{}, userID) } // UserID returns the authenticated user id carried by ctx, or "" if the request // never passed through [Middleware]. // // Returning "" rather than panicking keeps an unauthenticated request failing // *closed*: every query in Petal is scoped `WHERE user_id = ?`, so an empty id // matches no rows — a missing middleware leaks nothing, it just returns empty // results. Handlers may therefore use the value directly without checking it. func UserID(ctx context.Context) string { id, _ := ctx.Value(ctxKey{}).(string) return id } // Resolver maps an inbound request to the id of the user making it. Returning // an error, or an empty id, rejects the request with a 401. // // This is the seam a real identity provider drops into: an Authentik resolver // validates the session cookie and returns the user id it maps to. type Resolver interface { Resolve(r *http.Request) (string, error) } // StaticResolver resolves every request to the same user id, ignoring the // request entirely. It is how Petal runs today — a single-user app whose one // user now arrives through the same path a logged-in user eventually will. type StaticResolver string // Resolve implements [Resolver]. func (s StaticResolver) Resolve(*http.Request) (string, error) { return string(s), nil } // Middleware resolves the caller with res and stores the result in the request // context for [UserID]. Requests the resolver rejects — or resolves to an empty // id — never reach the handler; they get a 401 instead. func Middleware(res Resolver) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, err := res.Resolve(r) if err != nil || userID == "" { httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") return } next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), userID))) }) } }