Harden API-key model: per-key scoping + revocable keys, and configurable/whitelist-aware rate limiting (#16)

* API scope and rate limiting

* improvements from review
This commit is contained in:
Thijs Herman authored and GitHub committed 2026-05-28 11:45:16 +02:00
1 parent a400e07ae2
commit 495edffde3
11 files changed
+843 -42

No files matched your search

+64 -5
View File
@@ -5,12 +5,69 @@ import (
"crypto/subtle"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
"github.com/gofiber/fiber/v2"
)
// Middleware creates an authentication middleware for API key validation
func Middleware(apiKey string) fiber.Handler {
// Scope limits which secrets a key may read, enforced server-side.
// Entries may be organization/collection names or UUIDs (resolved per-request).
// An empty scope (no organizations and no collections) grants full access.
type Scope struct {
Organizations []string
Collections []string
}
// IsEmpty reports whether the scope imposes no constraint (full access).
func (s Scope) IsEmpty() bool {
return len(s.Organizations) == 0 && len(s.Collections) == 0
}
// APIKey is a single configured key with its server-side scope.
type APIKey struct {
Name string
Key string
Scope Scope
}
// Store holds the configured API keys and resolves a presented key to its scope.
type Store struct {
keys []APIKey
}
// NewStore builds a key store from the configured keys.
func NewStore(keys []APIKey) *Store {
return &Store{keys: keys}
}
// Match returns the configured key matching the presented secret, if any.
// It compares against every key without short-circuiting so that timing does
// not reveal a key's position in the list.
func (s *Store) Match(provided string) (APIKey, bool) {
var matched APIKey
found := false
for _, k := range s.keys {
if secureCompare(provided, k.Key) {
matched = k
found = true
}
}
return matched, found
}
// ctxKey is the unexported type for the scope stored in the request context.
type ctxKey struct{}
var scopeKey ctxKey
// ScopeFromCtx returns the authenticated key's scope from the request context.
func ScopeFromCtx(c *fiber.Ctx) (Scope, bool) {
scope, ok := c.Locals(scopeKey).(Scope)
return scope, ok
}
// Middleware creates an authentication middleware that validates the bearer
// API key against the store and attaches the matched key's scope to the context.
func Middleware(store *Store) fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the Authorization header
authHeader := c.Get("Authorization")
@@ -33,14 +90,16 @@ func Middleware(apiKey string) fiber.Handler {
providedKey := parts[1]
// Use constant-time comparison to prevent timing attacks
if !secureCompare(providedKey, apiKey) {
key, ok := store.Match(providedKey)
if !ok {
logger.Warn.Printf("Invalid API key from IP: %s", c.IP())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid api key",
})
}
c.Locals(scopeKey, key.Scope)
// Authentication successful
return c.Next()
}
+145
View File
@@ -0,0 +1,145 @@
package auth
import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
const (
keyFull = "full-access-key-0000000000000000000000"
keyScoped = "scoped-key-1111111111111111111111111111"
)
func testStore() *Store {
return NewStore([]APIKey{
{Name: "full", Key: keyFull},
{Name: "dev", Key: keyScoped, Scope: Scope{Collections: []string{"Secrets - DEV"}}},
})
}
func TestStoreMatch(t *testing.T) {
t.Parallel()
store := testStore()
tests := []struct {
name string
provided string
wantOK bool
wantName string
wantScope Scope
}{
{"full access key", keyFull, true, "full", Scope{}},
{"scoped key", keyScoped, true, "dev", Scope{Collections: []string{"Secrets - DEV"}}},
{"unknown key", "nope-nope-nope-nope-nope-nope-nope-nope", false, "", Scope{}},
{"empty provided", "", false, "", Scope{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, ok := store.Match(tt.provided)
if ok != tt.wantOK {
t.Fatalf("Match ok = %v, want %v", ok, tt.wantOK)
}
if !tt.wantOK {
return
}
if got.Name != tt.wantName {
t.Errorf("Match name = %q, want %q", got.Name, tt.wantName)
}
if !reflect.DeepEqual(got.Scope, tt.wantScope) {
t.Errorf("Match scope = %+v, want %+v", got.Scope, tt.wantScope)
}
})
}
}
func TestStoreMatchEmptyStore(t *testing.T) {
t.Parallel()
if _, ok := NewStore(nil).Match(keyFull); ok {
t.Error("empty store should not match any key")
}
}
func TestScopeIsEmpty(t *testing.T) {
t.Parallel()
if !(Scope{}).IsEmpty() {
t.Error("zero scope should be empty")
}
if (Scope{Organizations: []string{"x"}}).IsEmpty() {
t.Error("scope with organizations should not be empty")
}
if (Scope{Collections: []string{"x"}}).IsEmpty() {
t.Error("scope with collections should not be empty")
}
}
func TestMiddleware(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(Middleware(testStore()))
app.Get("/", func(c *fiber.Ctx) error {
scope, ok := ScopeFromCtx(c)
if !ok {
return c.Status(fiber.StatusInternalServerError).SendString("no scope")
}
if scope.IsEmpty() {
return c.SendString("full")
}
return c.SendString("scoped")
})
tests := []struct {
name string
header string
wantStatus int
wantBody string
}{
{"missing header", "", http.StatusUnauthorized, "missing authorization header"},
{"malformed header", "Token abc", http.StatusUnauthorized, "invalid authorization header format"},
{"unknown key", "Bearer wrong-key", http.StatusUnauthorized, "invalid api key"},
{"full access key", "Bearer " + keyFull, http.StatusOK, "full"},
{"scoped key", "Bearer " + keyScoped, http.StatusOK, "scoped"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
if tt.header != "" {
req.Header.Set("Authorization", tt.header)
}
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), tt.wantBody) {
t.Errorf("body = %q, want substring %q", body, tt.wantBody)
}
})
}
}
func TestScopeFromCtxAbsent(t *testing.T) {
t.Parallel()
app := fiber.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(ctx)
if _, ok := ScopeFromCtx(ctx); ok {
t.Error("ScopeFromCtx should report false when no scope set")
}
}