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}, }) 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}, }) 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) } guard.stamps.last.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) } } // TestTokenLastUsedThrottlePerGuard pins that the last_used_at debounce // belongs to the guard: two guards over separate stores that both hold token // ID 7 (fresh databases restart IDs) each stamp once. func TestTokenLastUsedThrottlePerGuard(t *testing.T) { reg := &Registry{tokenScopes: tracerCatalog()} dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}} users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}} newSide := func() (*memTokenStore, *compositeGuard, *http.Request) { store := &memTokenStore{byHash: map[string]*BackendAPIToken{}} plain, hash, hint, err := mintAPIToken() if err != nil { t.Fatal(err) } store.put(&BackendAPIToken{ ID: 7, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint, Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true}, }) r := httptest.NewRequest(http.MethodGet, "/x", nil) r.Header.Set("Authorization", "Bearer "+plain) return store, newCompositeGuard(&stubJWTGuard{}, store, users, reg), r } storeA, guardA, reqA := newSide() storeB, guardB, reqB := newSide() if _, _, err := guardA.AuthenticateCredential(reqA); err != nil { t.Fatal(err) } if _, _, err := guardB.AuthenticateCredential(reqB); err != nil { t.Fatal(err) } if storeA.stampN != 1 || storeB.stampN != 1 { t.Fatalf("stamps A=%d B=%d, want 1 each", storeA.stampN, storeB.stampN) } if _, _, err := guardB.AuthenticateCredential(reqB); err != nil { t.Fatal(err) } if storeB.stampN != 1 { t.Fatalf("within-guard debounce: B stamps = %d, want 1", storeB.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) } }