lens-protocol/lens-sdk

Get a refreshed access token if expired

pradel opened this issue ยท 29 comments

Is your feature request related to a problem? Please describe.

To authenticate a user against my Rest API, I forward the Lens access token and send it to my server, then call the Lens API to verify that it's valid. This process is working fine as recommended by the lens docs.
To get the access token, I use the useAccessToken hook but that returns the current accessToken that might be expired, I need a way to get a refreshed access token.

Describe the solution you'd like

I guess smth like the following would solve my problem

const {getAccessToken} = useAccessToken()

const myFunc = async () => {
  // SDK would get a new access token if expired from the refresh token 
  const validAccessToken = await getAccessToken()
  callMyAPI(validAccessToken)
}

@pradel while looking into this I've spotted an issue that, even implementing the pattern you mentioned, would prevent it from working properly. I am working on a fix, will let you know about an ETA as soon as I have an idea of problem.

Fix ready for next release: #840

@cesarenaldi will the token be refreshed if no calls are made to Lens for a long time?

Yes, the token from useAccessToken will automatically refresh seamlessly. As you would expect from a hook with that signature. Make sure any callback that depends on it are updated accordingly if leverage useCallback hook (or useMemo).

A fix for this is now available in:

  • @lens-protocol/react-native@0.1.0-alpha.9
  • @lens-protocol/react-web@2.0.0-alpha.32

Also if you use Wagmi bindings bear in mind the latest: @lens-protocol/wagmi@4.0.0-alpha.1 supports Wagmi v2.

See updated reference docs: https://lens-protocol.github.io/lens-sdk/modules/_lens_protocol_react_web.Core.html

BTW, the approach you suggested is probably better IMO, just didn't want to introduce an API breaking change as I am aware several teams rely on the useAccessToken hook.

I assume this issue is solved then.

@krzysu tried with the latest version and the token is still not refreshed, can you please reopen the issue?

@pradel sorry, I only got to this now. I managed to reproduce some issue at bootstrap time. Hook returns null and does not updates with the newly refreshed token. Is this the behaviour you are flagging?

The behavior I noticed is after 30 mins of inactivity (so when the access token expires)

Experiencing an issue with the client whereby the accessToken expires and does not get refresh. As the client has no method to do this, have to sign out and in again. Likely after 30 minutes I assume.

lensClient.authentication.getAccessToken() = NotAuthenticatedError

Is this related to this issue?

@pradel a fix for this in the next release.

@b-bot this issue was specifically for the Lens React SDK. The LensClient operates under different assumptions. It's a lower level integration with the Lens API so many things that in the Lens React SDK are taken care of by the SDK, with the LensClient are on the integrator to detect and renew.

Said that, at a quick glance I don't see the method you would need to invoke to refresh credentials when you encounter NotAuthenticatedError.

Can I ask you about your integration? Are you using the LensClient on the client-side? Is your use of the Access Token to validate user's identity?

@cesarenaldi Perhaps this is my misunderstanding but I thought the client manages the session entirely. Would I need to use the storage interface to manually do this or where is it persisted?

I'm running through the full auth flow on the backend via a tRPC API (This is why I cannot use the React Hooks SDK).
I get authenticated = true after doing this but all subsequent calls that are protected fail. Realised it's not after 30 minutes it's immediate.

Client is being used on FE and BE.
Do not need the access token at all - just need the authed calls to work.

Edit: here the docs say it automatically refreshes - https://docs.lens.xyz/docs/refresh-jwt#lensclient
Manages for you: https://docs.lens.xyz/docs/lensclient-sdk-1#configure-storage-optional

@b-bot ok, we have few things going on here. Let's start from the simple one.

The LensClient automatically refresh tokens only when a new instance is used for the first time and it was instantiated with a persistent storage.

In this regards it's similar to the Lens React SDK.

Contrary to the Lens React SDK it does not refresh tokens ahead of their expiry time (30 mins), nor when a request fails due to expired token. It instead propagates the CredentialsExpiredError error to the caller which is responsible to perform a manual refresh.

In my previous message I hinted at the fact I couldn't find the method you are supposed to call in this circumstances. So we might need to expose it and release a fixed version of the @lens-protocol/client SDK for you.

But before going into that I would like to understand more about your use case to make sure it's what you need.

Can I ask how do you authenticate the LensClient on the BE? Is this using an app Lens Profile of your choice?

And on the FE, is this authenticated as well? With what Profile? The user's Profile?

@b-bot sorry for editing my previous comment, there were imprecisions @krzysu made me realize. For all intent and purposes the LensClient refresh credentials if the internal refresh token is still valid.

The persistent storage is likely to play a role in you use case but still need to understand what Profile you expect to log-in with on either sides (BE and FE).

@cesarenaldi This is making more sense now. So I'm using Lens Client in a custom credentials provider via next-auth. Grabbing the profile and saving this as a user to the DB. I then persist certain info through the session cookie next-auth creates - I'll be able to add the access token and refresh token here from the initial login (as well as exp).

