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

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