feathersjs / feathers

The API and real-time application framework
https://feathersjs.com
MIT License
15.03k stars 748 forks source link

Add support for refresh tokens #1337

Open marshallswain opened 8 years ago

marshallswain commented 8 years ago

We currently allow getting a new token by posting a valid auth token to <loginEndpoint>/refresh. Refresh tokens have a slightly different workflow as explained here: https://auth0.com/learn/refresh-tokens

ekryski commented 8 years ago

:+1: @corymsmith and I were talking about this. Hoping to help kick some of this over the finish line over the "holidays".

ekryski commented 8 years ago

We have support for this in master but also have support for this in the decoupling branch. To refresh a token you have 2 options:

  1. You can either re-authenticate using email/password, twitter, etc.
  2. You can pass a valid token to GET /auth/token/refresh
marshallswain commented 8 years ago

We do have a token renewal process in place, but not quite full refresh token support as described in the Auth0 link I posted above. An actual refresh token works similar to a GitHub auth code/password, but can only be used to get a new JWT token. So even if your JWT token expires, if you have a refresh token you can use that to login again. They are persisted to the database with userId intact and can be revoked at any time. At least, that's what I'm gathering from the Auth0 article.

ekryski commented 8 years ago

Ah you are right @marshallswain. Guess I should have clicked the link :wink:

ekryski commented 8 years ago

I think for the first cut we'll leave this off the 1.0 milestone then. It's easy enough for people to just re-authenticate.

marshallswain commented 8 years ago

I kinda vote that we make this a feathers-authentication 2.0 thing.

marshallswain commented 8 years ago

Great minds.

parnurzeal commented 8 years ago

I am still quite confused as it is not clear of how exactly the authentication workflow works.

What I am currently doing now is. 1.) Client send username & password

 curl -X POST https://xxx/auth/local   -H "Content-Type: application/json"   -d '{ "email":"xxx", "password":"yyy"}'

This returns JWT token.

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzhhNjUyN2RkMTZiMjIwMDRhY2ZjNmEiLCJpYXQiOjE0NzAzMjYyODUsImV4cCI6MTQ3MDQxMjY4NSwiaXNzIjoiZmVhdGhlcnMifQ.OVvQbnxfoDGxPFm3Y6tBhRae2Qa6_mDq-PVIo8RcC8Y"}

2.) Then, I put this token in Authorizatin http header to access API.

curl -X GET https://xxx/users  -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzhhNjUyN2RkMTZiMjIwMDRhY2ZjNmEiLCJpYXQiOjE0NzAzMjU1NzYsImV4cCI6MTQ3MDQxMTk3NiwiaXNzIjoiZmVhdGhlcnMifQ._CHdx3RpEuI189t90mXq-IMPXRNuoVh7nBwY1ON7xCY'

The thing I don't understand is next how to actually refresh this token. What I tried is I send this token to xxx/auth/token/refresh What I got is just another very long token. I then tried to use both old and this new token to access API. both works... (shouldn't old one be disabled?)

