CI / test (push) Successful in 2m34s
Until now anything that could reach the client port could use every model in the catalog. A key now resolves to an identity, and that identity decides what it may do. Authentication wraps the whole mux rather than checking inside each handler, so adding a route cannot add an unauthenticated one by omission. Health stays open, because a load balancer has no key and a health check that needs one reports the gateway down. Nothing on the request path reads the database. A key is resolved once and answered from memory until the entry expires; rejections are cached too, so a client looping with a bad key cannot turn into a database read per request. The price is that a revocation takes up to cache_ttl to take effect, which is the trade stated in the configuration next to the knob. A store failure is not an authentication answer: it is never cached as one and returns 503, not 401 — sending someone hunting for a key problem that does not exist wastes the one thing an incident is short of. A revoked key is refused with the same message as an unknown one, because saying otherwise confirms the key was real. With auth on the band comes from the key and the band header is ignored. The header was trusted only while there was nothing to consult, and it let any client promote itself into the top priority band. Tenant fairness within a band is now per key rather than per session, so one caller cannot take a band by opening many sessions. Allowlists are decided per model, not per string: a model's canonical name and every alias name the same thing, so either form works in an allowlist and no alias can reach a model that was not allowed. The first version of that test passed without the resolution being there at all, which is why it now checks all four combinations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// fixture builds a store with one org/team/project and one key.
|
|
func fixture(t *testing.T) (*store.SQLite, string, string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "t.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
must(t, s.PutOrg(ctx, store.Org{ID: "org1", Name: "Acme"}))
|
|
must(t, s.PutTeam(ctx, store.Team{ID: "team1", OrgID: "org1", Name: "R"}))
|
|
must(t, s.PutProject(ctx, store.Project{ID: "proj1", TeamID: "team1", Name: "P"}))
|
|
|
|
secret, k, err := store.NewKey("key1", "laptop", "proj1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k.CreatedAt = time.Now()
|
|
k.Band = "gold"
|
|
must(t, s.CreateKey(ctx, k))
|
|
return s, secret, k.ID
|
|
}
|
|
|
|
func must(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAValidKeyResolvesItsWholeChain(t *testing.T) {
|
|
s, secret, keyID := fixture(t)
|
|
a := New(s, DefaultConfig())
|
|
|
|
p, err := a.Authenticate(context.Background(), secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.KeyID != keyID || p.ProjectID != "proj1" || p.TeamID != "team1" || p.OrgID != "org1" {
|
|
t.Errorf("principal %+v", p)
|
|
}
|
|
if p.Band != "gold" {
|
|
t.Errorf("band %q", p.Band)
|
|
}
|
|
}
|
|
|
|
func TestRejections(t *testing.T) {
|
|
s, secret, keyID := fixture(t)
|
|
ctx := context.Background()
|
|
a := New(s, DefaultConfig())
|
|
|
|
if _, err := a.Authenticate(ctx, ""); !errors.Is(err, ErrMissing) {
|
|
t.Errorf("no credential: %v", err)
|
|
}
|
|
// A bearer token from some other system must be rejected on shape alone.
|
|
if _, err := a.Authenticate(ctx, "ghp_0123456789abcdefghij"); !errors.Is(err, ErrInvalid) {
|
|
t.Errorf("foreign token: %v", err)
|
|
}
|
|
if _, err := a.Authenticate(ctx, secret[:len(secret)-1]+"X"); !errors.Is(err, ErrInvalid) {
|
|
t.Errorf("wrong key: %v", err)
|
|
}
|
|
|
|
must(t, s.RevokeKey(ctx, keyID, time.Now()))
|
|
a.Invalidate()
|
|
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrRevoked) {
|
|
t.Errorf("revoked key: %v", err)
|
|
}
|
|
}
|
|
|
|
// countingStore counts how often the database is actually read.
|
|
type countingStore struct {
|
|
store.Store
|
|
lookups atomic.Int64
|
|
fail atomic.Bool
|
|
}
|
|
|
|
func (c *countingStore) KeyByHash(ctx context.Context, hash []byte) (store.Key, error) {
|
|
c.lookups.Add(1)
|
|
if c.fail.Load() {
|
|
return store.Key{}, errors.New("database is down")
|
|
}
|
|
return c.Store.KeyByHash(ctx, hash)
|
|
}
|
|
|
|
func TestTheRequestPathDoesNotReadTheDatabase(t *testing.T) {
|
|
// The rule the whole package is shaped around. Without the cache this is a
|
|
// database read per request, on every request.
|
|
s, secret, _ := fixture(t)
|
|
c := &countingStore{Store: s}
|
|
a := New(c, DefaultConfig())
|
|
|
|
for range 100 {
|
|
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if n := c.lookups.Load(); n != 1 {
|
|
t.Errorf("100 requests caused %d database reads", n)
|
|
}
|
|
}
|
|
|
|
func TestARejectedKeyIsNotADatabaseReadPerRequest(t *testing.T) {
|
|
// A client looping with a bad key would otherwise be a free way to load the
|
|
// database from outside.
|
|
s, _, _ := fixture(t)
|
|
c := &countingStore{Store: s}
|
|
a := New(c, DefaultConfig())
|
|
|
|
bad := store.KeyPrefix + "0000000000000000000000000000000000000000000"
|
|
for range 50 {
|
|
if _, err := a.Authenticate(context.Background(), bad); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if n := c.lookups.Load(); n != 1 {
|
|
t.Errorf("50 bad requests caused %d database reads", n)
|
|
}
|
|
}
|
|
|
|
func TestCachedDecisionsExpire(t *testing.T) {
|
|
// The cache bounds how long a revocation can take to take effect, so it has
|
|
// to actually expire.
|
|
s, secret, keyID := fixture(t)
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
a := New(s, Config{TTL: time.Second, NegativeTTL: time.Second})
|
|
a.now = func() time.Time { return now }
|
|
|
|
if _, err := a.Authenticate(ctx, secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
must(t, s.RevokeKey(ctx, keyID, now))
|
|
|
|
// Still inside the TTL: the stale answer is the point of the cache.
|
|
if _, err := a.Authenticate(ctx, secret); err != nil {
|
|
t.Errorf("the cache expired early: %v", err)
|
|
}
|
|
|
|
now = now.Add(2 * time.Second)
|
|
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrRevoked) {
|
|
t.Errorf("a revoked key still works after the TTL: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAStoreFailureIsNotAnAuthenticationAnswer(t *testing.T) {
|
|
// Two things must not happen: the failure must not be cached as a
|
|
// rejection, and it must not read to the caller as a bad key.
|
|
s, secret, _ := fixture(t)
|
|
c := &countingStore{Store: s}
|
|
a := New(c, DefaultConfig())
|
|
|
|
c.fail.Store(true)
|
|
_, err := a.Authenticate(context.Background(), secret)
|
|
if err == nil {
|
|
t.Fatal("a broken database authenticated a request")
|
|
}
|
|
if isRejection(err) {
|
|
t.Errorf("a database failure was reported as a credential problem: %v", err)
|
|
}
|
|
|
|
c.fail.Store(false)
|
|
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
|
t.Errorf("the failure was cached: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExpiryIsNotCached(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, _, _ := fixture(t)
|
|
|
|
secret, k, _ := store.NewKey("key2", "temp", "proj1")
|
|
k.CreatedAt = time.Now()
|
|
k.ExpiresAt = time.Now().Add(time.Hour)
|
|
must(t, s.CreateKey(ctx, k))
|
|
|
|
now := time.Now()
|
|
a := New(s, Config{TTL: time.Minute})
|
|
a.now = func() time.Time { return now }
|
|
|
|
if _, err := a.Authenticate(ctx, secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Past the key's expiry but inside the cache TTL. The cached decision is
|
|
// allowed to be stale by at most the TTL, which is the documented trade.
|
|
now = now.Add(2 * time.Hour)
|
|
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrExpired) {
|
|
t.Errorf("an expired key authenticated: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAuthentication(t *testing.T) {
|
|
s, secret, _ := fixture(t)
|
|
a := New(s, DefaultConfig())
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 64)
|
|
for range 64 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMiddlewareGuardsEveryRouteButTheExemptOnes(t *testing.T) {
|
|
s, secret, _ := fixture(t)
|
|
a := New(s, DefaultConfig())
|
|
|
|
var reached atomic.Int64
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached.Add(1)
|
|
if _, ok := FromContext(r.Context()); !ok && r.URL.Path != "/health" {
|
|
t.Error("an authenticated request carried no principal")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
srv := httptest.NewServer(Middleware(a, nil, "/health")(inner))
|
|
defer srv.Close()
|
|
|
|
get := func(path, token string) int {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil) //nolint:noctx // test
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
if code := get("/health", ""); code != http.StatusOK {
|
|
t.Errorf("health needs no key, got %d", code)
|
|
}
|
|
if code := get("/v1/models", ""); code != http.StatusUnauthorized {
|
|
t.Errorf("an unauthenticated request got %d", code)
|
|
}
|
|
if code := get("/v1/models", "sk-llmgw-bogus"); code != http.StatusUnauthorized {
|
|
t.Errorf("a bad key got %d", code)
|
|
}
|
|
if code := get("/v1/models", secret); code != http.StatusOK {
|
|
t.Errorf("a good key got %d", code)
|
|
}
|
|
if n := reached.Load(); n != 2 {
|
|
t.Errorf("the handler was reached %d times; only health and the valid key should pass", n)
|
|
}
|
|
}
|
|
|
|
func TestARevokedKeyLooksExactlyLikeAnUnknownOne(t *testing.T) {
|
|
// Telling a caller that a key exists but was revoked confirms the key was
|
|
// real, which is information worth withholding.
|
|
s, secret, keyID := fixture(t)
|
|
must(t, s.RevokeKey(context.Background(), keyID, time.Now()))
|
|
|
|
a := New(s, DefaultConfig())
|
|
srv := httptest.NewServer(Middleware(a, nil)(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })))
|
|
defer srv.Close()
|
|
|
|
body := func(token string) string {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) //nolint:noctx // test
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var e struct {
|
|
Error struct{ Message string } `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
|
return e.Error.Message
|
|
}
|
|
|
|
unknown := store.KeyPrefix + "0000000000000000000000000000000000000000000"
|
|
if body(secret) != body(unknown) {
|
|
t.Errorf("revoked says %q, unknown says %q", body(secret), body(unknown))
|
|
}
|
|
}
|
|
|
|
func TestAStoreFailureDoesNotReadAsABadKey(t *testing.T) {
|
|
s, secret, _ := fixture(t)
|
|
c := &countingStore{Store: s}
|
|
c.fail.Store(true)
|
|
a := New(c, DefaultConfig())
|
|
|
|
srv := httptest.NewServer(Middleware(a, nil)(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })))
|
|
defer srv.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) //nolint:noctx // test
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
// 503 rather than 401: sending someone hunting for a key problem that does
|
|
// not exist wastes the one thing an incident is short of.
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestBearerTokenAcceptsWhatClientsActuallySend(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Bearer sk-llmgw-abc": "sk-llmgw-abc",
|
|
"sk-llmgw-abc": "sk-llmgw-abc",
|
|
"Bearer sk-llmgw-abc": "sk-llmgw-abc",
|
|
"": "",
|
|
}
|
|
for header, want := range cases {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if header != "" {
|
|
r.Header.Set("Authorization", header)
|
|
}
|
|
if got := BearerToken(r); got != want {
|
|
t.Errorf("Authorization %q -> %q, want %q", header, got, want)
|
|
}
|
|
}
|
|
|
|
// Some tools only manage to set this one.
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("api-key", "sk-llmgw-xyz")
|
|
if got := BearerToken(r); got != "sk-llmgw-xyz" {
|
|
t.Errorf("api-key header -> %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLastUsedIsBatchedNotWrittenPerRequest(t *testing.T) {
|
|
s, secret, keyID := fixture(t)
|
|
ctx := context.Background()
|
|
a := New(s, Config{TTL: time.Minute, TouchInterval: time.Hour})
|
|
|
|
for range 20 {
|
|
if _, err := a.Authenticate(ctx, secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Nothing written yet: the field is not read in real time.
|
|
k, _ := s.KeyByHash(ctx, store.HashKey(secret))
|
|
if !k.LastUsedAt.IsZero() {
|
|
t.Error("last-used was written on the request path")
|
|
}
|
|
|
|
must(t, a.FlushTouches(ctx))
|
|
k, _ = s.KeyByHash(ctx, store.HashKey(secret))
|
|
if k.LastUsedAt.IsZero() {
|
|
t.Error("last-used was never written")
|
|
}
|
|
if k.ID != keyID {
|
|
t.Errorf("wrong key touched: %s", k.ID)
|
|
}
|
|
}
|