Repository: lelylan/simple-oauth2 (JavaScript)

Issue:
Cannot refresh a second time after the first refresh
<!--
  ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
  You must complete this entire issue template to receive support. You MUST NOT remove, change, or replace the template with your own format. A missing or incomplete report will cause your issue to be closed without comment. Please respect the time and experience that went into this template. It is here for a reason. Thank you!
  ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
-->

#### Context

* *node version*: v19.7.0
* *module version with issue*: 5.0.0
* *last module version without issue*: unknown
* *environment* (e.g. node, browser, native): node
* *any other relevant information*:

#### What are you trying to achieve or the steps to reproduce?

* I am using authorization code flow to successfully fetch the first access token 1
* I can refresh token 1 to get token 2
* Token 2 cannot be refreshed because it no longer has a `refresh_token` associated. This is because our OAuth2 `/token` endpoint will only provide a new refresh token if the original refresh token is still within 60% of is validity period. It doesn't echo back the original refresh token.

This behavior is acceptable per [RFC 6749 Section 4.1.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4)

#### What was the result you got?

Got a token that cannot be refreshed while the initial refresh token is still valid.

#### What result did you expect?

Tokens should be refreshable until the current refresh token is no longer valid.

Perhaps the library could consider using the current refresh token if the response does not include one ?


Discussion:

Patch:
diff --git a/lib/access-token.js b/lib/access-token.js
index e8cbff2..528371d 100644
--- a/lib/access-token.js
+++ b/lib/access-token.js
@@ -48,6 +48,9 @@ module.exports = class AccessToken {
     const parameters = GrantTypeParams.forGrantType(REFRESH_TOKEN_PROPERTY_NAME, this.#config.options, refreshParams);
     const response = await this.#client.request(this.#config.auth.refreshPath, parameters.toObject(), httpOptions);
 
+    if (response[REFRESH_TOKEN_PROPERTY_NAME] === undefined) {
+      response.refresh_token = this.refresh_token;
+    }
     return new AccessToken(this.#config, this.#client, response);
   }
 


Test patch:
diff --git a/test/_authorization-server-mock.js b/test/_authorization-server-mock.js
index af96732..697875d 100644
--- a/test/_authorization-server-mock.js
+++ b/test/_authorization-server-mock.js
@@ -88,6 +88,14 @@ function createAuthorizationServer(authorizationServerUrl) {
       });
   }
 
+  function tokenSuccessWithoutRefreshToken(scopeOptions, params) {
+    return nock(authorizationServerUrl, scopeOptions)
+      .post('/oauth/token', params)
+      .reply(200, { ...accessToken, refresh_token: undefined }, {
+        'Content-Type': 'application/json',
+      });
+  }
+
   return {
     tokenError,
     tokenAuthorizationError,
@@ -98,6 +106,7 @@ function createAuthorizationServer(authorizationServerUrl) {
     tokenSuccessWithNonJSONContent,
     tokenSuccessWithCustomPath,
     tokenSuccess,
+    tokenSuccessWithoutRefreshToken,
   };
 }
 
diff --git a/test/access-token-refresh.js b/test/access-token-refresh.js
index 45b0450..bf4f7ef 100644
--- a/test/access-token-refresh.js
+++ b/test/access-token-refresh.js
@@ -271,3 +271,27 @@ test.serial('@refresh => creates a new access token with custom (inline) http op
   scope.done();
   t.true(has(refreshAccessToken.token, 'access_token'));
 });
+
+test.serial('@refresh => creates a new access token with keeping the old refresh token if refresh did not provide a new refresh token', async (t) => {
+  const config = createModuleConfig();
+
+  const accessTokenResponse = chance.accessToken({
+    expireMode: 'expires_in',
+  });
+
+  const client = new Client(config);
+
+  const refreshParams = {
+    grant_type: 'refresh_token',
+    refresh_token: accessTokenResponse.refresh_token,
+  };
+
+  const server = createAuthorizationServer('https://authorization-server.org:443');
+  const scope = server.tokenSuccessWithoutRefreshToken(scopeOptions, refreshParams);
+
+  const accessToken = new AccessToken(config, client, accessTokenResponse);
+  const refreshAccessToken = await accessToken.refresh();
+
+  scope.done();
+  t.true(has(refreshAccessToken.token, 'refresh_token'));
+});