Skip to content

Commit b88fa38

Browse files
committed
http: invalidate cached token scopes on token swap (#2203)
1 parent 1da41fa commit b88fa38

File tree

6 files changed

+113
-8
lines changed

6 files changed

+113
-8
lines changed

pkg/context/token.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,47 @@ func GetTokenInfo(ctx context.Context) (*TokenInfo, bool) {
2828

2929
type tokenScopesKey struct{}
3030

31+
type tokenScopesValue struct {
32+
Token string
33+
Scopes []string
34+
}
35+
3136
// WithTokenScopes adds token scopes to the context
3237
func WithTokenScopes(ctx context.Context, scopes []string) context.Context {
3338
return context.WithValue(ctx, tokenScopesKey{}, scopes)
3439
}
3540

41+
// WithTokenScopesForToken adds token scopes and the associated token to the context.
42+
func WithTokenScopesForToken(ctx context.Context, token string, scopes []string) context.Context {
43+
return context.WithValue(ctx, tokenScopesKey{}, tokenScopesValue{
44+
Token: token,
45+
Scopes: scopes,
46+
})
47+
}
48+
3649
// GetTokenScopes retrieves token scopes from the context
3750
func GetTokenScopes(ctx context.Context) ([]string, bool) {
51+
if scoped, ok := ctx.Value(tokenScopesKey{}).(tokenScopesValue); ok {
52+
return scoped.Scopes, true
53+
}
3854
if scopes, ok := ctx.Value(tokenScopesKey{}).([]string); ok {
3955
return scopes, true
4056
}
4157
return nil, false
4258
}
59+
60+
// GetTokenScopesForToken retrieves token scopes only when they are bound to the active token.
61+
func GetTokenScopesForToken(ctx context.Context, token string) ([]string, bool) {
62+
if scoped, ok := ctx.Value(tokenScopesKey{}).(tokenScopesValue); ok {
63+
if scoped.Token == token {
64+
return scoped.Scopes, true
65+
}
66+
return nil, false
67+
}
68+
if token == "" {
69+
if scopes, ok := ctx.Value(tokenScopesKey{}).([]string); ok {
70+
return scopes, true
71+
}
72+
}
73+
return nil, false
74+
}

pkg/context/token_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package context
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestGetTokenScopesForToken_MatchesBoundToken(t *testing.T) {
11+
ctx := WithTokenScopesForToken(context.Background(), "token-a", []string{"repo"})
12+
13+
scopes, ok := GetTokenScopesForToken(ctx, "token-a")
14+
assert.True(t, ok)
15+
assert.Equal(t, []string{"repo"}, scopes)
16+
17+
scopes, ok = GetTokenScopesForToken(ctx, "token-b")
18+
assert.False(t, ok)
19+
assert.Nil(t, scopes)
20+
}
21+
22+
func TestGetTokenScopesForToken_DoesNotReuseLegacyScopesForNonEmptyToken(t *testing.T) {
23+
ctx := WithTokenScopes(context.Background(), []string{"repo"})
24+
25+
scopes, ok := GetTokenScopesForToken(ctx, "token-a")
26+
assert.False(t, ok)
27+
assert.Nil(t, scopes)
28+
29+
legacyScopes, legacyOK := GetTokenScopes(ctx)
30+
assert.True(t, legacyOK)
31+
assert.Equal(t, []string{"repo"}, legacyScopes)
32+
}

pkg/http/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.Fetche
295295
// Fine-grained PATs and other token types don't support this, so we skip filtering.
296296
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
297297
// Check if scopes are already in context (should be set by WithPATScopes). If not, fetch them.
298-
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
298+
existingScopes, ok := ghcontext.GetTokenScopesForToken(ctx, tokenInfo.Token)
299299
if ok {
300300
return b.WithFilter(github.CreateToolScopeFilter(existingScopes))
301301
}

pkg/http/middleware/pat_scope.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) fu
2626
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
2727
// Fine-grained PATs and other token types don't support this, so we skip filtering.
2828
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
29-
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
29+
existingScopes, ok := ghcontext.GetTokenScopesForToken(ctx, tokenInfo.Token)
3030
if ok {
3131
logger.Debug("using existing scopes from context", "scopes", existingScopes)
3232
next.ServeHTTP(w, r)
@@ -41,7 +41,7 @@ func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) fu
4141
}
4242

4343
// Store fetched scopes in context for downstream use
44-
ctx = ghcontext.WithTokenScopes(ctx, scopesList)
44+
ctx = ghcontext.WithTokenScopesForToken(ctx, tokenInfo.Token, scopesList)
4545

4646
next.ServeHTTP(w, r.WithContext(ctx))
4747
return

pkg/http/middleware/pat_scope_test.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ import (
1616

1717
// mockScopeFetcher is a mock implementation of scopes.FetcherInterface
1818
type mockScopeFetcher struct {
19-
scopes []string
20-
err error
19+
scopes []string
20+
err error
21+
callCount int
22+
tokens []string
2123
}
2224

23-
func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
25+
func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, token string) ([]string, error) {
26+
m.callCount += 1
27+
m.tokens = append(m.tokens, token)
2428
return m.scopes, m.err
2529
}
2630

@@ -188,3 +192,40 @@ func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) {
188192
assert.True(t, scopesFound)
189193
assert.Equal(t, []string{"repo", "user"}, capturedScopes)
190194
}
195+
196+
func TestWithPATScopes_RefetchesWhenCachedScopesBelongToDifferentToken(t *testing.T) {
197+
logger := slog.Default()
198+
199+
var capturedScopes []string
200+
var scopesFound bool
201+
202+
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
203+
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
204+
w.WriteHeader(http.StatusOK)
205+
})
206+
207+
fetcher := &mockScopeFetcher{
208+
scopes: []string{"read:org"},
209+
}
210+
211+
middleware := WithPATScopes(logger, fetcher)
212+
handler := middleware(nextHandler)
213+
214+
req := httptest.NewRequest(http.MethodGet, "/test", nil)
215+
ctx := req.Context()
216+
ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{
217+
Token: "ghp_new_token",
218+
TokenType: utils.TokenTypePersonalAccessToken,
219+
})
220+
ctx = ghcontext.WithTokenScopesForToken(ctx, "ghp_old_token", []string{"repo"})
221+
req = req.WithContext(ctx)
222+
223+
rr := httptest.NewRecorder()
224+
handler.ServeHTTP(rr, req)
225+
226+
assert.True(t, scopesFound)
227+
assert.Equal(t, []string{"read:org"}, capturedScopes)
228+
assert.Equal(t, 1, fetcher.callCount)
229+
require.Len(t, fetcher.tokens, 1)
230+
assert.Equal(t, "ghp_new_token", fetcher.tokens[0])
231+
}

pkg/http/middleware/scope_challenge.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter
9696

9797
// Get OAuth scopes for Token. First check if scopes are already in context, then fetch from GitHub if not present.
9898
// This allows Remote Server to pass scope info to avoid redundant GitHub API calls.
99-
activeScopes, ok := ghcontext.GetTokenScopes(ctx)
99+
activeScopes, ok := ghcontext.GetTokenScopesForToken(ctx, tokenInfo.Token)
100100
if !ok || (len(activeScopes) == 0 && tokenInfo.Token != "") {
101101
activeScopes, err = scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
102102
if err != nil {
@@ -106,7 +106,7 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter
106106
}
107107

108108
// Store active scopes in context for downstream use
109-
ctx = ghcontext.WithTokenScopes(ctx, activeScopes)
109+
ctx = ghcontext.WithTokenScopesForToken(ctx, tokenInfo.Token, activeScopes)
110110
r = r.WithContext(ctx)
111111

112112
// Check if user has the required scopes

0 commit comments

Comments
 (0)