Skip to content

Commit a45dc6c

Browse files
authored
feat: return explicit refresh-token invalid error for OAuth refresh failures (#4751)
## Bug When a konnector calls POST /accounts/:accountType/:accountID/refresh, stack refreshes OAuth token with provider. If provider rejects refresh token, stack currently returns a generic internal error. Connector cannot reliably detect “refresh token is invalid, user must reconnect account”, so it often falls back to generic VENDOR_DOWN. This hides the real problem for users and support. And doesn't allow users to reinitialize the refresh token manually ## What changed - Added typed refresh error classification in account refresh flow. - On provider OAuth errors invalid_token or invalid_grant, stack now classifies as: - code: oauth_refresh_invalid_token - In /accounts/:accountType/:accountID/refresh, this classified error is returned as JSON:API: - status 401 - code oauth_refresh_invalid_token - detail: reconnect needed message - Kept other refresh failures on generic error path.
2 parents 73ceed4 + 6ea24ec commit a45dc6c

4 files changed

Lines changed: 168 additions & 3 deletions

File tree

docs/konnectors-workflow.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,25 @@ POST /accounts/:accountType/:accountID/refresh HTTP/1.1
644644
Host: bob.cozy.rocks
645645
```
646646

647+
If the OAuth provider rejects the refresh token, the stack returns a structured
648+
JSON:API error that can be mapped to a reconnect flow:
649+
650+
```http
651+
HTTP/1.1 401 Unauthorized
652+
Content-Type: application/vnd.api+json
653+
654+
{
655+
"errors": [
656+
{
657+
"status": "401",
658+
"title": "Unauthorized",
659+
"code": "oauth_refresh_invalid_token",
660+
"detail": "OAuth refresh token is invalid or expired; reconnect account."
661+
}
662+
]
663+
}
664+
```
665+
647666
### Konnectors Marketplace Requirements
648667

649668
The following is a few points to be careful for in konnectors when we start

model/account/type.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,56 @@ var RefreshToken = "refresh_token"
6565
// within an account does not allow refreshing it.
6666
var ErrUnrefreshable = errors.New("this account can not be refreshed")
6767

68+
const (
69+
// RefreshOAuthErrorCodeInvalidToken indicates the refresh token has been
70+
// rejected by the OAuth provider and the account should be reconnected.
71+
RefreshOAuthErrorCodeInvalidToken = "oauth_refresh_invalid_token"
72+
)
73+
74+
// RefreshOAuthError represents a classified OAuth refresh failure.
75+
type RefreshOAuthError struct {
76+
Code string
77+
ProviderStatus int
78+
ProviderError string
79+
}
80+
81+
func (e *RefreshOAuthError) Error() string {
82+
if e == nil {
83+
return "oauth refresh failed"
84+
}
85+
msg := "oauth refresh failed"
86+
if e.Code != "" {
87+
msg += ": " + e.Code
88+
}
89+
if e.ProviderStatus != 0 {
90+
msg += fmt.Sprintf(" (status %d)", e.ProviderStatus)
91+
}
92+
if e.ProviderError != "" {
93+
msg += ", provider_error=" + e.ProviderError
94+
}
95+
return msg
96+
}
97+
98+
// IsRefreshOAuthErrorCode checks if err is a refresh error with the given code.
99+
func IsRefreshOAuthErrorCode(err error, code string) bool {
100+
var refreshErr *RefreshOAuthError
101+
if !errors.As(err, &refreshErr) {
102+
return false
103+
}
104+
return refreshErr.Code == code
105+
}
106+
107+
func isInvalidRefreshTokenError(out tokenEndpointResponse) bool {
108+
switch out.Error {
109+
case "invalid_token":
110+
return true
111+
case "invalid_grant":
112+
return true
113+
default:
114+
return false
115+
}
116+
}
117+
68118
// AccountType holds configuration information for
69119
type AccountType struct {
70120
DocID string `json:"_id,omitempty"`
@@ -417,8 +467,38 @@ func (at *AccountType) RefreshAccount(a Account) error {
417467
}
418468

419469
if res.StatusCode != 200 {
420-
log.Errorf("Account %s: OAuth service error (status %d): %s", a.ID(), res.StatusCode, string(resBody))
421-
return fmt.Errorf("oauth service responded with status %d: %s", res.StatusCode, string(resBody))
470+
var out tokenEndpointResponse
471+
if err = json.Unmarshal(resBody, &out); err != nil {
472+
log.Errorf(
473+
"Account %s: OAuth service error (status %d): non-JSON response: %s (body=%s)",
474+
a.ID(),
475+
res.StatusCode,
476+
err,
477+
string(resBody),
478+
)
479+
return fmt.Errorf("oauth service responded with status %d", res.StatusCode)
480+
}
481+
482+
if isInvalidRefreshTokenError(out) {
483+
log.Errorf(
484+
"Account %s: OAuth refresh token rejected by provider (status %d, error=%s)",
485+
a.ID(),
486+
res.StatusCode,
487+
out.Error,
488+
)
489+
return &RefreshOAuthError{
490+
Code: RefreshOAuthErrorCodeInvalidToken,
491+
ProviderStatus: res.StatusCode,
492+
ProviderError: out.Error,
493+
}
494+
}
495+
496+
if out.Error != "" {
497+
log.Errorf("Account %s: OAuth service error (status %d, error=%s)", a.ID(), res.StatusCode, out.Error)
498+
} else {
499+
log.Errorf("Account %s: OAuth service error (status %d)", a.ID(), res.StatusCode)
500+
}
501+
return fmt.Errorf("oauth service responded with status %d", res.StatusCode)
422502
}
423503

424504
var out tokenEndpointResponse

web/accounts/oauth.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ func (a *apiAccount) Links() *jsonapi.LinksList {
3232
return &jsonapi.LinksList{Self: "/data/" + consts.Accounts + "/" + a.ID()}
3333
}
3434

35+
func mapRefreshError(err error) error {
36+
if account.IsRefreshOAuthErrorCode(err, account.RefreshOAuthErrorCodeInvalidToken) {
37+
return &jsonapi.Error{
38+
Status: http.StatusUnauthorized,
39+
Title: "Unauthorized",
40+
Code: account.RefreshOAuthErrorCodeInvalidToken,
41+
Detail: "OAuth refresh token is invalid or expired; reconnect account.",
42+
}
43+
}
44+
return err
45+
}
46+
3547
func start(c echo.Context) error {
3648
instance := middlewares.GetInstance(c)
3749

@@ -231,7 +243,7 @@ func refresh(c echo.Context) error {
231243

232244
err = accountType.RefreshAccount(acc)
233245
if err != nil {
234-
return err
246+
return mapRefreshError(err)
235247
}
236248

237249
err = couchdb.UpdateDoc(instance, &acc)

web/accounts/oauth_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/cozy/cozy-stack/pkg/couchdb"
1818
"github.com/cozy/cozy-stack/pkg/prefixer"
1919
"github.com/cozy/cozy-stack/tests/testutils"
20+
weberrors "github.com/cozy/cozy-stack/web/errors"
2021
"github.com/gavv/httpexpect/v2"
2122
"github.com/labstack/echo/v4"
2223
"github.com/stretchr/testify/assert"
@@ -36,6 +37,7 @@ func TestOauth(t *testing.T) {
3637

3738
setup := testutils.NewSetup(t, t.Name())
3839
ts := setup.GetTestServer("/accounts", Routes, func(r *echo.Echo) *echo.Echo {
40+
r.HTTPErrorHandler = weberrors.ErrorHandler
3941
r.POST("/login", func(c echo.Context) error {
4042
sess, _ := session.New(testInstance, session.LongRun, "")
4143
cookie, _ := sess.ToCookie()
@@ -330,6 +332,58 @@ func TestOauth(t *testing.T) {
330332
res.Cookies().Length().Equal(1)
331333
res.Cookie("cozysessid").Value().NotEmpty()
332334
})
335+
336+
t.Run("RefreshReturnsStructuredInvalidTokenError", func(t *testing.T) {
337+
e := testutils.CreateTestClient(t, ts.URL)
338+
_, token := setup.GetTestClient(consts.Accounts)
339+
340+
service := httptest.NewServer(http.HandlerFunc(func(c http.ResponseWriter, r *http.Request) {
341+
require.Equal(t, http.MethodPost, r.Method)
342+
require.NoError(t, r.ParseForm())
343+
assert.Equal(t, "refresh_token", r.FormValue("grant_type"))
344+
assert.Equal(t, "bad-refresh-token", r.FormValue("refresh_token"))
345+
assert.Equal(t, "the-client-id", r.FormValue("client_id"))
346+
assert.Equal(t, "the-client-secret", r.FormValue("client_secret"))
347+
c.Header().Set("Content-Type", "application/json")
348+
c.WriteHeader(http.StatusUnauthorized)
349+
_, _ = c.Write([]byte(`{"error":"invalid_token","error_description":"refresh token rejected"}`))
350+
}))
351+
t.Cleanup(service.Close)
352+
353+
serviceType := account.AccountType{
354+
DocID: "test-service-refresh",
355+
TokenEndpoint: service.URL + "/oauth2/token",
356+
ClientID: "the-client-id",
357+
ClientSecret: "the-client-secret",
358+
}
359+
err := couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
360+
require.NoError(t, err)
361+
t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
362+
363+
acc := &account.Account{
364+
AccountType: serviceType.DocID,
365+
Oauth: &account.OauthInfo{
366+
RefreshToken: "bad-refresh-token",
367+
},
368+
}
369+
require.NoError(t, couchdb.CreateDoc(testInstance, acc))
370+
t.Cleanup(func() {
371+
acc.ManualCleaning = true
372+
_ = couchdb.DeleteDoc(testInstance, acc)
373+
})
374+
375+
obj := e.POST("/accounts/test-service-refresh/"+acc.ID()+"/refresh").
376+
WithHeader("Authorization", "Bearer "+token).
377+
Expect().Status(http.StatusUnauthorized).
378+
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
379+
Object()
380+
381+
errObj := obj.Value("errors").Array().First().Object()
382+
errObj.ValueEqual("status", "401")
383+
errObj.ValueEqual("title", "Unauthorized")
384+
errObj.ValueEqual("code", account.RefreshOAuthErrorCodeInvalidToken)
385+
errObj.ValueEqual("detail", "OAuth refresh token is invalid or expired; reconnect account.")
386+
})
333387
}
334388

335389
func makeTestRedirectURLService(redirectURI string) *httptest.Server {

0 commit comments

Comments
 (0)