Pin bouncer Scope serialization, party Activated order, lagoon migration, compile/overlap/mint/expiry/validation, guard/debounce/resolver, interactive-only, account token API and CLI on Postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
285 lines
10 KiB
Go
285 lines
10 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
type errStampStore struct {
|
|
memTokenStore
|
|
err error
|
|
}
|
|
|
|
func (s *errStampStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
|
|
if s.err != nil {
|
|
return s.err
|
|
}
|
|
return s.memTokenStore.StampLastUsed(ctx, id, now)
|
|
}
|
|
|
|
func TestBackendTokenGuard(t *testing.T) {
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{
|
|
ID: 1, Backend: true,
|
|
PermissionGrants: map[string]bool{"acme.demo.access": true, "acme.demo.reports": true},
|
|
}
|
|
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
|
|
jwt := &stubJWTGuard{}
|
|
guard := newCompositeGuard(jwt, store, users, reg)
|
|
|
|
mint := func(id uint, scopes []string, mutate func(*BackendAPIToken)) string {
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
row := &BackendAPIToken{
|
|
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
|
}
|
|
if mutate != nil {
|
|
mutate(row)
|
|
}
|
|
store.put(row)
|
|
return plain
|
|
}
|
|
|
|
auth := func(method, token string) (*bouncer.Principal, any, error) {
|
|
r := httptest.NewRequest(method, "/x", nil)
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
return guard.AuthenticateCredential(r)
|
|
}
|
|
|
|
// Well-formedness (prefix must be exact g15_; upper-case hex refused).
|
|
for _, bad := range []string{
|
|
"g15_short",
|
|
"g15_" + strings.Repeat("0", 39),
|
|
"g15_" + strings.Repeat("0", 41),
|
|
"g15_" + strings.Repeat("G", 40),
|
|
"g15_" + strings.Repeat("0", 39) + "Z",
|
|
} {
|
|
if _, _, err := auth(http.MethodGet, bad); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("malformed %q: %v", bad, err)
|
|
}
|
|
}
|
|
// Upper-case prefix is not a personal token — routes to the JWT guard.
|
|
jwt.called = false
|
|
if _, _, err := auth(http.MethodGet, "G15_"+strings.Repeat("0", 40)); err != nil || !jwt.called {
|
|
t.Fatalf("upper-case prefix should hit JWT: err=%v called=%v", err, jwt.called)
|
|
}
|
|
|
|
// Prefix routing: JWT untouched; g15_ in cookie/query never authenticates.
|
|
jwt.called = false
|
|
if _, _, err := auth(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); err != nil || !jwt.called {
|
|
t.Fatalf("jwt path: err=%v called=%v", err, jwt.called)
|
|
}
|
|
// g15_ in a cookie or query string never authenticates (Bearer only).
|
|
noJWT := newCompositeGuard(nil, store, users, reg)
|
|
r := httptest.NewRequest(http.MethodGet, "/x?token=g15_"+strings.Repeat("a", 40), nil)
|
|
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "g15_" + strings.Repeat("b", 40)})
|
|
if _, cred, err := noJWT.AuthenticateCredential(r); !errors.Is(err, ErrUnauthenticated) || cred != nil {
|
|
t.Fatalf("cookie/query g15_ must not authenticate as a token: err=%v cred=%v", err, cred)
|
|
}
|
|
|
|
plain := mint(10, []string{"demo"}, nil)
|
|
if p, cred, err := auth(http.MethodGet, plain); err != nil || p == nil || cred == nil {
|
|
t.Fatalf("ok: %v", err)
|
|
} else if _, ok := cred.(*BackendAPIToken); !ok {
|
|
t.Fatalf("credential type %T", cred)
|
|
}
|
|
|
|
// Unknown hash
|
|
if _, _, err := auth(http.MethodGet, "g15_"+strings.Repeat("c", 40)); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("unknown: %v", err)
|
|
}
|
|
|
|
// Expired / revoked
|
|
past := time.Now().UTC().Add(-time.Hour)
|
|
expired := mint(11, []string{"demo"}, func(row *BackendAPIToken) { row.ExpiresAt = &past })
|
|
if _, _, err := auth(http.MethodGet, expired); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("expired: %v", err)
|
|
}
|
|
revoked := mint(12, []string{"demo"}, func(row *BackendAPIToken) { row.RevokedAt = &past })
|
|
if _, _, err := auth(http.MethodGet, revoked); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("revoked: %v", err)
|
|
}
|
|
|
|
// Deactivated / soft-deleted owner
|
|
users.byID[1] = nil
|
|
alive := mint(13, []string{"demo"}, nil)
|
|
if _, _, err := auth(http.MethodGet, alive); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("deactivated: %v", err)
|
|
}
|
|
users.byID[1] = dev
|
|
|
|
// All-unknown scopes → 401
|
|
unknown := mint(14, []string{"nope"}, nil)
|
|
if _, _, err := auth(http.MethodGet, unknown); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("unknown scopes: %v", err)
|
|
}
|
|
|
|
// Method refusal → ErrTokenScope (403 via WriteUnauthorized)
|
|
reports := mint(15, []string{"demo:reports"}, nil)
|
|
if _, _, err := auth(http.MethodPost, reports); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("method refusal: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
guard.WriteUnauthorized(rec, newScopeError())
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("WriteUnauthorized code %d", rec.Code)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "token_scope" {
|
|
t.Fatalf("reason body=%s err=%v", rec.Body.String(), err)
|
|
}
|
|
|
|
// Stamp failure does not fail auth
|
|
errStore := &errStampStore{memTokenStore: memTokenStore{byHash: map[string]*BackendAPIToken{}}, err: errors.New("stamp boom")}
|
|
plain2, hash2, hint2, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
errStore.put(&BackendAPIToken{
|
|
ID: 99, BackendUserID: 1, Name: "t", TokenHash: hash2, TokenHint: hint2,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
|
|
})
|
|
lastStamp.Delete(uint(99))
|
|
g2 := newCompositeGuard(jwt, errStore, users, reg)
|
|
r = httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
r.Header.Set("Authorization", "Bearer "+plain2)
|
|
if p, _, err := g2.AuthenticateCredential(r); err != nil || p == nil {
|
|
t.Fatalf("stamp error path: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTokenLastUsedDebounce(t *testing.T) {
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
|
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, reg)
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.put(&BackendAPIToken{
|
|
ID: 42, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
|
|
})
|
|
lastStamp.Delete(uint(42))
|
|
store.stampN = 0
|
|
r := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
r.Header.Set("Authorization", "Bearer "+plain)
|
|
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.stampN != 1 {
|
|
t.Fatalf("within minute stamps = %d", store.stampN)
|
|
}
|
|
lastStamp.Store(uint(42), time.Now().UTC().Add(-2*time.Minute))
|
|
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.stampN != 2 {
|
|
t.Fatalf("after minute stamps = %d", store.stampN)
|
|
}
|
|
}
|
|
|
|
func TestResolveBackend(t *testing.T) {
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{
|
|
"acme.demo.access": true, "acme.demo.reports": true,
|
|
}}
|
|
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, reg)
|
|
mint := func(id uint, scopes []string) string {
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.put(&BackendAPIToken{
|
|
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
|
})
|
|
return plain
|
|
}
|
|
star := mint(1, []string{"*"})
|
|
demo := mint(2, []string{"demo"})
|
|
reports := mint(3, []string{"demo:reports"})
|
|
|
|
if _, _, err := ResolveBackend(nil, httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("no app: %v", err)
|
|
}
|
|
app := backpack.New(nil)
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demo)
|
|
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("no resolver: %v", err)
|
|
}
|
|
if err := app.Publish(&backendResolver{guard: guard}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("no bearer: %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "eyJhbGciOiJIUzI1NiJ9.e30.sig"})
|
|
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("cookie-only: %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
|
|
out, p, err := ResolveBackend(app, r, "acme.other.*")
|
|
if err != nil || p == nil || out == nil {
|
|
t.Fatalf("jwt cover ignored: %v", err)
|
|
}
|
|
if _, ok := bouncer.Credential(out.Context()); ok {
|
|
t.Fatal("jwt must not set token credential")
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+star)
|
|
if out, p, err = ResolveBackend(app, r, "anything"); err != nil || p == nil || out == nil {
|
|
t.Fatalf("full scope: %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demo)
|
|
if out, p, err = ResolveBackend(app, r, "acme.demo.*"); err != nil || p == nil {
|
|
t.Fatalf("matching cover: %v", err)
|
|
}
|
|
if _, ok := bouncer.Credential(out.Context()); !ok {
|
|
t.Fatal("token credential missing")
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demo)
|
|
if _, _, err = ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("non-matching cover: %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodPost, "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+reports)
|
|
if _, _, err = ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("method refusal: %v", err)
|
|
}
|
|
}
|