detached b9504fc

fix(security): reject ephemeral tokens as credentials on /auth/token

commit
b9504fcde317e466bd4ce700fc588088bb3bdc8c
author
brice · · 2026-03-22 19:42 UTC
parent
2a0009c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
177177 // Validate JWT token sent via Basic Auth password field.
178178 claims, err := h.authSvc.ValidateToken(ctx, password)
179179 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+ }
180184 return true, claims
181185 }
182186
@@ -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, claims184 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) {
448448 }
449449 }
450450
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+
451502 func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452503 authSvc := mocks.NewMockAuthService(t)
453504
@@ -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

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 7

Tags 106

↑↓ moveenter openesc close