package bearer import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "testing/fstest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" ) // The identity this validator claims when it calls the daemon — the calling // service, not tokens.sr.ht. const ( callerClientID = "bench.sr.ht" callerNodeID = "bench-1" ) // TestMain initialises the process-global core-go crypto state this package // depends on and never sets up itself. // // Both keys are load-bearing here and for different steps. [webhooks]private-key // is what auth.BearerToken.Encode signs with and what step 1 verifies against — // it is derived into the HMAC key, not used directly. [sr.ht]network-key is the // fernet key that seals the Internal authorization of step 4, so without it the // revocation tests would not get past the request. The values are the ones // core-go's own tests use. func TestMain(m *testing.M) { config.FS = fstest.MapFS{ "config.ini": &fstest.MapFile{Data: []byte(` [webhooks] private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc= [sr.ht] network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk= `)}, } crypto.InitCrypto(config.LoadConfig()) m.Run() } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- // seal mints a token exactly the way tokens.sr.ht's service.seal does, so that // what these tests present is byte-for-byte the shape a real one has. func seal(username, clientID, grantString string, expires time.Time) string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(expires), Grants: grantString, ClientID: clientID, Username: username, } return bt.Encode() } // ourToken is a live working token from this instance's daemon. func ourToken(grantString string) string { return seal("bigbes", TokensClientID, grantString, time.Now().Add(time.Hour)) } // daemon is a stand-in for tokens.sr.ht's revocation endpoint. It records every // request so a test can assert not only what came back but whether anything was // asked at all — which for step 4 is half of what there is to check. // // statuses are consumed one per request, and the last one repeats forever, so // "fail once then recover" is written as newDaemon(t, 500, 204). type daemon struct { server *httptest.Server mu sync.Mutex statuses []int hits int paths []string auths []string } func newDaemon(t *testing.T, statuses ...int) *daemon { t.Helper() require.NotEmpty(t, statuses, "a daemon must be told what to answer") d := &daemon{statuses: statuses} d.server = httptest.NewServer(http.HandlerFunc(d.serve)) t.Cleanup(d.server.Close) return d } func (d *daemon) serve(w http.ResponseWriter, r *http.Request) { d.mu.Lock() defer d.mu.Unlock() d.paths = append(d.paths, r.URL.Path) d.auths = append(d.auths, r.Header.Get("Authorization")) status := d.statuses[len(d.statuses)-1] if d.hits < len(d.statuses) { status = d.statuses[d.hits] } d.hits++ w.WriteHeader(status) } func (d *daemon) count() int { d.mu.Lock() defer d.mu.Unlock() return d.hits } func (d *daemon) lastPath(t *testing.T) string { t.Helper() d.mu.Lock() defer d.mu.Unlock() require.NotEmpty(t, d.paths, "the daemon was never asked anything") return d.paths[len(d.paths)-1] } func (d *daemon) lastAuth(t *testing.T) string { t.Helper() d.mu.Lock() defer d.mu.Unlock() require.NotEmpty(t, d.auths, "the daemon was never asked anything") return d.auths[len(d.auths)-1] } // fakeClock drives the cache's ageing. It does not drive step 1's expiry check: // auth.DecodeBearerToken reads the real clock and nothing here can reach it. type fakeClock struct { mu sync.Mutex t time.Time } func newClock() *fakeClock { return &fakeClock{t: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)} } func (c *fakeClock) now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *fakeClock) advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.t = c.t.Add(d) } func newValidator(t *testing.T, origin string, now func() time.Time) *Validator { t.Helper() v, err := New(Options{ Origin: origin, ClientID: callerClientID, NodeID: callerNodeID, Now: now, }) require.NoError(t, err) return v } // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- func TestNewRefusesOptionsThatWouldOnlyFailLater(t *testing.T) { good := Options{Origin: "https://tokens.srht.bigb.es", ClientID: callerClientID, NodeID: callerNodeID} for name, mutate := range map[string]func(*Options){ "no origin": func(o *Options) { o.Origin = "" }, "no scheme": func(o *Options) { o.Origin = "tokens.srht.bigb.es" }, "wrong scheme": func(o *Options) { o.Origin = "ftp://tokens.srht.bigb.es" }, "no host": func(o *Options) { o.Origin = "https://" }, "no client id": func(o *Options) { o.ClientID = "" }, "no node id": func(o *Options) { o.NodeID = "" }, "negative ttl": func(o *Options) { o.CacheTTL = -time.Second }, "unparseable url": func(o *Options) { o.Origin = "https://%zz" }, } { t.Run(name, func(t *testing.T) { opts := good mutate(&opts) v, err := New(opts) require.Error(t, err, "a config mistake must not become a request-time 503") assert.Nil(t, v) }) } } func TestNewFillsTheDefaults(t *testing.T) { v, err := New(Options{Origin: "https://tokens.srht.bigb.es/", ClientID: callerClientID, NodeID: callerNodeID}) require.NoError(t, err) assert.Equal(t, DefaultCacheTTL, v.ttl, "a zero TTL means the 60s of SPEC ch. 6") assert.NotNil(t, v.client) assert.Equal(t, defaultHTTPTimeout, v.client.Timeout, "the default client must not be able to hang") assert.NotNil(t, v.now) assert.Equal(t, "https://tokens.srht.bigb.es", v.origin, "a trailing slash must not become a double slash in the path") } // --------------------------------------------------------------------------- // Step 1: decode and verify // --------------------------------------------------------------------------- func TestTamperedTokenIsInvalid(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) good := ourToken("bench:upload id:42") // Flip one character of the base64. The payload still decodes; the HMAC does // not verify, which is the whole of what makes the format worth anything. tampered := []byte(good) if tampered[3] == 'A' { tampered[3] = 'B' } else { tampered[3] = 'A' } for name, presented := range map[string]string{ "tampered": string(tampered), "empty": "", "not base64": "!!!not-a-token!!!", "too short": "aGVsbG8", "grants only": "bench:upload", } { t.Run(name, func(t *testing.T) { tok, err := v.Validate(context.Background(), presented, "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err) assert.Nil(t, tok, "a token that failed validation must not be handed back") }) } assert.Zero(t, d.count(), "nothing that fails to verify may become a request to the daemon") } // The expiry is checked by auth.DecodeBearerToken against the real clock, which // is why step 1 needs no network and no clock of ours. func TestExpiredTokenIsInvalidWithoutAskingAnybody(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) expired := seal("bigbes", TokensClientID, "bench:upload id:42", time.Now().Add(-time.Minute)) tok, err := v.Validate(context.Background(), expired, "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err) assert.Nil(t, tok) assert.Zero(t, d.count(), "an expired token is refused locally, before any network") } // --------------------------------------------------------------------------- // Step 2: is it ours? // --------------------------------------------------------------------------- // A meta.sr.ht PAT is signed with the same key and verifies perfectly. Only the // ClientID tells the two apart, and what to do about it is the service's policy // — so this returns the token along with the refusal. func TestForeignClientIDIsNotOursAndComesBackWithTheToken(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) expires := time.Now().Add(time.Hour) // Meta's grant vocabulary, not ours: this string does not parse under // grants.Parse, so a validator that checked ClientID after parsing would // answer ErrInvalid here and a service like dolt would lose the ability to // tell a foreign token from a broken one. pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", expires) tok, err := v.Validate(context.Background(), pat, "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err) require.NotNil(t, tok, "the caller has to be able to look at the token it may still accept") assert.Equal(t, "bigbes", tok.Username) assert.Equal(t, expires.UTC().Truncate(time.Second), tok.Expires) assert.True(t, tok.Grants.Empty(), "a foreign grant string is in a foreign vocabulary; this one must admit nothing") assert.Zero(t, tok.TokenID) assert.Zero(t, d.count(), "a token that is not ours has no revocation of ours to check") } // --------------------------------------------------------------------------- // Step 3: the grant // --------------------------------------------------------------------------- func TestMissingGrantIsForbiddenAndStopsBeforeTheNetwork(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) // Registered, so step 4 would have work to do — and must not get the chance. tok, err := v.Validate(context.Background(), ourToken("bench:read id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err) assert.Nil(t, tok) assert.Zero(t, d.count(), "step 3 refuses locally; a token that cannot do the thing must not cost a round trip") } func TestUniversalGrantAdmitsTheAction(t *testing.T) { v := newValidator(t, "https://tokens.srht.bigb.es", nil) tok, err := v.Validate(context.Background(), ourToken("*"), "bench:upload") require.NoError(t, err) assert.True(t, tok.Grants.All()) } // --------------------------------------------------------------------------- // Step 4: revocation // --------------------------------------------------------------------------- // The common case: a short token was never written down, so there is nothing to // ask and nobody to ask it of. This is the property that keeps tokens.sr.ht off // the hot path (SPEC ch. 1), so the assertion that matters is the request count. func TestStatelessTokenNeverTouchesTheNetwork(t *testing.T) { d := newDaemon(t, http.StatusNotFound) // would refuse, if it were ever asked v := newValidator(t, d.server.URL, nil) tok, err := v.Validate(context.Background(), ourToken("bench:upload cover:read"), "bench:upload") require.NoError(t, err) assert.Equal(t, "bigbes", tok.Username) assert.Zero(t, tok.TokenID) assert.False(t, tok.Registered()) assert.True(t, tok.Grants.Has("cover:read")) assert.Zero(t, d.count(), "a token with no id: must not reach the daemon at all") } func TestRegisteredTokenValidatesAgainstA204(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.NoError(t, err) assert.Equal(t, 42, tok.TokenID) assert.True(t, tok.Registered()) assert.Equal(t, 1, d.count()) assert.Equal(t, "/api/v1/revocations/42", d.lastPath(t)) } func TestRevokedTokenIsRefused(t *testing.T) { d := newDaemon(t, http.StatusNotFound) v := newValidator(t, d.server.URL, nil) tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrRevoked), "want ErrRevoked, got %v", err) assert.Nil(t, tok) assert.Equal(t, 1, d.count()) } // A daemon that cannot answer is not a daemon that answered "revoked". Getting // this wrong would refuse every registered token on the instance for the length // of a tokens.sr.ht restart, which is why each case asserts what the error is // *not* as well as what it is. func TestADaemonThatCannotAnswerIsUnavailableAndNeverRevoked(t *testing.T) { t.Run("500", func(t *testing.T) { d := newDaemon(t, http.StatusInternalServerError) v := newValidator(t, d.server.URL, nil) tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) assert.False(t, errors.Is(err, ErrRevoked), "unknown is not revoked") assert.Nil(t, tok) }) t.Run("401 from the internal guard", func(t *testing.T) { // A misconfigured internal-ipnet, or a network key that does not match. // It is a deployment fault and the operator has to see it as one, not as // every user's token suddenly being revoked. d := newDaemon(t, http.StatusUnauthorized) v := newValidator(t, d.server.URL, nil) _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) assert.False(t, errors.Is(err, ErrRevoked)) }) t.Run("nothing listening", func(t *testing.T) { dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) origin := dead.URL dead.Close() // the port is now refusing connections v := newValidator(t, origin, nil) tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) assert.False(t, errors.Is(err, ErrRevoked), "a transport failure is not an answer") assert.Nil(t, tok) }) t.Run("context cancelled", func(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := v.Validate(ctx, ourToken("bench:upload id:42"), "bench:upload") require.Error(t, err) assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err) assert.False(t, errors.Is(err, ErrRevoked)) }) } // The internal authorization is the credential that gets past the daemon's // guard, and it has to name the *calling* service. If it named something else // the guard would still admit it — it admits every internal service equally — // and the daemon's log would quietly attribute every check to the wrong caller. func TestTheInternalBlobNamesTheConfiguredCaller(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload") require.NoError(t, err) scheme, blob, found := strings.Cut(d.lastAuth(t), " ") require.True(t, found, "the header must be %q, got %q", "Internal ", d.lastAuth(t)) assert.Equal(t, "Internal", scheme) // Decrypted the way authn.InternalGuard does it, expiry window included: the // blob is minted per request precisely so that this window is meaningful. payload := crypto.DecryptWithExpiration([]byte(blob), 30*time.Second) require.NotNil(t, payload, "the blob must verify under the instance's network key, and be fresh") var ia auth.InternalAuth require.NoError(t, json.Unmarshal(payload, &ia)) assert.Equal(t, callerClientID, ia.ClientID) assert.Equal(t, callerNodeID, ia.NodeID) } // --------------------------------------------------------------------------- // The cache // --------------------------------------------------------------------------- func TestARevocationAnswerIsCachedWithinTheTTL(t *testing.T) { d := newDaemon(t, http.StatusNoContent) clock := newClock() v := newValidator(t, d.server.URL, clock.now) token := ourToken("bench:upload id:42") for i := 0; i < 5; i++ { _, err := v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err) } assert.Equal(t, 1, d.count(), "five validations inside the TTL are one question") clock.advance(DefaultCacheTTL - time.Second) _, err := v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err) assert.Equal(t, 1, d.count(), "still inside the TTL") clock.advance(2 * time.Second) // now past it _, err = v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err) assert.Equal(t, 2, d.count(), "past the TTL the daemon must be asked again") } // The trade SPEC ch. 6 makes on purpose, written down as a test so that nobody // has to rediscover it during an incident: a revocation takes up to CacheTTL to // be honoured by a service that already asked. func TestARevocationTakesUpToTheTTLToTakeEffect(t *testing.T) { d := newDaemon(t, http.StatusNoContent, http.StatusNotFound) clock := newClock() v := newValidator(t, d.server.URL, clock.now) token := ourToken("bench:upload id:42") _, err := v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err) // The owner revokes it here. The daemon would now answer 404 — but it is not // being asked. clock.advance(DefaultCacheTTL / 2) _, err = v.Validate(context.Background(), token, "bench:upload") assert.NoError(t, err, "inside the TTL the cached 'live' still stands") assert.Equal(t, 1, d.count()) clock.advance(DefaultCacheTTL) _, err = v.Validate(context.Background(), token, "bench:upload") assert.True(t, errors.Is(err, ErrRevoked), "past the TTL it must be refused; got %v", err) assert.Equal(t, 2, d.count()) } // ErrUnavailable must never be cached. If it were, a single blip would pin every // token checked during it to failure for a full TTL — turning a moment of // unavailability into a minute of it, with the daemon healthy the whole time. func TestUnavailableIsNotCachedWhileALiveAnswerIs(t *testing.T) { d := newDaemon(t, http.StatusInternalServerError, http.StatusNoContent) clock := newClock() v := newValidator(t, d.server.URL, clock.now) token := ourToken("bench:upload id:42") _, err := v.Validate(context.Background(), token, "bench:upload") require.True(t, errors.Is(err, ErrUnavailable), "got %v", err) assert.Equal(t, 1, d.count()) // Immediately afterwards, no clock movement at all: the failure left nothing // behind, so the daemon is asked again and the recovery is picked up at once. _, err = v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err, "a failure to ask must not be remembered as an answer") assert.Equal(t, 2, d.count()) // And the answer that *is* an answer is cached. _, err = v.Validate(context.Background(), token, "bench:upload") require.NoError(t, err) assert.Equal(t, 2, d.count(), "the 204 must be cached") } func TestForgetDropsOneCachedAnswer(t *testing.T) { d := newDaemon(t, http.StatusNoContent, http.StatusNoContent, http.StatusNotFound) clock := newClock() v := newValidator(t, d.server.URL, clock.now) first := ourToken("bench:upload id:42") second := ourToken("bench:upload id:43") _, err := v.Validate(context.Background(), first, "bench:upload") require.NoError(t, err) _, err = v.Validate(context.Background(), second, "bench:upload") require.NoError(t, err) require.Equal(t, 2, d.count()) v.Forget(42) // 42 is asked again — and the daemon has moved on to answering 404. _, err = v.Validate(context.Background(), first, "bench:upload") assert.True(t, errors.Is(err, ErrRevoked), "got %v", err) assert.Equal(t, 3, d.count()) // 43 was not forgotten and is still answered from the cache. _, err = v.Validate(context.Background(), second, "bench:upload") assert.NoError(t, err) assert.Equal(t, 3, d.count()) v.Forget(9999) // forgetting what was never cached is a no-op } // The cache must not be a leak in a process that runs for months. The bound is // a sweep of the expired entries, then — if that was not enough — dropping the // lot, because every entry costs exactly one round trip to rebuild. func TestTheCacheIsBounded(t *testing.T) { clock := newClock() v := newValidator(t, "https://tokens.srht.bigb.es", clock.now) for id := 1; id <= maxCacheEntries*2; id++ { v.remember(id, true) } v.mu.Lock() size := len(v.cache) v.mu.Unlock() assert.LessOrEqual(t, size, maxCacheEntries, "the cache must not grow without limit") assert.NotZero(t, size, "and it must still be a cache afterwards") } // A service holds one Validator and every request handler goes through it, so // "safe for concurrent use" is a requirement rather than a nicety. Worth running // under -race. func TestValidatorIsSafeForConcurrentUse(t *testing.T) { d := newDaemon(t, http.StatusNoContent) v := newValidator(t, d.server.URL, nil) tokens := []string{ ourToken("bench:upload id:42"), ourToken("bench:upload id:43"), ourToken("bench:upload"), } var wg sync.WaitGroup for i := 0; i < 32; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < 8; j++ { _, err := v.Validate(context.Background(), tokens[(i+j)%len(tokens)], "bench:upload") assert.NoError(t, err) } v.Forget(42) }(i) } wg.Wait() }