diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index e52c7b4..bd5237e 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -191,10 +191,7 @@ func postToken(ctx context.Context, httpClient *http.Client, endpoint string, fo Description string `json:"error_description"` } if json.Unmarshal(body, &oauthErr) == nil && oauthErr.Error != "" { - if oauthErr.Description != "" { - return nil, bug.Mark(fmt.Errorf("%s: %s", oauthErr.Error, oauthErr.Description)) - } - return nil, bug.Mark(errors.New(oauthErr.Error)) + return nil, bug.Mark(&tokenError{Code: oauthErr.Error, Description: oauthErr.Description}) } return nil, bug.Mark(fmt.Errorf("token endpoint returned %s", resp.Status)) } @@ -205,6 +202,20 @@ func postToken(ctx context.Context, httpClient *http.Client, endpoint string, fo return &tok, nil } +// tokenError is an OAuth error response from the token endpoint (RFC 6749 +// §5.2). +type tokenError struct { + Code string + Description string +} + +func (e *tokenError) Error() string { + if e.Description != "" { + return e.Code + ": " + e.Description + } + return e.Code +} + func randomURLSafe(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -331,6 +342,11 @@ func credentialsFromToken(apiURL string, tok *tokenResponse) *Credentials { // the stored session is unusable. var ErrRefreshFailed = errors.New("refreshing session failed") +// ErrSessionExpired means the server rejected the stored session's refresh +// token — it has expired or been revoked. The instance is then as good as +// logged out, so this matches ErrNotLoggedIn. +var ErrSessionExpired = fmt.Errorf("session expired: %w", ErrNotLoggedIn) + // EnsureFresh refreshes the access token if it is expired or about to expire. // The server rotates refresh tokens (120s grace), so refreshed credentials // must be saved by the caller. @@ -350,6 +366,10 @@ func EnsureFresh(ctx context.Context, httpClient *http.Client, c *Credentials) ( "refresh_token": {c.RefreshToken}, "client_id": {ClientID}, }) + var tokErr *tokenError + if errors.As(err, &tokErr) && tokErr.Code == "invalid_grant" { + return nil, false, ErrSessionExpired + } if err != nil { return nil, false, fmt.Errorf("%w: %w", ErrRefreshFailed, err) } diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go index 0d566fe..b0a28de 100644 --- a/internal/auth/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -660,7 +660,7 @@ func TestEnsureFresh(t *testing.T) { } }) - t.Run("revoked refresh token is a recognisable refresh failure", func(t *testing.T) { + t.Run("rejected refresh token means logged out", func(t *testing.T) { // Given f := newFakeAuthServer(t) f.tokenHandler = func(url.Values) (int, any) { @@ -672,8 +672,28 @@ func TestEnsureFresh(t *testing.T) { _, _, err := EnsureFresh(context.Background(), http.DefaultClient, c) // Then - if !errors.Is(err, ErrRefreshFailed) { - t.Errorf("err = %v, want ErrRefreshFailed", err) + if !errors.Is(err, ErrSessionExpired) || !errors.Is(err, ErrNotLoggedIn) { + t.Errorf("err = %v, want ErrSessionExpired matching ErrNotLoggedIn", err) + } + if errors.Is(err, bug.ErrUnexpected) { + t.Errorf("err = %v, want an expired session not to be reported as a bug", err) + } + }) + + t.Run("failing token endpoint is a refresh failure", func(t *testing.T) { + // Given + f := newFakeAuthServer(t) + f.tokenHandler = func(url.Values) (int, any) { + return http.StatusInternalServerError, map[string]string{"error": "server_error"} + } + c := &Credentials{APIURL: f.srv.URL, RefreshToken: "refresh-1", ExpiresAt: time.Now().Add(-time.Minute)} + + // When + _, _, err := EnsureFresh(context.Background(), http.DefaultClient, c) + + // Then + if !errors.Is(err, ErrRefreshFailed) || errors.Is(err, ErrNotLoggedIn) { + t.Errorf("err = %v, want ErrRefreshFailed, not ErrNotLoggedIn", err) } }) } diff --git a/internal/cmd/sentinel_test.go b/internal/cmd/sentinel_test.go index b169482..e9d4242 100644 --- a/internal/cmd/sentinel_test.go +++ b/internal/cmd/sentinel_test.go @@ -31,6 +31,7 @@ func TestEverySentinelHasAHintDecision(t *testing.T) { "auth.ErrNoDiscovery": auth.ErrNoDiscovery, "auth.ErrKeychainUnavailable": auth.ErrKeychainUnavailable, "auth.ErrRefreshFailed": auth.ErrRefreshFailed, + "auth.ErrSessionExpired": auth.ErrSessionExpired, "auth.ErrServerSideKey": auth.ErrServerSideKey, "auth.ErrLegacyAuthtoken": auth.ErrLegacyAuthtoken, "auth.ErrNotMasterKey": auth.ErrNotMasterKey,