Co-authored-by: Umar Khan <ufkhan97@gmail.com> Co-authored-by: Matthias Kurz <m.kurz@irregular.at>
631 lines
16 KiB
Go
631 lines
16 KiB
Go
package googleauth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type Service string
|
|
|
|
const (
|
|
ServiceGmail Service = "gmail"
|
|
ServiceCalendar Service = "calendar"
|
|
ServiceChat Service = "chat"
|
|
ServiceClassroom Service = "classroom"
|
|
ServiceDrive Service = "drive"
|
|
ServiceDocs Service = "docs"
|
|
ServiceSlides Service = "slides"
|
|
ServiceContacts Service = "contacts"
|
|
ServiceTasks Service = "tasks"
|
|
ServicePeople Service = "people"
|
|
ServiceSheets Service = "sheets"
|
|
ServiceForms Service = "forms"
|
|
ServiceAppScript Service = "appscript"
|
|
ServiceAds Service = "ads"
|
|
ServiceGroups Service = "groups"
|
|
ServiceKeep Service = "keep"
|
|
ServiceAdmin Service = "admin"
|
|
)
|
|
|
|
const (
|
|
scopeOpenID = "openid"
|
|
scopeEmail = "email"
|
|
scopeUserinfoEmail = "https://www.googleapis.com/auth/userinfo.email"
|
|
)
|
|
|
|
var (
|
|
errUnknownService = errors.New("unknown service")
|
|
errInvalidDriveScope = errors.New("invalid drive scope")
|
|
errInvalidGmailScope = errors.New("invalid gmail scope")
|
|
)
|
|
|
|
type DriveScopeMode string
|
|
|
|
const (
|
|
DriveScopeFull DriveScopeMode = "full"
|
|
DriveScopeReadonly DriveScopeMode = "readonly"
|
|
DriveScopeFile DriveScopeMode = "file"
|
|
)
|
|
|
|
type GmailScopeMode string
|
|
|
|
const (
|
|
GmailScopeFull GmailScopeMode = "full"
|
|
GmailScopeReadonly GmailScopeMode = "readonly"
|
|
)
|
|
|
|
type ScopeOptions struct {
|
|
Readonly bool
|
|
DriveScope DriveScopeMode
|
|
GmailScope GmailScopeMode
|
|
ExtraScopes []string
|
|
}
|
|
|
|
type serviceInfo struct {
|
|
scopes []string
|
|
user bool
|
|
apis []string
|
|
note string
|
|
}
|
|
|
|
var serviceOrder = []Service{
|
|
ServiceGmail,
|
|
ServiceCalendar,
|
|
ServiceChat,
|
|
ServiceClassroom,
|
|
ServiceDrive,
|
|
ServiceDocs,
|
|
ServiceSlides,
|
|
ServiceContacts,
|
|
ServiceTasks,
|
|
ServiceSheets,
|
|
ServicePeople,
|
|
ServiceForms,
|
|
ServiceAppScript,
|
|
ServiceAds,
|
|
ServiceGroups,
|
|
ServiceKeep,
|
|
ServiceAdmin,
|
|
}
|
|
|
|
var serviceInfoByService = map[Service]serviceInfo{
|
|
ServiceGmail: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/gmail.modify",
|
|
"https://www.googleapis.com/auth/gmail.settings.basic",
|
|
"https://www.googleapis.com/auth/gmail.settings.sharing",
|
|
},
|
|
user: true,
|
|
apis: []string{"Gmail API"},
|
|
},
|
|
ServiceCalendar: {
|
|
scopes: []string{"https://www.googleapis.com/auth/calendar"},
|
|
user: true,
|
|
apis: []string{"Calendar API"},
|
|
},
|
|
ServiceChat: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/chat.spaces",
|
|
"https://www.googleapis.com/auth/chat.messages",
|
|
"https://www.googleapis.com/auth/chat.memberships",
|
|
"https://www.googleapis.com/auth/chat.users.readstate.readonly",
|
|
},
|
|
user: true,
|
|
apis: []string{"Chat API"},
|
|
},
|
|
ServiceClassroom: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/classroom.courses",
|
|
"https://www.googleapis.com/auth/classroom.rosters",
|
|
"https://www.googleapis.com/auth/classroom.coursework.students",
|
|
"https://www.googleapis.com/auth/classroom.coursework.me",
|
|
"https://www.googleapis.com/auth/classroom.courseworkmaterials",
|
|
"https://www.googleapis.com/auth/classroom.announcements",
|
|
"https://www.googleapis.com/auth/classroom.topics",
|
|
"https://www.googleapis.com/auth/classroom.guardianlinks.students",
|
|
"https://www.googleapis.com/auth/classroom.profile.emails",
|
|
"https://www.googleapis.com/auth/classroom.profile.photos",
|
|
},
|
|
user: true,
|
|
apis: []string{"Classroom 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),
|
|
// but also request the Docs scope for parity/future use.
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/drive",
|
|
"https://www.googleapis.com/auth/documents",
|
|
},
|
|
user: true,
|
|
apis: []string{"Docs API", "Drive API"},
|
|
note: "Export/copy/create via Drive",
|
|
},
|
|
ServiceSlides: {
|
|
// Slides commands use both Slides API and Drive API
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/drive",
|
|
"https://www.googleapis.com/auth/presentations",
|
|
},
|
|
user: true,
|
|
apis: []string{"Slides API", "Drive API"},
|
|
note: "Create/edit presentations",
|
|
},
|
|
ServiceContacts: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/contacts",
|
|
"https://www.googleapis.com/auth/contacts.other.readonly",
|
|
"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/drive",
|
|
"https://www.googleapis.com/auth/spreadsheets",
|
|
},
|
|
user: true,
|
|
apis: []string{"Sheets API", "Drive API"},
|
|
note: "Export via Drive",
|
|
},
|
|
ServiceForms: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/forms.body",
|
|
"https://www.googleapis.com/auth/forms.responses.readonly",
|
|
},
|
|
user: true,
|
|
apis: []string{"Forms API"},
|
|
},
|
|
ServiceAppScript: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/script.projects",
|
|
"https://www.googleapis.com/auth/script.deployments",
|
|
"https://www.googleapis.com/auth/script.processes",
|
|
},
|
|
user: true,
|
|
apis: []string{"Apps Script API"},
|
|
},
|
|
ServiceAds: {
|
|
scopes: []string{"https://www.googleapis.com/auth/adwords"},
|
|
user: true,
|
|
apis: []string{"Google Ads API"},
|
|
note: "OAuth scope only",
|
|
},
|
|
ServiceGroups: {
|
|
scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
|
|
user: false,
|
|
apis: []string{"Cloud Identity API"},
|
|
note: "Workspace only",
|
|
},
|
|
ServiceKeep: {
|
|
scopes: []string{"https://www.googleapis.com/auth/keep"},
|
|
user: false,
|
|
apis: []string{"Keep API"},
|
|
note: "Workspace only; service account (domain-wide delegation)",
|
|
},
|
|
ServiceAdmin: {
|
|
scopes: []string{
|
|
"https://www.googleapis.com/auth/admin.directory.user",
|
|
"https://www.googleapis.com/auth/admin.directory.group",
|
|
"https://www.googleapis.com/auth/admin.directory.group.member",
|
|
},
|
|
user: false,
|
|
apis: []string{"Admin SDK Directory API"},
|
|
note: "Workspace only; service account with domain-wide delegation required",
|
|
},
|
|
}
|
|
|
|
func ParseService(s string) (Service, error) {
|
|
parsed := Service(strings.ToLower(strings.TrimSpace(s)))
|
|
if _, ok := serviceInfoByService[parsed]; ok {
|
|
return parsed, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("%w %q (expected %s)", errUnknownService, s, serviceNames(AllServices(), "|"))
|
|
}
|
|
|
|
// UserServices are the default OAuth services intended for consumer ("regular") accounts.
|
|
func UserServices() []Service {
|
|
return filteredServices(func(info serviceInfo) bool { return info.user })
|
|
}
|
|
|
|
func manageServices(services []Service) []Service {
|
|
if len(services) == 0 {
|
|
services = UserServices()
|
|
}
|
|
|
|
filtered := make([]Service, 0, len(services))
|
|
for _, svc := range services {
|
|
if svc == ServiceKeep {
|
|
continue
|
|
}
|
|
filtered = append(filtered, svc)
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
return UserServices()
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func AllServices() []Service {
|
|
out := make([]Service, len(serviceOrder))
|
|
copy(out, serviceOrder)
|
|
|
|
return out
|
|
}
|
|
|
|
func Scopes(service Service) ([]string, error) {
|
|
info, ok := serviceInfoByService[service]
|
|
if !ok {
|
|
return nil, errUnknownService
|
|
}
|
|
|
|
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{})
|
|
|
|
for _, svc := range services {
|
|
scopes, err := Scopes(svc)
|
|
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)
|
|
}
|
|
// stable ordering (useful for tests + auth URL diffs)
|
|
sort.Strings(out)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func ScopesForManage(services []Service) ([]string, error) {
|
|
scopes, err := ScopesForServices(services)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
merged := mergeScopes(scopes, []string{scopeOpenID, scopeEmail, scopeUserinfoEmail})
|
|
if len(opts.ExtraScopes) > 0 {
|
|
merged = mergeScopes(merged, opts.ExtraScopes)
|
|
}
|
|
|
|
return merged, 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)
|
|
}
|
|
|
|
gmailScope := strings.TrimSpace(string(opts.GmailScope))
|
|
switch gmailScope {
|
|
case "", string(GmailScopeFull), string(GmailScopeReadonly):
|
|
default:
|
|
return nil, fmt.Errorf("%w %q (expected full|readonly)", errInvalidGmailScope, opts.GmailScope)
|
|
}
|
|
|
|
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 || opts.GmailScope == GmailScopeReadonly {
|
|
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 ServiceChat:
|
|
if opts.Readonly {
|
|
return []string{
|
|
"https://www.googleapis.com/auth/chat.spaces.readonly",
|
|
"https://www.googleapis.com/auth/chat.messages.readonly",
|
|
"https://www.googleapis.com/auth/chat.memberships.readonly",
|
|
"https://www.googleapis.com/auth/chat.users.readstate.readonly",
|
|
}, nil
|
|
}
|
|
|
|
return Scopes(service)
|
|
case ServiceClassroom:
|
|
if opts.Readonly {
|
|
return []string{
|
|
"https://www.googleapis.com/auth/classroom.courses.readonly",
|
|
"https://www.googleapis.com/auth/classroom.rosters.readonly",
|
|
"https://www.googleapis.com/auth/classroom.coursework.students.readonly",
|
|
"https://www.googleapis.com/auth/classroom.coursework.me.readonly",
|
|
"https://www.googleapis.com/auth/classroom.courseworkmaterials.readonly",
|
|
"https://www.googleapis.com/auth/classroom.announcements.readonly",
|
|
"https://www.googleapis.com/auth/classroom.topics.readonly",
|
|
"https://www.googleapis.com/auth/classroom.guardianlinks.students.readonly",
|
|
"https://www.googleapis.com/auth/classroom.profile.emails",
|
|
"https://www.googleapis.com/auth/classroom.profile.photos",
|
|
}, 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 ServiceSlides:
|
|
slidesScope := "https://www.googleapis.com/auth/presentations"
|
|
if opts.Readonly {
|
|
slidesScope = "https://www.googleapis.com/auth/presentations.readonly"
|
|
}
|
|
|
|
return []string{driveScopeValue(), slidesScope}, 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:
|
|
sheetsScope := "https://www.googleapis.com/auth/spreadsheets"
|
|
if opts.Readonly {
|
|
sheetsScope = "https://www.googleapis.com/auth/spreadsheets.readonly"
|
|
}
|
|
|
|
return []string{driveScopeValue(), sheetsScope}, nil
|
|
case ServiceForms:
|
|
formBodyScope := "https://www.googleapis.com/auth/forms.body"
|
|
if opts.Readonly {
|
|
formBodyScope = "https://www.googleapis.com/auth/forms.body.readonly"
|
|
}
|
|
|
|
return []string{
|
|
formBodyScope,
|
|
"https://www.googleapis.com/auth/forms.responses.readonly",
|
|
}, nil
|
|
case ServiceAppScript:
|
|
if opts.Readonly {
|
|
return []string{
|
|
"https://www.googleapis.com/auth/script.projects.readonly",
|
|
"https://www.googleapis.com/auth/script.deployments.readonly",
|
|
}, nil
|
|
}
|
|
|
|
return Scopes(service)
|
|
case ServiceAds:
|
|
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))
|
|
|
|
for _, s := range scopes {
|
|
if s == "" {
|
|
continue
|
|
}
|
|
|
|
set[s] = struct{}{}
|
|
}
|
|
|
|
for _, s := range extras {
|
|
if s == "" {
|
|
continue
|
|
}
|
|
|
|
set[s] = struct{}{}
|
|
}
|
|
|
|
out := make([]string, 0, len(set))
|
|
for s := range set {
|
|
out = append(out, s)
|
|
}
|
|
|
|
sort.Strings(out)
|
|
|
|
return out
|
|
}
|
|
|
|
func UserServiceCSV() string {
|
|
return serviceNames(UserServices(), ",")
|
|
}
|
|
|
|
func serviceNames(services []Service, sep string) string {
|
|
names := make([]string, 0, len(services))
|
|
for _, svc := range services {
|
|
names = append(names, string(svc))
|
|
}
|
|
|
|
return strings.Join(names, sep)
|
|
}
|
|
|
|
func filteredServices(include func(info serviceInfo) bool) []Service {
|
|
out := make([]Service, 0, len(serviceOrder))
|
|
for _, svc := range serviceOrder {
|
|
info, ok := serviceInfoByService[svc]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if include == nil || include(info) {
|
|
out = append(out, svc)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|