awinogrodzki / next-firebase-auth-edge

Next.js Firebase Authentication for Edge and Node.js runtimes. Compatible with latest Next.js features.
https://next-firebase-auth-edge-docs.vercel.app/
MIT License
462 stars 41 forks source link

Clarification on Refreshing Tokens in Auth Middleware #237

Closed bono-ux closed 1 week ago

bono-ux commented 3 weeks ago

Hi, there,

Thank you for developing such a fantastic library !

I am using the inMemoryPersistence strategy, as mentioned on the page, and I primarily rely on server-side tokens.

However, in the section "Enable Refresh Token API endpoint in Auth Middleware," it states: "To fix this, authMiddleware can expose a special endpoint (by refreshTokenPath) to refresh client-side tokens if the current server-side token has expired." This has left me a bit confused. Why would we need to refresh the client-side token ? Shouldn't the focus be on refreshing the server-side token when it expires ?

Since I am using inMemoryPersistence, refreshing the client-side token would result in it being immediately cleared. Isn't this unnecessary? This part is a bit unclear to me.

Furthermore, just to clarify, when you refer to the server-side token, you mean the token stored in a cookie, correct ? Since I am using server-side tokens, refreshing the client-side token doesn’t seem necessary for my use case. Is there a way to refresh the server-side token (the token stored in the cookie) when it expires ?

Thank you for your time and assistance.

I appreciate any clarification you can provide on this matter.

awinogrodzki commented 3 weeks ago

Hey @bono-ux,

Very good questions. Let me address each of them.

This has left me a bit confused. Why would we need to refresh the client-side token ? Shouldn't the focus be on refreshing the server-side token when it expires ?

I can see a reason you are confused here. refreshTokenPath is used to serve a special case.

Before I explain the use-case, let me make sure we are aligned on several things:

Authentication cookie stores four things:

If a request has authentication cookie attached, we basically don't care about ID Token expiration date, because we can use refresh token to fetch a new ID token.

Thanks to this fact, you should not really care about server ID token expiration date when making API calls to your Next.js API routes. Since Next.js client app and API routes share the same domain, API routes would have access to cookies.

Which gets us to question: why would we need to refresh server ID token in the first place? In most cases, you would not need to refresh server ID token. Server ID token is mostly used to render user-specific content on the server-side. Some examples include email address, avatar or content that depends on user permissions stored in custom claims.

You can refresh server ID token if you need, just by calling router.refresh(). In starter example, router.refresh() is called after updating user custom claims.

Now back to your question:****

Why would we need to refresh the client-side token ?

When you communicate with external API services, which you don't share domain with, API service does not have access to your cookies (thankfully). There are different ways of authenticating with external services. One of the most common is by sending ID token as a Bearer Token.

As you see, external API service will not be able to refresh your token, so if it's expired, it will most likely respond with 403: No Access error

Now imagine a rich UI that constantly makes a lot of calls to external APIs. When user re-opens a tab after an hour, server ID token is expired and we can expect next API call to fail.

In theory, we could use router.refresh() to refresh the whole app at this point, but in some apps it might be a bad user experience.

getValidIdToken is designed for that specific reason. It checks if server ID token is expired. If not, it just passes it along. If it's expired, it calls /api/refresh-token endpoint to return a valid ID token that can be used to authenticate the call

Sorry for the long answer, I hope it makes it clear.

refreshing the client-side token would result in it being immediately cleared

I am not sure I understand this part. When you refresh client id token using getValidIdToken, which internally calls refresh API, the response can contain Set-Cookie headers with updated cookies. This is just for convenience, so we don't have to do the same on the next page refresh. getValidCustomToken has basically the same behaviour.

Neither getValidIdToken nor getValidCustomToken clears anything.

I think you refer to getValidCustomToken and signInWithCustomToken here.

After you use signInWithCustomToken, getAuth().currentUser would contain valid user until the end of the client session. It will return null after your next page refresh.

Since I am using inMemoryPersistence, refreshing the client-side token would result in it being immediately cleared. Isn't this unnecessary? This part is a bit unclear to me.

I think that the name refreshTokenPath is a bit confusing here. This endpoint is not used for refreshing token. It's used to get a valid token and refresh cookies only if necessary. I think I may need to change it to something more descriptive like currentTokenPath. I'll think about it.

As explained, it's not really about refreshing anything, but rather getting latest, valid token to communicate with external API services

Furthermore, just to clarify, when you refer to the server-side token, you mean the token stored in a cookie, correct ?

Yes, server side token is token returned by getTokens function. It is an ID Token

Since I am using server-side tokens, refreshing the client-side token doesn’t seem necessary for my use case. Is there a way to refresh the server-side token (the token stored in the cookie) when it expires ?

Yes, as explained above, you can use router.refresh(). When called, Next.js re-renders server-components, which causes getTokens to be called again. Middleware detects if id token is expired and if that's the case, it attaches Set-Cookie headers with fresh authentication cookies. getTokens works similarly, by always returning valid tokens

Let me know if I missed something! 🙌

bono-ux commented 3 weeks ago

Hi, @awinogrodzki

Thank you for your detailed response—it really helped me understand the background and context better.

