Ctrl K
ComunidadGuías…

internal/middleware/auth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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})
}