Skip to content

Instantly share code, notes, and snippets.

@degitgitagitya
Last active June 12, 2024 09:18
Show Gist options
  • Save degitgitagitya/db5c4385fc549f317eac64d8e5702f74 to your computer and use it in GitHub Desktop.
Save degitgitagitya/db5c4385fc549f317eac64d8e5702f74 to your computer and use it in GitHub Desktop.
Next JS + Next Auth + Keycloak + AutoRefreshToken
# KEYCLOAK BASE URL
KEYCLOAK_BASE_URL=
# KEYCLOAK CLIENT SECRET
KEYCLOAK_CLIENT_SECRET=
# KEYCLOAK CLIENT ID
KEYCLOAK_CLIENT_ID=
# BASE URL FOR NEXT AUTH
NEXTAUTH_URL=
# JWT SECRET KEY
JWT_SECRET=
# NEXT AUTH SECRET KEY
SECRET=
# JWT SIGNING PRIVATE KEY
JWT_SIGNING_PRIVATE_KEY=
import NextAuth from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak'
import type { JWT } from 'next-auth/jwt';
/**
* Takes a token, and returns a new token with updated
* `accessToken` and `accessTokenExpires`. If an error occurs,
* returns the old token and an error property
*/
/**
* @param {JWT} token
*/
const refreshAccessToken = async (token: JWT) => {
try {
if (Date.now() > token.refreshTokenExpired) throw Error;
const details = {
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
grant_type: ['refresh_token'],
refresh_token: token.refreshToken,
};
const formBody: string[] = [];
Object.entries(details).forEach(([key, value]: [string, any]) => {
const encodedKey = encodeURIComponent(key);
const encodedValue = encodeURIComponent(value);
formBody.push(encodedKey + '=' + encodedValue);
});
const formData = formBody.join('&');
const url = `${process.env.KEYCLOAK_BASE_URL}/token`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: formData,
});
const refreshedTokens = await response.json();
if (!response.ok) throw refreshedTokens;
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpired: Date.now() + (refreshedTokens.expires_in - 15) * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
refreshTokenExpired:
Date.now() + (refreshedTokens.refresh_expires_in - 15) * 1000,
};
} catch (error) {
return {
...token,
error: 'RefreshAccessTokenError',
};
}
};
// If you have the latest version of next-auth
// Please use this next auth provider instead of my custom provider https://next-auth.js.org/providers/keycloak
// const keycloakProvider = KeycloakProvider({
// clientId: process.env.KEYCLOAK_CLIENT_ID,
// clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
// issuer: process.env.KEYCLOAK_ISSUER,
// authorization: {
// params: {
// grant_type: 'authorization_code',
// scope:
// 'openid tts-saas-user-attribute email speech-api profile console-prosa payment-service',
// response_type: 'code'
// }
// },
// httpOptions: {
// timeout: 30000
// }
// })
export default NextAuth({
// providers: [keycloakProvider],
providers: [
{
id: 'keycloak',
name: 'Keycloak',
type: 'oauth',
version: '2.0', // Double check your keycloak version
params: { grant_type: 'authorization_code' },
scope: 'openid email profile console-prosa basic-user-attribute',
accessTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/token`,
requestTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`,
authorizationUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
profileUrl: `${process.env.KEYCLOAK_BASE_URL}/userinfo`,
profile: (profile) => {
return {
...profile,
id: profile.sub,
};
},
authorizationParams: {
response_type: 'code',
},
},
],
session: {
jwt: true,
},
jwt: {
secret: process.env.JWT_SECRET,
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY,
},
secret: process.env.SECRET,
callbacks: {
/**
* @param {object} user User object
* @param {object} account Provider account
* @param {object} profile Provider profile
* @return {boolean|string} Return `true` to allow sign in
* Return `false` to deny access
* Return `string` to redirect to (eg.: "/unauthorized")
*/
async signIn(user, account) {
if (account && user) {
return true;
} else {
// TODO : Add unauthorized page
return '/unauthorized';
}
},
/**
* @param {string} url URL provided as callback URL by the client
* @param {string} baseUrl Default base URL of site (can be used as fallback)
* @return {string} URL the client will be redirect to
*/
async redirect(url, baseUrl) {
return url.startsWith(baseUrl) ? url : baseUrl;
},
/**
* @param {object} session Session object
* @param {object} token User object (if using database sessions)
* JSON Web Token (if not using database sessions)
* @return {object} Session that will be returned to the client
*/
async session(session, token: JWT) {
if (token) {
session.user = token.user;
session.error = token.error;
session.accessToken = token.accessToken;
}
return session;
},
/**
* @param {object} token Decrypted JSON Web Token
* @param {object} user User object (only available on sign in)
* @param {object} account Provider account (only available on sign in)
* @param {object} profile Provider profile (only available on sign in)
* @param {boolean} isNewUser True if new user (only available on sign in)
* @return {object} JSON Web Token that will be saved
*/
async jwt(token, user, account) {
// Initial sign in
if (account && user) {
// Add access_token, refresh_token and expirations to the token right after signin
token.accessToken = account.accessToken;
token.refreshToken = account.refreshToken;
token.accessTokenExpired =
Date.now() + (account.expires_in - 15) * 1000;
token.refreshTokenExpired =
Date.now() + (account.refresh_expires_in - 15) * 1000;
token.user = user;
return token;
}
// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpired) return token;
// Access token has expired, try to update it
return refreshAccessToken(token);
},
},
});
// Client example
import { signIn, useSession } from "next-auth/client";
import { useEffect } from "react";
const HomePage() {
const [session] = useSession();
useEffect(() => {
if (session?.error === "RefreshAccessTokenError") {
signIn('keycloak', {
callbackUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/application`,
}); // Force sign in to hopefully resolve error
}
}, [session]);
return (...)
}
import type { User } from 'next-auth';
declare module 'next-auth' {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context
*/
interface Session {
user: {
sub: string;
email_verified: boolean;
name: string;
preferred_username: string;
given_name: string;
family_name: string;
email: string;
id: string;
org_name?: string;
telephone?: string;
};
error: string;
}
/**
* The shape of the user object returned in the OAuth providers' `profile` callback,
* or the second parameter of the `session` callback, when using a database.
*/
interface User {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
given_name: string;
family_name: string;
email: string;
id: string;
}
/**
* Usually contains information about the provider being used
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
*/
interface Account {
provider: string;
type: string;
id: string;
accessToken: string;
accessTokenExpires?: any;
refreshToken: string;
idToken: string;
access_token: string;
expires_in: number;
refresh_expires_in: number;
refresh_token: string;
token_type: string;
id_token: string;
'not-before-policy': number;
session_state: string;
scope: string;
}
/** The OAuth profile returned from your provider */
interface Profile {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
given_name: string;
family_name: string;
email: string;
}
}
declare module 'next-auth/jwt' {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
interface JWT {
name: string;
email: string;
sub: string;
name: string;
email: string;
sub: string;
accessToken: string;
refreshToken: string;
accessTokenExpired: number;
refreshTokenExpired: number;
user: User;
error: string;
}
}
@CuongNgMan
Copy link

Thank you so much for the implementation.

@samuel-esp
Copy link

samuel-esp commented Jul 25, 2022

Hey man, thank you for your implementation, I've got a question, where should i take/generate these keys from?

# JWT SECRET KEY
JWT_SECRET=

# JWT SIGNING PRIVATE KEY
JWT_SIGNING_PRIVATE_KEY=

I'm going to try to try your implementation as a last resort since Keycloak base next-auth is giving me this problem.

  error: {
    message: 'connect ECONNREFUSED ::1:8080',
    stack: 'Error: connect ECONNREFUSED ::1:8080\n' +
      '    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1237:16)',
    name: 'Error'
  },
  providerId: 'keycloak',
  message: 'connect ECONNREFUSED ::1:8080'
}

Have you ever encountered this issue before?

@nikkizol
Copy link

Hey, question my refreshAccessToken returns invalid_client. How is you client setup looks??

@Toyurc
Copy link

Toyurc commented Feb 14, 2023

Hi, I love this set-up and have used the same in multiple keycloak projects. I wanted to ask, when a user gets auto-logged out, maybe the refresh token or token gets expired, how do you handle going back to the previous page the user was on when they are logged back in?

Any ideas would be welcome. Thanks

@degitgitagitya
Copy link
Author

Hi, I love this set-up and have used the same in multiple keycloak projects. I wanted to ask, when a user gets auto-logged out, maybe the refresh token or token gets expired, how do you handle going back to the previous page the user was on when they are logged back in?

Any ideas would be welcome. Thanks

useEffect(() => {
    ...
      if (session) {
        if (session.error === 'RefreshAccessTokenError') {
          router.push(`/login?redirect=${router.asPath}`);
        }
        if (session.error === 'LoginError') {
          router.push('/?error=LoginError');
        }
      }
      if (session === null) {
        router.push(`/login?redirect=${router.asPath}`);
      }
    }
  ...
  }, [session, authMode, router]);

I have redirect=${router.asPath} as params when the user auto-logged out,

When sign in

const callbackUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${
      redirect || `${locale && locale !== 'id' ? `/${locale}` : ''}/application`
    }`;

    signIn('keycloak', {
      callbackUrl,
    });

@degitgitagitya
Copy link
Author

Hey, question my refreshAccessToken returns invalid_client. How is you client setup looks??

sorry for late response, I think something wrong inside the body / payload when you request to refresh token endpoint, make sure you check the endpoint documentation, (double check the keycloak version)

@degitgitagitya
Copy link
Author

Hey man, thank you for your implementation, I've got a question, where should i take/generate these keys from?

# JWT SECRET KEY
JWT_SECRET=

# JWT SIGNING PRIVATE KEY
JWT_SIGNING_PRIVATE_KEY=

I'm going to try to try your implementation as a last resort since Keycloak base next-auth is giving me this problem.

  error: {
    message: 'connect ECONNREFUSED ::1:8080',
    stack: 'Error: connect ECONNREFUSED ::1:8080\n' +
      '    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1237:16)',
    name: 'Error'
  },
  providerId: 'keycloak',
  message: 'connect ECONNREFUSED ::1:8080'
}

Have you ever encountered this issue before?

Hi, sorry for late response

You can randomly generate key for JWT using uuid4 or hash

For your issue, I'm sorry, I never personally encounter one of your error

@Toyurc
Copy link

Toyurc commented Feb 14, 2023

Hi, I love this set-up and have used the same in multiple keycloak projects. I wanted to ask, when a user gets auto-logged out, maybe the refresh token or token gets expired, how do you handle going back to the previous page the user was on when they are logged back in?
Any ideas would be welcome. Thanks

useEffect(() => {
    ...
      if (session) {
        if (session.error === 'RefreshAccessTokenError') {
          router.push(`/login?redirect=${router.asPath}`);
        }
        if (session.error === 'LoginError') {
          router.push('/?error=LoginError');
        }
      }
      if (session === null) {
        router.push(`/login?redirect=${router.asPath}`);
      }
    }
  ...
  }, [session, authMode, router]);

I have redirect=${router.asPath} as params when the user auto-logged out,

When sign in

const callbackUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${
      redirect || `${locale && locale !== 'id' ? `/${locale}` : ''}/application`
    }`;

    signIn('keycloak', {
      callbackUrl,
    });

Interesting Idea. I will try this. thanks

@GRCR
Copy link

GRCR commented Apr 17, 2023

Instead of localhost use 127.0.0.1 in .env url
This worked for me.

@thuurzz
Copy link

thuurzz commented Jun 25, 2023

Instead of localhost use 127.0.0.1 in .env url
This worked for me.

man, I try six different modes to do that, man thank u, really!
this works fine to me 🤙

@singleseeker
Copy link

I need some clarification. What if a post list needs permission, and you click the pagination, will this logic be called?

@degitgitagitya
Copy link
Author

I need some clarification. What if a post list needs permission, and you click the pagination, will this logic be called?

Which logic?

@cortehz
Copy link

cortehz commented Sep 29, 2023

Hey, @degitgitagitya quick one say I want to have a middleware to protect API routes in the app directory. I also want these routes to be accessible from outside clients using Bearer {token}. How do I approach this?

@degitgitagitya
Copy link
Author

degitgitagitya commented Sep 29, 2023

Hey, @degitgitagitya quick one say I want to have a middleware to protect API routes in the app directory. I also want these routes to be accessible from outside clients using Bearer {token}. How do I approach this?

FYI, I'm never use app directory, but let me answer your question since my next api has some kind of webhook that called by external service.

I'm using next-connect to manage my api routes, so I created a middleware like this to protect the route only using the accessToken

export const verifyAuthorization = async (
  req: NextApiRequest,
  res: NextApiResponse,
  next: NextHandler
) => {
  const auth = req.headers['authorization'] // get access token from Authorization header (handle external service request)

  if (auth) {
    const requestConfig: AxiosRequestConfig = {
      headers: {
        Authorization: auth as string
      }
    }

     const [data] = await resolvePromise<MyProfileSSOResponseType>( // hit user profile api from sso to validate the accessToken
       axios.get(
         `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/userinfo`,
         requestConfig
       )
     )

     if (data) { // if data is exist, the request is continue
       await next()
       return
     }
  }

  const session = await getServerSession(req, res, authOptions) // if data doesn't exist, we look for their session

  if (session?.error === 'RefreshAccessTokenError') {
    res.status(401).json({
      code: 401,
      detail: 'Your session has expired, trying to sign you back in'
    })
    return
  }

  if (session) {
    await next()
    return
  }

  res.status(403).json({
    code: 403,
    detail: 'Forbidden'
  })

}

@Shaju06
Copy link

Shaju06 commented Mar 20, 2024

Hey guys, I’ve a use case where I’m killing all the user keykloak sessions on change password and I want user to be logout from the website since next auth sign out function only logout user from a single device, how to do it from all the logged I. Devices?

@degitgitagitya
Copy link
Author

Hey guys, I’ve a use case where I’m killing all the user keykloak sessions on change password and I want user to be logout from the website since next auth sign out function only logout user from a single device, how to do it from all the logged I. Devices?

i think you have to know all of the refresh tokens that active at that moment, and revoke it when you trigger the reset password

you can see this discussion, maybe it's relevant for you https://keycloak.discourse.group/t/revoke-refresh-tokens-after-password-reset/11159/12

@Shaju06
Copy link

Shaju06 commented Mar 21, 2024

On client next auth session is being stored in cookies will it still validate if I've revoke refresh token?

@degitgitagitya
Copy link
Author

degitgitagitya commented Mar 21, 2024

On client next auth session is being stored in cookies will it still validate if I've revoke refresh token?

the token on the client cookie that you revoked will be invalid useless (but still a valid token), meaning that token can't be used for authorization

@Varinder06
Copy link

I tried with this api admin/realms/{realm}/users/{id}/consents/{client} but still on client side next auth doesn't logout that user.It only works when we delete cookies.

@Meguais
Copy link

Meguais commented May 19, 2024

hi Is there a public repo with next with keycloak installed?

@degitgitagitya
Copy link
Author

degitgitagitya commented Jun 12, 2024

I tried with this api admin/realms/{realm}/users/{id}/consents/{client} but still on client side next auth doesn't logout that user.It only works when we delete cookies.

you can't magically logout a user, you should verify the accessToken from keycloak every time user access the api. if the accessToken invalid (in a case of session invalidation from another source) we proceed to logout the user.

@degitgitagitya
Copy link
Author

hi Is there a public repo with next with keycloak installed?

i'm sorry, all of my keycloak related repositories are private, by any chance, have you seen this ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment