auth: silent login-token refresh for git-remote-entire · Entire
auth: silent login-token refresh for git-remote-entire
5c87370·
Device-flow login now requests offline_access, captures the returned refresh token through the poll plumbing, and persists it to the paired <service>:refresh keyring slot. git-remote-entire's login-JWT provider is now refresh-aware: an expired login JWT is re-minted from the refresh token instead of failing the operation with a re-login.
The provider is backed by auth-go's tokenmanager, which serialises refreshes across processes (advisory file lock) and goroutines and persists the rotated refresh token. That matters because the server issues single-use refresh tokens with reuse detection + family revocation: two concurrent git-remote-entire processes (e.g. a recursive submodule fetch) racing a naive refresh would replay the same token and get the whole family revoked. A thin per-context tokenstore.Store adapter maps the keyring slots onto tokenmanager.
Behaviour is a strict superset of before: a still-valid token is returned with no network call, and a context with no refresh token (a login predating this change) behaves exactly as it did — valid token used, expired token surfaces a re-login error.
Server-side support already existed in entire-core (offline_access on the device grant, refresh_token grant for the public client, rotating single-use families), so no server changes are required.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
0192d919e856View transcript
Changes
9
cmd
entire/cli
auth
Mclient.go+14/-8
Mcontexts.go+16/-2
Mcontexts_test.go+15/-15
Arefresh.go+147
Arefresh_test.go+190
Mauth_context_test.go+4/-4
Mlogin.go+13/-13
Mlogin_test.go+13/-13
git-remote-entire
Mmain.go+11/-3
27 unmodified lines
// callers a more actionable message than the bare error code.
type DeviceAuthPoll struct {
AccessToken string
RefreshToken string
TokenType string
ExpiresIn int
Scope string
}
transport = httpClient.Transport
}
return &Client{inner: &deviceflow.Client{
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
Scope: "cli",
UserAgent: p.ClientID,
DeviceCodePath: p.DeviceCodePath,
TokenPath: p.TokenPath,
}
return &DeviceAuthPoll{
AccessToken: t.AccessToken,
TokenType: t.TokenType,
ExpiresIn: secondsUntil(t),
Scope: t.Scope,
AccessToken: t.AccessToken,
RefreshToken: t.RefreshToken,
TokenType: t.TokenType,
ExpiresIn: secondsUntil(t),
Scope: t.Scope,
}, nil
}
Mcmd/entire/cli/auth/client.go+14/-8
43 unmodified lines
{
// The refresh token lives in the paired "<service>:refresh" slot (raw,
// no expiry suffix). Clear any prior one when this login carries none,
// so a stale token from an earlier session can't later be replayed
// against the server's single-use rotation and revoke the family.
refreshSlot := keychainService + ":refresh"
if refreshToken != "" {
if err := tokenstore.Set(refreshSlot, handle, refreshToken); err != nil {
return "", fmt.Errorf("store refresh token in keyring: %w", err)
}
} else {
_ = tokenstore.Delete(refreshSlot, handle) //nolint:errcheck // best-effort cleanup of a stale refresh token
}
}
36 unmodified lines
// Returns the context name on success. Errors are returned (not swallowed)
// so the caller can warn; login still succeeds on the legacy entry.
func RecordLoginContext(rawToken string, activate bool) (string, error) {
claims, err := tokens.ParseClaims(rawToken)
if err != nil {
return "", fmt.Errorf("parse login token claims: %w", err)
}
}
var name string
cfgDir := contexts.DefaultConfigDir()
if modErr := contexts.Modify(cfgDir, func(f *contexts.File) (bool, error) {
// activate=false: migrating an old login (e.g. on first `git clone`) must
// not silently switch the user's active context. RecordLoginContext still
// sets current_context when none exists yet.
}
Mcmd/entire/cli/auth/contexts.go+16/-2
// The full path: an expired access token is silently re-minted from the
// stored refresh token, and the rotated refresh token is persisted.
func TestNewRefreshingLoginProvider_RefreshesAndRotates(t *testing.T) {
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
newJWT := makeJWT(t, fmt.Sprintf(`{"iss":"https://core.example.com","handle":"alice","exp":%d}`, time.Now().Add(time.Hour).Unix()))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("parse form: %v", err)
}
if got := r.FormValue("grant_type"); got != "refresh_token" {
t.Errorf("grant_type = %q, want refresh_token", got)
}
if got := r.FormValue("refresh_token"); got != "entr_old" {
t.Errorf("refresh_token = %q, want entr_old", got)
}
if r.FormValue("client_id") == "" {
t.Error("missing client_id")
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w,
`{"access_token":%q,"refresh_token":"entr_new","token_type":"Bearer","expires_in":3600}`, newJWT)
}))
defer srv.Close()
svc := tokenstore.CoreKeyringService(srv.URL)
expired := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, srv.URL, time.Now().Add(-time.Hour).Unix()))
if err := tokenstore.Set(svc, "alice", tokenstore.EncodeTokenWithExpiration(expired, -3600)); err != nil {
t.Fatalf("seed access token: %v", err)
}
if err := tokenstore.Set(svc+":refresh", "alice", "entr_old"); err != nil {
t.Fatalf("seed refresh token: %v", err)
}
}
Acmd/entire/cli/auth/refresh_test.go+190