init
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
db, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "pages.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// mintInto creates a real key in the database and returns its token.
|
||||
func mintInto(t *testing.T, db *store.DB, scope store.Scope, projectID *int64) (token, keyID string) {
|
||||
t.Helper()
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := &store.APIKey{
|
||||
ID: keyID,
|
||||
SecretHash: hash[:],
|
||||
Scope: scope,
|
||||
ProjectID: projectID,
|
||||
Name: "test",
|
||||
}
|
||||
if err := db.CreateKey(context.Background(), k); err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
return token, keyID
|
||||
}
|
||||
|
||||
func newVerifier(t *testing.T, db *store.DB) *Verifier {
|
||||
t.Helper()
|
||||
return NewVerifier(db, slog.New(slog.NewTextHandler(io.Discard, nil)), DefaultCacheTTL)
|
||||
}
|
||||
|
||||
func TestVerifyAdminKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
id, err := v.Verify(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if id.KeyID != keyID {
|
||||
t.Errorf("KeyID = %q, want %q", id.KeyID, keyID)
|
||||
}
|
||||
if !id.IsAdmin() {
|
||||
t.Error("admin key did not produce an admin identity")
|
||||
}
|
||||
if !id.Owns(1) || !id.Owns(999) {
|
||||
t.Error("an admin must own every project")
|
||||
}
|
||||
if id.ProjectID != nil {
|
||||
t.Errorf("admin identity carries project %v", *id.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProjectKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
p := store.DefaultProject("demo")
|
||||
if err := db.CreateProject(ctx, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := mintInto(t, db, store.ScopeProject, &p.ID)
|
||||
|
||||
id, err := v.Verify(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if id.IsAdmin() {
|
||||
t.Error("project key produced an admin identity")
|
||||
}
|
||||
if !id.Owns(p.ID) {
|
||||
t.Error("project key does not own its own project")
|
||||
}
|
||||
if id.Owns(p.ID + 1) {
|
||||
t.Error("project key owns someone else's project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
_, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A well-formed token for a key that was never created.
|
||||
otherToken, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The right key id with someone else's secret.
|
||||
strangerToken, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, wrong, err := Parse(strangerToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
want error
|
||||
}{
|
||||
{"garbage", "not-a-token", ErrMalformedToken},
|
||||
{"unknown key", otherToken, ErrUnknownKey},
|
||||
{"wrong secret", Prefix + "_" + keyID + "_" + wrong, ErrBadSecret},
|
||||
{"empty", "", ErrMalformedToken},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := v.Verify(ctx, tc.token); !errors.Is(err, tc.want) {
|
||||
t.Errorf("Verify: got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: the real token still works after all those failures.
|
||||
if _, err := v.Verify(ctx, Prefix+"_"+keyID+"_"+secret); err != nil {
|
||||
t.Errorf("valid token rejected after failed attempts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsExpiredKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour)
|
||||
if err := db.CreateKey(ctx, &store.APIKey{
|
||||
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &past,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
|
||||
t.Errorf("got %v, want ErrExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry must be re-evaluated on every call, not frozen into the cache entry,
|
||||
// or a key cached one second before it expires would stay valid for the whole
|
||||
// cache TTL.
|
||||
func TestCachedKeyStillExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
now := time.Now()
|
||||
fake := now
|
||||
var mu sync.Mutex
|
||||
v.now = func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fake
|
||||
}
|
||||
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := now.Add(30 * time.Second)
|
||||
if err := db.CreateKey(ctx, &store.APIKey{
|
||||
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &exp,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatalf("key should be valid before expiry: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
fake = now.Add(31 * time.Second) // still inside the 60s cache TTL
|
||||
mu.Unlock()
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
|
||||
t.Errorf("expired key still accepted from cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsRevokedKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("got %v, want ErrRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation has to take effect immediately in the process that performed it;
|
||||
// this both proves that and demonstrates the cache is really being consulted.
|
||||
func TestInvalidateMakesRevocationImmediate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Revoke behind the verifier's back. The cached entry is still live, so the
|
||||
// key keeps working — which is exactly what makes the next assertion mean
|
||||
// something.
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatalf("cache was not consulted (or the test is not measuring it): %v", err)
|
||||
}
|
||||
|
||||
v.Invalidate()
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("after Invalidate: got %v, want ErrRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key id must not create a cache entry: the id space is
|
||||
// attacker-chosen, so caching misses would be an unbounded memory sink.
|
||||
func TestUnknownKeysAreNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
token, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrUnknownKey) {
|
||||
t.Fatalf("got %v, want ErrUnknownKey", err)
|
||||
}
|
||||
}
|
||||
entries := 0
|
||||
v.cache.Range(func(any, any) bool { entries++; return true })
|
||||
if entries != 0 {
|
||||
t.Errorf("%d unknown key ids were cached", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// Same idea for revoked keys: a dead credential must not pin memory.
|
||||
func TestRevokedKeysAreNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries := 0
|
||||
v.cache.Range(func(any, any) bool { entries++; return true })
|
||||
if entries != 0 {
|
||||
t.Errorf("%d revoked keys were cached", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushTouches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if err := v.FlushTouches(ctx); err != nil {
|
||||
t.Errorf("flushing an empty batch: %v", err)
|
||||
}
|
||||
before, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.LastUsedAt != nil {
|
||||
t.Error("last_used_at set before any use")
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Nothing is written until the batch is flushed; that is the whole point of
|
||||
// keeping the write connection out of the request path.
|
||||
mid, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mid.LastUsedAt != nil {
|
||||
t.Error("last_used_at written per request instead of in a batch")
|
||||
}
|
||||
|
||||
if err := v.FlushTouches(ctx); err != nil {
|
||||
t.Fatalf("FlushTouches: %v", err)
|
||||
}
|
||||
after, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.LastUsedAt == nil {
|
||||
t.Fatal("last_used_at still unset after flush")
|
||||
}
|
||||
|
||||
// A flush drains the pending set, so a second one has nothing to do.
|
||||
v.mu.Lock()
|
||||
pending := len(v.touch)
|
||||
v.mu.Unlock()
|
||||
if pending != 0 {
|
||||
t.Errorf("%d pending touches survived the flush", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFlusherFlushesOnShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
v.RunFlusher(ctx, time.Hour) // never ticks; only the shutdown path runs
|
||||
}()
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
k, err := db.KeyByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k.LastUsedAt == nil {
|
||||
t.Error("shutdown flush dropped the pending timestamps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIsConcurrencySafe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
bad, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 16; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
if g%4 == 0 {
|
||||
if _, err := v.Verify(ctx, bad); err == nil {
|
||||
t.Error("bad token accepted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Errorf("good token rejected: %v", err)
|
||||
return
|
||||
}
|
||||
if i%25 == 0 {
|
||||
v.Invalidate()
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user