fix(security): reject ephemeral tokens as credentials on /auth/token
- commit
- b9504fcde317e466bd4ce700fc588088bb3bdc8c
- author
- brice · · 2026-03-22 19:42 UTC
- parent
- 2a0009c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +55 -0
M internal/adapters/in/http/auth/handler.go +4 -0
| @@ -177,6 +177,10 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ | |||
| 177 | 177 | // Validate JWT token sent via Basic Auth password field. | |
| 178 | 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 179 | 179 | if err == nil && claims.Subject == username { | |
| 180 | + | if claims.IsEphemeral { | |
| 181 | + | log.Debug().Msg("rejected ephemeral token as exchange credential") | |
| 182 | + | return false, nil | |
| 183 | + | } | |
| 180 | 184 | return true, claims | |
| 181 | 185 | } | |
| 182 | 186 | ||
| @@ -177,6 +177,10 @@ func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Requ | |||||
| 177 | // Validate JWT token sent via Basic Auth password field. | 177 | // Validate JWT token sent via Basic Auth password field. | ||
| 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | ||
| 179 | if err == nil && claims.Subject == username { | 179 | if err == nil && claims.Subject == username { | ||
| 180 | + | if claims.IsEphemeral { | |||
| 181 | + | log.Debug().Msg("rejected ephemeral token as exchange credential") | |||
| 182 | + | return false, nil | |||
| 183 | + | } | |||
| 180 | return true, claims | 184 | return true, claims | ||
| 181 | } | 185 | } | ||
| 182 | 186 | ||||
M internal/adapters/in/http/auth/handler_test.go +51 -0
| @@ -448,6 +448,57 @@ func TestHandler_isInternalAuth(t *testing.T) { | |||
| 448 | 448 | } | |
| 449 | 449 | } | |
| 450 | 450 | ||
| 451 | + | func TestHandler_Token_EphemeralTokenRejected(t *testing.T) { | |
| 452 | + | authSvc := mocks.NewMockAuthService(t) | |
| 453 | + | ||
| 454 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 455 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{ | |
| 456 | + | Subject: "myuser", | |
| 457 | + | Scopes: []string{"repository:*:pull"}, | |
| 458 | + | IsEphemeral: true, | |
| 459 | + | }, nil) | |
| 460 | + | ||
| 461 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 462 | + | ||
| 463 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |
| 464 | + | req.SetBasicAuth("myuser", "ephemeral-jwt-token") | |
| 465 | + | rec := httptest.NewRecorder() | |
| 466 | + | ||
| 467 | + | handler.ServeHTTP(rec, req) | |
| 468 | + | ||
| 469 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 470 | + | assert.Contains(t, rec.Body.String(), "unauthorized") | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) { | |
| 474 | + | authSvc := mocks.NewMockAuthService(t) | |
| 475 | + | ||
| 476 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 477 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{ | |
| 478 | + | Subject: "myuser", | |
| 479 | + | Scopes: []string{"repository:*:pull"}, | |
| 480 | + | IsEphemeral: false, | |
| 481 | + | }, nil) | |
| 482 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 483 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute). | |
| 484 | + | Return("new-access-token", nil) | |
| 485 | + | ||
| 486 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 487 | + | ||
| 488 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |
| 489 | + | req.SetBasicAuth("myuser", "long-lived-jwt-token") | |
| 490 | + | rec := httptest.NewRecorder() | |
| 491 | + | ||
| 492 | + | handler.ServeHTTP(rec, req) | |
| 493 | + | ||
| 494 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 495 | + | ||
| 496 | + | var resp map[string]any | |
| 497 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 498 | + | assert.NoError(t, err) | |
| 499 | + | assert.Equal(t, "new-access-token", resp["token"]) | |
| 500 | + | } | |
| 501 | + | ||
| 451 | 502 | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | |
| 452 | 503 | authSvc := mocks.NewMockAuthService(t) | |
| 453 | 504 | ||
| @@ -448,6 +448,57 @@ func TestHandler_isInternalAuth(t *testing.T) { | |||||
| 448 | } | 448 | } | ||
| 449 | } | 449 | } | ||
| 450 | 450 | ||||
| 451 | + | func TestHandler_Token_EphemeralTokenRejected(t *testing.T) { | |||
| 452 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 453 | + | ||||
| 454 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 455 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{ | |||
| 456 | + | Subject: "myuser", | |||
| 457 | + | Scopes: []string{"repository:*:pull"}, | |||
| 458 | + | IsEphemeral: true, | |||
| 459 | + | }, nil) | |||
| 460 | + | ||||
| 461 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 462 | + | ||||
| 463 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |||
| 464 | + | req.SetBasicAuth("myuser", "ephemeral-jwt-token") | |||
| 465 | + | rec := httptest.NewRecorder() | |||
| 466 | + | ||||
| 467 | + | handler.ServeHTTP(rec, req) | |||
| 468 | + | ||||
| 469 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 470 | + | assert.Contains(t, rec.Body.String(), "unauthorized") | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) { | |||
| 474 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 475 | + | ||||
| 476 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 477 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{ | |||
| 478 | + | Subject: "myuser", | |||
| 479 | + | Scopes: []string{"repository:*:pull"}, | |||
| 480 | + | IsEphemeral: false, | |||
| 481 | + | }, nil) | |||
| 482 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |||
| 483 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute). | |||
| 484 | + | Return("new-access-token", nil) | |||
| 485 | + | ||||
| 486 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 487 | + | ||||
| 488 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |||
| 489 | + | req.SetBasicAuth("myuser", "long-lived-jwt-token") | |||
| 490 | + | rec := httptest.NewRecorder() | |||
| 491 | + | ||||
| 492 | + | handler.ServeHTTP(rec, req) | |||
| 493 | + | ||||
| 494 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 495 | + | ||||
| 496 | + | var resp map[string]any | |||
| 497 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 498 | + | assert.NoError(t, err) | |||
| 499 | + | assert.Equal(t, "new-access-token", resp["token"]) | |||
| 500 | + | } | |||
| 501 | + | ||||
| 451 | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | 502 | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | ||
| 452 | authSvc := mocks.NewMockAuthService(t) | 503 | authSvc := mocks.NewMockAuthService(t) | ||
| 453 | 504 | ||||