Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ grw contacts create --given-name Test --email t@example.com --dry-run
grw drive trash --query "name contains 'old'" --dry-run
```

One desktop OAuth client can be used by both tools, but each tool asks for consent and stores its token under its own identity. Google Workspace administrators should start with [`WORKSPACE_ADMINS.md`](WORKSPACE_ADMINS.md).
Each `--profile` selects one complete account context: its token, OAuth client JSON, granted scopes, and Drive metadata cache. `init --profile work --credentials-file client.json` binds that client to `work`; an unconfigured profile never falls back to another profile's client or cache. The same desktop OAuth client may be reused by both tools for the same profile, but each tool still asks for consent and stores its token under its own identity. Google Workspace administrators should start with [`WORKSPACE_ADMINS.md`](WORKSPACE_ADMINS.md).

## Documentation

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The write `NewCommand()` starts with `internal/cmd/<domain>.NewCommand()` and at

`cmd/grw/main.go` registers `internal/app/grw.Identity()` before running the `grw` root. That root composes setup/configuration and profile commands with the extended Gmail, Calendar, Contacts, and Drive commands.

Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Their OAuth client JSON may be reused across identities, but tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar, Contacts, and Drive read/write scopes, and basic profile access.
Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Each bare profile owns its OAuth client JSON, granted scopes, token, and Drive cache; a client JSON may be reused across identities only for the same profile, while tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar, Contacts, and Drive read/write scopes, and basic profile access.

## Structural enforcement

Expand Down
36 changes: 29 additions & 7 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,29 @@ func CheckScopesMigration(grantedScopes []string) string {
return msg
}

// GetOAuthConfig loads the OAuth client config from the deployment-material
// OAuth client JSON referenced by config.yml's oauth_client_path (§1.2 — not
// a secret; lives on disk, never the keyring), with all scopes.
// GetOAuthConfig loads the OAuth client for the effective credential ref. The
// ref is resolved without opening the keyring so all account-derived state
// follows the same selection boundary.
func GetOAuthConfig() (*oauth2.Config, error) {
ref, err := keychain.ResolveEffectiveCredentialRef()
if err != nil {
return nil, err
}
return GetOAuthConfigForRef(ref)
}

// GetOAuthConfigForRef loads the exact profile-owned OAuth client JSON for
// ref. An unconfigured profile is a hard miss; it never falls back to the
// active profile's client or scopes.
func GetOAuthConfigForRef(ref string) (*oauth2.Config, error) {
cfg, err := config.LoadConfigForRuntime()
if err != nil {
return nil, err
}
path := config.ExpandPath(cfg.OAuthClientPath)
path := cfg.OAuthClientPathForRef(ref)
if path == "" {
return nil, fmt.Errorf("no OAuth client configured for credential %s (run '%s init --profile %s --credentials-file <path>' or import a client for this profile)", ref, config.ProductName(), profileName(ref))
}
b, err := os.ReadFile(path) //nolint:gosec // deployment-material path from config
if err != nil {
return nil, fmt.Errorf("unable to read OAuth client JSON %s (run '%s init'): %w",
Expand Down Expand Up @@ -106,7 +120,9 @@ func GetHTTPClientForRef(ctx context.Context, ref string) (*http.Client, error)
// lifetime). Shared by the active-ref and explicit-ref entry points so the
// persist-on-refresh and error-attribution behavior can't diverge.
func clientFromStore(ctx context.Context, st *keychain.Store) (*http.Client, error) {
oauthCfg, err := GetOAuthConfig()
ref := st.Ref()
refSource := st.RefSource()
oauthCfg, err := GetOAuthConfigForRef(ref)
if err != nil {
_ = st.Close()
return nil, err
Expand All @@ -118,8 +134,6 @@ func clientFromStore(ctx context.Context, st *keychain.Store) (*http.Client, err
return nil, fmt.Errorf("no OAuth token stored for credential %s (selected via %s) - run '%s init' first: %w",
st.Ref(), keychain.DescribeRefSource(st.RefSource()), config.ProductName(), err)
}
ref := st.Ref()
refSource := st.RefSource()
_ = st.Close() // do not hold the Store for the client's lifetime

persist := func(t *oauth2.Token) error {
Expand All @@ -135,6 +149,14 @@ func clientFromStore(ctx context.Context, st *keychain.Store) (*http.Client, err
return oauth2.NewClient(ctx, &attributedTokenSource{base: tokenSource, ref: ref, source: refSource}), nil
}

func profileName(ref string) string {
_, profile, ok := strings.Cut(ref, "/")
if !ok {
return ref
}
return profile
}

// attributedTokenSource decorates auth failures from the wrapped source with
// the credential ref that failed and where that ref was selected. Without
// this, an expired/revoked refresh token surfaces as a bare
Expand Down
132 changes: 132 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,146 @@
package auth

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"golang.org/x/oauth2"

"github.com/open-cli-collective/google-cli/internal/config"
"github.com/open-cli-collective/google-cli/internal/credtest"
"github.com/open-cli-collective/google-cli/internal/keychain"
)

func clientJSON(clientID, tokenURI string) string {
return fmt.Sprintf(`{"installed":{"client_id":%q,"project_id":"p","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":%q,"client_secret":"secret","redirect_uris":["http://localhost"]}}`, clientID, tokenURI)
}

func TestGetOAuthConfigForRefUsesExactProfileClient(t *testing.T) {
credtest.Setup(t)
dir := credtest.ConfigDir(t)
defaultPath := filepath.Join(dir, "default-client.json")
workPath := filepath.Join(dir, "work-client.json")
if err := os.WriteFile(defaultPath, []byte(clientJSON("default-client", "https://oauth2.googleapis.com/token")), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(workPath, []byte(clientJSON("work-client", "https://oauth2.googleapis.com/token")), 0o600); err != nil {
t.Fatal(err)
}
if err := config.SaveConfig(&config.Config{
CredentialRef: config.DefaultCredentialRef,
Profiles: map[string]config.ProfileConfig{
"default": {OAuthClientPath: defaultPath, GrantedScopes: []string{"default-scope"}},
"work": {OAuthClientPath: workPath, GrantedScopes: []string{"work-scope"}},
},
}); err != nil {
t.Fatal(err)
}
got, err := GetOAuthConfigForRef("google-readonly/work")
if err != nil {
t.Fatal(err)
}
if got.ClientID != "work-client" {
t.Fatalf("work client ID = %q, want work-client", got.ClientID)
}
if _, err := GetOAuthConfigForRef("google-readonly/other"); err == nil || !strings.Contains(err.Error(), "no OAuth client configured") {
t.Fatalf("unconfigured ref result = %v, want actionable miss", err)
}
}

func TestGetHTTPClientForRefUsesMatchingOAuthClientOnRefresh(t *testing.T) {
credtest.Setup(t)
const ref = "google-readonly/work"
seenClientID := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
clientID := r.Form.Get("client_id")
if clientID == "" {
clientID, _, _ = r.BasicAuth()
}
seenClientID <- clientID
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"work-access","token_type":"Bearer","expires_in":3600}`)
case "/api":
if r.Header.Get("Authorization") != "Bearer work-access" {
http.Error(w, "wrong token", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()

dir := credtest.ConfigDir(t)
defaultPath := filepath.Join(dir, "default-client.json")
workPath := filepath.Join(dir, "work-client.json")
if err := os.WriteFile(defaultPath, []byte(clientJSON("default-client", server.URL+"/token")), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(workPath, []byte(clientJSON("work-client", server.URL+"/token")), 0o600); err != nil {
t.Fatal(err)
}
if err := config.SaveConfig(&config.Config{
CredentialRef: config.DefaultCredentialRef,
Profiles: map[string]config.ProfileConfig{
"default": {OAuthClientPath: defaultPath},
"work": {OAuthClientPath: workPath},
},
}); err != nil {
t.Fatal(err)
}
st, err := keychain.OpenRef(ref)
if err != nil {
t.Fatal(err)
}
if err := st.SetToken(&oauth2.Token{AccessToken: "expired", RefreshToken: "refresh", Expiry: time.Now().Add(-time.Hour)}); err != nil {
_ = st.Close()
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}

httpClient, err := GetHTTPClientForRef(context.Background(), ref)
if err != nil {
t.Fatal(err)
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/api", nil)
if err != nil {
t.Fatal(err)
}
response, err := httpClient.Do(request)
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("API status = %d, want %d", response.StatusCode, http.StatusNoContent)
}
select {
case got := <-seenClientID:
if got != "work-client" {
t.Fatalf("refresh client ID = %q, want work-client", got)
}
case <-time.After(2 * time.Second):
t.Fatal("refresh endpoint was not called")
}
}

// TestDeprecatedWrappers verifies that auth package wrappers delegate to config package
func TestDeprecatedWrappers(t *testing.T) {
t.Run("GetConfigDir delegates to config package", func(t *testing.T) {
Expand Down
93 changes: 18 additions & 75 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
// state at os.UserCacheDir()/google-readonly (via statedir.Cache). Writes are
// atomic via cli-common/cache's temp+rename envelope; TTL is hard-coded per
// resource (no user-configurable cache_ttl_hours — §4.4); reads classify a
// version/identity mismatch as a miss so schema bumps self-heal. The pre-B2b
// "<configdir>/cache/" relocation is retained for installs that pre-date the
// B2b cache move.
// version/identity mismatch as a miss so schema bumps self-heal. Legacy
// unscoped cache data is intentionally treated as a miss because it cannot be
// safely attributed to a profile.
package cache

import (
Expand All @@ -18,20 +18,18 @@ import (
"time"

clicache "github.com/open-cli-collective/cli-common/cache"
"github.com/open-cli-collective/cli-common/credstore"

"github.com/open-cli-collective/google-cli/internal/config"
"github.com/open-cli-collective/google-cli/internal/keychain"
)

const (
// DrivesFile is the cache file for shared drives. Retained as the legacy
// pre-B2b file name so migrateLegacyCacheDir can find and relocate it.
// DrivesFile is the cache file for shared drives.
DrivesFile = "drives.json"
// drivesResource is the cli-common cache resource name. The on-disk file
// becomes <cachedir>/<instanceKey>/drives.json.
drivesResource = "drives"
// instanceKey is gro's single-instance discriminator (Locator requires
// one). Per cli-common docs: single-instance CLIs use "default".
instanceKey = "default"
// drivesTTL is the §4.4 hard-coded per-resource TTL for shared drives —
// same 24-hour default the user-configurable knob previously defaulted to.
drivesTTL = "24h"
Expand All @@ -50,82 +48,27 @@ type Cache struct {
}

// New creates a new Cache instance rooted at the OS cache dir (B2b via
// cli-common/statedir). It also runs a transparent, best-effort one-time
// relocation of a pre-B2b cache that lived inside the config dir; relocation
// never fails New (the cache is disposable — it simply repopulates).
// cli-common/statedir), isolated by the selected bare credential profile.
// Legacy unscoped cache is intentionally ignored: it is disposable and cannot
// be safely attributed to any profile.
func New() (*Cache, error) {
ref, err := keychain.ResolveEffectiveCredentialRef()
if err != nil {
return nil, err
}
_, profile, err := credstore.ParseRef(ref)
if err != nil {
return nil, fmt.Errorf("invalid credential ref %q: %w", ref, err)
}
cacheDir, err := config.GetCacheDir()
if err != nil {
return nil, err
}
migrateLegacyCacheDir(cacheDir)
return &Cache{
loc: clicache.Locator{Root: cacheDir, InstanceKey: instanceKey},
loc: clicache.Locator{Root: cacheDir, InstanceKey: profile},
}, nil
}

// migrateLegacyCacheDir relocates a pre-B2b cache (a "cache" subdir of the
// config dir) into newDir, then removes the legacy dir. Strictly silent and
// best-effort: any failure is abandoned without touching New's result. If the
// warm-cache carry fails the legacy dir is left intact; a stuck legacy dir is
// force-cleaned by `config clear --all`. The carry is a byte copy and does
// NOT parse the envelope; a stale shape arriving in newDir is harmless
// because cli-common/cache's identity/version check + this package's
// parse-error-mapped-to-miss demote it on the next read.
func migrateLegacyCacheDir(newDir string) {
// Two possible legacy locations on macOS/Windows: (a) the pre-B2b
// "cache" subdir under the NEW (statedir-resolved) config dir — what a
// user reached if they already ran post-port code once — and (b) the
// same subdir under the OLD hand-rolled config dir, which is where a
// pure pre-MON-5371 install lived. Both are byte-carried best-effort;
// any failure abandons the migration without touching New's result. On
// Linux the two paths collapse, so the second probe is a stat-only no-op.
probes := []func() (string, error){
config.LegacyCacheDir,
config.OldHandRolledLegacyCacheDir,
}
tried := map[string]bool{}
for _, p := range probes {
legacy, err := p()
if err != nil || tried[legacy] {
continue
}
tried[legacy] = true
tryCarryLegacyCache(legacy, newDir)
}
}

func tryCarryLegacyCache(legacy, newDir string) {
if _, err := os.Stat(legacy); err != nil {
return
}

legacyDrives := filepath.Join(legacy, DrivesFile)
newDrives := filepath.Join(newDir, instanceKey, DrivesFile)
switch _, serr := os.Stat(newDrives); {
case serr == nil:
// New cache already present: nothing to carry, safe to drop legacy.
case os.IsNotExist(serr):
data, rerr := os.ReadFile(legacyDrives) //nolint:gosec // path from legacy config dir
switch {
case rerr == nil:
if mkErr := os.MkdirAll(filepath.Dir(newDrives), 0o700); mkErr != nil {
return
}
if werr := os.WriteFile(newDrives, data, config.TokenPerm); werr != nil { //nolint:gosec // disposable cache
return
}
case os.IsNotExist(rerr):
// No warm file to carry — fine, fall through to cleanup.
default:
return
}
default:
return
}
_ = os.RemoveAll(legacy)
}

// GetDrives returns cached shared drives, or nil if cache is stale, missing,
// or corrupt. Corrupt-as-miss preserves the pre-MON-5371 behavior: caches
// are disposable, so a JSON parse error self-heals on the next API call. I/O
Expand Down
Loading
Loading