Files
anchorage/internal/pkg/auth/middleware.go
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

167 lines
5.3 KiB
Go

// Package auth is the thin JWT/session middleware that gates every
// anchorage API call.
//
// It covers both halves of the auth story: authentication (who are
// you — parsed Bearer token) and authorization (are you allowed — role
// + scope + org checks via Require*). The JWT cryptography itself lives
// in internal/pkg/token.
//
// The middleware extracts the Bearer token, populates a ClientContext,
// and attaches it to context.Context (huma handlers read via FromContext)
// and Fiber Locals (WebSocket handlers read via FromLocals).
package auth
import (
"context"
"errors"
"strings"
"github.com/gofiber/fiber/v2"
"anchorage/internal/pkg/ids"
"anchorage/internal/pkg/token"
)
// ClientContext describes the authenticated client attached to a request.
type ClientContext struct {
Org ids.OrgID
User ids.UserID
Role string
Scopes []string
// TokenJTI is set only for API-token auth (not session cookies).
TokenJTI *ids.TokenID
}
// contextKey is unexported so callers must round-trip through FromContext.
type contextKey struct{}
// WithClient returns ctx with cc attached.
func WithClient(ctx context.Context, cc *ClientContext) context.Context {
return context.WithValue(ctx, contextKey{}, cc)
}
// FromContext recovers the client, if any. Returns nil when unauthenticated.
func FromContext(ctx context.Context) *ClientContext {
cc, _ := ctx.Value(contextKey{}).(*ClientContext)
return cc
}
// ErrUnauthorized is returned when a request lacks or fails authentication.
var ErrUnauthorized = errors.New("auth: unauthorized")
// SessionCookieName is the cookie anchorage issues for logged-in web
// users after POST /v1/auth/session succeeds. Exported so the session
// handler and the logout handler can agree on it without a constant
// drift.
const SessionCookieName = "anchorage_session"
// BearerMiddleware validates the request's authentication token — either
// an `Authorization: Bearer <jwt>` header (API clients) or an
// `anchorage_session=<jwt>` cookie (web UI after POST /v1/auth/session).
// On success it attaches a ClientContext to both context.Context and
// Fiber Locals.
//
// Precedence: if both a bearer header and a session cookie are present,
// the header wins — API clients tend to be more deliberate and we don't
// want a stale cookie to silently shadow a freshly-minted token.
//
// Missing tokens are NOT rejected here; handlers that need auth call
// Require(ctx). Leaving unauthenticated requests un-annotated lets
// /health, /ready, /docs and /openapi.json respond without ceremony.
func BearerMiddleware(signer *token.Signer) fiber.Handler {
return func(c *fiber.Ctx) error {
raw, source, err := extractToken(c)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
if raw == "" {
return c.Next()
}
claims, err := signer.Parse(c.UserContext(), raw)
if err != nil {
// Cookie-based auth errors should clear the bad cookie so the
// browser stops sending it on every retry. Header-based auth
// errors just bubble up.
if source == sourceCookie {
c.Cookie(expiringCookie())
}
return fiber.NewError(fiber.StatusUnauthorized, "auth: "+err.Error())
}
jti, _ := ids.ParseToken(claims.ID)
cc := &ClientContext{
Org: claims.Org,
User: claims.User,
Role: claims.Role,
Scopes: claims.Scopes,
TokenJTI: &jti,
}
c.SetUserContext(WithClient(c.UserContext(), cc))
c.Locals(localsKey, cc)
return c.Next()
}
}
type tokenSource int
const (
sourceNone tokenSource = iota
sourceHeader
sourceCookie
)
// extractToken pulls a JWT off the Authorization header first, then the
// session cookie. Returns ("", sourceNone, nil) when neither is present
// so the middleware can let unauthenticated requests through.
func extractToken(c *fiber.Ctx) (string, tokenSource, error) {
if h := c.Get(fiber.HeaderAuthorization); h != "" {
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", sourceNone, errors.New("auth: malformed Authorization header")
}
return strings.TrimSpace(parts[1]), sourceHeader, nil
}
if v := c.Cookies(SessionCookieName); v != "" {
return v, sourceCookie, nil
}
return "", sourceNone, nil
}
// expiringCookie returns a Set-Cookie that immediately clears the
// session cookie on the client. Used on logout and on a bad-cookie
// 401 so stale JWTs don't keep round-tripping.
func expiringCookie() *fiber.Cookie {
return &fiber.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
MaxAge: -1,
}
}
// localsKey is the Fiber Locals key where BearerMiddleware parks the
// authenticated ClientContext for downstream handlers that can't reach
// UserContext — notably the /v1/events WebSocket upgrade.
const localsKey = "anchorage.auth.client"
// FromLocals recovers a ClientContext stored on a Fiber request by
// BearerMiddleware. Returns nil when unauthenticated.
func FromLocals(locals func(key string) interface{}) *ClientContext {
v := locals(localsKey)
cc, _ := v.(*ClientContext)
return cc
}
// Require asserts that the request is authenticated. Handlers call this
// as the first line of their body.
func Require(ctx context.Context) (*ClientContext, error) {
cc := FromContext(ctx)
if cc == nil {
return nil, ErrUnauthorized
}
return cc, nil
}