Skip to content

Commit 2a10446

Browse files
authored
fix: Generate a new refresh token if an instance domain was changed (#4657)
After migrating an instance from one domain to another (e.g., alice.mycozy.cloud → alice.twake.app), sharings between migrated and non-migrated instances can break. When the migration is done by: 1. Changing the instance's Domain attribute 2. Setting OldDomain to the previous domain value The OAuth tokens behave as follows: - Refresh token: Still has iss: "alice.mycozy.cloud" (old domain) - Access token: After refresh, has iss: "alice.twake.app" (new domain) However, after a successful refresh: 1. A new access token is generated with the current domain as issuer ✅ 2. The refresh token is NOT regenerated ❌ This means the client continues using the old refresh token. While this works as long as OldDomain is set, it's fragile, and the refresh token should be updated to use the current domain.
2 parents 8a79da5 + ee46bc7 commit 2a10446

File tree

4 files changed

+95
-0
lines changed

4 files changed

+95
-0
lines changed

model/oauth/client_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,29 @@ func TestClient(t *testing.T) {
276276
assert.False(t, ok, "The token should be invalid")
277277
})
278278

279+
t.Run("ParseJWTValidWithOldDomain", func(t *testing.T) {
280+
// Simulate a migrated instance where the domain changed but OldDomain is set
281+
originalDomain := testInstance.Domain
282+
283+
// Create a refresh token with the current domain as issuer
284+
tokenString, err := c.CreateJWT(testInstance, "refresh", "foo:read")
285+
assert.NoError(t, err)
286+
287+
// Simulate migration: change Domain and set OldDomain to the original
288+
testInstance.Domain = "new.example.com"
289+
testInstance.OldDomain = originalDomain
290+
291+
// The token should still be valid because OldDomain matches the issuer
292+
claims, ok := c.ValidToken(testInstance, consts.RefreshTokenAudience, tokenString)
293+
assert.True(t, ok, "The token should be valid when OldDomain matches the issuer")
294+
assert.Equal(t, originalDomain, claims.Issuer)
295+
assert.Equal(t, "foo:read", claims.Scope)
296+
297+
// Restore the instance
298+
testInstance.Domain = originalDomain
299+
testInstance.OldDomain = ""
300+
})
301+
279302
t.Run("ParseJWTInvalidSubject", func(t *testing.T) {
280303
other := &oauth.Client{
281304
CouchID: "my-other-client",

model/sharing/member.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,11 @@ func (c *Credentials) Refresh(inst *instance.Instance, s *Sharing, m *Member) er
673673
return err
674674
}
675675
c.AccessToken.AccessToken = token.AccessToken
676+
// Also update the refresh token if a new one is provided (e.g., when the
677+
// instance domain has changed and a new refresh token is issued)
678+
if token.RefreshToken != "" {
679+
c.AccessToken.RefreshToken = token.RefreshToken
680+
}
676681
if err = couchdb.UpdateDoc(inst, s); err != nil && !couchdb.IsConflictError(err) {
677682
return err
678683
}

web/auth/auth_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,66 @@ func TestAuth(t *testing.T) {
12831283
assertValidToken(t, testInstance, obj.Value("access_token").String().Raw(), "access", clientID, "files:read")
12841284
})
12851285

1286+
t.Run("RefreshTokenWithOldDomainIssuer", func(t *testing.T) {
1287+
// This test simulates the scenario where an instance has been migrated
1288+
// from an old domain to a new domain. The refresh token was issued with
1289+
// the old domain as issuer, but the instance now has OldDomain set.
1290+
// After refresh, a new refresh token should be issued with the current domain.
1291+
1292+
e := testutils.CreateTestClient(t, ts.URL)
1293+
1294+
// Set OldDomain on the instance to simulate migration
1295+
oldDomain := "old." + domain
1296+
testInstance.OldDomain = oldDomain
1297+
require.NoError(t, instance.Update(testInstance))
1298+
1299+
// Reload the instance to get the updated version
1300+
var err error
1301+
testInstance, err = lifecycle.GetInstance(testInstance.Domain)
1302+
require.NoError(t, err)
1303+
1304+
// Create a refresh token with the OLD domain as issuer
1305+
// (simulating a token created before migration)
1306+
oldDomainToken, err := crypto.NewJWT(testInstance.OAuthSecret, permission.Claims{
1307+
RegisteredClaims: jwt.RegisteredClaims{
1308+
Audience: jwt.ClaimStrings{consts.RefreshTokenAudience},
1309+
Issuer: oldDomain, // OLD domain as issuer
1310+
IssuedAt: jwt.NewNumericDate(time.Now()),
1311+
Subject: clientID,
1312+
},
1313+
Scope: "files:read",
1314+
})
1315+
require.NoError(t, err)
1316+
1317+
// Refresh should succeed and return a NEW refresh token with current domain
1318+
obj := e.POST("/auth/access_token").
1319+
WithFormField("grant_type", "refresh_token").
1320+
WithFormField("client_id", clientID).
1321+
WithFormField("client_secret", clientSecret).
1322+
WithFormField("refresh_token", oldDomainToken).
1323+
WithHost(domain).
1324+
Expect().Status(200).
1325+
JSON().Object()
1326+
1327+
obj.HasValue("token_type", "bearer")
1328+
obj.HasValue("scope", "files:read")
1329+
1330+
// Verify the access token is valid
1331+
assertValidToken(t, testInstance, obj.Value("access_token").String().Raw(), "access", clientID, "files:read")
1332+
1333+
// A new refresh token should be returned with the current domain as issuer
1334+
newRefreshToken := obj.Value("refresh_token").String().NotEmpty().Raw()
1335+
assertValidToken(t, testInstance, newRefreshToken, "refresh", clientID, "files:read")
1336+
1337+
// Verify the new refresh token has the current domain as issuer
1338+
var newClaims permission.Claims
1339+
err = crypto.ParseJWT(newRefreshToken, func(token *jwt.Token) (interface{}, error) {
1340+
return testInstance.OAuthSecret, nil
1341+
}, &newClaims)
1342+
require.NoError(t, err)
1343+
assert.Equal(t, domain, newClaims.Issuer, "New refresh token should have current domain as issuer")
1344+
})
1345+
12861346
t.Run("OAuthWithPKCE", func(t *testing.T) {
12871347
e := testutils.CreateTestClient(t, ts.URL)
12881348

web/auth/oauth.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,13 @@ func accessToken(c echo.Context) error {
988988
out.Scope = claims.Scope
989989
}
990990

991+
// If the refresh token was issued by the old domain (validated via
992+
// OldDomain), generate a new refresh token with the current domain
993+
// so that future refreshes work without depending on OldDomain.
994+
if claims.Issuer != instance.Domain && out.Refresh == "" {
995+
out.Refresh, _ = client.CreateJWT(instance, consts.RefreshTokenAudience, out.Scope)
996+
}
997+
991998
default:
992999
return c.JSON(http.StatusBadRequest, echo.Map{
9931000
"error": "invalid grant type",

0 commit comments

Comments
 (0)