feat(auth): add --readonly and --drive-scope (#58) (thanks @jeremys)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled

This commit is contained in:
Peter Steinberger 2026-01-10 23:57:15 +01:00
parent 471324b451
commit 2bb6a8b37c
7 changed files with 372 additions and 4 deletions

View File

@ -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.

View File

@ -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

View File

@ -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 <credentials.json|->`
- `gog auth add <email> [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people,groups] [--manual] [--force-consent]`
- `gog auth add <email> [--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 <email> --key <service-account.json>` (Google Keep; Workspace only)
- `gog auth list`

View File

@ -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
}

View File

@ -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
}

View File

@ -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))

View File

@ -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 {