detached a6040f2

fix(auth): prevent access tokens from overwriting CLI tokens

The /v2/token endpoint was storing short-lived access tokens, which
overwrote CLI-generated tokens (both keyed by subject). When Docker
sent the original CLI token on subsequent requests, the token ID
mismatch caused 401 Unauthorized errors.

- Add GenerateAccessToken method that signs JWT without storing
- Skip store validation for short-lived access tokens (≤5min)
- Update /v2/token endpoint to use GenerateAccessToken
- Add unit test for ephemeral access token generation

Fixes registry auth 401 after successful docker login.
commit
a6040f26c4810e6d7592019a8c3b2d0bd9abd906
author
brice · · 2026-01-15 11:39 UTC
parent
64d39bf
signature
unsigned
6 files · +166 -10
M internal/adapters/in/http/middleware/auth_test.go +4 -0
@@ -481,6 +481,10 @@ func (s stubAuthService) GenerateToken(context.Context, string, []string, time.D
481481 return "", errors.New("not implemented")
482482 }
483483
484+func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
485+ return "", errors.New("not implemented")
486+}
487+
484488 func (s stubAuthService) RevokeToken(context.Context, string) error {
485489 return errors.New("not implemented")
486490 }
@@ -481,6 +481,10 @@ func (s stubAuthService) GenerateToken(context.Context, string, []string, time.D
481 return "", errors.New("not implemented")481 return "", errors.New("not implemented")
482 }482 }
483 483
484+func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
485+ return "", errors.New("not implemented")
486+}
487+
484 func (s stubAuthService) RevokeToken(context.Context, string) error {488 func (s stubAuthService) RevokeToken(context.Context, string) error {
485 return errors.New("not implemented")489 return errors.New("not implemented")
486 }490 }
M internal/adapters/in/http/registry/token.go +2 -2
@@ -111,8 +111,8 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
111111 // Format: scope=repository:name:action1,action2 (can appear multiple times)
112112 requestedScopes := h.parseRequestedScopes(r, log)
113113
114- // Generate a short-lived access token (5 minutes)
115- accessToken, err := h.authSvc.GenerateToken(ctx, username, requestedScopes, 5*time.Minute)
114+ // Generate a short-lived access token (5 minutes) - not stored
115+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116116 if err != nil {
117117 log.Error().Err(err).Msg("failed to generate access token")
118118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -111,8 +111,8 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
111 // Format: scope=repository:name:action1,action2 (can appear multiple times)111 // Format: scope=repository:name:action1,action2 (can appear multiple times)
112 requestedScopes := h.parseRequestedScopes(r, log)112 requestedScopes := h.parseRequestedScopes(r, log)
113 113
114- // Generate a short-lived access token (5 minutes)114+ // Generate a short-lived access token (5 minutes) - not stored
115- accessToken, err := h.authSvc.GenerateToken(ctx, username, requestedScopes, 5*time.Minute)115+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116 if err != nil {116 if err != nil {
117 log.Error().Err(err).Msg("failed to generate access token")117 log.Error().Err(err).Msg("failed to generate access token")
118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
M internal/boundaries/in/auth.go +5 -1
@@ -23,10 +23,14 @@ type AuthService interface {
2323 // ValidateToken validates a JWT token and returns its claims.
2424 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
2525
26- // GenerateToken creates a new JWT token for the given subject.
26+ // GenerateToken creates a new JWT token for the given subject and stores it.
2727 // If expiry is 0, the token never expires.
2828 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
2929
30+ // GenerateAccessToken creates a short-lived JWT for registry access without storing it.
31+ // Used by /v2/token endpoint for Docker client sessions.
32+ GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
33+
3034 // RevokeToken revokes a token by its ID.
3135 RevokeToken(ctx context.Context, tokenID string) error
3236
@@ -23,10 +23,14 @@ type AuthService interface {
23 // ValidateToken validates a JWT token and returns its claims.23 // ValidateToken validates a JWT token and returns its claims.
24 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)24 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
25 25
26- // GenerateToken creates a new JWT token for the given subject.26+ // GenerateToken creates a new JWT token for the given subject and stores it.
27 // If expiry is 0, the token never expires.27 // If expiry is 0, the token never expires.
28 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)28 GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
29 29
30+ // GenerateAccessToken creates a short-lived JWT for registry access without storing it.
31+ // Used by /v2/token endpoint for Docker client sessions.
32+ GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)
33+
30 // RevokeToken revokes a token by its ID.34 // RevokeToken revokes a token by its ID.
31 RevokeToken(ctx context.Context, tokenID string) error35 RevokeToken(ctx context.Context, tokenID string) error
32 36
M internal/boundaries/in/mocks/mock_auth_service.go +78 -0
@@ -39,6 +39,84 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter {
3939 return &MockAuthService_Expecter{mock: &_m.Mock}
4040 }
4141
42+// GenerateAccessToken provides a mock function for the type MockAuthService
43+func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
44+ ret := _mock.Called(ctx, subject, scopes, expiry)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GenerateAccessToken")
48+ }
49+
50+ var r0 string
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) (string, error)); ok {
53+ return returnFunc(ctx, subject, scopes, expiry)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) string); ok {
56+ r0 = returnFunc(ctx, subject, scopes, expiry)
57+ } else {
58+ r0 = ret.Get(0).(string)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, time.Duration) error); ok {
61+ r1 = returnFunc(ctx, subject, scopes, expiry)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockAuthService_GenerateAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateAccessToken'
69+type MockAuthService_GenerateAccessToken_Call struct {
70+ *mock.Call
71+}
72+
73+// GenerateAccessToken is a helper method to define mock.On call
74+// - ctx context.Context
75+// - subject string
76+// - scopes []string
77+// - expiry time.Duration
78+func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateAccessToken_Call {
79+ return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
80+}
81+
82+func (_c *MockAuthService_GenerateAccessToken_Call) Run(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration)) *MockAuthService_GenerateAccessToken_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ var arg2 []string
93+ if args[2] != nil {
94+ arg2 = args[2].([]string)
95+ }
96+ var arg3 time.Duration
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Duration)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *MockAuthService_GenerateAccessToken_Call) Return(s string, err error) *MockAuthService_GenerateAccessToken_Call {
111+ _c.Call.Return(s, err)
112+ return _c
113+}
114+
115+func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)) *MockAuthService_GenerateAccessToken_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
42120 // GeneratePasswordHash provides a mock function for the type MockAuthService
43121 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
44122 ret := _mock.Called(password)
@@ -39,6 +39,84 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter {
39 return &MockAuthService_Expecter{mock: &_m.Mock}39 return &MockAuthService_Expecter{mock: &_m.Mock}
40 }40 }
41 41
42+// GenerateAccessToken provides a mock function for the type MockAuthService
43+func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
44+ ret := _mock.Called(ctx, subject, scopes, expiry)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GenerateAccessToken")
48+ }
49+
50+ var r0 string
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) (string, error)); ok {
53+ return returnFunc(ctx, subject, scopes, expiry)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, time.Duration) string); ok {
56+ r0 = returnFunc(ctx, subject, scopes, expiry)
57+ } else {
58+ r0 = ret.Get(0).(string)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, time.Duration) error); ok {
61+ r1 = returnFunc(ctx, subject, scopes, expiry)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockAuthService_GenerateAccessToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateAccessToken'
69+type MockAuthService_GenerateAccessToken_Call struct {
70+ *mock.Call
71+}
72+
73+// GenerateAccessToken is a helper method to define mock.On call
74+// - ctx context.Context
75+// - subject string
76+// - scopes []string
77+// - expiry time.Duration
78+func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateAccessToken_Call {
79+ return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
80+}
81+
82+func (_c *MockAuthService_GenerateAccessToken_Call) Run(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration)) *MockAuthService_GenerateAccessToken_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 string
89+ if args[1] != nil {
90+ arg1 = args[1].(string)
91+ }
92+ var arg2 []string
93+ if args[2] != nil {
94+ arg2 = args[2].([]string)
95+ }
96+ var arg3 time.Duration
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Duration)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *MockAuthService_GenerateAccessToken_Call) Return(s string, err error) *MockAuthService_GenerateAccessToken_Call {
111+ _c.Call.Return(s, err)
112+ return _c
113+}
114+
115+func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error)) *MockAuthService_GenerateAccessToken_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
42 // GeneratePasswordHash provides a mock function for the type MockAuthService120 // GeneratePasswordHash provides a mock function for the type MockAuthService
43 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {121 func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
44 ret := _mock.Called(password)122 ret := _mock.Called(password)
M internal/usecase/auth/service.go +51 -7
@@ -101,13 +101,18 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
101101
102102 tokenClaims := buildTokenClaims(claims)
103103
104- // SECURITY: Verify token exists in store (prevents use of externally-created tokens)
105- if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
106- return nil, err
107- }
108-
109- if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
110- return nil, err
104+ // Access tokens (short-lived, ≤5min) are not stored - skip store validation
105+ // CLI-generated tokens must exist in store to prevent use of externally-created tokens
106+ isAccessToken := tokenClaims.ExpiresAt > 0 &&
107+ (tokenClaims.ExpiresAt-tokenClaims.IssuedAt) <= 300
108+
109+ if !isAccessToken {
110+ if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
111+ return nil, err
112+ }
113+ if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
114+ return nil, err
115+ }
111116 }
112117
113118 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
@@ -287,6 +292,45 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
287292 return tokenString, nil
288293 }
289294
295+// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
296+// Used by /v2/token endpoint - these tokens don't need persistence.
297+func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
298+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
299+ zerowrap.FieldLayer: "usecase",
300+ zerowrap.FieldUseCase: "GenerateAccessToken",
301+ "subject": subject,
302+ })
303+ log := zerowrap.FromCtx(ctx)
304+
305+ tokenID := uuid.New().String()
306+ now := time.Now()
307+
308+ claims := jwt.MapClaims{
309+ "jti": tokenID,
310+ "sub": subject,
311+ "iss": TokenIssuer,
312+ "iat": now.Unix(),
313+ "scopes": scopes,
314+ }
315+
316+ if expiry > 0 {
317+ claims["exp"] = now.Add(expiry).Unix()
318+ }
319+
320+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
321+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
322+ if err != nil {
323+ return "", log.WrapErr(err, "failed to sign access token")
324+ }
325+
326+ log.Debug().
327+ Str("token_id", tokenID).
328+ Str("subject", subject).
329+ Msg("access token generated")
330+
331+ return tokenString, nil
332+}
333+
290334 // RevokeToken revokes a token by its ID.
291335 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292336 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -101,13 +101,18 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai
101 101
102 tokenClaims := buildTokenClaims(claims)102 tokenClaims := buildTokenClaims(claims)
103 103
104- // SECURITY: Verify token exists in store (prevents use of externally-created tokens)104+ // Access tokens (short-lived, ≤5min) are not stored - skip store validation
105- if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {105+ // CLI-generated tokens must exist in store to prevent use of externally-created tokens
106- return nil, err106+ isAccessToken := tokenClaims.ExpiresAt > 0 &&
107- }107+ (tokenClaims.ExpiresAt-tokenClaims.IssuedAt) <= 300
108-108+
109- if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {109+ if !isAccessToken {
110- return nil, err110+ if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
111+ return nil, err
112+ }
113+ if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
114+ return nil, err
115+ }
111 }116 }
112 117
113 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {118 if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
@@ -287,6 +292,45 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
287 return tokenString, nil292 return tokenString, nil
288 }293 }
289 294
295+// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
296+// Used by /v2/token endpoint - these tokens don't need persistence.
297+func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
298+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
299+ zerowrap.FieldLayer: "usecase",
300+ zerowrap.FieldUseCase: "GenerateAccessToken",
301+ "subject": subject,
302+ })
303+ log := zerowrap.FromCtx(ctx)
304+
305+ tokenID := uuid.New().String()
306+ now := time.Now()
307+
308+ claims := jwt.MapClaims{
309+ "jti": tokenID,
310+ "sub": subject,
311+ "iss": TokenIssuer,
312+ "iat": now.Unix(),
313+ "scopes": scopes,
314+ }
315+
316+ if expiry > 0 {
317+ claims["exp"] = now.Add(expiry).Unix()
318+ }
319+
320+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
321+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
322+ if err != nil {
323+ return "", log.WrapErr(err, "failed to sign access token")
324+ }
325+
326+ log.Debug().
327+ Str("token_id", tokenID).
328+ Str("subject", subject).
329+ Msg("access token generated")
330+
331+ return tokenString, nil
332+}
333+
290 // RevokeToken revokes a token by its ID.334 // RevokeToken revokes a token by its ID.
291 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {335 func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292 ctx = zerowrap.CtxWithFields(ctx, map[string]any{336 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/auth/service_test.go +26 -0
@@ -179,6 +179,32 @@ func TestService_GenerateToken_NeverExpires(t *testing.T) {
179179 assert.NotEmpty(t, token)
180180 }
181181
182+func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
183+ // Access tokens should NOT be stored - this is the fix for the 401 bug
184+ tokenStore := mocks.NewMockTokenStore(t)
185+ // No SaveToken expectation - it should NOT be called
186+
187+ svc := NewService(Config{
188+ Enabled: true,
189+ AuthType: domain.AuthTypeToken,
190+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
191+ }, tokenStore, zerowrap.Default())
192+
193+ ctx := testContext()
194+ token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
195+
196+ assert.NoError(t, err)
197+ assert.NotEmpty(t, token)
198+
199+ // Verify the token is valid and has correct claims
200+ claims, err := svc.ValidateToken(ctx, token)
201+ assert.NoError(t, err)
202+ assert.Equal(t, "testuser", claims.Subject)
203+ assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
204+ // Access token should have short expiry (5 min = 300 seconds)
205+ assert.True(t, claims.ExpiresAt-claims.IssuedAt <= 300)
206+}
207+
182208 func TestService_ValidateToken_Success(t *testing.T) {
183209 tokenStore := mocks.NewMockTokenStore(t)
184210
@@ -179,6 +179,32 @@ func TestService_GenerateToken_NeverExpires(t *testing.T) {
179 assert.NotEmpty(t, token)179 assert.NotEmpty(t, token)
180 }180 }
181 181
182+func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
183+ // Access tokens should NOT be stored - this is the fix for the 401 bug
184+ tokenStore := mocks.NewMockTokenStore(t)
185+ // No SaveToken expectation - it should NOT be called
186+
187+ svc := NewService(Config{
188+ Enabled: true,
189+ AuthType: domain.AuthTypeToken,
190+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
191+ }, tokenStore, zerowrap.Default())
192+
193+ ctx := testContext()
194+ token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
195+
196+ assert.NoError(t, err)
197+ assert.NotEmpty(t, token)
198+
199+ // Verify the token is valid and has correct claims
200+ claims, err := svc.ValidateToken(ctx, token)
201+ assert.NoError(t, err)
202+ assert.Equal(t, "testuser", claims.Subject)
203+ assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
204+ // Access token should have short expiry (5 min = 300 seconds)
205+ assert.True(t, claims.ExpiresAt-claims.IssuedAt <= 300)
206+}
207+
182 func TestService_ValidateToken_Success(t *testing.T) {208 func TestService_ValidateToken_Success(t *testing.T) {
183 tokenStore := mocks.NewMockTokenStore(t)209 tokenStore := mocks.NewMockTokenStore(t)
184 210

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close