@@ -35,6 +35,7 @@ import (
3535 "go.podman.io/image/v5/types"
3636 "go.podman.io/storage/pkg/fileutils"
3737 "go.podman.io/storage/pkg/homedir"
38+ "golang.org/x/sync/semaphore"
3839)
3940
4041const (
@@ -86,8 +87,19 @@ type extensionSignatureList struct {
8687 Signatures []extensionSignature `json:"signatures"`
8788}
8889
89- // bearerToken records a cached token we can use to authenticate.
90+ // bearerToken records a cached token we can use to authenticate, or a pending process to obtain one.
91+ //
92+ // The goroutine obtaining the token holds lock to block concurrent token requests, and fills the structure (err and possibly the other fields)
93+ // before releasing the lock.
94+ // Other goroutines obtain lock to block on the token request, if any; and then inspect err to see if the token is usable.
95+ // If it is not, they try to get a new one.
9096type bearerToken struct {
97+ // lock is held while obtaining the token. Potentially nested inside dockerClient.tokenCacheLock.
98+ // This is a counting semaphore only because we need a cancellable lock operation.
99+ lock * semaphore.Weighted
100+
101+ // The following fields can only be accessed with lock held.
102+ err error // nil if the token was successfully obtained (but may be expired); an error if the next lock holder _must_ obtain a new token.
91103 token string
92104 expirationTime time.Time
93105}
@@ -116,8 +128,9 @@ type dockerClient struct {
116128 challenges []challenge
117129 supportsSignatures bool
118130
119- // Private state for setupRequestAuth (key: string, value: bearerToken)
120- tokenCache sync.Map
131+ // Private state for setupRequestAuth
132+ tokenCacheLock sync.Mutex // Protects tokenCache.
133+ tokenCache map [string ]* bearerToken
121134 // Private state for detectProperties:
122135 detectPropertiesOnce sync.Once // detectPropertiesOnce is used to execute detectProperties() at most once.
123136 detectPropertiesError error // detectPropertiesError caches the initial error.
@@ -274,6 +287,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc
274287 registry : registry ,
275288 userAgent : userAgent ,
276289 tlsClientConfig : tlsClientConfig ,
290+ tokenCache : map [string ]* bearerToken {},
277291 reportedWarnings : set .New [string ](),
278292 }, nil
279293}
@@ -716,50 +730,11 @@ func (c *dockerClient) setupRequestAuth(req *http.Request, extraScope *authScope
716730 req .SetBasicAuth (c .auth .Username , c .auth .Password )
717731 return nil
718732 case "bearer" :
719- registryToken := c .registryToken
720- if registryToken == "" {
721- cacheKey := ""
722- scopes := []authScope {c .scope }
723- if extraScope != nil {
724- // Using ':' as a separator here is unambiguous because getBearerToken below
725- // uses the same separator when formatting a remote request (and because
726- // repository names that we create can't contain colons, and extraScope values
727- // coming from a server come from `parseAuthScope`, which also splits on colons).
728- cacheKey = fmt .Sprintf ("%s:%s:%s" , extraScope .resourceType , extraScope .remoteName , extraScope .actions )
729- if colonCount := strings .Count (cacheKey , ":" ); colonCount != 2 {
730- return fmt .Errorf (
731- "Internal error: there must be exactly 2 colons in the cacheKey ('%s') but got %d" ,
732- cacheKey ,
733- colonCount ,
734- )
735- }
736- scopes = append (scopes , * extraScope )
737- }
738- var token bearerToken
739- t , inCache := c .tokenCache .Load (cacheKey )
740- if inCache {
741- token = t .(bearerToken )
742- }
743- if ! inCache || time .Now ().After (token .expirationTime ) {
744- var (
745- t * bearerToken
746- err error
747- )
748- if c .auth .IdentityToken != "" {
749- t , err = c .getBearerTokenOAuth2 (req .Context (), challenge , scopes )
750- } else {
751- t , err = c .getBearerToken (req .Context (), challenge , scopes )
752- }
753- if err != nil {
754- return err
755- }
756-
757- token = * t
758- c .tokenCache .Store (cacheKey , token )
759- }
760- registryToken = token .token
733+ token , err := c .obtainBearerToken (req .Context (), challenge , extraScope )
734+ if err != nil {
735+ return err
761736 }
762- req .Header .Set ("Authorization" , fmt .Sprintf ("Bearer %s" , registryToken ))
737+ req .Header .Set ("Authorization" , fmt .Sprintf ("Bearer %s" , token ))
763738 return nil
764739 default :
765740 logrus .Debugf ("no handler for %s authentication" , challenge .Scheme )
@@ -769,16 +744,94 @@ func (c *dockerClient) setupRequestAuth(req *http.Request, extraScope *authScope
769744 return nil
770745}
771746
772- func (c * dockerClient ) getBearerTokenOAuth2 (ctx context.Context , challenge challenge ,
773- scopes []authScope ) (* bearerToken , error ) {
747+ // obtainBearerToken gets an "Authorization: Bearer" token if one is available, or obtains a fresh one.
748+ func (c * dockerClient ) obtainBearerToken (ctx context.Context , challenge challenge , extraScope * authScope ) (string , error ) {
749+ if c .registryToken != "" {
750+ return c .registryToken , nil
751+ }
752+
753+ cacheKey := ""
754+ scopes := []authScope {c .scope }
755+ if extraScope != nil {
756+ // Using ':' as a separator here is unambiguous because getBearerToken below
757+ // uses the same separator when formatting a remote request (and because
758+ // repository names that we create can't contain colons, and extraScope values
759+ // coming from a server come from `parseAuthScope`, which also splits on colons).
760+ cacheKey = fmt .Sprintf ("%s:%s:%s" , extraScope .resourceType , extraScope .remoteName , extraScope .actions )
761+ if colonCount := strings .Count (cacheKey , ":" ); colonCount != 2 {
762+ return "" , fmt .Errorf (
763+ "Internal error: there must be exactly 2 colons in the cacheKey ('%s') but got %d" ,
764+ cacheKey ,
765+ colonCount ,
766+ )
767+ }
768+ scopes = append (scopes , * extraScope )
769+ }
770+
771+ token , newEntry , err := func () (* bearerToken , bool , error ) { // A scope for defer
772+ c .tokenCacheLock .Lock ()
773+ defer c .tokenCacheLock .Unlock ()
774+ token , ok := c .tokenCache [cacheKey ]
775+ if ok {
776+ return token , false , nil
777+ } else {
778+ token = & bearerToken {
779+ lock : semaphore .NewWeighted (1 ),
780+ }
781+ // If this is a new *bearerToken, lock the entry before adding it to the cache, so that any other goroutine that finds
782+ // this entry blocks until we obtain the token for the first time, and does not see an empty object
783+ // (and does not try to obtain the token itself when we are going to do so).
784+ if err := token .lock .Acquire (ctx , 1 ); err != nil {
785+ // We do not block on this Acquire, so we don’t really expect to fail here — but if ctx is canceled,
786+ // there is no point in trying to continue anyway.
787+ return nil , false , err
788+ }
789+ c .tokenCache [cacheKey ] = token
790+ return token , true , nil
791+ }
792+ }()
793+ if err != nil {
794+ return "" , err
795+ }
796+ if ! newEntry {
797+ // If this is an existing *bearerToken, obtain the lock only after releasing c.tokenCacheLock,
798+ // so that users of other cacheKey values are not blocked for the whole duration of our HTTP roundtrip.
799+ if err := token .lock .Acquire (ctx , 1 ); err != nil {
800+ return "" , err
801+ }
802+ }
803+
804+ defer token .lock .Release (1 )
805+
806+ if ! newEntry && token .err == nil && ! time .Now ().After (token .expirationTime ) {
807+ return token .token , nil // We have a usable token already.
808+ }
809+
810+ if c .auth .IdentityToken != "" {
811+ err = c .getBearerTokenOAuth2 (ctx , token , challenge , scopes )
812+ } else {
813+ err = c .getBearerToken (ctx , token , challenge , scopes )
814+ }
815+ token .err = err
816+ if token .err != nil {
817+ return "" , token .err
818+ }
819+ return token .token , nil
820+ }
821+
822+ // getBearerTokenOAuth2 obtains an "Authorization: Bearer" token using a pre-existing identity token per
823+ // https://github.com/distribution/distribution/blob/main/docs/spec/auth/oauth.md for challenge and scopes,
824+ // and writes it into dest.
825+ func (c * dockerClient ) getBearerTokenOAuth2 (ctx context.Context , dest * bearerToken , challenge challenge ,
826+ scopes []authScope ) error {
774827 realm , ok := challenge .Parameters ["realm" ]
775828 if ! ok {
776- return nil , errors .New ("missing realm in bearer auth challenge" )
829+ return errors .New ("missing realm in bearer auth challenge" )
777830 }
778831
779832 authReq , err := http .NewRequestWithContext (ctx , http .MethodPost , realm , nil )
780833 if err != nil {
781- return nil , err
834+ return err
782835 }
783836
784837 // Make the form data required against the oauth2 authentication
@@ -803,26 +856,29 @@ func (c *dockerClient) getBearerTokenOAuth2(ctx context.Context, challenge chall
803856 logrus .Debugf ("%s %s" , authReq .Method , authReq .URL .Redacted ())
804857 res , err := c .client .Do (authReq )
805858 if err != nil {
806- return nil , err
859+ return err
807860 }
808861 defer res .Body .Close ()
809862 if err := httpResponseToError (res , "Trying to obtain access token" ); err != nil {
810- return nil , err
863+ return err
811864 }
812865
813- return newBearerTokenFromHTTPResponseBody (res )
866+ return dest . readFromHTTPResponseBody (res )
814867}
815868
816- func (c * dockerClient ) getBearerToken (ctx context.Context , challenge challenge ,
817- scopes []authScope ) (* bearerToken , error ) {
869+ // getBearerToken obtains an "Authorization: Bearer" token using a GET request, per
870+ // https://github.com/distribution/distribution/blob/main/docs/spec/auth/token.md for challenge and scopes,
871+ // and writes it into dest.
872+ func (c * dockerClient ) getBearerToken (ctx context.Context , dest * bearerToken , challenge challenge ,
873+ scopes []authScope ) error {
818874 realm , ok := challenge .Parameters ["realm" ]
819875 if ! ok {
820- return nil , errors .New ("missing realm in bearer auth challenge" )
876+ return errors .New ("missing realm in bearer auth challenge" )
821877 }
822878
823879 authReq , err := http .NewRequestWithContext (ctx , http .MethodGet , realm , nil )
824880 if err != nil {
825- return nil , err
881+ return err
826882 }
827883
828884 params := authReq .URL .Query ()
@@ -850,22 +906,22 @@ func (c *dockerClient) getBearerToken(ctx context.Context, challenge challenge,
850906 logrus .Debugf ("%s %s" , authReq .Method , authReq .URL .Redacted ())
851907 res , err := c .client .Do (authReq )
852908 if err != nil {
853- return nil , err
909+ return err
854910 }
855911 defer res .Body .Close ()
856912 if err := httpResponseToError (res , "Requesting bearer token" ); err != nil {
857- return nil , err
913+ return err
858914 }
859915
860- return newBearerTokenFromHTTPResponseBody (res )
916+ return dest . readFromHTTPResponseBody (res )
861917}
862918
863- // newBearerTokenFromHTTPResponseBody parses a http.Response to obtain a bearerToken .
919+ // readFromHTTPResponseBody sets token data by parsing a http.Response .
864920// The caller is still responsible for ensuring res.Body is closed.
865- func newBearerTokenFromHTTPResponseBody ( res * http.Response ) ( * bearerToken , error ) {
921+ func ( bt * bearerToken ) readFromHTTPResponseBody ( res * http.Response ) error {
866922 blob , err := iolimits .ReadAtMost (res .Body , iolimits .MaxAuthTokenBodySize )
867923 if err != nil {
868- return nil , err
924+ return err
869925 }
870926
871927 var token struct {
@@ -881,12 +937,10 @@ func newBearerTokenFromHTTPResponseBody(res *http.Response) (*bearerToken, error
881937 if len (bodySample ) > bodySampleLength {
882938 bodySample = bodySample [:bodySampleLength ]
883939 }
884- return nil , fmt .Errorf ("decoding bearer token (last URL %q, body start %q): %w" , res .Request .URL .Redacted (), string (bodySample ), err )
940+ return fmt .Errorf ("decoding bearer token (last URL %q, body start %q): %w" , res .Request .URL .Redacted (), string (bodySample ), err )
885941 }
886942
887- bt := & bearerToken {
888- token : token .Token ,
889- }
943+ bt .token = token .Token
890944 if bt .token == "" {
891945 bt .token = token .AccessToken
892946 }
@@ -899,7 +953,7 @@ func newBearerTokenFromHTTPResponseBody(res *http.Response) (*bearerToken, error
899953 token .IssuedAt = time .Now ().UTC ()
900954 }
901955 bt .expirationTime = token .IssuedAt .Add (time .Duration (token .ExpiresIn ) * time .Second )
902- return bt , nil
956+ return nil
903957}
904958
905959// detectPropertiesHelper performs the work of detectProperties which executes
0 commit comments