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.
167 lines
5.3 KiB
Go
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
|
|
}
|