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
466 stars 41 forks source link

[Question] Do I have to refresh token on every database operation? #223

Closed Jkense closed 1 week ago

Jkense commented 1 month ago

I wrote this function to follow the pattern described in the starter example for client side code:

async authenticateUser(serverCustomToken: string) {
        const auth = getFirebaseAuth();

        // Retrieve a valid custom token
        const customToken = await getValidCustomToken({
            serverCustomToken,
            refreshTokenUrl: '/api/refresh-token',
        });

        if (!customToken) {
            throw new Error('Invalid custom token');
        }

        // Sign in the user with the custom token
        const { user: firebaseUser } = await signInWithCustomToken(auth, customToken);

        return firebaseUser;
    },

My current application is build with loads of database operations, it seems silly if I have to add this as a line at everyone of those operations. It that indeed the case? I am unsure if I am completely understanding the deeper functionality.

Gabrimarin commented 1 month ago

I have this question too. My idea is to setup an useEffect on the AuthProvider like this:

export function AuthProvider({ user, children }: Props) {
  const [clientUser, setClientUser] = useState<User | null>(null);
  useEffect(() => {
    if (user?.customToken) {
      signInWithCustomToken(auth, user.customToken).then(user => {
        setClientUser(user.user);
      });
    }
  }, [user?.customToken]);
 // ....
}

Then i ensure that the the logged-in part of my app can only be reachable when clientUser is defined

This works, but I'm not really sure if it makes any sense.

rossanodr commented 1 month ago
 signInWithCustomToken(auth, user.customToken).then(user => {
        setClientUser(user.user);
      });

I'm having some problems with it. I have no idea on how to set the custom tokens in my app:

 useEffect(() => {
        setIsLoadingApp(true);
        const unsubscribe = onAuthStateChanged(auth, async (userSigned) => {
            if (!userSigned) {
                setUser(null);

                await fetch("/api/logout", {
                    method: "GET",
                });

                return;
            }

            const userData = await getUserData(userSigned, setPremiumCoins);
            if (userData) {
                setUser(userData);

                firebaseLoginRedirect(userSigned);
                if (analytics) {
                    setUserId(analytics, userData.id);
                }
            }
        });

        return () => unsubscribe();
    }, []);

If I use setPersistence(auth, inMemoryPersistence) the app does not work

awinogrodzki commented 1 month ago

Hey @Jkense,

If your app makes a lot of database operations using Firebase Client SDK you should consider two approaches:

  1. Call signInWithCustomToken once, when the app first renders, and then rely on getFirebaseAuth().currentUser
  2. Remove setPersistence(auth, inMemoryPersistence) to let Firebase store user credential in the browser. This may lead to consistency issues between server and client token

I would go with 1. approach. See official docs on how and when to use signInWithCustomToken: https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase

awinogrodzki commented 1 month ago

Hey @Gabrimarin,

You're approach looks correct, but you most likely don't need to store clientUser in state. Once you call signInWithCustomToken user will be stored in getAuth().currentUser for the whole session. I might miss some context though, so let me know if that works

awinogrodzki commented 1 month ago

Hey @rossanodr,

You should not use onAuthStateChanged together with setPersistence(auth, inMemoryPersistence), unless you expect to handle null on each page refresh.

You can follow two approaches mentioned above:

  1. Call signInWithCustomToken once, when the app first renders, and then rely on getFirebaseAuth().currentUser
  2. Remove setPersistence(auth, inMemoryPersistence) to let Firebase store user credential in the browser. This may lead to consistency issues between server and client token
rossanodr commented 1 month ago
  1. getFirebaseAuth()

Thanks for the response. getFirebaseAuth(authServerConfig).currentUser leads to the error Property 'currentUser' does not exist on type '{ verifyAndRefreshExpiredIdToken: (customTokens: CustomTokens, verifyOptions?: VerifyOptions | undefined) => Promise

I'm using "next-firebase-auth-edge": "^1.7.0-canary.2",

awinogrodzki commented 1 month ago

@rossanodr you're using getFirebaseAuth from next-firebase-auth-edge, which is a collection of advanced methods

I am referring to client-side getFirebaseAuth from starter example: https://github.com/awinogrodzki/next-firebase-auth-edge/blob/ce1d5f4edcb6196f61abb1d98e2afa6d6d992aa0/examples/next-typescript-starter/app/auth/firebase.ts#L25

rossanodr commented 1 month ago

@rossanodr you're using getFirebaseAuth from next-firebase-auth-edge, which is a collection of advanced methods

I am referring to client-side getFirebaseAuth from starter example:

https://github.com/awinogrodzki/next-firebase-auth-edge/blob/ce1d5f4edcb6196f61abb1d98e2afa6d6d992aa0/examples/next-typescript-starter/app/auth/firebase.ts#L25

thanks man you're amazing

huangfulei commented 3 weeks ago

@awinogrodzki When my token expires and I call getTokens() to get customToken, I get null, making it impossible to use getValidCustomToken() which requires a custom token as the input.

const customToken = await getValidCustomToken({ serverCustomToken, refreshTokenUrl: '/api/refresh-token' });

awinogrodzki commented 3 weeks ago

Hey @huangfulei!

What version of next-firebase-auth-edge are you using? Custom tokens have been introduced since v1.6.0

Could you clear your browser cookies and login again? Let me know if it solved the issue.

huangfulei commented 3 weeks ago

Hey @huangfulei!

What version of next-firebase-auth-edge are you using? Custom tokens have been introduced since v1.6.0

Could you clear your browser cookies and login again? Let me know if it solved the issue.

I'm using 1.7.0-canary.2 reproduce steps:

  1. set the cookieSerializeOptions maxAge to 5 seconds in the server-config
  2. open app in incognito mode
  3. login, and now you can see your customToken can be logged
  4. wait for 5 seconds, refresh the page then you will get null from the getTokens() function

is that expected? or do I miss anything?

awinogrodzki commented 3 weeks ago

The behaviour you described is expected. When you set maxAge to 5 seconds, browser will remove authentication cookies shortly after that time passes. Authentication cookies contain custom token, so if they are removed, getTokens no longer has access to the token and returns null

huangfulei commented 3 weeks ago

The behaviour you described is expected. When you set maxAge to 5 seconds, browser will remove authentication cookies shortly after that time passes. Authentication cookies contain custom token, so if they are removed, getTokens no longer has access to the token and returns null

@awinogrodzki any idea why this always returns the same existing custom token for me? const customToken = await getValidCustomToken({ serverCustomToken: user.customToken, refreshTokenUrl: "/api/refresh-token", });

I have /api/refresh-token endpoint configured in middleware, and no matter I pass a valid or expired customToken to it, it always returns the same value back to me.

awinogrodzki commented 2 weeks ago

Hey @huangfulei,

You can look at getValidCustomToken function for clues:

export async function getValidCustomToken({
  serverCustomToken,
  refreshTokenUrl,
  checkRevoked
}: GetValidCustomTokenOptions): Promise<string | null> {
  // If serverCustomToken is empty, we assume user is unauthenticated and token refresh will yield null
  if (!serverCustomToken) {
    return null;
  }

  const token = customTokenCache.get(serverCustomToken);
  const payload = decodeJwt(token);
  const exp = payload?.exp ?? 0;

  if (!checkRevoked && exp > Date.now() / 1000) {
    return serverCustomToken;
  }

  const response = await fetchApi<{customToken: string}>(refreshTokenUrl);

  if (!response?.customToken) {
    throw new AuthError(
      AuthErrorCode.INTERNAL_ERROR,
      'Refresh token endpoint returned invalid response. This URL should point to endpoint exposed by the middleware and configured using refreshTokenPath option'
    );
  }

  customTokenCache.set(serverCustomToken, response.customToken);

  return response.customToken;
}

Basically, it will only fetch new custom token, if exp claim of the current token is greater than current timestamp

You can use sites like jwt.io to debug your custom token and check what is the exp claim of the token

and no matter I pass a valid or expired customToken to it, it always returns the same value back to me.

Both getValidCustomToken and Refresh Token endpoint will return new token only if the previous one is expired. The expiration is checked against exp claim.

What do you mean by expired customToken? Is the exp claim in the past, and you still receive old token?

huangfulei commented 2 weeks ago

@awinogrodzki I found the issue. the maxAge in the cookieSerializeOption is only for the id token, not the custom token. the custom toke is always expired in 1 hour, and if I set the maxAge for id token to less than 1 hour, then I would not be able to refresh the id token

awinogrodzki commented 2 weeks ago

@huangfulei maxAge controls the lifetime of cookies rather than lifetime of token. Both id and custom tokens are valid for 1 hour. Currently, there is no way of changing the expiration date of those tokens. It is the same behaviour as observed in official firebase-admin library.

if I set the maxAge for id token to less than 1 hour, then I would not be able to refresh the id token

Yes, that is correct. This is expected behaviour. Middleware should not have access to refresh token after maxAge has passed.

maxAge describes the maximum duration of user session. If you set it to, let's say, 30 minutes, you should expect that user is logged out after 30 minutes, so refreshing token after 30 minutes should not be possible.

awinogrodzki commented 1 week ago

I will close the issue now, as I feel every question has been answered. Feel free to open new issues if otherwise. Cheers 🎉