diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b3c22..a672db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Gmail: allow drafts without a recipient; drafts update preserves existing `To` when `--to` is omitted. (#57) — thanks @antons. +### Added + +- Auth: `gog auth add --readonly` and `--drive-scope` for least-privilege tokens. (#58) — thanks @jeremys. + ### Fixed - Paths: expand leading `~` in user-provided file paths (e.g. `--out "~/Downloads/file.pdf"`). (#56) — thanks @salmonumbrella. diff --git a/README.md b/README.md index 07df52d..5114349 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,18 @@ To request fewer scopes: gog auth add you@gmail.com --services drive,calendar ``` +To request read-only scopes (write operations will fail with 403 insufficient scopes): + +```bash +gog auth add you@gmail.com --services drive,calendar --readonly +``` + +To use Drive's file-limited scope (write-capable, but limited to files created/opened by this app): + +```bash +gog auth add you@gmail.com --services drive --drive-scope file +``` + If you need to add services later and Google doesn't return a refresh token, re-run with `--force-consent`: ```bash diff --git a/docs/spec.md b/docs/spec.md index e0058ee..74464a7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -103,7 +103,7 @@ Scope selection note: - The consent screen shows the scopes the CLI requested. - Users cannot selectively un-check individual requested scopes in the consent screen; they either approve all requested scopes or cancel. -- To request fewer scopes, choose fewer services via `gog auth add --services ...`. +- To request fewer scopes, choose fewer services via `gog auth add --services ...` or use `gog auth add --readonly` where applicable. ## Config layout @@ -134,7 +134,7 @@ Flag aliases: ### Implemented - `gog auth credentials ` -- `gog auth add [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--manual] [--force-consent]` +- `gog auth add [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]` - `gog auth services [--markdown]` - `gog auth keep --key ` (Google Keep; Workspace only) - `gog auth list` diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 14f5968..9b046aa 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -341,6 +341,8 @@ type AuthAddCmd struct { Manual bool `name:"manual" help:"Browserless auth flow (paste redirect URL)"` ForceConsent bool `name:"force-consent" help:"Force consent screen to obtain a refresh token"` ServicesCSV string `name:"services" help:"Services to authorize: user|all or comma-separated ${auth_services} (Keep uses service account: gog auth keep)" default:"user"` + Readonly bool `name:"readonly" help:"Use read-only scopes where available (still includes OIDC identity scopes)"` + DriveScope string `name:"drive-scope" help:"Drive scope mode: full|readonly|file" enum:"full,readonly,file" default:"full"` } func (c *AuthAddCmd) Run(ctx context.Context) error { @@ -354,7 +356,13 @@ func (c *AuthAddCmd) Run(ctx context.Context) error { return fmt.Errorf("no services selected") } - scopes, err := googleauth.ScopesForManage(services) + if c.Readonly && c.DriveScope == strFile { + return usage("cannot combine --readonly with --drive-scope=file (file is write-capable)") + } + scopes, err := googleauth.ScopesForManageWithOptions(services, googleauth.ScopeOptions{ + Readonly: c.Readonly, + DriveScope: googleauth.DriveScopeMode(c.DriveScope), + }) if err != nil { return err } diff --git a/internal/cmd/auth_add_test.go b/internal/cmd/auth_add_test.go index 042021a..8b3658f 100644 --- a/internal/cmd/auth_add_test.go +++ b/internal/cmd/auth_add_test.go @@ -226,3 +226,146 @@ func TestAuthAddCmd_EmailMismatch(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestAuthAddCmd_ReadonlyScopes(t *testing.T) { + origAuth := authorizeGoogle + origOpen := openSecretsStore + origKeychain := ensureKeychainAccess + origFetch := fetchAuthorizedEmail + t.Cleanup(func() { + authorizeGoogle = origAuth + openSecretsStore = origOpen + ensureKeychainAccess = origKeychain + fetchAuthorizedEmail = origFetch + }) + + ensureKeychainAccess = func() error { return nil } + + store := newMemSecretsStore() + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + var gotOpts googleauth.AuthorizeOptions + authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) { + gotOpts = opts + gotOpts.Services = append([]googleauth.Service(nil), opts.Services...) + gotOpts.Scopes = append([]string(nil), opts.Scopes...) + return "rt", nil + } + fetchAuthorizedEmail = func(context.Context, string, []string, time.Duration) (string, error) { + return "user@example.com", nil + } + + _ = captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "auth", + "add", + "user@example.com", + "--services", + "gmail,drive,calendar", + "--readonly", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.readonly") { + t.Fatalf("missing gmail.readonly in %v", gotOpts.Scopes) + } + if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.readonly") { + t.Fatalf("missing drive.readonly in %v", gotOpts.Scopes) + } + if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/calendar.readonly") { + t.Fatalf("missing calendar.readonly in %v", gotOpts.Scopes) + } + if containsStringInSlice(gotOpts.Scopes, "https://mail.google.com/") { + t.Fatalf("unexpected https://mail.google.com/ in %v", gotOpts.Scopes) + } + if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.settings.basic") { + t.Fatalf("unexpected gmail.settings.basic in %v", gotOpts.Scopes) + } + if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") { + t.Fatalf("unexpected drive in %v", gotOpts.Scopes) + } + if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/calendar") { + t.Fatalf("unexpected calendar in %v", gotOpts.Scopes) + } +} + +func TestAuthAddCmd_DriveScopeFile(t *testing.T) { + origAuth := authorizeGoogle + origOpen := openSecretsStore + origKeychain := ensureKeychainAccess + origFetch := fetchAuthorizedEmail + t.Cleanup(func() { + authorizeGoogle = origAuth + openSecretsStore = origOpen + ensureKeychainAccess = origKeychain + fetchAuthorizedEmail = origFetch + }) + + ensureKeychainAccess = func() error { return nil } + + store := newMemSecretsStore() + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + var gotOpts googleauth.AuthorizeOptions + authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) { + gotOpts = opts + gotOpts.Services = append([]googleauth.Service(nil), opts.Services...) + gotOpts.Scopes = append([]string(nil), opts.Scopes...) + return "rt", nil + } + fetchAuthorizedEmail = func(context.Context, string, []string, time.Duration) (string, error) { + return "user@example.com", nil + } + + _ = captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "auth", + "add", + "user@example.com", + "--services", + "drive", + "--drive-scope", + "file", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.file") { + t.Fatalf("missing drive.file in %v", gotOpts.Scopes) + } + if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") { + t.Fatalf("unexpected drive in %v", gotOpts.Scopes) + } +} + +func TestAuthAddCmd_ReadonlyWithDriveScopeFileRejected(t *testing.T) { + err := Execute([]string{"auth", "add", "user@example.com", "--services", "drive", "--readonly", "--drive-scope", "file"}) + if err == nil { + t.Fatalf("expected error") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected exit code 2, got %T %#v", err, err) + } + if !strings.Contains(err.Error(), "--drive-scope=file") { + t.Fatalf("unexpected error: %v", err) + } +} + +func containsStringInSlice(items []string, want string) bool { + for _, it := range items { + if it == want { + return true + } + } + return false +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 112f0fc..4701089 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -28,7 +28,23 @@ const ( scopeUserinfoEmail = "https://www.googleapis.com/auth/userinfo.email" ) -var errUnknownService = errors.New("unknown service") +var ( + errUnknownService = errors.New("unknown service") + errInvalidDriveScope = errors.New("invalid drive scope") +) + +type DriveScopeMode string + +const ( + DriveScopeFull DriveScopeMode = "full" + DriveScopeReadonly DriveScopeMode = "readonly" + DriveScopeFile DriveScopeMode = "file" +) + +type ScopeOptions struct { + Readonly bool + DriveScope DriveScopeMode +} type serviceInfo struct { scopes []string @@ -256,6 +272,119 @@ func ScopesForManage(services []Service) ([]string, error) { return mergeScopes(scopes, []string{scopeOpenID, scopeEmail, scopeUserinfoEmail}), nil } +func ScopesForManageWithOptions(services []Service, opts ScopeOptions) ([]string, error) { + scopes, err := scopesForServicesWithOptions(services, opts) + if err != nil { + return nil, err + } + + return mergeScopes(scopes, []string{scopeOpenID, scopeEmail, scopeUserinfoEmail}), nil +} + +func scopesForServicesWithOptions(services []Service, opts ScopeOptions) ([]string, error) { + set := make(map[string]struct{}) + + for _, svc := range services { + scopes, err := scopesForServiceWithOptions(svc, opts) + if err != nil { + return nil, err + } + + for _, s := range scopes { + set[s] = struct{}{} + } + } + + out := make([]string, 0, len(set)) + for s := range set { + out = append(out, s) + } + + sort.Strings(out) + + return out, nil +} + +func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, error) { + driveScope := strings.TrimSpace(string(opts.DriveScope)) + switch driveScope { + case "", string(DriveScopeFull), string(DriveScopeReadonly), string(DriveScopeFile): + default: + return nil, fmt.Errorf("%w %q (expected full|readonly|file)", errInvalidDriveScope, opts.DriveScope) + } + + driveScopeValue := func() string { + if opts.Readonly { + return "https://www.googleapis.com/auth/drive.readonly" + } + + switch opts.DriveScope { + case DriveScopeFile: + return "https://www.googleapis.com/auth/drive.file" + case DriveScopeReadonly: + return "https://www.googleapis.com/auth/drive.readonly" + default: + return "https://www.googleapis.com/auth/drive" + } + } + + switch service { + case ServiceGmail: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/gmail.readonly"}, nil + } + + return Scopes(service) + case ServiceCalendar: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/calendar.readonly"}, nil + } + + return Scopes(service) + case ServiceDrive: + return []string{driveScopeValue()}, nil + case ServiceDocs: + docScope := "https://www.googleapis.com/auth/documents" + if opts.Readonly { + docScope = "https://www.googleapis.com/auth/documents.readonly" + } + + return []string{driveScopeValue(), docScope}, nil + case ServiceContacts: + contactsScope := "https://www.googleapis.com/auth/contacts" + if opts.Readonly { + contactsScope = "https://www.googleapis.com/auth/contacts.readonly" + } + + return []string{ + contactsScope, + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/directory.readonly", + }, nil + case ServiceTasks: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/tasks.readonly"}, nil + } + + return Scopes(service) + case ServicePeople: + // No read-only equivalent; profile is already read-ish. + return Scopes(service) + case ServiceSheets: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/spreadsheets.readonly"}, nil + } + + return Scopes(service) + case ServiceGroups: + return Scopes(service) + case ServiceKeep: + return Scopes(service) + default: + return nil, errUnknownService + } +} + func mergeScopes(scopes []string, extras []string) []string { set := make(map[string]struct{}, len(scopes)+len(extras)) diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 008d7fd..27f3288 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -210,6 +210,78 @@ func TestScopesForServices_UnionSorted(t *testing.T) { } } +func TestScopesForManageWithOptions_Readonly(t *testing.T) { + scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople}, ScopeOptions{ + Readonly: true, + DriveScope: DriveScopeFull, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + + want := []string{ + scopeOpenID, + scopeEmail, + scopeUserinfoEmail, + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/tasks.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "profile", + } + for _, w := range want { + if !containsScope(scopes, w) { + t.Fatalf("missing %q in %v", w, scopes) + } + } + + notWant := []string{ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/documents", + } + for _, nw := range notWant { + if containsScope(scopes, nw) { + t.Fatalf("unexpected %q in %v", nw, scopes) + } + } +} + +func TestScopesForManageWithOptions_DriveScopeFile(t *testing.T) { + scopes, err := ScopesForManageWithOptions([]Service{ServiceDrive, ServiceDocs}, ScopeOptions{ + DriveScope: DriveScopeFile, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/drive.file") { + t.Fatalf("missing drive.file in %v", scopes) + } + + if containsScope(scopes, "https://www.googleapis.com/auth/drive") { + t.Fatalf("unexpected drive in %v", scopes) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/documents") { + t.Fatalf("missing documents scope in %v", scopes) + } +} + +func TestScopesForManageWithOptions_InvalidDriveScope(t *testing.T) { + if _, err := ScopesForManageWithOptions([]Service{ServiceDrive}, ScopeOptions{DriveScope: DriveScopeMode("nope")}); err == nil { + t.Fatalf("expected error") + } +} + func TestScopes_DocsIncludesDriveAndDocsScopes(t *testing.T) { scopes, err := Scopes(ServiceDocs) if err != nil {