curl -X GET https://xxx/auth/token/refresh  -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzhhNjUyN2RkMTZiMjIwMDRhY2ZjNmEiLCJpYXQiOjE0NzAzMjU1NzYsImV4cCI6MTQ3MDQxMTk3NiwiaXNzIjoiZmVhdGhlcnMifQ._CHdx3RpEuI189t90mXq-IMPXRNuoVh7nBwY1ON7xCY'
{"query":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzhhNjUyN2RkMTZiMjIwMDRhY2ZjNmEiLCJpYXQiOjE0NzAzMjU1NzYsImV4cCI6MTQ3MDQxMTk3NiwiaXNzIjoiZmVhdGhlcnMifQ._CHdx3RpEuI189t90mXq-IMPXRNuoVh7nBwY1ON7xCY"},"provider":"rest","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJxdWVyeSI6eyJ0b2tlbiI6ImV5SjBlWEFpT2lKS1YxUWlMQ0poYkdjaU9pSklVekkxTmlKOS5leUpmYVdRaU9pSTFOemhoTmpVeU4yUmtNVFppTWpJd01EUmhZMlpqTm1FaUxDSnBZWFFpT2pFME56QXpNalUxTnpZc0ltVjRjQ0k2TVRRM01EUXhNVGszTml3aWFYTnpJam9pWm1WaGRHaGxjbk1pZlEuX0NIZHgzUnBFdUkxODl0OTBtWHEtSU1QWFJOdW9WaDduQndZMU9ON3hDWSJ9LCJwcm92aWRlciI6InJlc3QiLCJ0b2tlbiI6ImV5SjBlWEFpT2lKS1YxUWlMQ0poYkdjaU9pSklVekkxTmlKOS5leUpmYVdRaU9pSTFOemhoTmpVeU4yUmtNVFppTWpJd01EUmhZMlpqTm1FaUxDSnBZWFFpT2pFME56QXpNalUxTnpZc0ltVjRjQ0k2TVRRM01EUXhNVGszTml3aWFYTnpJam9pWm1WaGRHaGxjbk1pZlEuX0NIZHgzUnBFdUkxODl0OTBtWHEtSU1QWFJOdW9WaDduQndZMU9ON3hDWSIsImRhdGEiOnsiX2lkIjoiNTc4YTY1MjdkZDE2YjIyMDA0YWNmYzZhIiwiaWF0IjoxNDcwMzI1NTc2LCJleHAiOjE0NzA0MTE5NzYsImlzcyI6ImZlYXRoZXJzIiwidG9rZW4iOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuZXlKZmFXUWlPaUkxTnpoaE5qVXlOMlJrTVRaaU1qSXdNRFJoWTJaak5tRWlMQ0pwWVhRaU9qRTBOekF6TWpVMU56WXNJbVY0Y0NJNk1UUTNNRFF4TVRrM05pd2lhWE56SWpvaVptVmhkR2hsY25NaWZRLl9DSGR4M1JwRXVJMTg5dDkwbVhxLUlNUFhSTnVvVmg3bkJ3WTFPTjd4Q1kifSwiaWF0IjoxNDcwMzI2NDQyLCJleHAiOjE0NzA0MTI4NDIsImlzcyI6ImZlYXRoZXJzIn0.TqUv3051TTGbX4cPfkN-6pOOB5SN9nH-E7TU1HHSsb8","data":{"_id":"578a6527dd16b22004acfc6a","iat":1470325576,"exp":1470411976,"iss":"feathers","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzhhNjUyN2RkMTZiMjIwMDRhY2ZjNmEiLCJpYXQiOjE0NzAzMjU1NzYsImV4cCI6MTQ3MDQxMTk3NiwiaXNzIjoiZmVhdGhlcnMifQ._CHdx3RpEuI189t90mXq-IMPXRNuoVh7nBwY1ON7xCY"}}

Even weirder things is I tried to use this new token and send to /auth/token/refresh again. I got even longer token than this one.

I am not sure what I did wrong or misunderstand here. Please suggest.

ekryski commented 8 years ago

@parnurzeal we don't really have refresh token support yet. That's why this is a proposed feature.

The way to get a new token is to do a POST to /auth/token with your existing valid JWT or login using another auth mechanism. Looks like you are doing everything correct.

parnurzeal commented 8 years ago

RIght, but please look closely on how I did and the result I got.

Let's use a easy example. When I request a new token using abcdefghijklmno (just random nonsense token). Response back is just a longer version of the previous token -> abcdefghijklmnopqrstuvwxyz If I try to do it again using abcdefghijklmnopqrstuvwxyz, I will get a longer version of it -> abcdefghijklmnopqrstuvwxyz1234567890 and loop goes on (requesting more you get longer longer version of the previous one).

Also, all three tokens above are all usable at the same time. Shouldn't the previous token become expired after we request for a new token?

ekryski commented 8 years ago

@parnurzeal what I'm saying is do not do what you did because that feature isn't really implemented. Based on the implementation (thus far) the fact that the token keeps growing every time you hit /auth/token/refresh is because we are just shoving the data back into the token. This isn't how it is intended to work and we haven't had time to finish it and why this isn't documented. You are not supposed to use it.

Shouldn't the previous token become expired after we request for a new token?

This is the nature of JWT. They expire on their own from their TTL. If you want to prevent old tokens from being used that have not expired yet then you need to maintain a blacklist. Currently, this is left up to you and we have an open issue (#133) around that but likely won't get to that soon (if ever).

aboutlo commented 7 years ago

Hi there, I looked into /auth/refresh/token and I came out with something like that:

...
function pick (o, ...props) {
  return Object.assign({}, ...props.map(prop => ({[prop]: o[prop]})));
}

// Provider specific config
const defaults = {
  payload: ['id', 'role'],
  passwordField: 'password',
  issuer: 'feathers',
  algorithm: 'HS256',
  expiresIn: '1d', // 1 day
};
...
// GET /auth/token/refresh
  get (id, params) {
    if (id !== 'refresh') {
      return Promise.reject(new errors.NotFound());
    }

    const options = this.options;

    // Add payload fields
    const data = pick(params.payload, options.payload);

    return new Promise(resolve => {
      jwt.sign(data, config.get('auth').token.secret, options, token => {
        return resolve({token: token});
      });
    });

  }

Is it too naive as implementation? If not I could try to polish, add a couple of tests and create a PR.

ekryski commented 7 years ago

@aboutlo thanks for the effort! It's best to wait until v0.8 is out (it's been in alpha for a while now) as there are a bunch of changes that have happened and that route might be going away this week. I'm cutting a beta release today and currently wrapping up the migration guide. So it won't be long and v0.8 addresses a lot of the current issues with auth.

We've given a lot of thought to refresh tokens so once 0.8 is released (this week) I'd love to take to this issue to discuss. I'll likely put up our preliminary thoughts later this week.

aboutlo commented 7 years ago

fair enough @ekryski, I will wait for the 0.8 :)

