Skip to content

Feature: seed an initial access token in gog auth import (parity with mcporter vault) #598

@alexminza

Description

@alexminza

What

Extend gog auth import to accept an initial access token alongside the refresh token, and persist both in the keyring. Today auth import writes only the refresh token; the first API call after auth import pays a refresh round-trip to Google's token endpoint (golang.org/x/oauth2's TokenSource sees no cached access token, hits the refresh endpoint to mint one, then proceeds with the original API call) — even when the orchestrator already has a fresh access token in hand from the same OAuth-callback exchange. mcporter's OAuth vault already carries both — gogcli should match.

Why

Today's shape

gog auth import --refresh-token-{stdin,file,env} writes secrets.Token{Client, Email, Services, RefreshToken} to the keyring. Verified at internal/cmd/auth_import.go:

if err := store.SetToken(client, email, secrets.Token{
    Client:       client,
    Email:        email,
    Services:     services,
    RefreshToken: refreshToken,
}); err != nil {
    return err
}

The secrets.Token struct at internal/secrets/store.go has no AccessToken field — there is nowhere to store one, and the refresh path constructs the first access token by hitting Google's oauth2.googleapis.com/token endpoint via the golang.org/x/oauth2 library.

type Token struct {
    Client       string    `json:"client,omitempty"`
    Subject      string    `json:"subject,omitempty"`
    Email        string    `json:"email"`
    Services     []string  `json:"services,omitempty"`
    Scopes       []string  `json:"scopes,omitempty"`
    CreatedAt    time.Time `json:"created_at,omitempty"`
    RefreshToken string    `json:"-"`
    // No AccessToken field
}

The --access-token flag exists (gog auth import --access-token) but it's a per-invocation bypass ("Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h)") — it doesn't persist; the value is consumed for that one call and forgotten.

Project-side cost

Headless orchestrators that complete the OAuth authorization-code flow server-side end up with a {access_token, refresh_token, expires_in, ...} payload from Google's token endpoint. They forward both to the instance. Today:

  1. Orchestrator runs gog auth import --refresh-token-env=REFRESH ... on the instance.
  2. The setup hook completes; the access token the orchestrator just had is discarded.
  3. First subsequent gog ... call has no access token cached → oauth2.TokenSource proactively makes an HTTP POST to oauth2.googleapis.com/token → mints a new access token → proceeds with the API call.

The refresh endpoint round-trip adds 100-500ms to the first call after every setup fire, and a token-endpoint quota hit that's pure waste (the orchestrator already paid for an access token <1 minute earlier). For agent-driven workflows where the first action after connect is "use the integration immediately," this is a visible latency bump.

Precedent: mcporter does this

openclaw/mcporter (also maintained by @steipete; same target user) stores both access and refresh tokens in its OAuth vault, populated by mcporter vault set --stdin via the JSON envelope:

{
  "tokens": {
    "access_token": "...",
    "refresh_token": "...",
    "token_type": "Bearer"
  },
  "clientInfo": { "client_id": "..." }
}

Third-party init scripts that seed the vault pass both tokens specifically to avoid the first-call token-endpoint refresh round-trip — the access token's purpose in the vault is to skip the proactive refresh oauth2.TokenSource would otherwise issue when the cached access token slot is empty. The token-endpoint hit is wasted work when the orchestrator already has a valid access token from the same OAuth exchange.

Same threat model, same intake shape, gogcli is missing the access-token half.

Suggested shape

Three changes, narrowly scoped:

1. Extend Token and storedToken

Keyring persistence uses a private storedToken struct constructed inside KeyringStore.SetToken (internal/secrets/store.go) and serialized via json.Marshal — that's the on-the-wire shape, not the Token struct directly. Both structs need the new fields:

// In-memory API surface
type Token struct {
    Client       string    `json:"client,omitempty"`
    Subject      string    `json:"subject,omitempty"`
    Email        string    `json:"email"`
    Services     []string  `json:"services,omitempty"`
    Scopes       []string  `json:"scopes,omitempty"`
    CreatedAt    time.Time `json:"created_at,omitempty"`
    RefreshToken string    `json:"-"`                                    // existing
    AccessToken  string    `json:"-"`                                    // NEW
    AccessTokenExpiresAt time.Time `json:"-"`                            // NEW
}

