Skip to content

Commit 39b711b

Browse files
authored
Merge pull request #2098 from dipti-pai/cache-notation-verify
Cache registry token during Notation verification
2 parents e5c9880 + 69305ed commit 39b711b

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

internal/oci/notation/notation.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ func (v *NotationVerifier) remoteRepo(repoUrl string) (*oras.Repository, error)
366366
"User-Agent": {"flux"},
367367
},
368368
Credential: credentialProvider,
369+
// Cache the registry authorization token so that the manifest
370+
// resolution, signature listing and signature blob fetches performed
371+
// during a single verification reuse the same token instead of
372+
// re-authenticating on every request.
373+
Cache: oauth.NewCache(),
369374
}
370375

371376
remoteRepo.Client = repoClient

internal/oci/notation/notation_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ package notation
1818

1919
import (
2020
"context"
21+
"encoding/json"
2122
"fmt"
2223
"net/http"
24+
"net/http/httptest"
2325
"net/url"
2426
"path"
2527
"reflect"
28+
"strings"
29+
"sync/atomic"
2630
"testing"
2731

2832
"github.com/go-logr/logr"
@@ -32,6 +36,7 @@ import (
3236
"github.com/notaryproject/notation-go"
3337
"github.com/notaryproject/notation-go/verifier/trustpolicy"
3438
. "github.com/onsi/gomega"
39+
oauth "oras.land/oras-go/v2/registry/remote/auth"
3540

3641
"github.com/fluxcd/source-controller/internal/oci"
3742
testproxy "github.com/fluxcd/source-controller/tests/proxy"
@@ -542,6 +547,154 @@ func TestRepoUrlWithDigest(t *testing.T) {
542547
}
543548
}
544549

550+
func TestRemoteRepoAuthCache(t *testing.T) {
551+
testCases := []struct {
552+
name string
553+
auth authn.Authenticator
554+
keychain authn.Keychain
555+
transport bool
556+
}{
557+
{
558+
name: "anonymous",
559+
},
560+
{
561+
name: "with authenticator",
562+
auth: &authn.Basic{Username: "foo", Password: "bar"},
563+
},
564+
{
565+
name: "with keychain",
566+
keychain: authn.DefaultKeychain,
567+
},
568+
{
569+
name: "with custom transport",
570+
auth: &authn.Basic{Username: "foo", Password: "bar"},
571+
transport: true,
572+
},
573+
}
574+
575+
for _, tc := range testCases {
576+
t.Run(tc.name, func(t *testing.T) {
577+
g := NewWithT(t)
578+
579+
v := &NotationVerifier{
580+
auth: tc.auth,
581+
keychain: tc.keychain,
582+
}
583+
if tc.transport {
584+
v.transport = http.DefaultTransport.(*http.Transport).Clone()
585+
}
586+
587+
repo, err := v.remoteRepo("ghcr.io/stefanprodan/charts/podinfo")
588+
g.Expect(err).NotTo(HaveOccurred())
589+
590+
client, ok := repo.Client.(*oauth.Client)
591+
g.Expect(ok).To(BeTrue(), "expected repository client to be *oauth.Client")
592+
593+
// The auth cache must be set so that the manifest resolution,
594+
// signature listing and signature blob fetches performed during a
595+
// single verification reuse the same registry token instead of
596+
// re-authenticating on every request.
597+
g.Expect(client.Cache).NotTo(BeNil())
598+
})
599+
}
600+
}
601+
602+
// TestRemoteRepoAuthTokenReuse exercises the registry authorization flow against
603+
// a fake bearer-auth registry and counts how many times the token endpoint is
604+
// hit. It demonstrates the effect of the auth cache fix: without a cache every
605+
// registry request performs its own token exchange (modelling the pre-fix
606+
// behaviour), whereas the client built by remoteRepo fetches the token once and
607+
// reuses it for all subsequent requests of the same scope.
608+
func TestRemoteRepoAuthTokenReuse(t *testing.T) {
609+
g := NewWithT(t)
610+
611+
const (
612+
repository = "test/podinfo"
613+
bearerToken = "secret-token"
614+
numRequests = 5
615+
)
616+
scope := oauth.ScopeRepository(repository, "pull")
617+
618+
var tokenRequests atomic.Int32
619+
620+
var baseURL string
621+
mux := http.NewServeMux()
622+
// Token endpoint: counts every issuance and returns a static token.
623+
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
624+
tokenRequests.Add(1)
625+
w.Header().Set("Content-Type", "application/json")
626+
_ = json.NewEncoder(w).Encode(map[string]any{
627+
"token": bearerToken,
628+
"access_token": bearerToken,
629+
"expires_in": 3600,
630+
})
631+
})
632+
// Registry endpoint: challenges unauthenticated requests with a bearer
633+
// challenge pointing at the token endpoint, and accepts the issued token.
634+
mux.HandleFunc("/v2/", func(w http.ResponseWriter, r *http.Request) {
635+
if r.Header.Get("Authorization") != "Bearer "+bearerToken {
636+
w.Header().Set("Www-Authenticate",
637+
fmt.Sprintf(`Bearer realm="%s/token",service="registry",scope=%q`, baseURL, scope))
638+
w.WriteHeader(http.StatusUnauthorized)
639+
return
640+
}
641+
w.WriteHeader(http.StatusOK)
642+
})
643+
644+
server := httptest.NewServer(mux)
645+
defer server.Close()
646+
baseURL = server.URL
647+
host := strings.TrimPrefix(baseURL, "http://")
648+
649+
anonymousCredential := func(context.Context, string) (oauth.Credential, error) {
650+
return oauth.EmptyCredential, nil
651+
}
652+
653+
// doRequests issues n authenticated GET requests through the given client,
654+
// setting the scope hint on the context exactly like the ORAS repository
655+
// methods do during verification.
656+
doRequests := func(client *oauth.Client, n int) {
657+
for i := 0; i < n; i++ {
658+
ctx := oauth.WithScopesForHost(context.Background(), host, scope)
659+
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
660+
fmt.Sprintf("%s/v2/%s/manifests/latest", baseURL, repository), nil)
661+
g.Expect(err).NotTo(HaveOccurred())
662+
resp, err := client.Do(req)
663+
g.Expect(err).NotTo(HaveOccurred())
664+
resp.Body.Close()
665+
g.Expect(resp.StatusCode).To(Equal(http.StatusOK))
666+
}
667+
}
668+
669+
// Before the fix: an ORAS client without a cache re-authenticates on every
670+
// request, so the token endpoint is hit once per request.
671+
tokenRequests.Store(0)
672+
noCacheClient := &oauth.Client{
673+
Client: server.Client(),
674+
Header: http.Header{"User-Agent": {"flux"}},
675+
Credential: anonymousCredential,
676+
// Cache is deliberately unset to model the pre-fix behaviour.
677+
}
678+
doRequests(noCacheClient, numRequests)
679+
g.Expect(tokenRequests.Load()).To(Equal(int32(numRequests)),
680+
"without an auth cache each request performs its own token exchange")
681+
682+
// After the fix: remoteRepo wires an auth cache, so the token is fetched
683+
// once and reused for all subsequent requests of the same scope.
684+
tokenRequests.Store(0)
685+
v := &NotationVerifier{insecure: true}
686+
repo, err := v.remoteRepo(fmt.Sprintf("%s/%s", host, repository))
687+
g.Expect(err).NotTo(HaveOccurred())
688+
cachedClient, ok := repo.Client.(*oauth.Client)
689+
g.Expect(ok).To(BeTrue(), "expected repository client to be *oauth.Client")
690+
g.Expect(cachedClient.Cache).NotTo(BeNil())
691+
// Route the cached client through the test server's HTTP client.
692+
cachedClient.Client = server.Client()
693+
doRequests(cachedClient, numRequests)
694+
g.Expect(tokenRequests.Load()).To(Equal(int32(1)),
695+
"with an auth cache the token is fetched once and reused across requests")
696+
}
697+
545698
func TestVerificationWithProxy(t *testing.T) {
546699
g := NewWithT(t)
547700

0 commit comments

Comments
 (0)