Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions base/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions base/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion gcalbot/gcalbot/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions gcalbot/gcalbot/reminderscheduler/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading