auth/oidc: cache OIDC access token and refresh before expiry (#5175)

* auth/oidc: cache OIDC access token and refresh before expiry

- Use Config.TokenSource(ctx) once at init to create a persistent
  oauth2.TokenSource that caches the token and only refreshes on expiry
- Wrap with oauth2.ReuseTokenSourceWithExpiry for configurable early refresh
- Add tokenRefreshAdvanceDuration config option (default: 300s)
- Add unit test verifying token caching with mock HTTP server

* address review comments

* auth/oidc: fallback to per-request token fetch when expires_in is missing

When an OIDC provider omits the expires_in field, oauth2.ReuseTokenSource
treats the cached token as valid forever and never refreshes it. This causes
server-side OIDC verification to fail once the JWT's exp claim passes.

Add a nonCachingTokenSource fallback: after fetching the initial token, if
its Expiry is the zero value, swap the caching TokenSource for one that
fetches a fresh token on every request, preserving the old behavior for
providers that don't return expires_in.

* auth/oidc: fix gosec lint and add test for zero-expiry fallback

Suppress G101 false positive on test-only dummy token responses.
Add test to verify per-request token fetch when expires_in is missing.
Update caching test to account for eager initial token fetch.

* fix lint
This commit is contained in:
Shani Pathak
2026-03-11 21:54:46 +05:30
committed by GitHub
parent 6b1be922e1
commit 94a631fe9c
2 changed files with 131 additions and 14 deletions

View File

@@ -2,6 +2,10 @@ package auth_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
@@ -62,3 +66,90 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
r.Error(err)
r.Contains(err.Error(), "received different OIDC subject in login and ping")
}
func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
r := require.New(t)
var requestCount atomic.Int32
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
"access_token": "fresh-test-token",
"token_type": "Bearer",
})
}))
defer tokenServer.Close()
provider, err := auth.NewOidcAuthSetter(
[]v1.AuthScope{v1.AuthScopeHeartBeats},
v1.AuthOIDCClientConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
TokenEndpointURL: tokenServer.URL,
},
)
r.NoError(err)
// Constructor fetches the initial token (1 request).
// Each subsequent call should also fetch a fresh token since there is no expiry.
loginMsg := &msg.Login{}
err = provider.SetLogin(loginMsg)
r.NoError(err)
r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
for range 3 {
pingMsg := &msg.Ping{}
err = provider.SetPing(pingMsg)
r.NoError(err)
r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
}
// 1 initial (constructor) + 1 login + 3 pings = 5 requests
r.Equal(int32(5), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
}
func TestOidcAuthProviderCachesToken(t *testing.T) {
r := require.New(t)
var requestCount atomic.Int32
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
"access_token": "cached-test-token",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
provider, err := auth.NewOidcAuthSetter(
[]v1.AuthScope{v1.AuthScopeHeartBeats},
v1.AuthOIDCClientConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
TokenEndpointURL: tokenServer.URL,
},
)
r.NoError(err)
// Constructor eagerly fetches the initial token (1 request).
r.Equal(int32(1), requestCount.Load())
// SetLogin should reuse the cached token
loginMsg := &msg.Login{}
err = provider.SetLogin(loginMsg)
r.NoError(err)
r.Equal("cached-test-token", loginMsg.PrivilegeKey)
r.Equal(int32(1), requestCount.Load())
// Subsequent calls should also reuse the cached token
for range 5 {
pingMsg := &msg.Ping{}
err = provider.SetPing(pingMsg)
r.NoError(err)
r.Equal("cached-test-token", pingMsg.PrivilegeKey)
}
r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
}