deiucanta commented 7 years ago

This feature is a MUST when it comes to React Native apps. The user logs in at the beginning and when he opens the app after several weeks he expects to be still logged in.

marshallswain commented 7 years ago

@deiucanta the good news is that we kept this feature in mind while we designed auth@1.0. I don't think it will be long before we get it in place and documented.

deiucanta commented 7 years ago

that's good news! đź‘Ť looking forward for that

atulrpandey commented 7 years ago

@marshallswain Looking forward for an update on this feature. Please let me know when can we expect this. Or is it already released? Thanks in advance.

petermikitsh commented 7 years ago

@deiucanta In the meantime, until this feature gets released, you could use longer-lived tokens. Once released, you can rotate your auth secret to a new value to wipe out all existing sessions, and get all of your users on the shorter, renewing ones.

ekryski commented 7 years ago

@atulrpandey it's not officially released but it's not hard to implement either. You simply add a hook to generate a new refresh token and store that on the user object in the DB and once it is used up or expired you remove it from the user.

@petermikitsh another thing you can do (if you are on mobile) is store a clientId and clientSecret securely on the client and if the JWT accessToken expires you just re-auth with those.

backupManager commented 7 years ago

@ekryski can you please provide an example of either one of these strategies? that will be really helpful. or, will it take long for the official support to be released? this will really help with mobile auth!

petermikitsh commented 7 years ago

On the topic of refresh tokens:

Refresh tokens carry the information necessary to get a new access token. In other words, whenever an access token is required to access a specific resource, a client may use a refresh token to get a new access token issued by the authentication server. Common use cases include getting new access tokens after old ones have expired, or getting access to a new resource for the first time. Refresh tokens can also expire but are rather long-lived. Refresh tokens are usually subject to strict storage requirements to ensure they are not leaked. They can also be blacklisted by the authorization server. - https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/

So I got into React Native myself sooner than I thought I would. I'm thinking of possibly contributing this, but I'd want to be sure I fully understand the mechanics to be sure it's the correct implementation. Since refresh tokens are not stateless, there will be some constraints on usage (e.g., developers will need to supply a storage adapter).

Can you use a valid JWT to get a refresh token? Or are refresh tokens automatically returned in the authentication response (e.g., in addition to the accessToken)? It looks like Auth0 is including them in authentication responses (both accessToken and refreshToken) in their example at https://auth0.com/learn/refresh-tokens/.

abhishekbhardwaj commented 7 years ago

@petermikitsh I don't think you should be able to use a valid JWT to get a refresh token. If you do that, anyone can get a JWT and keep access to an account.

Refresh Tokens are typically returned with login/signup responses and then the client can't really get access to them again for the specific session unless they login/signup again which gives them a new session and a new refresh token.

