Problems with the authentication state after changing the user's e-mail address
Closed this issue · 4 comments
Hello, first of all, thank you for this library, it is super helpful!
I am currently facing the following problem and have no idea how to solve it:
As suggested in the Docs, I am using AuthContext
/ AuthProvider
to pass the user-object to my client components. In those components I use useDocumentData
from react-firebase-hooks/firestore
to access the firestore.
To enable users to change their email-address, I created a form that uses verifyBeforeUpdateEmail that sends a confirmation link to the users new email address.
Apparently, the users firebase token gets invalidated after the new email was confirmed. I was hoping that next-firebase-auth-edge
would detect the invalid token and refresh it automatically, but that seems not to be the case. Instead, after changing my email I am in some weird "half authenticated" state: As next-firebase-auth-edge
does not recognize the invalid token, the middleware doesn't call handleInvalidToken
so I can freely navigate my protected pages. However, I can't access any firebase documents as the token is invalid.
I noticed that my getTokens(getCookies(),...)
call still returns a user object, but it still contains the old email address (I therefore assume that the token has not been updated either). I stumbled accross #237 which made me try to use the refreshTokenPath
to refresh my token, but that didn't seem to help. I also tried to the verifyAndRefreshExpiredIdToken
but could not get it to work either. I can't even find a way to react to the confirmation of the email address, because onAuthStateChanged
doesn't seem to be triggered either 😩. The only thing that helps is to log out and then log in with the new e-mail address..
Please excuse the long and convoluted error description, but perhaps someone has already had a similar problem and knows what to do?
Hey @c-goettert!
Thanks for reporting this. A similar issue has already been discussed in #136, but yours is slightly different as it pertains to the characteristics of verifyBeforeUpdateEmail
,
The behavior you're describing is not ideal, but expected. Let me explain:
I was hoping that next-firebase-auth-edge would detect the invalid token and refresh it automatically
The is no way for the library to detect such change automatically. Library is designed to run on Edge, so it's not able to maintain a live connection with Firebase services to detect a change in the token.
Instead, after changing my email I am in some weird "half authenticated" state: As next-firebase-auth-edge does not recognize the invalid token, the middleware doesn't call handleInvalidToken so I can freely navigate my protected pages.
This is expected as next-firebase-auth-edge
isn't aware that token has been revoked and the token is not expired. Library can be instructed to check for revoked token (See below)
I stumbled accross #237 which made me try to use the refreshTokenPath to refresh my token, but that didn't seem to help
refreshTokenPath
also does not check for a revoked token. It will only return fresh token if it's expired. The name is a bit unfortunate, but refreshTokenWhenExpiredPath
seems a bit over the top. This is also expected.
Possible solutions
next-firebase-auth-edge
provides two ways to deal with it inside Middleware, but both require the developer to say explicitly if the token was revoked or not
-
You can refresh revoked token by providing
checkRevoked: true
. This will cause additional request to Firebase to check if the token is revoked, so you should only passtrue
when you know the token should be revoked. This is quite complex to setup. -
You could use refreshCredentials in Middleware, but it has the same problem – you need to provide information if the token should be checked against Firebase.
onAuthStateChanged doesn't seem to be triggered either
That's weird. Have you tried onAuthStateChanged
after signInWithCustomToken?
I wouldn't recommend that approach though, as it will require additional call on the client-side to check if user was revoked.
This is quite a huge problem, because there is no way of knowing that user has changed. We only know that user e-mail will probably change in the future. The easiest way I can think of:
- You setup some API route, eg.
POST /email-change-events?userId=xxx
. You call this method right afterawait verifyBeforeUpdateEmail(...)
.
In route handler, you should use some quick access key-value storage, to determine if user token should be updated. It can be distributed cache (in case of multiple-instance environment), or in-memory cache (in case of single-instance apps). Here is simplified version of the route:
export async function POST(request: NextRequest) {
const uid = request.nextUrl.searchParams.get('userId');
cache.set(`revoke-check:${uid}`, true);
}
- In Middleware, use the value to determine if credentials should be updated. As in refreshCredentials example
Other approaches
You could use updateUser method to update email address in Next.js API route together with refreshServerCookies, but then you'll have to handle e-mail verification differently.
Alternatives considered
I tested verifyBeforeUpdateEmail
locally to find a way to detect e-mail change. External rewrite over __/auth/action
was potential workaround, but even with latest Next.js, Middleware doesn't allow to modify headers after external rewrites have been applied, so there is no simple way to solve this problem
Hello @awinogrodzki👋! First of all, thank you very much for your efforts and this very detailed answer! This is not a matter of course and I really appreciate it. Based on that, I've already made a lot of progress:
First attempt: In-Memory cache 🔴
Since I have not configured anything special with Firebase Hosting, I assume my app runs on a "single instance". So first I followed your suggestion with an in-memory cache, this was my minimalistic implementation for it:
const cache = new Map();
export default cache;
I imported this cache in my middleware and in my app/api/email-change-events
route. However, the cache object within the handeValidToken
block always remained empty and never got updated, even if I had it filled by the API call. I tried using singleton-patterns or embedding the cache in global.cache
but it just didn't work. I probably just lack a little understanding of how serverside components execute my JavaScript..
Second attempt: Using custom claims 🟡
I decidet to try using custom claims as I can access those in handeValidToken
. My approach is to store the updated email in a claim called pendingEmail
whenever the user updates his email with verifyBeforeUpdateEmail
. To ensure the claims are updated I use refreshServerCookies
in my server-function after the custom claim was set. In my client component I use router.refresh()
to immediatly trigger a refresh and the token/claims are updated.
These are the relevant parts of the code (maybe it can help someone):
- Trigger email-pending api from client component 🟢
// Send verification link to new email via firebase
await verifyBeforeUpdateEmail(auth.currentUser, newEmail);
// call serverside API to set custom claims
await fetch('/api/set-pending-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: auth.currentUser.uid,
pendingEmail: newEmail,
}),
});
// refresh router to immediatly refresh tokens / auth data
router.refresh();
- Server-action to update custom claims (
app/api/reset-pending-email/route.js
) 🟢
export async function POST(req) {
const body = await req.json(); // Parse JSON body
const { userId, pendingEmail } = body;
const tokens = await getTokens(getCookies(), {
apiKey: clientConfig.apiKey,
cookieName: serverConfig.cookieName,
cookieSignatureKeys: serverConfig.cookieSignatureKeys,
serviceAccount: serverConfig.serviceAccount,
});
// Use verifiy ID-token to ensure users can only update their own claims..
const decodedToken = await adminAuth.verifyIdToken(tokens.token);
if (decodedToken?.uid === userId) {
// User with given userId is currently logged in - call is valid
await adminAuth.setCustomUserClaims(userId, {
pendingEmail,
});
await refreshServerCookies(
getCookies(),
new Headers(headers()),
commonOptions,
);
}
}
- Adjust middleware to call
refreshCredentials
if needed 🟡
Thanks to the emailPending
custom claim I can check if new credentials might be needed. To check for new credentials, I use refreshCredentials as you suggested. Unfortunately, the automatic refresh of the credentials in my middleware is still not working as expected.
Here is my code:
handleValidToken: async ({ token: _token, decodedToken }, headers) => {
// We only need to check if pending email and actual email are different.
// This check is handy as we do not need to reset “pendingEmail” after the email was changed.
const shouldRefreshCredentials = Boolean(
decodedToken.pendingEmail &&
decodedToken.pendingEmail !== decodedToken.email,
);
if (shouldRefreshCredentials) {
// If the user has changed his email but did not verify it yet, refresh credentials on every request until the email was finally changed..
return refreshCredentials(
request,
commonOptions,
({ headers: _headers, tokens: _tokens }) => {
// Optionally perform additional verification on refreshed `tokens`...
return NextResponse.next({
request: {
headers: _headers,
},
});
},
);
}
...
Expected behaviour:
After the email was verified, the users credentials are updated without the user noticing anything.
Real behaviour:
After the email was verified, the users is automatically logged out. It seems refreshCredentials
fails with the following error:Invalid credentials: Error fetching access token: { "code": 400," message": "TOKEN_EXPIRED", "status": "INVALID_ARGUMENT"}
Is it possible that the refreshTokens are no longer valid after an email change?
In any case, the logout is better than the “semi-authenticated” state before.
@awinogrodzki If you don't see an obvious error, I'll close this issue and just replace refreshCredentials
with a redirect to a fallback page that informs the user that he has to reauthenticate - I can absolutely live with that.
*Note on a pitfall / edge case of the above implementation
Only interesting for people who want to implement it in a similar way
Unfortunately, I could not find any information about how long a confirmation link is actually valid. Also, in Firebase there seems to be no native way to check if a user has currently an email-change pending. I have also not found a way to manually invalidate / revoke validation links that have been sent.
This can quickly drive you to despair if you want to display the current status in the UI. Additionally, it makes it very hard to deal with undesirable side effects: For example, if a user does not use the verification link for some time you might want to reset the "pendingEmail" claim - but when?? 😭. If the user clicks on the link afterwards, it will break the solution and introcude the original bug again, as pendingEmail
was reset..
My current very hacky approach to this mess is:
- I display the "verification-pending"-state in the UI as long as
pendingEmail
claim is set AND the value differs from the users current email - I offer a "cancel" button, which allows users to actively cancel the email-change process at any time
- To cancel the process, I reset the customClaim AND I call
verifyBeforeUpdateEmail
again to send a new confirmation link to a PRIVATE address that no one else can access.. 💩 This was the only way I could ensure that all previous verification links would be invalidated.
Hey @c-goettert!
Thank you for a rich description of the path you've taken and sorry for a delayed response.
After the email was verified, the users is automatically logged out. It seems refreshCredentials fails with the following error:Invalid credentials: Error fetching access token: { "code": 400," message": "TOKEN_EXPIRED", "status": "INVALID_ARGUMENT"}
This is unfortunately expected. It seems that Google is expiring refresh token after e-mail has been changed and the desired behaviour on their behalf is to logout user and let them login again.
To fix it properly, I think that a link sent via verifyBeforeUpdateEmail
should redirect back to the app and allow users to access latest credentials, which then could be used by calling /api/login
endpoint. Similar to how it's done with login with redirect or login via email link. Unfortunately, verifyBeforeUpdateEmail
does not seem to provide any of such functionality.
I will close the issue now. Feel free to reopen (or create a new issue) if you have any other questions or ideas!