Skip to content
Open
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
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,44 @@ 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).
One desktop OAuth client can be imported into profiles in both tools, but each CLI asks for consent and stores its token separately. See [OAuth setup](WORKSPACE_ADMINS.md) for personal External/testing and organization Internal guidance, including profile-specific client imports.

## Profiles

Each tool has its own profile namespace. Use a bare profile name with the global
`--profile` shorthand, or pass the full credential reference with `--ref`:

```bash
gro --profile work mail list
grw --profile work calendar today
gro --ref google-readonly/work mail list
```

The selector precedence is explicit flag (`--profile` or `--ref`), credential

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precedence description names a credential-reference environment override but never tells users which variable to set, leaving that documented selector unusable without source inspection. Document the concrete per-binary names, e.g. GOOGLE_READONLY_CREDENTIAL_REF for gro and GOOGLE_READWRITE_CREDENTIAL_REF for grw, alongside a short example.

Reply inline to this comment.

reference environment variable, saved `credential_ref`, then the built-in
`default` profile. For environment selection, use
`GOOGLE_READONLY_CREDENTIAL_REF` with `gro` or
`GOOGLE_READWRITE_CREDENTIAL_REF` with `grw`, for example:

```bash
GOOGLE_READONLY_CREDENTIAL_REF=google-readonly/work gro mail list
```

`--profile` and `--ref` cannot be used together. To add an account without
changing the active profile, run `gro --profile work init` (or the equivalent
`grw` command). Inspect and manage profiles with:

```bash
gro profiles list
gro profiles rename old-name new-name
```

Renaming moves the stored credentials without re-authentication, updates the
saved active profile when necessary, and refuses a destination that already
has credentials. If saving the active-profile update fails, the copied

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This states that the copied destination is removed after an active-profile save failure, but the implementation only attempts that rollback; if deleting the copy also fails, it reports that the destination may remain. Qualify this as an attempted removal and mention that a rollback failure is reported, so users do not assume a retry cannot encounter a destination collision.

Reply inline to this comment.

destination is removed when rollback succeeds while the source remains, so the
command can be retried. If rollback also fails, the command reports that the
destination may remain.

## Documentation

Expand Down
71 changes: 40 additions & 31 deletions WORKSPACE_ADMINS.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
# Google CLI for Workspace administrators
# OAuth setup for `gro` and `grw`

This guide sets up one organization-managed desktop OAuth client that employees can use with both `gro` and `grw`. Each user still grants consent, and each binary stores its token under a separate identity.
This guide covers OAuth clients for personal Google accounts and Google Workspace organizations. A CLI profile selects a saved account token; an OAuth client identifies the app shown during consent. Each profile can use its own OAuth client, and each CLI stores its token separately.

## Before you begin
## Choose an audience

You need:
- **Personal Google account:** use an **External** app. Testing is the simplest initial setup: add your account as a test user and expect a testing warning. Because these CLIs request scopes beyond basic profile information, refresh tokens for an External app in Testing expire after seven days. An External app in Production avoids this Testing-specific limit, but unverified warnings and the 100-user cap can still apply. Personal-only apps or apps for a few personally known users may qualify for a verification exemption; review Google's current requirements before publishing.
- **Google Workspace organization:** a Workspace admin can create an **Internal** app for accounts in the project's organization. This avoids the External-app verification path, while Workspace admin policies still apply. The project must belong to the organization for Internal to be available.

- A Google Workspace administrator account.
- Permission to create a Google Cloud project under the Workspace organization.
- A controlled channel for distributing the downloaded OAuth client JSON.
## Required APIs and scopes

An Internal audience limits consent to accounts in the Workspace organization and avoids the External-app verification path. If the project is not attached to the organization, the Internal option will not appear.

## Create the project and client

