feat(auth): add --readonly and --drive-scope (#58) (thanks @jeremys)
This commit is contained in:
parent
471324b451
commit
2bb6a8b37c
@ -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.
|
||||
|
||||
12
README.md
12
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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user