Refresh Tokens don't really need to expire but they can be evoked if they are being stored in the database and so this way, the user can also see how many active sessions they have. You could store more info when issuing refresh tokens (like os, ip, device name etc. to make them identifiable - like how Facebook, GitHub do).

At least that's how I do it.

marshallswain commented 7 years ago

It should be ok to use a valid JWT to get a refresh token as long as you check authorization both when you issue the refresh token and when you attempt to use the refresh token.

kuncevic commented 7 years ago

@marshallswain

We currently allow getting a new token by posting a valid auth token to <loginEndpoint>/refresh. Refresh tokens have a slightly different workflow ...

So then it should be called renew not refresh to avoid the confusion <loginEndpoint>/renew

m0dch3n commented 6 years ago

As @abhishekbhardwaj said

accessToken, should not be refreshable by an accessToken, but only by a refresehToken or username/password, a refreshToken should only be refreshable by a user/password authentication, or some other secret, which is not accessible by the browser, like a 2 factor auth...

Currently, it is possible to refresh your accessToken with an accessToken, like mentioned here:

https://github.com/feathersjs/authentication-jwt/issues/61

arash16 commented 5 years ago

Another approach is to store refresh token inside accessToken's payload, then current refresh api checks if refresh token is not revoked (through database or redis call). This way refresh token could be a simple auto-increment id. Also refresh api should not check expiration anymore. Since refresh token (simple integer) is signed with access token, it's secure.