1. In [Google Cloud Console](https://console.cloud.google.com/), create or select a project under the Workspace organization.
2. Enable Gmail API, Google Calendar API, People API, and Google Drive API.
3. In Google Auth Platform (or OAuth consent screen), set the audience to **Internal** and provide the application/support details.
4. Add the union of scopes below under Data Access.
5. Create an OAuth client with application type **Desktop app** and download its JSON.

One desktop client is sufficient because the binaries request scopes at authorization time. Users authorize each binary independently.

## Scope inventory
Enable Gmail API, Google Calendar API, People API, and Google Drive API for the APIs your CLI uses. If both binaries will be used, enable all four and declare the union of scopes below in Google Auth Platform's Data Access settings.

### `gro`

Expand Down Expand Up @@ -55,28 +42,50 @@ https://www.googleapis.com/auth/drive.metadata

Gmail settings access supports filters. The broad mail scope is required for permanent deletion; the command defaults to recoverable Trash and gates permanent deletion behind `--permanent --yes`. Calendar scopes support reading and mutating events, the Contacts scope supports reading and mutating contacts and groups, the Drive scopes support reading, uploading, organizing, trashing, restoring, and permanently deleting files, and the profile scope supports `grw me`.

## Distribute and verify
## Personal account: External app

1. In [Google Cloud Console](https://console.cloud.google.com/), create or select a project you control.
2. Enable the required APIs above.
3. In Google Auth Platform's **Branding** page, enter the app name, user support email, and developer contact email. Under **Audience**, choose **External**. For the simplest initial setup, leave publishing status at **Testing** and add your Google account under **Test users**.
4. Add the scopes needed by your CLI under **Data Access**.
5. Create an OAuth client with application type **Desktop app** and download its JSON file.

Distribute the OAuth client JSON through an access-controlled vault, MDM, or internal file service. Desktop OAuth client material identifies the application; user refresh tokens are separate secrets and must remain in each user's selected keyring backend.
The testing warning is expected, and reauthorization is required seven days after each authorization. Moving an External app to Production removes this Testing-specific token limit, but other token expiration rules still apply. Publishing and verification are separate steps: personal-only use or a few personally known users may qualify for a verification exemption, while unverified warnings and the 100-user cap can remain. If **Publish app** is unavailable, complete the Branding requirements shown in Google Auth Platform, including any required app homepage, privacy policy, and terms links. Review Google's current [personal-use and branding requirements](https://developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification#personal-use), [OAuth app states](https://developers.google.com/identity/protocols/oauth2/production-readiness/overview), [Gmail scope requirements](https://developers.google.com/workspace/gmail/api/auth/scopes), and [restricted-scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification) before distributing an app more broadly.

Each user can give the JSON to the setup wizard from a file, clipboard, or terminal paste:
Import the client for the profile you want to authorize:

```bash
gro init --credentials-file /path/to/oauth-client.json
gro me
grw --profile personal init --credentials-file /path/to/oauth-client.json
grw --profile personal me

grw init --credentials-file /path/to/oauth-client.json
grw mail list --max 1
# If you also use gro, import the same client for its separate profile/token:
gro --profile personal init --credentials-file /path/to/oauth-client.json
```

If one binary is already configured, the other setup wizard can discover and reuse its OAuth client JSON. It does not reuse the token: the user sees a separate consent flow for the other identity and scope set.
## Workspace organization: Internal app

You need a Google Workspace administrator account, permission to create a Google Cloud project under the organization, and a controlled channel for distributing the OAuth client JSON.

1. In Google Cloud Console, create or select a project under the Workspace organization.
2. Enable the required APIs above.
3. In Google Auth Platform, set the audience to **Internal**, provide the app and support details, and add the union of scopes above under **Data Access**.
4. Create an OAuth client with application type **Desktop app** and download its JSON.

Distribute the JSON through an access-controlled vault, MDM, or internal file service. Each CLI still asks the user for consent and keeps its token in a separate keyring namespace. Use `gro init --credentials-file /path/to/oauth-client.json` and `grw init --credentials-file /path/to/oauth-client.json`, adding `--profile <name>` before `init` when authorizing named profiles.

## Profile-specific OAuth clients

`--credentials-file` imports the JSON for the selected CLI profile and stores it in a profile-specific managed file. For example, `grw --profile work init --credentials-file ...` associates the client with `google-readwrite/work`; it does not replace the legacy shared client or another profile's client. Run a matching `gro` command to import that JSON for a `gro` profile. The automatic sibling-client lookup applies to the legacy shared `oauth_client.json`; profile-specific imports are not discovered by the other binary.

If the selected profile already has a token, re-importing the same OAuth client ID is allowed. Importing a different client ID is refused so the existing token is not used with the wrong app. Use a new profile, or clear only the selected profile's token with the same `--profile` selector before authorizing a different client.

## Administration and troubleshooting

- If users see an unverified-app warning, confirm the project belongs to the Workspace organization and the audience is Internal.
- If access is blocked, use Admin Console → Security → Access and data control → API controls to trust or allow the OAuth client for the intended organizational units or groups.
- If a personal-account user cannot consent while the app is in Testing, confirm that their Google account is listed under **Test users**.
- If Google returns `org_internal`, the OAuth app is restricted to the Workspace organization that owns the project. Use an External app/client for a personal Google account; CLI `--profile` names do not change an OAuth app's audience.
- If Workspace access is blocked, use Admin Console → Security → Access and data control → API controls to trust or allow the OAuth client for the intended organizational units or groups.
- If an API reports `SERVICE_DISABLED`, enable the named API in the Cloud project and wait for propagation.
- Revoke a user's grant through Google Account permissions, or block the client in Admin Console to revoke organization access.
- Rotate and redistribute the client JSON if its distribution boundary is breached; test the rotation with one user first.

For personal accounts or cross-organization distribution, create an External-audience client and follow Google's current verification requirements. See Google's guidance on [OAuth app audience](https://support.google.com/cloud/answer/15549945), [when verification is not needed](https://support.google.com/cloud/answer/13464323), and [installed applications](https://developers.google.com/identity/protocols/oauth2/native-app).
See Google's guides for [app audiences and test users](https://support.google.com/cloud/answer/15549945), [OAuth 2.0 for desktop apps](https://developers.google.com/identity/protocols/oauth2/native-app), and [refresh-token expiration](https://developers.google.com/identity/protocols/oauth2).
98 changes: 98 additions & 0 deletions internal/app/gro/credref_wire_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package gro

import (
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

initcmd "github.com/open-cli-collective/google-cli/internal/cmd/init"
"github.com/open-cli-collective/google-cli/internal/cmd/setcred"
"github.com/open-cli-collective/google-cli/internal/credtest"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)
Expand Down Expand Up @@ -116,3 +120,97 @@ func TestCredentialRef_SetCredentialShadowsPersistent(t *testing.T) {
t.Errorf("read command --%s = %p, want canonical %p (unexpected shadow)", rootutil.CredentialRefFlagName, got, canonical)
}
}

func selectorTestRoot() *cobra.Command {
var verbose, noColor bool
root := &cobra.Command{
Use: "gro",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return rootutil.ApplyGlobalFlags(cmd, verbose, noColor)
},
}
rootutil.AddGlobalFlags(root, &verbose, &noColor)
root.AddCommand(initcmd.NewCommand())
root.AddCommand(setcred.NewCmd())
return root
}

func TestProfileFlagInheritedByInitInBothFlagOrders(t *testing.T) {
for _, tc := range []struct {
name string
args func(string) []string
}{
{name: "before command", args: func(path string) []string {
return []string{"--profile", "work", "init", "--credentials-file", path}
}},
{name: "after command", args: func(path string) []string {
return []string{"init", "--profile", "work", "--credentials-file", path}
}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetArgs(tc.args(filepath.Join(t.TempDir(), "missing.json")))
if err := root.Execute(); err == nil {
t.Fatal("init should fail for the intentionally missing client file")
}
if got, set := keychain.GetCredentialRefOverride(); !set || got != "google-readonly/work" {
t.Fatalf("selector after init path = (%q, %v), want google-readonly/work", got, set)
}
})
}
}