In my case, I use the uid from the server ID token as the user ID in my app’s database (MySQL). Therefore, if the server ID token expires and becomes unusable, it becomes critical as I won’t be able to retrieve user data. I use Firebase primarily for authentication (and to obtain the user ID), while authorization is managed through my app's database (MySQL) using the user's status (e.g., free/paid status). I don't use Firestore or any external APIs.

I now understand that the refreshTokenPath is intended for scenarios where the server-side token isn’t available, such as with external APIs. Although I don't use external APIs currently, I'm glad I asked, as it might be useful in the future.

I apologize for asking more questions, but I’d like to clarify my specific case. As I mentioned earlier, I use the server ID token’s uid to fetch the user like this in server component, server action or API routes.

const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
});

if(!tokens) {
  throw new Error("User not found")
}

// get user data from MySQL
const user = await fetchUserByFirebaseUid(tokens.decodedToken.uid)

My concern is whether there would be an issue if the server ID token expires, and I cannot retrieve tokens using getTokens. However, looking at the implementation of getTokens(), it seems that even if the token is expired, it should automatically refresh within the verifyAndRefreshExpiredIdToken function. Could you confirm if this understanding is correct?

You mentioned that to refresh the server ID token, I can call router.refresh(). Is the difference between refreshing the token with getTokens() and router.refresh() simply whether or not the Set-Cookie header is sent? (router.refresh() sends Set-Cookie but getTokens() doesn't)

Thank you again for your time and assistance !

bono-ux commented 3 weeks ago

After writing the above response, I realized something:

If the following code is executed in a server component or server action, and the server ID token has expired, wouldn't it be blocked by middleware.ts, preventing access to the page in the first place?

const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
});

if(!tokens) {
  throw new Error("User not found")
}

const user = await fetchUserByFirebaseUid(tokens.decodedToken.uid)

Or does middleware.ts automatically refresh the server ID token if it has expired and then proceed to the page ?

awinogrodzki commented 3 weeks ago

My concern is whether there would be an issue if the server ID token expires, and I cannot retrieve tokens using getTokens. However, looking at the implementation of getTokens(), it seems that even if the token is expired, it should automatically refresh within the verifyAndRefreshExpiredIdToken function. Could you confirm if this understanding is correct?

You should not worry about token being expired when using getTokens(). If token is expired, new token will be fetched using refreshToken available in authentication cookies.

You mentioned that to refresh the server ID token, I can call router.refresh(). Is the difference between refreshing the token with getTokens() and router.refresh() simply whether or not the Set-Cookie header is sent? (router.refresh() sends Set-Cookie but getTokens() doesn't)

It depends on where you are calling getTokens().

If you call it inside server components (eg. page.tsx or layout.tsx) and correctly configure Middleware matcher, getTokens should be called just after Middleware is executed. If you correctly pass Modified Request Headers to the response, getTokens will only have access to valid token, and response will come with Set-Cookie headers with fresh token.

If you call getTokens inside server actions, Set-Cookie headers won't be set on the server response by default.

There is a way to update cookies from server actions. You can use refreshServerCookies function. It comes with a cost however. Each refreshServerCookies will generate a new token on each call, so I would not recommend to use it.

Actually, I think I should provide a way to update cookies on getTokens call from inside a server action. That would be quite useful. Thanks for this insight!

After writing the above response, I realized something:

If the following code is executed in a server component or server action, and the server ID token has expired, wouldn't it be blocked by middleware.ts, preventing access to the page in the first place?

You should not worry about anything getting rejected by the middleware. Middleware automatically handles token refresh, the same as getTokens. Whether you're rendering app in server components or using server actions, the credentials will always be up-to-date unless cookie is removed or Google certificate expires

In short, you should never worry about Middleware or server actions rejecting a response with valid authentication cookies attached.

I will work on updating response cookies when token is refreshed from inside server actions. That should be helpful in the long run. Thanks again and let me know if you have some more questions!

awinogrodzki commented 3 weeks ago

If you call getTokens inside server actions, Set-Cookie headers won't be set on the server response by default.

Actually, I've just run a number of experiments and it seems that Middleware also runs before processing server action request. This means that even when running getTokens inside Server Actions, tokens will always be valid and Set-Cookie headers would be attached to the response 🎉

I've added some improvements in regards to this discussion, that would give user more control over when cookies are set on the response

awinogrodzki commented 3 weeks ago

I've released v1.7.0-canary.10. Now getTokens accepts optional cookieSerializeOptions argument that will make sure that response cookies are updated from inside server action. This should not matter though if you have Middleware matcher configured correctly. If that's the case, getTokens() will never have to refresh the token, as it will be done by the middleware

bono-ux commented 2 weeks ago

Thank you for confirming that getTokens() automatically refreshes the token. It’s also great to learn that the middleware runs even for server actions if the matcher is set up correctly—I wasn’t aware of that before. I tested the middleware with server actions myself, and it worked perfectly!

With this clarification, I now fully understand the behavior, and I can confidently use getTokens() in my application. I sincerely appreciate the detailed explanations and the time you’ve taken to help me.

Thank you very much!