Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CredentialsProvider session token cookie not updated on getServerSideProps #4075

Closed
killjoy2013 opened this issue Feb 25, 2022 · 13 comments
Closed
Labels
docs Relates to documentation

Comments

@killjoy2013
Copy link

killjoy2013 commented Feb 25, 2022

Environment

System:
OS: Windows 10 10.0.19042
CPU: (8) x64 Intel(R) Xeon(R) CPU E3-1505M v6 @ 3.00GHz
Memory: 5.44 GB / 15.86 GB
Binaries:
Node: 14.17.2 - C:\Program Files\nodejs\node.EXE
Yarn: 1.22.4 - C:\Program Files (x86)\Yarn\bin\yarn.CMD
npm: 6.14.13 - C:\Program Files\nodejs\npm.CMD
Browsers:
Edge: Spartan (44.19041.1266.0)
Internet Explorer: 11.0.19041.1202
npmPackages:
next: ~12.1.0 => 12.1.0
next-auth: ~4.2.1 => 4.2.1
react: 17.0.2 => 17.0.2

Reproduction URL

https://github.com/killjoy2013/nextauth-credential-rotate

Describe the issue

Hi,
Using CredentialsProvider and need to rotate the token. My [...nextauth.ts] is below;

import NextAuth from 'next-auth';
import { JWT, JWTEncodeParams, JWTDecodeParams } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import jsonwebtoken from 'jsonwebtoken';

function createToken(username: string) {
  return {
    username,
    willExpire: Date.now() + parseInt(process.env.TOKEN_REFRESH_PERIOD) * 1000,
  };
}

function refreshToken(token) {
  const { iat, exp, ...others } = token;

  return {
    ...others,
    willExpire: Date.now() + parseInt(process.env.TOKEN_REFRESH_PERIOD) * 1000,
  };
}

export default NextAuth({
  secret: process.env.TOKEN_SECRET,
  jwt: {
    secret: process.env.TOKEN_SECRET,
    maxAge: parseInt(process.env.TOKEN_MAX_AGE),
    encode: async (params: JWTEncodeParams): Promise<string> => {
      const { secret, token } = params;
      let encodedToken = '';
      if (token) {
        const jwtClaims = {
          username: token.username,
          willExpire: token.willExpire,
        };

        encodedToken = jsonwebtoken.sign(jwtClaims, secret, {
          expiresIn: parseInt(process.env.TOKEN_REFRESH_PERIOD),
          algorithm: 'HS512',
        });
      } else {
        console.log('TOKEN EMPTY. SO, LOGOUT!...');
        return '';
      }
      return encodedToken;
    },
    decode: async (params: JWTDecodeParams) => {
      const { token, secret } = params;
      const decoded = jsonwebtoken.decode(token);

      return { ...(decoded as JWT) };
    },
  },
  session: {
    maxAge: parseInt(process.env.TOKEN_MAX_AGE),
    updateAge: 0,
    strategy: 'jwt',
  },
  callbacks: {
    async jwt({ token, user, account }) {
      if (user) {
        token = createToken(user.username as string);
      }

      let left = ((token.willExpire as number) - Date.now()) / 1000;
      console.log({
        now: Date.now(),
        willExpire: token.willExpire,
        left,
      });

      if (left > 0) {
        return token;
      } else {
        let newToken = await refreshToken(token);
        return { ...newToken };
      }
    },
    async session({ session, token, user }) {
      // Send properties to the client, like an access_token from a provider.
      session.username = token.username;
      session.willExpire = token.willExpire;
      return session;
    },
  },
  providers: [
    CredentialsProvider({
      name: 'LDAP',
      credentials: {
        username: { label: 'Username', type: 'text' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const { username, password } = credentials;

        if (!username || !password) {
          throw new Error('enter username or password');
        }
        try {
          let token = createToken(username);
          return token;
        } catch (error) {
          console.log(error);
          throw new Error('Authentication error');
        }
      },
    }),
  ],
});

TOKEN_MAX_AGE is 3600 seconds and TOKEN_REFRESH_PERIOD is 60 seconds. So, in 60 seconds after JWT is created, token is supposed to get rotated. Initial token creation is in createToken and tried to simulate token refresh in refreshToken. I put username & willExpire as claims. In Jwt callback, a token is created and returned. Then, encode runs and encoded token returned. Both home page (index.tsx) and cities.tsx has

  const session = await getSession({ req });
  const token = await getToken({
    req,
    secret: process.env.TOKEN_SECRET,
    raw: true,
  });

in their getServerSideProps. So, each navigation to home & cities page triggers jwt callback and even though encode function encodes and return a new token, next-auth.session-token cookie never gets updated. It's just created when user first login, and stays always the same. Normally, I'll decide token rotation in jwt as in token rotation sample in tutorials like this;

 let left = ((token.willExpire as number) - Date.now()) / 1000;
      console.log({
        now: Date.now(),
        willExpire: token.willExpire,
        left,
      });

      if (left > 0) {
        return token;
      } else {
        let newToken = await refreshToken(token);
        return { ...newToken };
      }

Since the session token cookie is not updated, this rotation logic fails because the token received in jwt callback is always the same.

How to reproduce

npm i & npm run dev

navigate to http://localhost:3000/

you'll be redirected to login page. Provide a dummy username and a password

You'll be redirected to home page. Now you can display next-auth.session-token cookie now.

click Cities in the left menu and you'll navigate to cities page.

click toolbar to go back home page.

In every navigation between pages, getServerSideProps runs with getSession and getToken inside.

Note that, next-auth.session-token is not changing :-(

Close the browser and wait until token refresh period ( 60 seconds ) has passed. Then open a browser and navigate to http://localhost:3000/cities

Now, notice that, next-auth.session-token cookie still has the old token from last login before closing the browser.

Expected behavior

next-auth.session-token is supposed to get updated with the returned token from encode.
On the client side, we can force the token to update using

  const event = new Event('visibilitychange');
  document.dispatchEvent(event);

However, users can directly navigate to http://localhost:3000/cities In this case, on getServerSideProps an updated token is supposed to be obtained because this token will be used in a graphql query that will be run from serverside.

@killjoy2013 killjoy2013 added the triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. label Feb 25, 2022
@ThangHuuVu
Copy link
Member

Please try getServerSession and see if the issue persists. We recommend users stay away from getSession while in Backend; this is not reflected in docs yet (PR #3982 in draft). cc @balazsorban44

@killjoy2013
Copy link
Author

killjoy2013 commented Feb 26, 2022

Please try getServerSession and see if the issue persists. We recommend users stay away from getSession while in Backend; this is not reflected in docs yet (PR #3982 in draft). cc @balazsorban44

Thank You @ThangHuuVu . Just tried like this;

 const session = await getServerSession(ctx, {
    providers: [],
  });

Receiving below error just when I call getServerSession ;

[next-auth][error][JWT_SESSION_ERROR] 
https://next-auth.js.org/errors#jwt_session_error Invalid Compact JWE {
  message: 'Invalid Compact JWE',
  stack: 'JWEInvalid: Invalid Compact JWE\n' +
    '    at compactDecrypt (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\jose\\dist\\node\\cjs\\jwe\\compact\\decrypt.js:16:15)\n' +
    '    at jwtDecrypt (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\jose\\dist\\node\\cjs\\jwt\\decrypt.js:8:61)\n' +
    '    at Object.decode (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next-auth\\jwt\\index.js:64:34)\n' +
    '    at processTicksAndRejections (internal/process/task_queues.js:95:5)\n' +
    '    at async Object.session (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next-auth\\core\\routes\\session.js:41:28)\n' +
    '    at async NextAuthHandler (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next-auth\\core\\index.js:96:27)\n' +
    '    at async getServerSession (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next-auth\\next\\index.js:67:19)\n' +
    '    at async getServerSideProps (webpack-internal:///./pages/index.tsx:51:21)\n' +
    '    at async Object.renderToHTML (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next\\dist\\server\\render.js:589:20)\n' +
    '    at async doRender (D:\\Dev\\GIT\\nextauth-credential-rotate\\node_modules\\next\\dist\\server\\base-server.js:879:38)',
  name: 'JWEInvalid'
}

Should I call it differently ?

@ThangHuuVu
Copy link
Member

To use getServerSession, export the configuration object in [...nextauth].ts like this:

export const authOptions: NextAuthOptions = {
  secret: process.env.TOKEN_SECRET,
  // other configs...
}
export default NextAuth(authOptions);

In index.tsx:

 const session = await getServerSession(ctx, authOptions);

@killjoy2013
Copy link
Author

@ThangHuuVu @balazsorban44 getServerSession works the way I need, thank you a lot 👍

One point I observed is, I need to supply default token properties explicitly;

const defaultToken = {
  name: '',
  email: '',
  picture: '',
};

function createToken(username: string) {
  return {
    ...defaultToken,
    username,
    accessTokenExpires:
      Date.now() + parseInt(process.env.TOKEN_REFRESH_PERIOD) * 1000,
  };
}

If I try to create the token without them;

function createToken(username: string) {
  return { 
    username,
    accessTokenExpires:
      Date.now() + parseInt(process.env.TOKEN_REFRESH_PERIOD) * 1000,
  };
}

I received

error - Error: Error serializing `.session.user.name` returned from `getServerSideProps` in "/".
Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value.

Maybe those default props can be supplied internally. So, we wouldn't have to supply them.

Thanks again & regards

@ThangHuuVu ThangHuuVu added docs Relates to documentation and removed triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. labels Feb 27, 2022
@killjoy2013
Copy link
Author

@ThangHuuVu using getToken right after I called getServerSession to get the updated raw token. I'm using it to send GraphQL query. However, I noticed that, even though the session cookie updated in the browser correctly, getToken still returns the old one. Should I refrain from using getToken on the serverside and grab the cookie from response my self?

  const token = await getToken({
    req,
    secret: process.env.TOKEN_SECRET,
    raw: true,
  });

@killjoy2013
Copy link
Author

@ThangHuuVu @balazsorban44 a very elegant solution would be to return the token together with the session like this;

const {session, token} = await getServerSession(ctx, authOptions);

What would you think?

@balazsorban44
Copy link
Member

balazsorban44 commented Feb 27, 2022

The session is usually a subset of the token, the part that you want to expose to the client. If you want the token, you can use getToken instead. getServerSession is most useful when you use a database persisted session, which you cannot do with a Credentials provider anyway. (At least we don't recommend it)

@killjoy2013
Copy link
Author

@balazsorban44 not all the next.js apps run on the internet. Some are in house developed enterprise applications. And the users are in Active Directory, no social providers :( So, we have to use LDAP to make the authentication. Credentials Provider seems the first option. Is your recommened way to use a custom oauth provider for this situation? Can you suggest any LDAP oauth implementation?

Thanks

@killjoy2013
Copy link
Author

The session is usually a subset of the token, the part that you want to expose to the client. If you want the token, you can use getToken instead. getServerSession is most useful when you use a database persisted session, which you cannot do with a Credentials provider anyway. (At least we don't recommend it)

Prohibiting Credentials Provider from using database isn't it too imperative? Why don't you just make your recommendation about its use and let the user decide? I'm sure, in most of the cases a simple JWT based authentication workflow is perfectly enough. Trying to implement a custom OAuth provider just to be able to use a database, is an overkill !

@balazsorban44
Copy link
Member

balazsorban44 commented Feb 28, 2022

You don't have to create a custom OAuth Identity Provider to use it with the Credentials Provider. In that case, you should just use it directly, as we support OAuth out of the box.

There are way too many footguns when one tries to implement custom auth, so we discourage anyone doing so. This is stated in our docs: https://next-auth.js.org/providers/credentials

It would complicate our codebase unnecessarily.

We do support Azure AD and Azure AD B2C by the way, you can check if that's an option for your use case.

Looks like this is turning into a feature request, so I'll close.

We are not going to support databases with the credentials provider in the foreseeable future.

We are recommending best practices, and it might be that your use case is just outside the scope of NextAuth.js or what we would like to help with. You can utilize NextAuth.js for your use case through the CredentialsProvider and the getToken and getServerSession as recommended above, but we won't be doing any other changes.

@cassioseffrin
Copy link

To use getServerSession, export the configuration object in [...nextauth].ts like this:

export const authOptions: NextAuthOptions = {
  secret: process.env.TOKEN_SECRET,
  // other configs...
}
export default NextAuth(authOptions);

In index.tsx:

 const session = await getServerSession(ctx, authOptions);

It's save my day! Thks so much! may it would be very welcome to official docs of next-auth!

@darknessxk
Copy link

darknessxk commented Feb 7, 2023

Tried to make it work but unable to do that in any way

Using Keycloak as my provider

the following code is being used

const session = await getServerSession(context.req, context.res, authOptions);

And my authOptions

function getProviderKeyCloak() {
   return KeycloakProvider({
                clientId: process.env.KEYCLOAK_CID!,
                clientSecret: process.env.KEYCLOAK_SID!,
                issuer: process.env.KEYCLOAK_ISS!,
                name: "Athena Auth",
                style: {
                    logo: "",
                    logoDark: "",
                    bg: "#fff",
                    text: "#000",
                    bgDark: "#fff",
                    textDark: "#000",
                }
            })
}

export const authOptions: AuthOptions = {
    secret: process.env.NEXTAUTH_SECRET,
    pages: {
        signIn: "/auth/signin",
        newUser: "/profile",
        signOut: "/"
    },
    providers: [ getProviderKeyCloak() ]
}

I am trying to debug it myself but still unable to figure out what is really happening to make this Invalid JWE Compact stops

Tried everything in the post but still not being able to work it out

@gregg-cbs
Copy link

I am getting this error in Next 13.4.19 and I cant figure out why. I do not have this error in my next 13.1.6 app.
Left a comment here:
#4255 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Relates to documentation
Projects
None yet
Development

No branches or pull requests

6 participants