diff --git a/base/oauth.go b/base/oauth.go index b640bcfc..09d911d4 100644 --- a/base/oauth.go +++ b/base/oauth.go @@ -24,12 +24,17 @@ func (e OAuthRequiredError) Error() string { } // ShouldRetryAuth reports whether err means the user's OAuth credentials are -// permanently unusable and should be deleted. Transient token-fetch failures -// (network, 5xx) are not treated as credential errors. +// permanently unusable and should be deleted: invalid_grant, invalid_token, +// a missing refresh token, or a Google Workspace Account Restricted refresh +// failure. Transient token-fetch failures (network, 5xx) and a bare +// access_not_configured (API not enabled on the Cloud project) are not. func ShouldRetryAuth(err error) bool { if err == nil { return false } + if IsOAuthAccountRestricted(err) { + return true + } var retr *oauth2.RetrieveError if errors.As(err, &retr) { switch strings.ToLower(retr.ErrorCode) { @@ -45,6 +50,34 @@ func ShouldRetryAuth(err error) bool { strings.Contains(msg, "token expired and refresh token is not set") } +// IsOAuthAccountRestricted reports whether Google Workspace has blocked this +// OAuth client for the user (token refresh returns access_not_configured / +// Account Restricted). This is per-account admin policy, not a missing API on +// the Cloud project. +func IsOAuthAccountRestricted(err error) bool { + if err == nil { + return false + } + restricted := func(s string) bool { + s = strings.ToLower(s) + return strings.Contains(s, "account restricted") || + strings.Contains(s, "servicenotallowed") + } + var retr *oauth2.RetrieveError + if errors.As(err, &retr) { + code := strings.ToLower(retr.ErrorCode) + body := strings.ToLower(string(retr.Body)) + if code != "access_not_configured" && !strings.Contains(body, "access_not_configured") { + return false + } + return restricted(retr.ErrorDescription) || + restricted(retr.ErrorURI) || + restricted(body) + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "access_not_configured") && restricted(msg) +} + type OAuthStorage interface { GetToken(ctx context.Context, identifier string) (*oauth2.Token, error) PutToken(ctx context.Context, identifier string, token *oauth2.Token) error diff --git a/base/oauth_test.go b/base/oauth_test.go index 0d38bede..c217ab8c 100644 --- a/base/oauth_test.go +++ b/base/oauth_test.go @@ -43,6 +43,24 @@ func TestShouldRetryAuth(t *testing.T) { t.Run("unrelated", func(t *testing.T) { require.False(t, ShouldRetryAuth(errors.New("calendar: 404 not found"))) }) + + t.Run("workspace account restricted", func(t *testing.T) { + err := &oauth2.RetrieveError{ + ErrorCode: "access_not_configured", + ErrorDescription: "Account Restricted", + ErrorURI: "https://access.workspace.google.com/ServiceNotAllowed?application=435070579839&source=scrip", + } + require.True(t, IsOAuthAccountRestricted(err)) + require.True(t, ShouldRetryAuth(err)) + require.True(t, ShouldRetryAuth(fmt.Errorf("unable to renew token: %w", err))) + require.True(t, IsOAuthAccountRestricted(errors.New(`oauth2: "access_not_configured" "Account Restricted" "https://access.workspace.google.com/ServiceNotAllowed"`))) + }) + + t.Run("access_not_configured without restriction is not credential error", func(t *testing.T) { + err := &oauth2.RetrieveError{ErrorCode: "access_not_configured", ErrorDescription: "API not enabled"} + require.False(t, IsOAuthAccountRestricted(err)) + require.False(t, ShouldRetryAuth(err)) + }) } type stubTokenSource struct { diff --git a/gcalbot/gcalbot/account.go b/gcalbot/gcalbot/account.go index 83723b0e..384a5325 100644 --- a/gcalbot/gcalbot/account.go +++ b/gcalbot/gcalbot/account.go @@ -135,6 +135,8 @@ func getCalendarService(ctx context.Context, account *Account, config *oauth2.Co const reconnectAccountMsg = "Your account '%s' needs to be reconnected. Please run `!gcal accounts connect %s` again." +const restrictedAccountMsg = "Google Workspace has blocked this bot from accessing your account '%s' (an admin may have restricted the app). I disconnected it so it stops retrying. If you get access again, run `!gcal accounts connect %s`." + // CalendarAuth obtains a Calendar client and recovers from invalid OAuth credentials. type CalendarAuth struct { oauth *oauth2.Config @@ -200,7 +202,11 @@ func (c *CalendarAuth) InvalidateIfAuthError(ctx context.Context, account *Accou if delErr := c.db.DeleteAccount(ctx, account.KeybaseUsername, account.AccountNickname); delErr != nil { c.debug.Errorf("failed to delete account after auth error: %v", delErr) } - if _, sendErr := c.kbc.SendMessageByTlfName(account.KeybaseUsername, reconnectAccountMsg, + msg := reconnectAccountMsg + if base.IsOAuthAccountRestricted(err) { + msg = restrictedAccountMsg + } + if _, sendErr := c.kbc.SendMessageByTlfName(account.KeybaseUsername, msg, account.AccountNickname, account.AccountNickname); sendErr != nil { c.debug.Errorf("failed to DM user after auth error: %v", sendErr) } diff --git a/gcalbot/gcalbot/reminderscheduler/sync.go b/gcalbot/gcalbot/reminderscheduler/sync.go index af680512..beb81b17 100644 --- a/gcalbot/gcalbot/reminderscheduler/sync.go +++ b/gcalbot/gcalbot/reminderscheduler/sync.go @@ -3,10 +3,12 @@ package reminderscheduler import ( "container/list" "context" + "errors" "time" "github.com/keybase/managed-bots/gcalbot/gcalbot" "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" ) func (r *ReminderScheduler) eventSyncLoop(shutdownCh chan struct{}) error { @@ -71,6 +73,12 @@ func (r *ReminderScheduler) syncEvents(account *gcalbot.Account, subscription *g return nil }) if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) && gerr.Code == 404 { + // Calendar was deleted or the user lost access. + r.Debug("calendar no longer accessible (404): %s", subscription.CalendarID) + err = nil + } return } for _, event := range events {