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
28 changes: 24 additions & 4 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down
26 changes: 23 additions & 3 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
})
}
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/sentinel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading