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

Keycloak: 'State cookie was missing' if user started login in Keycloak and is waiting for 10 minutes or more. #9928

Open
AliakseiPaseishviliSyntheticabio opened this issue Feb 6, 2024 · 3 comments
Labels
bug Something isn't working providers triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.

Comments

@AliakseiPaseishviliSyntheticabio

Provider type

Keycloak

Environment

My package.json:

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "prepare": "husky install",
    "test": "jest"
  },
  "dependencies": {
    "@floating-ui/react": "^0.26.2",
    "@headlessui/react": "^1.7.16",
    "@heroicons/react": "^2.0.18",
    "@hookform/resolvers": "^3.2.0",
    "@lexical/react": "^0.12.5",
    "@tanstack/react-query": "^5.4.3",
    "@tanstack/react-query-devtools": "^5.4.3",
    "@types/node": "20.5.0",
    "@types/react": "18.2.20",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.15",
    "classnames": "^2.3.2",
    "dayjs": "^1.11.10",
    "eslint": "8.47.0",
    "eslint-config-next": "13.4.13",
    "eslint-plugin-json-es": "^1.5.7",
    "i18next": "^23.4.5",
    "jwt-decode": "^4.0.0",
    "lexical": "^0.12.5",
    "lodash": "^4.17.21",
    "next": "14.1.0",
    "next-auth": "^4.24.5",
    "postcss": "8.4.31",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-error-boundary": "^4.0.11",
    "react-hook-form": "^7.45.4",
    "react-i18next": "^13.2.0",
    "react-toastify": "^9.1.3",
    "react-virtualized-auto-sizer": "^1.0.20",
    "react-window": "^1.8.9",
    "reactflow": "^11.8.1",
    "sass": "^1.67.0",
    "tailwindcss": "3.3.3",
    "typescript": "5.1.6",
    "yup": "^1.2.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.1.5",
    "@testing-library/react": "^14.1.2",
    "@types/jest": "^29.5.11",
    "@types/lodash": "^4.14.199",
    "@types/react-window": "^1.8.6",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-import": "^2.28.0",
    "eslint-plugin-no-loops": "^0.3.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.33.1",
    "eslint-plugin-react-hooks": "^5.0.0-canary-7118f5dd7-20230705",
    "eslint-plugin-simple-import-sort": "^10.0.0",
    "eslint-plugin-unused-imports": "^3.0.0",
    "husky": "^8.0.3",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "lint-staged": "^14.0.0",
    "prettier": "3.0.1",
    "ts-node": "^10.9.2"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx,json}": [
      "npx eslint --quiet --fix"
    ]
  }
}

config for next-auth:

import dayjs from 'dayjs';
import { jwtDecode } from 'jwt-decode';
import { AuthOptions } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import KeycloakProvider from 'next-auth/providers/keycloak';

declare module 'next-auth/jwt' {
  interface JWT {
    access_token: string;
    id_token: string;
    expires_at: number;
    refresh_token: string;
    error?: string;
    roles: string[];
  }
}

declare module 'jwt-decode' {
  export interface JwtPayload {
    realm_access: { roles: string[] };
  }
}

declare module 'next-auth' {
  interface Session {
    error?: string;
    roles: string[];
    sub?: string;
  }
}

const refreshAccessToken = async (token: JWT) => {
  const url = `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/token`;
  const resp = await fetch(url, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.KEYCLOACK_CLIENT_ID,
      client_secret: process.env.KEYCLOACK_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: token.refresh_token,
    }),
    method: 'POST',
  });
  const refreshToken = await resp.json();
  if (!resp.ok) throw refreshToken;

  const tokenData = jwtDecode(refreshToken.access_token);

  return {
    ...token,
    access_token: refreshToken.access_token,
    id_token: refreshToken.id_token,
    expires_at: Math.floor(Date.now() / 1000) + refreshToken.expires_in,
    refresh_token: refreshToken.refresh_token,
    roles: tokenData.realm_access.roles,
  };
};

export const authOptions: AuthOptions = {
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOACK_CLIENT_ID,
      clientSecret: process.env.KEYCLOACK_CLIENT_SECRET,
      issuer: process.env.KEYCLOACK_ISSUER,
    }),
  ],
  callbacks: {
    async jwt({ token, account, trigger }) {
      const nowTimeStamp = dayjs();

      try {
        // we refresh token on update action
        if (trigger === 'update') {
          return await refreshAccessToken(token);
        }

        // we get intial token on sign in
        if (
          account &&
          account.access_token &&
          account.id_token &&
          account.expires_at &&
          account.refresh_token
        ) {
          const tokenData = jwtDecode(account.access_token);

          token.roles = tokenData.realm_access.roles;
          token.access_token = account.access_token;
          token.id_token = account.id_token;
          token.expires_at = account.expires_at;
          token.refresh_token = account.refresh_token;
          return token;
          // if time of expires is more then current date then we return existing token.
        } else if (
          token &&
          nowTimeStamp.isBefore(dayjs.unix(token.expires_at))
        ) {
          return token;
          // this happens after user session is expired, so in this case we try to update user session
        } else {
          return await refreshAccessToken(token);
        }
        // if session update is failed we return error and on client we are doing logout(TokenExpireController).
      } catch (error) {
        return { ...token, error: 'RefreshAccessTokenError' };
      }
    },
    async session({ session, token }) {
      session.error = token.error;
      session.roles = token.roles;
      session.sub = token.sub;

      return session;
    },
  },
  events: {
    async signOut({ token }) {
      const logOutUrl = new URL(
        `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/logout`,
      );
      logOutUrl.searchParams.set('id_token_hint', token.id_token!);

      await fetch(logOutUrl);
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: '/auth/signin', // we added this page to manually cover issue with missed state
  },
};

Reproduction URL

https://github.com/AliakseiPaseishviliSyntheticabio/test-repo

Describe the issue

Sometime user doing login to our app for too long and in this case we are navigated to sign-in page with next error, instead of giving access to the app.:
image
Pressing on sign in with keycloak will navigate our user to app without any keycloak login pages.

UPD: repo is not here. This is private code and I can't share env details to you.

How to reproduce

Steps to reproduce:

  1. Open your app with keycloak.
  2. Start login with keycloak
  3. wait for 10+ minutes.
  4. finish login with keycloak.
  5. App will redirect you to api/signin?error='OAuthCallback'
  6. pressing on keycloak button will do fast signin without doing step 2.
    in logs we got error: 'State cookie was missing'.

Expected behavior

It does login without any issues even in 10 or 15 minutes, because default login time in keycloak is set to 30 minutes.

@AliakseiPaseishviliSyntheticabio AliakseiPaseishviliSyntheticabio added bug Something isn't working providers triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. labels Feb 6, 2024
@swag-overflow
Copy link

I've the same problem on my site. On our setup the User doesn't need to wait it always happens (also on immediate login).
I'm using:
"next": "14.1.0",
"next-auth": "4.24.5",

@MarkShawn2020
Copy link

I came across the same problem yesterday.
And I managed to realize I should call the signIn function in component, instead of jumping to the authorization url which although is a GET request in signIn function.
May my experience helpful to you : ).

@AliakseiPaseishviliSyntheticabio
Copy link
Author

Hi everyone, hope my answer will help to everyone. We work with Keycloak.

Our config looks like this:

import dayjs from 'dayjs';
import { jwtDecode } from 'jwt-decode';
import { AuthOptions } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import KeycloakProvider from 'next-auth/providers/keycloak';

declare module 'next-auth/jwt' {
  interface JWT {
    access_token: string;
    id_token: string;
    expires_at: number;
    refresh_token: string;
    error?: string;
    roles: string[];
  }
}

declare module 'jwt-decode' {
  export interface JwtPayload {
    realm_access: { roles: string[] };
  }
}

declare module 'next-auth' {
  interface Session {
    error?: string;
    roles: string[];
    sub?: string;
  }
}

const COOKIES_LIFE_TIME = 24 * 60 * 60;
const COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Secure-' : '';

const refreshAccessToken = async (token: JWT) => {
  const url = `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/token`;
  const resp = await fetch(url, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.KEYCLOACK_CLIENT_ID,
      client_secret: process.env.KEYCLOACK_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: token.refresh_token,
    }),
    method: 'POST',
  });
  const refreshToken = await resp.json();
  if (!resp.ok) throw refreshToken;

  const tokenData = jwtDecode(refreshToken.access_token);

  return {
    ...token,
    access_token: refreshToken.access_token,
    id_token: refreshToken.id_token,
    expires_at: Math.floor(Date.now() / 1000) + refreshToken.expires_in,
    refresh_token: refreshToken.refresh_token,
    roles: tokenData.realm_access.roles,
  };
};

export const authOptions: AuthOptions = {
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOACK_CLIENT_ID,
      clientSecret: process.env.KEYCLOACK_CLIENT_SECRET,
      issuer: process.env.KEYCLOACK_ISSUER,
    }),
  ],
  callbacks: {
    async jwt({ token, account, trigger }) {
      const nowTimeStamp = dayjs();

      try {
        // we refresh token on update action
        if (trigger === 'update') {
          return await refreshAccessToken(token);
        }

        // we get intial token on sign in
        if (
          account &&
          account.access_token &&
          account.id_token &&
          account.expires_at &&
          account.refresh_token
        ) {
          const tokenData = jwtDecode(account.access_token);

          token.roles = tokenData.realm_access.roles;
          token.access_token = account.access_token;
          token.id_token = account.id_token;
          token.expires_at = account.expires_at;
          token.refresh_token = account.refresh_token;
          return token;
          // if time of expires is more then current date then we return existing token.
        } else if (
          token &&
          nowTimeStamp.isBefore(dayjs.unix(token.expires_at))
        ) {
          return token;
          // this happens after user session is expired, so in this case we try to update user session
        } else {
          return await refreshAccessToken(token);
        }
        // if session update is failed we return error and on client we are doing logout(TokenExpireController).
      } catch (error) {
        return { ...token, error: 'RefreshAccessTokenError' };
      }
    },
    async session({ session, token }) {
      session.error = token.error;
      session.roles = token.roles;
      session.sub = token.sub;

      return session;
    },
  },
  events: {
    async signOut({ token }) {
      const logOutUrl = new URL(
        `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/logout`,
      );
      logOutUrl.searchParams.set('id_token_hint', token.id_token!);

      await fetch(logOutUrl);
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: '/auth/signin',
  },
  cookies: {
    sessionToken: {
      name: `${COOKIE_PREFIX}next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: true,
      },
    },
    callbackUrl: {
      name: `${COOKIE_PREFIX}next-auth.callback-url`,
      options: {
        sameSite: 'lax',
        path: '/',
        secure: true,
      },
    },
    csrfToken: {
      name: `${COOKIE_PREFIX}next-auth.csrf-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: true,
      },
    },
    pkceCodeVerifier: {
      name: `${COOKIE_PREFIX}next-auth.pkce.code_verifier`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: true,
        maxAge: COOKIES_LIFE_TIME,
      },
    },
    state: {
      name: `${COOKIE_PREFIX}next-auth.state`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: true,
        maxAge: COOKIES_LIFE_TIME,
      },
    },
    nonce: {
      name: `${COOKIE_PREFIX}next-auth.nonce`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: true,
      },
    },
  },
};

So why I added cookies to config?
I figured out that main 2 cookies pkceCodeVerifier and state live only 15 minutes, which creates bug.
For example user can do login via keycloak for 30 minutes, and if user just waits for 15 minutes and more, then after successful login in keycloak with redirect we got State cookie was missing, which actually shows wierd signin page from nextauth.
Changing time to 1 day totally fix the issue.

In this case we do not need to turn off 'checks' in config.
From other point in documentation said that rewriting cookies is not good, but this is only the way to fix our issue.

We also do redirect from nextauth signin page to our custom signin page with nice design.

But from my side here is question to devs of next auth: Why do we use so small lifetime for state and pkceCodeVerifier?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working providers triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.
Projects
None yet
Development

No branches or pull requests

3 participants