func TestProfileFlagInheritedBySetCredentialTargetsNamedProfile(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "before command", args: []string{"--profile", "work", "set-credential", "--key", "oauth_token", "--stdin"}},
{name: "after command", args: []string{"set-credential", "--profile", "work", "--key", "oauth_token", "--stdin"}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token","refresh_token":"refresh"}`))
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("set-credential: %v", err)
}
st, err := keychain.OpenRef("google-readonly/work")
if err != nil {
t.Fatal(err)
}
tok, err := st.Token()
_ = st.Close()
if err != nil || tok.AccessToken != "profile-token" {
t.Fatalf("named profile token = %+v, err=%v", tok, err)
}
assertNoTokenAtRef(t, "google-readonly/default")
})
}
}

func TestProfileAndSetCredentialRefAreMutuallyExclusive(t *testing.T) {
credtest.Setup(t)
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token"}`))
root.SetArgs([]string{"--profile", "work", "set-credential", "--ref", "google-readonly/other", "--key", "oauth_token", "--stdin"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("profile/local --ref conflict = %v, want mutual-exclusion error", err)
}
}

func assertNoTokenAtRef(t *testing.T, ref string) {
t.Helper()
st, err := keychain.OpenRef(ref)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if has, err := st.HasToken(); err != nil || has {
t.Fatalf("%s token presence = (%v, %v), want (false, nil)", ref, has, err)
}
}
109 changes: 109 additions & 0 deletions internal/app/grw/credref_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package grw

import (
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

initcmd "github.com/open-cli-collective/google-cli/internal/cmd/init"
"github.com/open-cli-collective/google-cli/internal/cmd/setcred"
"github.com/open-cli-collective/google-cli/internal/credtest"
"github.com/open-cli-collective/google-cli/internal/keychain"
"github.com/open-cli-collective/google-cli/internal/rootutil"
)

func selectorTestRoot() *cobra.Command {
var verbose, noColor bool
root := &cobra.Command{
Use: "grw",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return rootutil.ApplyGlobalFlags(cmd, verbose, noColor)
},
}
rootutil.AddGlobalFlags(root, &verbose, &noColor)
root.AddCommand(initcmd.NewCommand())
root.AddCommand(setcred.NewCmd())
return root
}

func TestProfileFlagInheritedByInitInBothFlagOrders(t *testing.T) {
for _, tc := range []struct {
name string
args func(string) []string
}{
{name: "before command", args: func(path string) []string {
return []string{"--profile", "work", "init", "--credentials-file", path}
}},
{name: "after command", args: func(path string) []string {
return []string{"init", "--profile", "work", "--credentials-file", path}
}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetArgs(tc.args(filepath.Join(t.TempDir(), "missing.json")))
if err := root.Execute(); err == nil {
t.Fatal("init should fail for the intentionally missing client file")
}
if got, set := keychain.GetCredentialRefOverride(); !set || got != "google-readwrite/work" {
t.Fatalf("selector after init path = (%q, %v), want google-readwrite/work", got, set)
}
})
}
}

func TestProfileFlagInheritedBySetCredentialTargetsNamedProfile(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "before command", args: []string{"--profile", "work", "set-credential", "--key", "oauth_token", "--stdin"}},
{name: "after command", args: []string{"set-credential", "--profile", "work", "--key", "oauth_token", "--stdin"}},
} {
t.Run(tc.name, func(t *testing.T) {
credtest.Setup(t)
t.Setenv(keychain.CredentialRefEnvVar(), "")
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token","refresh_token":"refresh"}`))
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("set-credential: %v", err)
}
st, err := keychain.OpenRef("google-readwrite/work")
if err != nil {
t.Fatal(err)
}
tok, err := st.Token()
_ = st.Close()
if err != nil || tok.AccessToken != "profile-token" {
t.Fatalf("named profile token = %+v, err=%v", tok, err)
}
assertNoTokenAtRef(t, "google-readwrite/default")
})
}
}

func TestProfileAndSetCredentialRefAreMutuallyExclusive(t *testing.T) {
credtest.Setup(t)
root := selectorTestRoot()
root.SetIn(strings.NewReader(`{"access_token":"profile-token"}`))
root.SetArgs([]string{"--profile", "work", "set-credential", "--ref", "google-readwrite/other", "--key", "oauth_token", "--stdin"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("profile/local --ref conflict = %v, want mutual-exclusion error", err)
}
}

func assertNoTokenAtRef(t *testing.T, ref string) {
t.Helper()
st, err := keychain.OpenRef(ref)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if has, err := st.HasToken(); err != nil || has {
t.Fatalf("%s token presence = (%v, %v), want (false, nil)", ref, has, err)
}
}
13 changes: 13 additions & 0 deletions internal/app/grw/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package grw

import (
"os"
"testing"

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

func TestMain(m *testing.M) {
config.Register(Identity())
os.Exit(m.Run())
}
Loading
Loading