This way changes to current code base should be minimal: For refresh api provide a way so that user can provide a hook to check if a access token is not revoked (through checking refresh token inside it's payload), if this hook is provided don't validate expiration time anymore.

BigAB commented 5 years ago

Could you explain this approach some more @arash16 ?

If you store the refresh-token in the accessTokens payload, and the refresh API doesn't check expiration, haven't you just effectively made every accessToken "non-expiring"

Because any accessToken could be used to get a new access token, right?

Am I missing something?

arash16 commented 5 years ago

@BigAB I meant don't check expiration only for refresh api, the same accessToken is used as both refresh-token and access-token. This token is non-expiring only for refreshing and getting a new access-token, the refresh-id itself could be revoked by user manually.

Developer should have a db-table/redis to store all refresh-ids. When a user needs to revoke or sign out of some (or all) other sessions, we can provide him a list of all refresh-ids (plus some other extra info such as browser or creation date etc) and he chooses to remove (sign-out of) them selectively. After that once the actual token containing those refresh-ids is expired, refresh api refuses to give a new one.

The refresh-id inside token is not used most of the time and authorization is stateless until the token expires, after that we may have a single call to db to validate refresh id and return a fresh access-token.

Access token's expiration time could be short (less than 10 minutes), user may close the page and walks away, later when he opens the page access-token is already expired and he is logged out. But refresh-id inside the token has a much longer time-to-live managed by database (for example 7 or 30 days), and also manually revocable.

From security point of view, the access-token used this way should be treated like an old session-key, with extra benefit that we won't have to call database to validate it every time (only once expired).

OnnoGabriel commented 5 years ago

@arash16, I like your idea to store the refresh token inside the access JWT. Is there any example, how to retrieve this refresh token on the server side?

My current problem: If the access token is expired, the payload is not available in feathers's hook context. I guess, a way would be to use the verifyJWT() utility function of the @feathersjs/authentication package, e.g. in the very beginning of app.service('authentication').hooks({ before: { create: ... } })?

philoez98 commented 5 years ago

Is there any mindful way of using refresh tokens in feathers right now? Any plans of adding support for them?

bujji1 commented 5 years ago

Hello All ,

Looks like it is still not available ( or Am I missing some thing ) . Could you please let us know when it will be available ? I see there is passport refresh token repository available . Any one tried this ? https://github.com/fiznool/passport-oauth2-refresh

khanshakeeb commented 5 years ago

Hello @daffl , Can anyone help me to understand how token can be refresh for the google strategy? Because I will not have password for the google login scenario?

Thanks

th0r commented 5 years ago

Another approach is to store refresh token inside accessToken's payload

So anyone who have even one of your even expired access tokens will be able to easily generate an endless count of a new access tokens or do I miss something?

Refresh tokens should be securely stored on the client and nobody except this client should have access to them!

@deiucanta the good news is that we kept this feature in mind while we designed auth@1.0. I don't think it will be long before we get it in place and documented.

This was posted in 2016. Guys, do you still have plans to support this must-have feature?

daffl commented 5 years ago

No. You can't do anything with an expired token. Also, refresh tokens are much more easily possible in the v4 prerelease. A cookbook entry for how to do it will be part of the final release.

th0r commented 5 years ago

Also, refresh tokens are much more easily possible in the v4 prerelease.

Is there an approximate release date?

A cookbook entry for how to do it will be part of the final release.

Until this guide is released, could you explain in a few words how it can be done in v4 prerelease?

th0r commented 5 years ago

@daffl ping

ellisadigvom commented 4 years ago

Is there an official way to do this yet? v4 is here and I don't see anything in the docs

MichaelErmer commented 4 years ago

@daffl could you elaborate how this is achievable with 4.0 and without hacks to the authentication service?

sarkistlt commented 4 years ago

@MichaelErmer as a workaround you can use local or any custom strategy to renew jwt, not ideal, but works fine for internal communication, let's say between worker and api.

function initAuth() {
  return async (ctx) => {
    if (ctx.path !== 'authentication') {
      const [authenticated, accessToken] = await Promise.all([
        ctx.app.get('authentication'),
        ctx.app.authentication.getAccessToken(),
      ]);

      if (!accessToken || !authenticated) {
        const result = await ctx.app.authenticate(apiLocalCreds);
        ctx.params = {
          ...ctx.params,
          ...result,
          headers: { ...(ctx.params.headers || {}), Authorization: result.accessToken },
        };
      } else {
        const { exp } = decode(accessToken);
        const expired = Date.now() / 1000 > exp - 60 * 60;
        if (expired) {
          const result = await ctx.app.authenticate(apiLocalCreds);
          ctx.params = {
            ...ctx.params,
            ...result,
            headers: { ...(ctx.params.headers || {}), Authorization: result.accessToken },
          };
        }
      }
    }
    return ctx;
  };
}

client
  .configure(rest(apiHost).superagent(superagent))
  .configure(auth(authConfig))
  .hooks({ before: [initAuth()] });
m0dch3n commented 4 years ago

Currently I'm using this after hook in v4 authentication, to update my accessToken after 20 days...

const {DateTime} = require('luxon')
const renewAfter = {days: 20}

module.exports = () => {
  return async context => {
    if (
      context.method === 'create' &&
      context.type === 'after' &&
      context.path === 'authentication' &&
      context.data && context.data.strategy === 'jwt' &&
      context.result &&
      context.result.accessToken) {
      // check if token needs to be renewed
      const payload = await context.app.service('authentication').verifyAccessToken(context.result.accessToken)
      const issuedAt = DateTime.fromMillis(payload.iat * 1000)
      const renewAfter = issuedAt.plus(renewAfter)
      const now = DateTime.local()
      if (now > renewAfter) {
        context.result.accessToken = await context.app.service('authentication').createAccessToken({sub: payload.sub})
      }
    }
    return context
  }
}

It's important to have this hook in after and as last hook, so that all the verifications etc have passed

PowerMogli commented 4 years ago

Any plans to integrate refresh tokens in feathers?

1valdis commented 4 years ago

I second that question one message earlier.

rdewolff commented 4 years ago

Am wondering about the refresh token workflow as well. Is the solution drafted by @m0dch3n a good practice? Should we implement it another way ?

m0dch3n commented 4 years ago

The whole refreshToken workflow in my opion only protects only a little bit against man in the middle attacks, so that if the middle man steals the accessToken he can at least not refresh it and have infinite access to the ressources.

It does not protect against XSS, because in that case, the attacker is able to steal anything stored on the client side. So also the refreshToken...

The problem now is, that if you make your accessToken expiration time too small (i.e. 5 minutes), you also have too refresh it more often. The man in the middle only needs to listen during 5 minutes to the clients requests in order to intercept the refreshToken then... If you make the expiration longer, he has longer access with just the accessToken...

Honestly if some client tells me, his access got stolen, I need to blacklist accessToken AND refreshToken anyway to be sure. So I'm forced to make a DB request on each request anyway.

In my case, when I'm aware of such a case, I blacklist all the accessTokens from the last 40 days, because my accessTokens have a validity of 40 days...

rdewolff commented 4 years ago

Using HTTPS request makes man in the middle attacks really difficult. Aren't you using HTTPS requests?

m0dch3n commented 4 years ago

Of course I'm using https, but there are 3 possibilities to steal the accessToken. First is on client side (XSS i.e.), second on transport (man in the middle), and third on server side.

On client and on transport, I'm only half responsible for the security, and the other half is the client, which is not totally under my control. But I can help the client, to avoid security risks, by making XSS impossible and by securing the transport with https...

The goal of a refreshToken is, to make the expiration of an accessToken shorter AND to not transmit a longer or infinit valid token on EACH request

So the only security it brings, is that from 100 requests i.e, you don't make all the 100 vulnerable on transport, but only 1 request

So basically a man in the middle attack, can't be protected by a refeshToken and of course not by an XSS... It can only be reduced, by how many times you transmit this refreshToken... The cost of transmitting it lesser however is, that the accessToken needs to be longer valid...

jackywxd commented 4 years ago

I just copy/past my comments from Slack channel:

I think refresh token is a must support feature and it is not about automatically renewing existing access token. Access token is stateless and won’t be stored in server side. The down side is it is valid forever! The longer of access token, more risk is imposed. But if access token is too short, then your users have to login quite often, that will greatly impact usability.

That’s where refresh token comes in, the token used to refresh access token and it is a long live token. When access token expired, client can use refresh token to get a new access token, and that’s the only purpose of refresh token.

Refresh token is revokable in case of user account has been compromised. And that’s the big difference between access token and refresh token. To revoke issued refresh token, server must store all issued refresh tokens. In other words, refresh token is stateful. Server needs to know which one is valid which one is invalid.

To properly implement refresh token, we need some sort of token store to persist refresh token. We also need to implement at least three flows:

Refresh token validation Refresh access token with valid refresh token Revoke compromised user’s refresh token

There are other management functionalities also nice to have such as token usage stats.

Above is my current understanding regarding how to implement refresh token. It is not easy but it definitely necessary to build a more secure system.

jackywxd commented 4 years ago

It turns out Feathers already built-in all functionalities/modules required to properly implement refresh-tokens:

  1. Refresh-token store: can be easily supported by Feathers Service.
  2. Issuing and validating refresh token: can just re-used existing JWT support which built-in AuthenticationService.

Based on the work done by TheSinding (https://github.com/TheSinding/authentication-refresh-token), I implemented my own version of refresh-tokens with one custom service and three hooks (https://github.com/jackywxd/feathers-refresh-token) which enables basic refresh-tokens functionalities:

  1. Issue refresh-token after user authentication successfully;
  2. Refresh access token with a valid JWT refresh-token;
  3. Logout user by deleting the refresh-token

While fully leverage existing code base in Feathres, the actually coding effort is minimum, and it integrates with current Feathers architecture nicely. It proves that current Feathers architecture is very extendable.

But a full feature of Refresh-token also requires support at Client side, such as store refresh-token in client side, reAuthenticate user after access-token expiration, logout user with refresh-token.

After review the source code of feathers-authentication and authentication-client, I believe refresh-token could be tapped into existing Features code based to allow turning on refresh-token support as easy as turning on authentication.

I already ported my hooks version refresh-token code base into @feathersjs/authentication. Next I would try to make change on authentication-client to enable client side features. My ultimate goal is to enable refresh-token support in both server and client side.

bwgjoseph commented 4 years ago

My question/concern is how would the refresh token be stored in the client?

See https://auth0.com/blog/securing-single-page-applications-with-refresh-token-rotation/

Unfortunately, long-lived RTs are not suitable for SPAs because there is no persistent storage mechanism in a browser that can assure access by the intended application only. As there are vulnerabilities that can be exploited to obtain these high-value artifacts and grant malicious actors access to protected resources, using refresh tokens in SPAs has been strongly discouraged.

See https://afteracademy.com/blog/implement-json-web-token-jwt-authentication-using-access-token-and-refresh-token

So, what is the best possible place to store the tokens securely? You can read more about it on the internet if you are passionate to achieve completely secure storage. Some of the solutions are ideal but not very practical. Practically I would store it in the Cookies with httpOnly and Secure flags. It is not 100 percent secure but it gets the job done.

See this long discussion on cookie - https://github.com/feathersjs-ecosystem/authentication/issues/132 too