Here's a snippet.

     async authorize(credentials) {
        if (!credentials) return;

        try {
          await lensClient.authentication.authenticate({
            id: credentials.id,
            signature: credentials.signature,
          });

          const authenticated =
            await lensClient.authentication.isAuthenticated();

          const profile = await lensClient.profile.fetch({
            forProfileId: credentials.profileId,
          });
        }
        return profile;
      }

My question is what should I use as the storage interface? I cannot use local storage because I need to use this in the backend too. Unless I purely use it on the backend which should be fine.

What would be great is if you could pass the access token into the client options (from the session) and it would assume this is the auth on the frontend.

Then yes the omission of the refresh method is what made me think that happens automatically - and that a persistent storage was already included (should have dug deeper in the code).

Edit: forgot to mention this is all with the users wallet and their profile signing a request.

Not sure how accurate is the snippet, but if the lensClient instance is just available in scope of the authorize method, it seems to me you risk to re-use the same instance across multiple requests, possibly belonging to different users.

It appears to me your use of LensClient on the BE is on a user-by-user base, so you should use ephemeral instances of the LensClient that you throwaway once done with a given request. The LensClient class is quite cheap to instantiate so no issue in instantiating one on a request base.

With ephemeral instances scoped to the request, you can keep the default storage which is an in-memory storage. You actually want to avoid using a shared storage cause you would risk to use an authentication state from a request that possibly belong to another user.

Now the question is: do you plan to mediate other Lens request through endpoints in your BE or is this just for issuing your authenticated session?

If your only purpose is to verify the user identity and issue an authenticated session token to your user, I recommend complete the auth handshake on the client (authentication.generateChallenge(...) and authentication.authenticate(...)) and then send to your server authentication request the Lens accessToken (you can get it via authentication.getAccessToken), on the server you can then verify its legitimacy via authentication.verify(accessToken) and on positive verification issue your auth session token.

You can also fetch info about the profile, the Lens accessToken JWT contains the Profile ID you can extract and use to call
lensClient.profile.fetch which does not require authentication. In this case the BE instance of the LensClient never needs to be authenticated so you could also consider sharing it across requests.

If, on the other hand, you actually need to mediate requests to the Lens API via other endpoints in your BE, the need of using ephemeral LensClient instances is paramount. As this is sligthly more involved, let me know if it's your case and I can guide you through it.

@cesarenaldi Alright so I've gone ahead and adjusted my flow to your suggested. So I at least have persisted auth on the frontend. I passed the verified accessToken to my session and now I can also get that from the backend (or frontend).

The LensClient is just stored in a shared util file and imported when needed. This is so I don't have to add the storage interface every time. Would this be ephemeral?

import {
  type IStorageProvider,
  LensClient,
  development,
  production,
} from '@lens-protocol/client';

import { isDev } from '..';

class BrowserStorageProvider implements IStorageProvider {
  getItem(key: string) {
    return window.localStorage.getItem(key);
  }

  setItem(key: string, value: string) {
    window.localStorage.setItem(key, value);
  }

  removeItem(key: string) {
    window.localStorage.removeItem(key);
  }
}

const isBrowser = typeof window !== 'undefined';

const storageProvider = isBrowser && new BrowserStorageProvider();

const lensClientConfig = {
  environment: isDev() ? development : production,
  ...(isBrowser && storageProvider ? { storage: storageProvider } : {}),
};

export const lensClient = new LensClient(lensClientConfig);

The problem is I do need to use this in the backend. I have a /lens endpoint that I want to use for profile admin, posts etc. And I need to sync it with my DB transactionally.

How is this possible?

First question: is this ephemeral?
TL;DR; No

In my experience with NextJS the lensClient instance imported this way on the server-side risks to be leaked across requests that are likely made by different clients/users.

While the approach is fine on the client-side where the only main thread is for the user, on the server you might have a pool of threads (or processes) that is used to serve incoming requests.

Said that, there is a lot of magic going on with NextJS and Vercel so don't know if they are spawning and destroying an new JS execution context for each request. Gut feeling is that they might not, cause it would make start up time unpredictable depending of how much code run at module level of the overall import chain (and we are talking about dependencies of dependencies being imported and evaluated).

By ephemeral I meant an instance of the LensClient that is bound to the request. It gets created when request comes in and it gets destroyed once it finishes. Each request deals with a new instance of the LensClient. It could be created in the request handler itself or in any middleware.

Sending the access token to your server is useful for occasional usage. For example to verify the user identify before issuing app specific session token(s). However, because it last only for 30 minutes and then it needs to be refreshed, you need to make sure you pass a fresh one with every request. You can't simply put in in the shared session after successful login. In 30 minutes time it will not be usable by your BE request handlers.

An approach I know people are using is to define a cookie based storage: #842 (comment)

With such approach the client and the server will automatically share the same internal state (i.e. refresh token). The ephemeral instance of the LensClient created on the server will be authenticated from the get-go.

This NextJS doc page gives some insights when they talk about Serverless Node.js and raises the same concern about boot up time: https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes

So I guess, it depends on the way you run it.

As instantiating a LensClient is not expensive your could encapsulate all the decision making around isDev into a factory function:

function createLensClient() {
  return new LensClient(...);
}

and use such function to create an ephemeral instance in the API route handler or a middleware for it.

@cesarenaldi Thanks for all the help dude, really appreciate it.

Went through the cookie storage method for the client and ended up removing it after. I'm using Lens in such a wide array of environments it becomes a little messy. (tRPC, middleware, frontend) The client doesn't really suit serverless architecture IMO and would be better if running Next in a Docker image.

My idea with storing the access token in the session was to also get the refresh token there and use it to persist the auth. There is a callback I have that runs every time the session is accessed and I wanted to validate it in there (possibly refresh, was going to submit a PR to add that method to the client)

Ultimately what I settled on was lens client authenticated calls strictly on frontend and unauthenticated calls going through my API. I would have preferred to just use the React Hooks but no integration exists yet for my library (thirdweb)

So yeah everything works now. I look forward to the new docs teased on X - can maybe explain some of these concepts in this thread there and help others.

Hey @b-bot can you please share some insights on

The client doesn't really suit serverless architecture IMO

would love to hear your take. If this is an issue with cold boot or specific to your auth setup.

Regarding:

My idea with storing the access token in the session was to also get the refresh token there and use it to persist the auth.
There is a callback I have that runs every time the session is accessed and I wanted to validate it in there (possibly refresh, was going to submit a PR to add that method to the client)

This is possible. You can have an authenticated instance of the LensClient via client.authentication.authenticateWith({ refreshToken: '...' }).

There is not a clear way to 'extract' the refresh token from the React SDK but can provide you a workaround in the short term and add a bespoke hook for that.

I would have preferred to just use the React Hooks but no integration exists yet for my library (thirdweb)

This is where custom IBindings comes into play. We provided a wagmi binding just because of its popularity, but you don't need to wait for us to create one for your library, usually it involves few lines of code. See for example this template for PWA using Privy from a community dev: https://github.com/bartomolina/lens-pwa/blob/main/lib/lens-privy-bindings.ts

I would also recommend to use React SDK if your app is leveraging React. All the heavy lifting of dealing with Lens Protocol and API intricacies is done for you.

I will be happy to help on TG, are you in the Lens Dev Garden group?

@cesarenaldi Sure, so a couple of things.
It is fine if you want to do unauthenticated calls but all my issues arise when needing to do authenticated calls and primarily to do with the storage mechanism as you can see from above ๐Ÿ˜…

Since RSC the runtime can be confusing so I needed to almost write variations of it to maintain context. I tried async local storage as that looked the most promising as it provides a unique store per call chain. However I also do some logic using S3 triggers - this invokes an AWS Lambda separate from Vercel in response to bucket CRUD operations. I realised it's impossible to make authenticated calls because my storage doesn't work here. So I do the call on the frontend and pass the dynamic data to it now.

I realise this is not a normal scenario most devs will be doing but this explains my comment.
Even briefly considered storing the sessions in Redis :D

If we can get that refresh token somehow that would be ideal because I could pass that where I need authenticated access briefly when no store is present. This would essentially create a makeshift per user "API Token". I could also bind my auth session to Lens - if it cannot refresh I terminate the browser session too! If there is no method to retrieve currently how is authenticateWith supposed to work?

I actually wrote a binding and got it to work, but it did seem a little hacky. Will take a look at the example. What's your opinion on using React Hooks AND the client, would this complicate authentication further?

I am not but you can add me there @b_b0t - I did message you on Discord so I am happy to liaise on either!

authenticateWith was born for a very specific use case: a Lens app that has its own API integration and needs to create a LensClient instance out of a Refresh Token. So that's why currently there is no React SDK counterpart. But it's easy to create an hook, we already have useAccessToken and useIdToken hooks. Adding a useRefreshToken is pretty quick.

In the meantime you could extract the refresh token by accessing the client storage use in the React SDK. Either you create it using whatever underlying mechanism you like for the client and you pass it around, or you rely on the default (i.e. leverages window.localStorage) and use the useStorage hook to get hold of it.

Once you have storage, you call:

const item = storage.getItem('lens.development.credentials');

const content = JSON.parse(item);
const refreshToken = content.data.refreshToken;

Notice the item label contains the environment name (development or production) so it differs depending on your LensConfig setup (this was done mostly to avoid annoying clashes/error during the development experience).

Regarding:

What's your opinion on using React Hooks AND the client, would this complicate authentication further?
We made is so it's possible to have them working in the client side-by-side, but this is meant either for token-gated support or for very remote cases where a feature is available on the LensClient but didn't make it to the React SDK (yet).

Just added you to the TG group. Feel free to DM me to discuss details further.

@b-bot the PR with the useRefreshToken hook that should smooth the DX described above is going to land soon on the next canary release.

A fix for this Issue thread was released as part of last 2.x alpha. Current stable 2.1 includes such fix.

@pradel can you please confirm this is solved in your integration?

@cesarenaldi did a quick test and it seems to be working, I guess we can close the issue and reopen it in case the issue appears again?

@pradel awesome news! Closing this for now.