@@ -18,11 +18,15 @@ package notation
1818
1919import (
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+
545698func TestVerificationWithProxy (t * testing.T ) {
546699 g := NewWithT (t )
547700
0 commit comments