internal/middleware/auth.go
Rama main · réplica de lectura del repositorio original.
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/sarathsp06/sparrow/pkg/access"
"github.com/sarathsp06/sparrow/pkg/access/httpauth"
)
// APIKeyHeader is the HTTP header used to pass the API key (or a token).
const APIKeyHeader = httpauth.APIKeyHeader
// Auth guards the /v1 API. When SPARROW_API_KEY is set, every request needs
// either that master key or a full-access access token (both work in the
// X-API-Key header or as "Authorization: Bearer"). Consumer-scoped tokens are
// refused here: they may only use the portal gateway, which pins them to their
// consumer. When SPARROW_API_KEY is unset, everything is open.
//
// Tokens belong to one tenant: a token is refused on a request for another
// tenant as if it did not exist. The master key belongs to the server and is
// accepted for every tenant.
//
// The authenticated principal is stored in the request context
// (httpauth.FromContext) so handlers can record who did what.
type Auth struct {
// Enabled is true when SPARROW_API_KEY is set.
Enabled bool
// Realm returns the realm of the tenant a request runs as; every token
// must belong to it.
Realm Realm
Authn *httpauth.Authenticator
// ExcludedPathPrefixes are never checked (health, docs, spec).
ExcludedPathPrefixes []string
}
// Realm returns the realm of the tenant a request runs as.
type Realm func(ctx context.Context) string
// NewAuth builds the /v1 guard. apiKey is SPARROW_API_KEY ("" disables auth);
// svc must already know it as a root key.
func NewAuth(apiKey string, svc *access.Service, realm Realm, excluded ...string) *Auth {
return &Auth{
Enabled: apiKey != "",
Realm: realm,
Authn: &httpauth.Authenticator{Service: svc},
ExcludedPathPrefixes: excluded,
}
}
// AnonymousName is the principal name recorded when authentication is off.
const AnonymousName = "anonymous (authentication disabled)"
// HTTPMiddleware enforces authentication on the wrapped routes.
func (a *Auth) HTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, prefix := range a.ExcludedPathPrefixes {
if strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(w, r)
return
}
}
// The portal gateway already authenticated and scoped this request.
if PortalAuthorized(r.Context()) {
next.ServeHTTP(w, r)
return
}
if !a.Enabled {
anon := access.Principal{Realm: a.Realm(r.Context()), Name: AnonymousName, Root: true}
next.ServeHTTP(w, r.WithContext(httpauth.WithPrincipal(r.Context(), anon)))
return
}
p, err := a.Authn.Authenticate(r)
if err == nil && !p.Root && p.Realm != a.Realm(r.Context()) {
err = &access.AuthError{Reason: access.ReasonInvalid}
}
if err != nil {
httpauth.WriteError(w, err)
return
}
if !p.FullAccess() {
writeJSONError(w, http.StatusForbidden, "forbidden", "consumer-scoped tokens can only call the portal API under /portal/api/")
return
}
next.ServeHTTP(w, r.WithContext(httpauth.WithPrincipal(r.Context(), p)))
})
}
// Errors returned by a PortalVerifier.
var (
// ErrPortalFullAccessToken rejects full-access tokens at the portal
// gateway: the portal needs a consumer to scope to.
ErrPortalFullAccessToken = errors.New("full-access tokens cannot use the portal API; use /v1 instead")
// ErrPortalTokenInvalid rejects a token of another tenant.
ErrPortalTokenInvalid = errors.New("invalid or expired portal token")
)
// PortalVerifier resolves a portal bearer token to the consumer it is scoped to.
type PortalVerifier func(ctx context.Context, token string) (consumer string, err error)
// NewPortalVerifier accepts consumer-scoped access tokens (minted by
// POST /v1/tokens with a consumer, or by consumer invites) of the tenant the
// request runs as.
func NewPortalVerifier(svc *access.Service, realm Realm) PortalVerifier {
return func(ctx context.Context, token string) (string, error) {
p, err := svc.Authenticate(ctx, token)
if err != nil {
return "", err
}
if p.Realm != realm(ctx) {
return "", ErrPortalTokenInvalid
}
if p.FullAccess() {
return "", ErrPortalFullAccessToken
}
return *p.Scope, nil
}
}
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code, "message": message})
}