mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
fix(registry): read the Desktop session expiry from the token when claims lack it
The session payload may carry no claims, in which case the access token was reused for a minute without any expiry check, so an expired token could reach the registry and fail the pull instead of falling back. Read the exp claim from the JWT itself in that case. An expiry that cannot be read is still tolerated. Signed-off-by: Alano Terblanche <[email protected]>
This commit is contained in:
committed by
Nicolas De loof
parent
fd4dae8351
commit
816c83e401
@@ -18,8 +18,11 @@ package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -195,8 +198,8 @@ func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) {
|
||||
|
||||
now := s.now()
|
||||
validUntil := now.Add(sessionTTL)
|
||||
if exp := session.Claims.ExpiresAt; exp != nil {
|
||||
validUntil = exp.Add(-sessionExpirySkew)
|
||||
if expiresAt, ok := sessionExpiry(session); ok {
|
||||
validUntil = expiresAt.Add(-sessionExpirySkew)
|
||||
if !now.Before(validUntil) {
|
||||
return clitypes.AuthConfig{}, time.Time{}, errSessionExpired
|
||||
}
|
||||
@@ -209,6 +212,38 @@ func (s *desktopSession) fetch() (clitypes.AuthConfig, time.Time, error) {
|
||||
}, validUntil, nil
|
||||
}
|
||||
|
||||
// sessionExpiry returns when the session's access token expires: from the
|
||||
// session claims, or, when the payload carries none, from the token itself.
|
||||
// An expiry that cannot be read is tolerated, the token is then reused for
|
||||
// sessionTTL only.
|
||||
func sessionExpiry(session dockerhub.UserSession) (time.Time, bool) {
|
||||
if exp := session.Claims.ExpiresAt; exp != nil {
|
||||
return exp.Time, true
|
||||
}
|
||||
return jwtExpiry(session.AccessToken)
|
||||
}
|
||||
|
||||
// jwtExpiry reads the exp claim of a JWT without verifying its signature: it
|
||||
// only decides whether the token is still worth sending, the registry
|
||||
// verifies it.
|
||||
func jwtExpiry(token string) (time.Time, bool) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
var claims struct {
|
||||
ExpiresAt *dockerhub.NumericDate `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil || claims.ExpiresAt == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return claims.ExpiresAt.Time, true
|
||||
}
|
||||
|
||||
// logDesktopFallback reports why Docker Hub credentials come from the
|
||||
// fallback. Desktop not running and nobody signed in are routine and only
|
||||
// logged at debug level. That includes an expired session: Desktop keeps the
|
||||
|
||||
@@ -18,6 +18,7 @@ package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
@@ -293,6 +294,52 @@ func TestDesktopAuthProvider_RefetchesSessionWithoutExpiry(t *testing.T) {
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
|
||||
}
|
||||
|
||||
// fakeJWT builds an unsigned JWT carrying only an exp claim.
|
||||
func fakeJWT(expiresAt time.Time) string {
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
return enc([]byte(`{"alg":"none"}`)) + "." + enc(fmt.Appendf(nil, `{"exp":%d}`, expiresAt.Unix())) + ".sig"
|
||||
}
|
||||
|
||||
func TestDesktopAuthProvider_ExpiryFromTokenWhenClaimsLackIt(t *testing.T) {
|
||||
t.Run("expired token falls back", func(t *testing.T) {
|
||||
session := hubSession("hubuser", time.Time{})
|
||||
session.AccessToken = fakeJWT(testNow.Add(-time.Minute))
|
||||
p := newTestProvider(&fakeHub{session: session}, nil)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, got, cliCredentials("docker.io"))
|
||||
})
|
||||
|
||||
t.Run("valid token is reused until it expires", func(t *testing.T) {
|
||||
session := hubSession("hubuser", time.Time{})
|
||||
session.AccessToken = fakeJWT(testNow.Add(10 * time.Minute))
|
||||
hub := &fakeHub{session: session}
|
||||
p := newTestProvider(hub, nil)
|
||||
|
||||
got, err := p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got.Password, session.AccessToken)
|
||||
|
||||
// Past sessionTTL, but the token's own expiry still applies.
|
||||
p.clock.advance(sessionTTL + time.Minute)
|
||||
_, err = p.GetAuthConfig("docker.io")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTExpiry(t *testing.T) {
|
||||
exp, ok := jwtExpiry(fakeJWT(testNow))
|
||||
assert.Assert(t, ok)
|
||||
assert.Assert(t, exp.Equal(testNow))
|
||||
|
||||
for _, token := range []string{"", "opaque-token", "a.%%%.c", "a." + base64.RawURLEncoding.EncodeToString([]byte(`{}`)) + ".c"} {
|
||||
_, ok := jwtExpiry(token)
|
||||
assert.Assert(t, !ok, token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopAuthProvider_ConcurrentLookupsShareOneFetch(t *testing.T) {
|
||||
hub := &fakeHub{session: hubSession("hubuser", testNow.Add(time.Hour))}
|
||||
p := newTestProvider(hub, nil)
|
||||
|
||||
Reference in New Issue
Block a user