package auth import ( "context" "errors" "log/slog" "net/http" "net/netip" "strconv" "strings" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/httpx" "github.com/iceBear67/simplepages/internal/store" ) type ctxKey int const identityKey ctxKey = iota // IdentityFrom returns the identity established by Authenticate. // // A handler mounted behind Authenticate can treat a false result as a // programming error: the middleware answers 401 itself and never calls through // without an identity. func IdentityFrom(ctx context.Context) (*Identity, bool) { id, ok := ctx.Value(identityKey).(*Identity) return id, ok } // ContextWithIdentity is used by tests and by handlers that authenticate out of // band; ordinary request handling gets its identity from Authenticate. func ContextWithIdentity(ctx context.Context, id *Identity) context.Context { return context.WithValue(ctx, identityKey, id) } // errUnauthorized is the single response every authentication failure produces. // Distinguishing "no such key" from "wrong secret" from "revoked" would tell an // unauthenticated caller which key ids are real. func errUnauthorized() *api.Error { return api.Errorf(api.CodeUnauthorized, "missing or invalid API token") } // Middleware carries the collaborators the auth handlers need. type Middleware struct { V *Verifier Limiter *Limiter Trusted []netip.Prefix Log *slog.Logger } // Authenticate requires a valid bearer token and puts the identity in the // request context. // // The token is read only from the Authorization header, never from a query // parameter: query strings land in proxy access logs, browser history and // Referer headers, and a credential that ends up there is a credential leaked. func (m *Middleware) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := bearerToken(r) if !ok { m.reject(w, r, errors.New("auth: no bearer token")) return } client := m.clientKey(r) if !m.Limiter.Allow(client) { if d := m.Limiter.RetryAfter(client); d > 0 { w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds()))) } httpx.WriteError(w, r, m.Log, api.Errorf(api.CodeRateLimited, "too many failed authentication attempts; slow down")) return } id, err := m.V.Verify(r.Context(), token) if err != nil { m.Limiter.Fail(client) m.reject(w, r, err) return } // The key id is the public half of the token and is safe to log; the // secret never leaves this function. httpx.LogAttr(r.Context(), "key_id", id.KeyID) httpx.LogAttr(r.Context(), "scope", string(id.Scope)) next.ServeHTTP(w, r.WithContext(ContextWithIdentity(r.Context(), id))) }) } // reject logs why authentication failed and tells the client only that it did. func (m *Middleware) reject(w http.ResponseWriter, r *http.Request, cause error) { if m.Log != nil { // cause is one of this package's sentinels or a store error. None of // them embed the token, which is what makes it safe to log at all. m.Log.Debug("authentication failed", "reason", cause, "req_id", httpx.RequestIDFrom(r.Context()), "path", r.URL.Path) } w.Header().Set("WWW-Authenticate", `Bearer realm="pages"`) httpx.WriteError(w, r, m.Log, errUnauthorized()) } // clientKey identifies the caller for rate limiting. func (m *Middleware) clientKey(r *http.Request) string { if addr, ok := httpx.ClientIP(r, m.Trusted); ok { return addr.String() } return r.RemoteAddr } // bearerToken extracts the credential from the Authorization header. The scheme // comparison is case-insensitive per RFC 7235; the token itself is not touched. func bearerToken(r *http.Request) (string, bool) { h := r.Header.Get("Authorization") if h == "" { return "", false } scheme, token, ok := strings.Cut(h, " ") if !ok || !strings.EqualFold(scheme, "Bearer") { return "", false } token = strings.TrimSpace(token) if token == "" { return "", false } return token, true } // RequireAdmin rejects identities that are not admin-scoped. func RequireAdmin(log *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id, ok := IdentityFrom(r.Context()) if !ok { httpx.WriteError(w, r, log, errUnauthorized()) return } if !id.IsAdmin() { httpx.WriteError(w, r, log, api.Errorf(api.CodeForbidden, "this operation requires an admin key")) return } next.ServeHTTP(w, r) }) } } // ProjectResolver maps a project name from the URL to its row id. // // It is an interface rather than a concrete type so this package does not // depend on the site registry, which does not exist until the serving layer is // wired up, and so tests can supply a two-line fake. type ProjectResolver interface { ResolveProject(ctx context.Context, name string) (int64, error) } // ResolverFunc adapts a function to ProjectResolver. type ResolverFunc func(ctx context.Context, name string) (int64, error) func (f ResolverFunc) ResolveProject(ctx context.Context, name string) (int64, error) { return f(ctx, name) } // RequireProject allows admins through and otherwise requires the caller's key // to belong to the project named by the {pathValue} URL wildcard. // // The comparison is on resolved row ids, never on the name string. Comparing // names would make the trust boundary depend on every handler normalising the // same way, and would break the moment two names can resolve to one project. func RequireProject(pathValue string, r ProjectResolver, log *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { id, ok := IdentityFrom(req.Context()) if !ok { httpx.WriteError(w, req, log, errUnauthorized()) return } name := req.PathValue(pathValue) if name == "" { httpx.WriteError(w, req, log, api.Errorf(api.CodeBadRequest, "missing project name")) return } projectID, err := r.ResolveProject(req.Context(), name) if err != nil { if errors.Is(err, store.ErrNotFound) { // A project-scoped key must not be able to probe which // project names exist, so an unknown name looks the same as // someone else's project. if !id.IsAdmin() { httpx.WriteError(w, req, log, forbiddenProject()) return } httpx.WriteError(w, req, log, api.Errorf(api.CodeNotFound, "no such project: %s", name)) return } httpx.WriteError(w, req, log, err) return } if !id.Owns(projectID) { httpx.WriteError(w, req, log, forbiddenProject()) return } httpx.LogAttr(req.Context(), "project", name) next.ServeHTTP(w, req) }) } } func forbiddenProject() *api.Error { return api.Errorf(api.CodeForbidden, "this key does not have access to that project") }