package token import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" disttoken "github.com/distribution/distribution/v3/registry/auth/token" "atcr.io/pkg/auth" ) // The margin exists to cover distribution's JWT leeway. distribution's registry // auth accepts a token for Leeway past its exp, and the JWT's exp is stamped // from (service token exp - margin). If the margin ever drops below the leeway, // a client can hold an accepted JWT after the service token behind it is dead, // the hold answers 403 "token has expired", and the pull fails with no 401 to // make the client re-authenticate. That is the production bug this closes. func TestServiceTokenSafetyMarginCoversDistributionLeeway(t *testing.T) { if auth.ServiceTokenSafetyMargin < disttoken.Leeway { t.Fatalf("auth.ServiceTokenSafetyMargin is %v, which is under distribution's token.Leeway of %v: "+ "a registry JWT would stay acceptable after its service token expired", auth.ServiceTokenSafetyMargin, disttoken.Leeway) } } // The handler must stamp exactly the expiry the fetcher hands it. That value // already has the margin subtracted (pkg/auth/cache.go), so stamping anything // later would reopen the window, and the margin has to survive the round trip // through the token response. func TestHandler_ServiceAuthFetcher_MarginSurvivesStamping(t *testing.T) { keyPath := getSharedTestKey(t) issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) if err != nil { t.Fatalf("NewIssuer() error = %v", err) } deviceStore, database := setupTestDeviceStore(t) deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social") handler := NewHandler(issuer, deviceStore) // What a 5 minute PDS grant looks like coming out of the cache. serviceTokenExp := time.Now().Add(5 * time.Minute) handler.SetServiceAuthFetcher(&stubServiceAuthFetcher{ expiresAt: serviceTokenExp.Add(-auth.ServiceTokenSafetyMargin), }) req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil) req.SetBasicAuth("alice", deviceSecret) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) } var resp TokenResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } jwtExpiresAt := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) // The last instant distribution will accept this JWT must still be inside // the service token's real life. lastAccepted := jwtExpiresAt.Add(disttoken.Leeway) if lastAccepted.After(serviceTokenExp.Add(2 * time.Second)) { t.Errorf("JWT is accepted until %v but the service token dies at %v", lastAccepted, serviceTokenExp) } // And it must still be a usable token, not one squeezed to nothing. if resp.ExpiresIn < 200 { t.Errorf("expires_in = %d, want ~240s (5 min grant minus the %v margin)", resp.ExpiresIn, auth.ServiceTokenSafetyMargin) } }