// Keyring payload (private to internal/secrets); already json.Marshal'd into the keyring entry
type storedToken struct {
    RefreshToken string    `json:"refresh_token"`
    Subject      string    `json:"subject,omitempty"`
    Email        string    `json:"email"`
    Services     []string  `json:"services,omitempty"`
    Scopes       []string  `json:"scopes,omitempty"`
    CreatedAt    time.Time `json:"created_at,omitempty"`
    AccessToken  string    `json:"access_token,omitempty"`              // NEW
    AccessTokenExpiresAt time.Time `json:"access_token_expires_at,omitempty"` // NEW
}

The json:"-" tags on Token's secret fields are intentional — the Token struct is never json.Marshal'd directly by gogcli today (existing tags are placeholders for the field shape, not active serialization). Persistence happens via the explicit storedToken{...} construction inside SetToken. The new fields follow the existing precedent (refresh token in-memory: json:"-"; on the wire: serialized through storedToken).

2. Add intake flags on auth import

Mirror the existing --refresh-token-* flags:

gog auth import \
  --email=user@example.com \
  --client=my-app \
  --refresh-token-env=REFRESH_TOKEN \
  --access-token-env=ACCESS_TOKEN \
  --access-token-expires-at='<RFC3339_FUTURE_EXPIRY>'

The last two flags are new. --access-token-expires-at is optional (default: now + 1h).

resolveRefreshToken() already validates "exactly one source" for the refresh-token input modes. Add a parallel resolveAccessToken() with the same shape, but optionalauth import without an access token continues to work exactly as today.

If --access-token-expires-at isn't provided, fall back to now + 1h (matching Google's default access-token TTL). If provided, parse as RFC 3339 and refuse if it's already in the past.

3. Teach the refresh path to use the cached access token

Update tokenSourceForAccountScopes in internal/googleapi/client_auth.go:

baseSource := cfg.TokenSource(ctx, &oauth2.Token{
    RefreshToken: tok.RefreshToken,
    AccessToken:  tok.AccessToken,                    // NEW
    Expiry:       tok.AccessTokenExpiresAt,           // NEW
})

The golang.org/x/oauth2 library's TokenSource already does the right thing when both AccessToken and Expiry are present — it returns the cached token while it's still valid, only hits the refresh endpoint when expired. Zero changes needed in persistingTokenSource.

Scope

  • internal/secrets/store.go: Token struct additions; SetToken / GetToken carry the new fields through.
  • internal/cmd/auth_import.go: new flags + validation, optional.
  • internal/googleapi/client_auth.go: pass AccessToken + Expiry to oauth2.Token.
  • Migration: empty AccessToken → falls through to refresh path (current behavior). No breakage.

Out of scope

  • Persisting rotated access tokens from persistingTokenSource. Today it only persists the refresh token on rotation; should it also write back the new access token? Yes, naturally — but that's an internal optimization, not strictly required by this issue (the second call onwards already benefits from the in-memory oauth2.TokenSource cache within a single gog process). File as a follow-up if it doesn't fall out of (3) for free.
  • auth tokens import parity. That command imports a JSON file containing one or more tokens (not a single refresh token); if the file format adds an access-token field, the loader should round-trip it through the new keyring fields. Mention but don't block.
  • auth add (interactive OAuth) already obtains both tokens from Google during the auth-code exchange; updating it to persist the access token alongside the refresh token is a natural follow-up.

Context

  • Filed alongside #596 (move client_secret to keyring). The two issues together close the gap between gogcli's and mcporter's OAuth-secret-handling shapes — currently asymmetric across the two projects @steipete maintains.
  • No existing issue, PR, or discussion proposes this — searched for access token seeding, access token persist, auth import access, cache access token; discussions are disabled on this repo. Filing as a clean new ask.

References

All references pinned to v0.17.0:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions