vvo / iron-session

🛠 Secure, stateless, and cookie-based session library for JavaScript
https://get-iron-session.vercel.app
MIT License
3.68k stars 253 forks source link

v6 and Next 13 pages middleware #663

Closed viachaslau-latushkin closed 5 months ago

viachaslau-latushkin commented 11 months ago

Hi! Thank you for your work!

I faced with problem in v6: save() and destroy() not working before return in nextjs middleware. And changes applying only on next tick or after reloading page or on new request to pages/api folder.

"iron-session": "^6.3.1", "next": "13.2.4"

Thanaen commented 11 months ago

@viachaslau-latushkin ➡️ https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704

viachaslau-latushkin commented 11 months ago

@viachaslau-latushkin ➡️ vercel/next.js#49442 (comment)

Thank you! But in this case cookie from middleware is not encrypted.

Thanaen commented 11 months ago

Hello @viachaslau-latushkin , iron-session adds the encrypted cookie via the "set-cookie" header, so the applySetCookie method applies the correctly encrypted cookie!

What makes you think otherwise?

viachaslau-latushkin commented 11 months ago

@Thanaen in middleware I am trying to apply refresh token logic. I get a new token and try to rewrite cookie with new encrypted (encrypted token) value. Yes, applySetCookie really add new cookie from middleware and it exists in next steps. But how get new encrypted value with help of iron-session which will be encrypted in middleware with help of sessionOptions.password and after successfully decrypt this value (again with help of sessionOptions.password) in time of SSR or in Api Router handler?

arthurjdam commented 10 months ago

You can use sealData() and unsealData() to encrypt/decrypt the cookie values.

Assuming you're doing an oauth/refresh token kind of thing, here's something I used recently:

import {
  IRON_COOKIE_NAME,
  SessionData,
  ironOptions,
} from './auth';

export async function middleware() {
  const session = await getIronSession<SessionData>(cookies(), ironOptions);
  const next = NextResponse.next();

  if (!session.token?.expires_at) {
    // no/invalid token, send user to auth
    return NextResponse.redirect(getGoogleAuthURL());
  }
  if (session.token.expires_at < Date.now()) {
    // refresh token
    try {
      const token = await refreshToken(session.token);

      const sealed = await sealData(
        {
          ...session,
          token: { ...token, expires_at: Date.now() + token.expires_in * 1000 },
        },
        ironOptions,
      );
      next.cookies.set(IRON_COOKIE_NAME, sealed);
      // await session.save(); // won't work
    } catch (e) {
      // some error while refreshing token, send user to auth
      console.error(e);
      return NextResponse.redirect(getGoogleAuthURL());
    }
  }

  return next;
}
viachaslau-latushkin commented 10 months ago

@arthurjdam Thank you. Nice solution. And good point to read documentation from start to end.

MalakJoseph commented 6 months ago

As @arthurjdam solution, here's the updated version that worked for me. Hope it may save time for others.

This solution fixes the error Cookies can only be modified in a Server Action or Route Handler after calling the await session.save().

middleware.ts

export default async function middleware(req: NextRequest) {
  const { isLoggedIn, exp } = await getSession();

  // Refresh Token Rotation
  if (isLoggedIn && exp && exp < Date.now() / 1000) {
    try {
      const newSession = await refreshToken();
      const sealed = await sealData(newSession, sessionOptions);
      const res = NextResponse.redirect(req.url);

      res.cookies.set({
        name: '[your-cookie-name]',
        value: sealed,
      });
      return res;
    } catch (e) {
      await logout();
    }
  }

  return NextResponse.next();
}

authActions.ts

export async function getSession() {
  return await getIronSession<User>(cookies(), sessionOptions);
}

export async function refreshToken() {
  const session = await getSession();
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_BACKEND_BASE_URL}/user/refresh`,
    {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + session.refreshToken },
    }
  );
  const { accessToken, refreshToken } = await res.json();
  const newUser = jwt.decode(accessToken) as JwtPayload;

  session.userToken = accessToken;
  session.refreshToken = refreshToken;
  session.exp = newUser.exp;

  return session;
}

P.S. I'm assuming the interface of my User object is:

export interface User {
  id: string;
  isLoggedIn: boolean;
  name: string;
  userToken: string;
  refreshToken: string;
  exp: number | undefined;
}

I'm available for any further clarifications or inquiries...

fbold commented 5 months ago

Please correct me if I'm wrong because this is doing my head in. I know this is an old thread but its the most relevant one I can find.

I want to implement something similar, however I'm trying to avoid having the refresh token and the access token in the same session, which essentially means in the same cookie, no? Because iron-session uses a cookie (encrypted) to store the session on the client side. Because isn't the whole point of having a refresh token that you don't send it on every request like the access token? From what I can see here, if an attacker gets your cookie they got the whole pie, they can refresh tokens indefinitely and their session won't expire until the refresh token does. So you might as well just have an access token with an expiry of the refresh token, no? Am I missing something?

I have been trying to find a way to have the refresh token separate and only send it up to the refresh endpoint when we need a new token, my thinking being that the fewer times it gets sent around the less likely it is for an attacker to steal it, but maybe that's not an accurate assumption. If it isn't, then it still leaves me with the question of why a refresh token in the first place, as this is the only case I can think of for having one.

Please if someone could help me out and point out what I'm missing and how I should be thinking about this. Thanks.

viachaslau-latushkin commented 5 months ago

@vurak sorry, but in your difficult case, separate access and refresh token but still save them in cookie - not really good solution. To avoid hack of encrypted cookie you can store secret inside some kind of vault tool and regenerate it every day. Also, you can additionally encrypt tokens with your own encryption before save in encrypted cookie. Maybe make sense to store in cookie only access token. But refresh token store inside server's db. But honestly, if you right now have so difficult question, may be you should use another way to store tokens. Inside web worker on client, for example.

rifat-dhrubo commented 5 months ago

@vurak I have implemented similar feature by having the refresh token encrypted in a separate cookie using the sealData from "iron-session." I made the refresh cookie a server only cookie like the access cookie. So, attacker can't really access it from the client side. Frankly, it's not ideal. I would rather have the refresh token only on the server side but can't really do that in this project.

fbold commented 5 months ago

@rifat-dhrubo thanks for the reply, that sounds exactly like the setup I've got. When you say you've made it a server only cookie, do you mean that because the token is always encrypted (through the use of sealData) on the client that an attacker wouldn't know what's in it? but supposedly, if they got access to the token itself it would still allow them to refresh the token, no?

I've been doing some reading to see if my assumptions about why a refresh token is used were right, I found this reply to be very good and confirmed my intuitions: that their main purpose is to reduce the opportunities for attack by allowing the lifetime of the access token (which is included in every request) to be shortened, meaning that if an attacker is to "steal" it, they would only have say a 30 minutes window to do bad stuff. Of course this only makes sense if the refresh token, which will have a much longer lifetime, say 30 days, is considerably less likely to be stolen; so the logic is that if the refresh token is only sent up to server a couple times an hour (i.e. only to the /refresh endpoint, or even better if you have a separate service for authentication) it supposedly reduces the opportunity window for an attack to steal it.

So knowing all of that, what does you refresh logic look like? How do you determine when you should refresh without having the refresh cookie sent with every request (which would defeat the purpose of having one)? The problem I'm facing regarding this issue is that since iron-session encrypts the token info making it only accessible on back end, I can't determine on the client side when I need to get a new one, meaning I either have to have some annoying to-and-fro setup where when a request is made to the backend it checks how much longer the access token is valid for and if it needs to be refreshed, returns some response to client which tells it to make a refresh request. That way the refresh token can only be sent up a occasionally as intended. But this seems like quite an impractical and annoying solution.

I am otherwise considering just implementing my own session solution with unencrypted (or maybe partially encrypted) JWTs, so that I can check the expiry of access token on the client side to skip the excessive back and forth of the aforementioned method.

I hope this makes sense. Cheers.

rifat-dhrubo commented 5 months ago

@vurak you are welcome. Not necessarily. The refresh cookie is http only. So, it so can only be accessed in server side and, also it's encrypted. The only way for attackers to gain access is to have access to the server side and also break the encryption. I use middleware with an api route in next.js for refresh token.

When the access token expires, I redirect the request to the api route. That route checks if the user has a valid session if so refreshes the cookie and sends the user back to the app.