feat(auth): add services metadata and listing
This commit is contained in:
parent
4af95ae7e8
commit
024deee55d
@ -123,7 +123,7 @@ gog auth list
|
||||
|
||||
### Service Scopes
|
||||
|
||||
By default, `gog auth add` requests access to the **user** services (gmail, calendar, drive, docs, contacts, tasks, sheets, people).
|
||||
By default, `gog auth add` requests access to the **user** services (see `gog auth services` for the current list and scopes).
|
||||
|
||||
To request fewer scopes:
|
||||
|
||||
@ -143,6 +143,12 @@ gog auth add you@gmail.com --services sheets --force-consent
|
||||
|
||||
Docs commands are implemented via the Drive API, and `docs` requests both Drive and Docs API scopes.
|
||||
|
||||
To render a Markdown table of services and scopes:
|
||||
|
||||
```bash
|
||||
gog auth services --markdown
|
||||
```
|
||||
|
||||
### Google Keep (Workspace only)
|
||||
|
||||
The Google Keep API requires a service account with domain-wide delegation (Workspace).
|
||||
|
||||
@ -131,6 +131,7 @@ Environment:
|
||||
|
||||
- `gog auth credentials <credentials.json|->`
|
||||
- `gog auth add <email> [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people] [--manual] [--force-consent]`
|
||||
- `gog auth services [--markdown]`
|
||||
- `gog auth keep <email> --key <service-account.json>` (Google Keep; Workspace only)
|
||||
- `gog auth list`
|
||||
- `gog auth status`
|
||||
|
||||
@ -29,6 +29,7 @@ var (
|
||||
type AuthCmd struct {
|
||||
Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Store OAuth client credentials"`
|
||||
Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"`
|
||||
Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"`
|
||||
List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"`
|
||||
Status AuthStatusCmd `cmd:"" name:"status" help:"Show auth configuration and keyring backend"`
|
||||
Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"`
|
||||
@ -475,6 +476,38 @@ func (c *AuthListCmd) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthServicesCmd struct {
|
||||
Markdown bool `name:"markdown" help:"Output Markdown table"`
|
||||
}
|
||||
|
||||
func (c *AuthServicesCmd) Run(ctx context.Context) error {
|
||||
infos := googleauth.ServicesInfo()
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"services": infos})
|
||||
}
|
||||
if c.Markdown {
|
||||
_, err := io.WriteString(os.Stdout, googleauth.ServicesMarkdown(infos))
|
||||
return err
|
||||
}
|
||||
|
||||
w, done := tableWriter(ctx)
|
||||
defer done()
|
||||
|
||||
_, _ = fmt.Fprintln(w, "SERVICE\tUSER\tAPIS\tSCOPES\tNOTE")
|
||||
for _, info := range infos {
|
||||
_, _ = fmt.Fprintf(
|
||||
w,
|
||||
"%s\t%t\t%s\t%s\t%s\n",
|
||||
info.Service,
|
||||
info.User,
|
||||
strings.Join(info.APIs, ", "),
|
||||
strings.Join(info.Scopes, ", "),
|
||||
info.Note,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthRemoveCmd struct {
|
||||
Email string `arg:"" name:"email" help:"Email"`
|
||||
}
|
||||
|
||||
68
internal/cmd/auth_services_test.go
Normal file
68
internal/cmd/auth_services_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestAuthServices_JSON(t *testing.T) {
|
||||
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if uiErr != nil {
|
||||
t.Fatalf("ui.New: %v", uiErr)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
cmd := &AuthServicesCmd{}
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("parse json: %v", err)
|
||||
}
|
||||
raw, ok := parsed["services"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
t.Fatalf("missing services in output")
|
||||
}
|
||||
|
||||
var docs map[string]any
|
||||
for _, entry := range raw {
|
||||
item, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if svc, _ := item["service"].(string); svc == "docs" {
|
||||
docs = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if docs == nil {
|
||||
t.Fatalf("missing docs service")
|
||||
}
|
||||
|
||||
scopes, _ := docs["scopes"].([]any)
|
||||
if !containsString(scopes, "https://www.googleapis.com/auth/drive") {
|
||||
t.Fatalf("docs missing drive scope")
|
||||
}
|
||||
if !containsString(scopes, "https://www.googleapis.com/auth/documents") {
|
||||
t.Fatalf("docs missing documents scope")
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(items []any, want string) bool {
|
||||
for _, item := range items {
|
||||
if s, _ := item.(string); s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -26,6 +26,8 @@ var errUnknownService = errors.New("unknown service")
|
||||
type serviceInfo struct {
|
||||
scopes []string
|
||||
user bool
|
||||
apis []string
|
||||
note string
|
||||
}
|
||||
|
||||
var serviceOrder = []Service{
|
||||
@ -44,14 +46,17 @@ var serviceInfoByService = map[Service]serviceInfo{
|
||||
ServiceGmail: {
|
||||
scopes: []string{"https://mail.google.com/"},
|
||||
user: true,
|
||||
apis: []string{"Gmail API"},
|
||||
},
|
||||
ServiceCalendar: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/calendar"},
|
||||
user: true,
|
||||
apis: []string{"Calendar API"},
|
||||
},
|
||||
ServiceDrive: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/drive"},
|
||||
user: true,
|
||||
apis: []string{"Drive API"},
|
||||
},
|
||||
ServiceDocs: {
|
||||
// Docs commands are implemented via Drive APIs (export/copy/create),
|
||||
@ -61,6 +66,8 @@ var serviceInfoByService = map[Service]serviceInfo{
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
},
|
||||
user: true,
|
||||
apis: []string{"Docs API", "Drive API"},
|
||||
note: "Export/copy/create via Drive",
|
||||
},
|
||||
ServiceContacts: {
|
||||
scopes: []string{
|
||||
@ -69,23 +76,32 @@ var serviceInfoByService = map[Service]serviceInfo{
|
||||
"https://www.googleapis.com/auth/directory.readonly",
|
||||
},
|
||||
user: true,
|
||||
apis: []string{"People API"},
|
||||
note: "Contacts + other contacts + directory",
|
||||
},
|
||||
ServiceTasks: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/tasks"},
|
||||
user: true,
|
||||
apis: []string{"Tasks API"},
|
||||
},
|
||||
ServicePeople: {
|
||||
// Needed for "people/me" requests.
|
||||
scopes: []string{"profile"},
|
||||
user: true,
|
||||
apis: []string{"People API"},
|
||||
note: "OIDC profile scope",
|
||||
},
|
||||
ServiceSheets: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/spreadsheets"},
|
||||
user: true,
|
||||
apis: []string{"Sheets API", "Drive API"},
|
||||
note: "Export via Drive",
|
||||
},
|
||||
ServiceKeep: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/keep"},
|
||||
user: false,
|
||||
apis: []string{"Keep API"},
|
||||
note: "Workspace only; service account",
|
||||
},
|
||||
}
|
||||
|
||||
@ -119,6 +135,77 @@ func Scopes(service Service) ([]string, error) {
|
||||
return append([]string(nil), info.scopes...), nil
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
Service Service `json:"service"`
|
||||
User bool `json:"user"`
|
||||
Scopes []string `json:"scopes"`
|
||||
APIs []string `json:"apis,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func ServicesInfo() []ServiceInfo {
|
||||
out := make([]ServiceInfo, 0, len(serviceOrder))
|
||||
for _, svc := range serviceOrder {
|
||||
info, ok := serviceInfoByService[svc]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, ServiceInfo{
|
||||
Service: svc,
|
||||
User: info.user,
|
||||
Scopes: append([]string(nil), info.scopes...),
|
||||
APIs: append([]string(nil), info.apis...),
|
||||
Note: info.note,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ServicesMarkdown(infos []ServiceInfo) string {
|
||||
if len(infos) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("| Service | User | APIs | Scopes | Notes |\n")
|
||||
b.WriteString("| --- | --- | --- | --- | --- |\n")
|
||||
|
||||
for _, info := range infos {
|
||||
userLabel := "no"
|
||||
if info.User {
|
||||
userLabel = "yes"
|
||||
}
|
||||
|
||||
b.WriteString("| ")
|
||||
b.WriteString(string(info.Service))
|
||||
b.WriteString(" | ")
|
||||
b.WriteString(userLabel)
|
||||
b.WriteString(" | ")
|
||||
b.WriteString(strings.Join(info.APIs, ", "))
|
||||
b.WriteString(" | ")
|
||||
b.WriteString(markdownScopes(info.Scopes))
|
||||
b.WriteString(" | ")
|
||||
b.WriteString(info.Note)
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func markdownScopes(scopes []string) string {
|
||||
if len(scopes) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(scopes))
|
||||
|
||||
for _, scope := range scopes {
|
||||
parts = append(parts, "`"+scope+"`")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "<br>")
|
||||
}
|
||||
|
||||
func ScopesForServices(services []Service) ([]string, error) {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
|
||||
@ -120,6 +120,56 @@ func TestServiceOrderCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesInfo_Metadata(t *testing.T) {
|
||||
infos := ServicesInfo()
|
||||
if len(infos) != len(serviceOrder) {
|
||||
t.Fatalf("unexpected services info length: %d", len(infos))
|
||||
}
|
||||
|
||||
docsInfo, foundDocs := findServiceInfo(infos, ServiceDocs)
|
||||
|
||||
if !foundDocs {
|
||||
t.Fatalf("missing docs info")
|
||||
}
|
||||
|
||||
if len(docsInfo.APIs) == 0 {
|
||||
t.Fatalf("docs APIs missing")
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
} {
|
||||
if !containsScope(docsInfo.Scopes, want) {
|
||||
t.Fatalf("docs missing scope %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
if markdown := ServicesMarkdown(infos); markdown == "" {
|
||||
t.Fatalf("expected markdown output")
|
||||
}
|
||||
}
|
||||
|
||||
func findServiceInfo(infos []ServiceInfo, svc Service) (ServiceInfo, bool) {
|
||||
for _, info := range infos {
|
||||
if info.Service == svc {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceInfo{}, false
|
||||
}
|
||||
|
||||
func containsScope(scopes []string, want string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestScopesForServices_UnionSorted(t *testing.T) {
|
||||
scopes, err := ScopesForServices([]Service{ServiceContacts, ServiceGmail, ServiceTasks, ServicePeople, ServiceContacts})
|
||||
if err != nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user