Authentication with Keycloak and NextAuth.js

Create a new Next.js App

npx create-next-app@latest

What is your project named? my-app
Would you like to use TypeScript? No / Yes (recommended)
Would you like to use ESLint? No / Yes (recommended)
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No (recommended) / Yes
Would you like to use App Router? (recommended) No / Yes (recommended)
Would you like to customize the default import alias (@/*)? No (recommended) / Yes

Add NextAuth.js

npm install next-auth

Add environment variables for local development

For local environments, point your API_URL at the environment you intend to work against.

For auth, point your Keycloak Base URL at the environment you intend to work against.

Replace {yourBusinessAccount} with the business account that has been created for you in Keycloak.

// .env.local
# -----------------------------------------------------------------------------
# Client Variables (Used in client-side code)
# NEXT_PUBLIC_APP_URL: The URL of the client application. When deployed, this should be the full URL of the API.
# API_URL: The URL of the API server
# KEYCLOAK_BASE_URL: The URL of the KeyCloak server
# -----------------------------------------------------------------------------

NEXT_PUBLIC_APP_URL=http://localhost:3000
API_URL=https://api-ec-sandbox.socotra.com
KEYCLOAK_BASE_URL=https://idp-ec-sandbox.socotra.com/auth/realms/{yourBusinessAccount}

# -----------------------------------------------------------------------------
# Authentication (NextAuth.js)
# NEXTAUTH_URL: The URL of the client application
# NEXTAUTH_SECRET: A random string used to encrypt cookies, generate with openssl rand -base64 32
# -----------------------------------------------------------------------------

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET={{generate}}

# -----------------------------------------------------------------------------
# Authentication (KeyCloak)
# KEYCLOAK_BASE_URL: The URL of the KeyCloak server
# KEYCLOAK_CLIENT_ID: The client ID of the KeyCloak client (kern-ui)
# KEYCLOAK_CLIENT_SECRET:  A random string used to encrypt cookies, generate with openssl rand -base64 32
# -----------------------------------------------------------------------------

KEYCLOAK_CLIENT_ID=kern-ui
KEYCLOAK_CLIENT_SECRET={{generate}}

Your business account must have a corresponding client created.

For local development at https://localhost:3000, you may use kern-ui as the CLIENT_ID

For production deployments, a new client must be added with the app’s public URL taken into account. This can be created by a member of Socotra’s DevOps team.

(Optional) Add t3-oss/t3-env

Uses TypeScript and Zod to validate your app is receiving the necessary environment variables and throws errors when using secrets on the client

npm install @t3-oss/env-core zod
// from Next.js, add env.mjs to the root of the directory
touch env.mjs

Paste the following:

// env.mjs
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
    server: {
        // This is optional because it's only used in development.
        // See https://next-auth.js.org/deployment.
        NEXTAUTH_URL: z.string().url().optional(),
        NEXTAUTH_SECRET: z.string().optional(),
        KEYCLOAK_BASE_URL: z.string().min(1),
        KEYCLOAK_CLIENT_ID: z.string().min(1),
        KEYCLOAK_CLIENT_SECRET: z.string().min(1),
        API_URL: z.string().min(1),
    },
    client: {
        NEXT_PUBLIC_APP_URL: z.string().min(1),
    },
    runtimeEnv: {
        NEXTAUTH_URL: process.env.NEXTAUTH_URL,
        NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
        NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
        API_URL: process.env.API_URL,
        KEYCLOAK_BASE_URL: process.env.KEYCLOAK_BASE_URL,
        KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID,
        KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET,
    },
});

Setup Next.js and NextAuth with Keycloak

Create a new file at /lib/auth.ts for reuse.

Inside of auth.ts, first define your providers:

// auth.ts
import type { NextAuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';

import { env } from '@/env.mjs';

const auth: NextAuthOptions = {
    providers: [
        KeycloakProvider({
            clientId: env.KEYCLOAK_CLIENT_ID,
            clientSecret: env.KEYCLOAK_CLIENT_SECRET,
            issuer: env.KEYCLOAK_BASE_URL,
        }),
    ],
};

export default auth;

Next, add the following API routes.

  • app/api/auth/[...nextauth]/route.ts

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';

import auth from '@/lib/auth';

const handler = NextAuth(auth);

export { handler as GET, handler as POST };

Define Middleware for protecting routes

The most straightforward approach to securing the site is to protect all pages.

Create a middleware.ts file at the root of the directory:

export { default } from 'next-auth/middleware';

This will protect every page and result in a Login page when navigating to the site without a session.

To customize, follow the section at the bottom titled Customizing the login page

Add Logout Functionality

Because we are authenticating with an external service, logging out via next-auth’s signOut function will only end the session on the client. Subsequent logins will not require a keycloak login. In order to end the session fully, a logout route must be created.

First though, we need access to some of the session data at login and we want to encrypt it.

npm i cryptr @types/cryptr

Create a new util for encrypting and decrypting the session tokens

// utils/encrypt-decrypt-auth.ts
import Cryptr from 'cryptr';

import { env } from '@/env.mjs';

export function encrypt(text: string) {
    const secretKey = env.NEXTAUTH_SECRET;
    const cryptr = new Cryptr(secretKey ?? '');

    const encryptedString = cryptr.encrypt(text);
    return encryptedString;
}

export function decrypt(encryptedString: string) {
    const secretKey = env.NEXTAUTH_SECRET;
    const cryptr = new Cryptr(secretKey ?? '');

    const text = cryptr.decrypt(encryptedString);
    return text;
}

Next, create a session token accessor function.

// utils/session-token-accessor.ts
import { decrypt } from '@/utils/encrypt-decrypt-auth';
import { getServerSession } from 'next-auth';

import auth from '@/lib/auth';

export async function getAccessToken() {
    const session = await getServerSession(auth);
    if (session) {
        const accessTokenDecrypted = decrypt(session.access_token);
        return accessTokenDecrypted;
    }
    return null;
}

export async function getIdToken() {
    const session = await getServerSession(auth);
    if (session) {
        const idTokenDecrypted = decrypt(session.id_token);
        return idTokenDecrypted;
    }
    return null;
}

At this point, TypeScript will complain that session.id_token does not exist on type Session.

To fix, we need to propagate the Keycloak account response to our session and encrypt it at login.

First, let’s add some type definitions.

Add a new type definition at /types/next-auth.d.ts

// types/next-auth.d.ts
import { User } from 'next-auth';

declare module 'next-auth/jwt' {
    interface JWT {
        id_token: string;
        access_token: string;
        expires_at: number;
        refresh_token: string;
        realm: string;
        decoded: Decoded;
        error?: 'RefreshAccessTokenError';
    }
}

declare module 'next-auth' {
    interface Session {
        access_token: string;
        id_token: string;
        roles: string[];
        error?: 'RefreshAccessTokenError';
        user: User & {
            id: UserId;
        };
    }
}

interface Decoded {
    exp: number;
    iat: number;
    auth_time: number;
    jti: string;
    iss: string;
    aud: string;
    sub: string;
    typ: string;
    azp: string;
    session_state: string;
    acr: string;
    realm_access: RealmAccess;
    resource_access: ResourceAccess;
    scope: string;
    sid: string;
    soc_tenants: string[];
    email_verified: boolean;
    soc_roles: string[];
    name: string;
    preferred_username: string;
    given_name: string;
    family_name: string;
}

export interface RealmAccess {
    roles: string[];
}

export interface ResourceAccess {
    account: Account;
}

export interface Account {
    roles: string[];
}

Finally, we need to update /lib/auth.ts with callbacks and a refresh token function.

npm i jwt-decode
// lib/auth.ts
import { encrypt } from '@/utils/encrypt-decrypt-auth';
import { jwtDecode } from 'jwt-decode';
import type { NextAuthOptions } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import KeycloakProvider from 'next-auth/providers/keycloak';

import { env } from '@/env.mjs';

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

    return {
        ...token,
        access_token: refreshToken.access_token,
        decoded: jwtDecode(refreshToken.access_token),
        id_token: refreshToken.id_token,
        expires_at: Math.floor(Date.now() / 1000) + refreshToken.expires_in,
        refresh_token: refreshToken.refresh_token,
    } as JWT;
}

const auth: NextAuthOptions = {
    providers: [
        KeycloakProvider({
            clientId: env.KEYCLOAK_CLIENT_ID,
            clientSecret: env.KEYCLOAK_CLIENT_SECRET,
            issuer: env.KEYCLOAK_BASE_URL,
        }),
    ],
    callbacks: {
        async jwt({ token, account }) {
            const nowTimeStamp = Math.floor(Date.now() / 1000);

            if (account) {
                token.decoded = jwtDecode(account.access_token ?? '');
                token.access_token = account.access_token ?? '';
                token.id_token = account.id_token ?? '';
                token.expires_at = account.expires_at ?? 0;
                token.refresh_token = account.refresh_token ?? '';
                return token;
            } else if (nowTimeStamp < token.expires_at) {
                return token;
            } else {
                try {
                    console.log('Refreshing access token');
                    const refreshedToken = await refreshAccessToken(token);
                    console.log('Access token refreshed');
                    return refreshedToken;
                } catch (error) {
                    console.error('Error refreshing access token', error);
                    return { ...token, error: 'RefreshAccessTokenError' };
                }
            }
        },
        async session({ session, token }) {
            session.access_token = encrypt(token.access_token);
            session.id_token = encrypt(token.id_token);
            session.error = token.error;
            session.user.id = token.sub;
            session.user.name = token.name;
            session.user.email = token.email;
            return session;
        },
    },
};

export default auth;

Above, we do a few things.

  • Define a callback to processing the JWT we are returned at login

    • If the account is available, that means it is the first time we are logging in so we grab everything we need off the account object

  • If the account is not available, check if the session as expired. If it has not, we return the token. If it has, we attempt to refresh the access token.

  • Refresh Access Token

    • If the token was expired, we make a call to keycloak using the grant type of refresh_token in an attempt to refresh the token.

    • If the refresh fails, we append an error called RefreshAccessTokenError to watch for on the client.

Now that we have stored all important information on our session, we can make a logout route.

Add a new route at app/api/auth/logout/route.ts

// app/api/auth/logout/route.ts
import { getIdToken } from '@/utils/session-token-accessor';
import { getServerSession } from 'next-auth';

import { env } from '@/env.mjs';
import auth from '@/lib/auth';

export async function GET() {
    const session = await getServerSession(auth);

    if (session) {
        const idToken = await getIdToken();

        // this will log out the user on Keycloak side
        var url = env.KEYCLOAK_BASE_URL + '/protocol/openid-connect/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(' + env.NEXT_PUBLIC_APP_URL + ')';

        try {
            await fetch(url, { method: 'GET' });
        } catch (err) {
            console.error(err);
            return new Response();
        }
    }
    return new Response();
}

In order to successfully log out, we have to make a fetch to the URL we created above and then call next-auth’s signOut function.

// components/logout-button.tsx
'use client';

import { signOut } from 'next-auth/react';

async function keycloakSessionLogOut() {
    try {
        await fetch(`/api/auth/logout`, { method: 'GET' });
    } catch (err) {
        console.error(err);
    }
}

export default function LogoutButton() {
    return (
        <button
            onClick={() =>
                keycloakSessionLogOut().then(() => signOut({ callbackUrl: '/' }))
            }
        >
            Log out
        </button>
    );
}

Add the button to your app and call it to end the session.

Customizing the login page ————————^^

In order to use a custom login page, we have to update our middleware to tell NextAuth where to send users when they are not authenticated.

Update middleware.ts

// middleware.ts
import { withAuth } from 'next-auth/middleware';

export default withAuth({
    callbacks: {
        authorized: ({ token }) => token != null,
    },
    pages: {
        signIn: '/login', // The route you wish to send users to login
    },
});

Then, create a new component at /components/login-button.tsx

// components/login-button.tsx
'use client';

import { useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';

export default function LoginButton() {
    const searchParams = useSearchParams();

    const handleLogin = async () => {
        await signIn('keycloak', {
            callbackUrl: searchParams?.get('callbackUrl') || '/',
        });
    };

    return <button onClick={handleLogin}>Log In</button>;
}

And finally, add the route at /app/login/page.tsx

// app/login/page.tsx
import LoginButton from '@/components/login-button';

export default async function Page() {
    return (
        <main className='flex min-h-screen flex-col items-center justify-between p-24'>
            Welcome
            <LoginButton />
        </main>
    );
}

Using this pattern, you can customize the login page as you need.