Enterprise-Level Authentication in a Containerized Environment for NextJS 13
Ulaş Özdemir
Posted on June 16, 2024
Enterprise-Level Authentication in a Containerized Environment for NextJS 13
TL;DR
https://github.com/ozdemirrulass/keycloak-nextjs-mysql-docker
This article aims to provide a step by step guide for Keycloak + NextJS 13 authentication and containerization. By the end of this article you will be able to
- Set up a Keycloak server based on MYSQL database for authentication.
- Integrate Keycloak with a Next.js 13 application for user authentication.
- Implement authentication flows such as login, registration in your Next.js app.
- Containerize your Keycloak and Next.js applications using Docker for easy deployment and scalability.
- Understand best practices for managing authentication tokens and sessions in a containerized environment.
From start to end we will be using compose to build our application in a containerized environment. Since we will be building our containers for development environment, output source code of this tutorial will be ready to convert multi-environment. Since the requirements and configurations of production and development environments are different, Multi-environment development is strongly advised but we will be only working on development environment for educational purposes.
Prerequisites
I did my best to keep this article as simple as possible to teach the basics but to get the most out of this article it is strongly advised to have some knowledge on Next.js, Docker, and authentication concepts.
Tools and Software: Docker, TypeScript, NodeJS, Docker Compose, Keycloak
Setting Up Development Environment
Let’s start creating a new project directory named keycloak-nextjs-docker-tutorial and open it using your favorite IDE. I'll be using VS Code.
As I mentioned earlier we will be using docker compose tool to create our containers.
For those unfamiliar with docker compose tool:
It is simply a tool for defining and running multi-container applications. To be able to run containers we must define the properties of our containers in a yml file.
By default, Docker Compose looks for a file named docker-compose.yml or docker-compose.yaml in the current directory. However, you can specify a different file using the -f or --file option when running Docker Compose commands.
For example, if your docker-compose.yml file is named my-compose.yml, you can use the following command to specify the file:
docker-compose -f my-compose.yml up
We will be building a development environment so let’s create a file named docker-compose.dev.yml to define our container properties and .env file to specify some environment values.
Typical docker-compose.yml file contains the following definitions:
- Version: This field specifies the version of the Docker Compose file format being used. It’s usually first line of the file and defines the schema of the file.
version: '3.8'
- Services: This section defines the containers that make up your application. Each service must be identified by a unique name, and its configuration must be under that name.
services:
keycloak:
...
mysql:
...
next-app:
...
Be careful while working on YAML or YML files. YAML and YML files uses indentation to indicate the structure of the data. The recommended indentation is two spaces per level but as long as it is consistent across the file it can be 2–3 or anything.
- Container Configuration : Within each service definition, you can configure various aspects of the container, such as: 1- Image : Specifies the Docker image to use for the container. 2- Build : Specifies the path to the Dockerfile if the image needs to be built. 3- Ports : Maps ports from the container to the host machine. 4- Volumes : Mounts volumes from the host machine into the container. 5- Environment Variables: Sets environment variables for the container. 6- Dependencies : Defines dependencies on other services within the same docker-compose.yml file. 7- Networks : Configures networking options for the container. 8- Command : Overrides the default command specified in the Docker image. 9- Healthcheck : Configures a health check for the container.
Having said that let’s start to configure our containers.
MySQL
Let’s start with the database. We need a database for our Keycloak service to store user data. I’ll be using MYSQL for this tutorial but after completing this tutorial I encourage you to try to build the same structure with different database technologies.
version: '3.8'
services:
mysql:
container_name: mysql
image: "mysql:${MYSQL_VERSION}"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
environment:
MYSQL_DATABASE: keycloak
MYSQL_USER: keycloak
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
volumes:
- ./mysql_data:/var/lib/mysql
We’ve defined a MYSQL container named mysql and a database named keycloak. User credentials of keycloak database are username: keycloak, password: password and the root user's password is also password.
It is strongly advised to check https://hub.docker.com/_/mysql and learn more about MySQL environment variables.
I’d like to draw your attention to the usage of ${MYSQL_VERSION} in the Docker Compose file. This is an example of variable substitution or interpolation.${VARIABLE_NAME} syntax is used to reference environment variables defined either in the shell environment or in an .env file. Docker Compose automatically detects the .env file within the same directory.
Open the .env file we created earlier in the /keycloak-nextjs-docker-tutorial directory and add the following variable.
MYSQL_VERSION=8.0
I’ll be using mysql:8.0
Another very important part of this service definition is:
volumes:
- ./mysql_data:/var/lib/mysql
This setup is commonly used to persist data generated by the MySQL database even if the container is stopped or removed. It ensures that data stored within the container’s /var/lib/mysql directory is stored on your local machine and remains accessible across container restarts or recreations.
Keycloak
In this part we will be adding the Keycloak service to our compose definitions and ensure the connectivity between our database.
Open the docker-compose.dev.yml file we created earlier in the /keycloak-nextjs-docker-tutorial and add the following definitions after the mysql service.
keycloak:
container_name: keycloak
image: "quay.io/keycloak/keycloak:${KC_VERSION}"
command: ["start-dev"]
restart: unless-stopped
depends_on:
- mysql
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080"]
environment:
- KC_DB=mysql
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD=password
- KC_DB_URL=jdbc:mysql://mysql:3306/keycloak
- KC_FEATURES=${KC_FEATURES}
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=${KC_PASSWORD}
ports:
- ${KC_PORT}:8080
As you can see, we have a few variables here, just like in our mysql service. Let’s quickly open the .env file we created earlier in the /keycloak-nextjs-docker-tutorial directory and add the related variables.
KC_VERSION=17.0.1 KC_PORT=8080 KC_FEATURES=account2,admin2,account-api,token-exchange KC_PASSWORD=keycloak
Visit https://quay.io/repository/keycloak/keycloak to check other versions of Keycloak.
There is another very important feature of Docker I’d like to draw your attention.
- KC_DB_URL=jdbc:mysql://mysql:3306/keycloak
Visit https://quay.io/repository/keycloak/keycloak to check other versions of Keycloak.
Here we specify a database connection url using Java Database Connectivity API but as you can see instead of using localhost or a domain or an ip address we are using the container name!
When using Docker Compose to define multiple services, Docker Compose creates a bridge network by default and assigns a unique name to each container within that network. By specifying a container name in Docker Compose, you are giving that container a human-readable alias within the context of the Docker network.
Good job! 👏 We’ve just prepared a Docker Compose environment for Keycloak based on MYSQL database.
Let’s pause here, run our containers, and take a closer look at what we have accomplished.
To build and run our containers execute the following command in the same directory with docker-compose.dev.yml file which in our case /keycloak-nextjs-docker-tutorial.
docker compose -f docker-compose.dev.yml up -d
Before executing the code I’d like to explain the -f and -d flags.
- -f flag stands for "file." as I mentioned earlier if your compose file is not docker-compose.yml/yaml you must specify the filename.
- -d flag stands for "detached mode". When you use this flag, Docker Compose will run the containers in the background, allowing you to continue using the terminal for other tasks. If you don't use -d flag containers will start in the foreground, and the logs from the containers will be streamed to your terminal. This means you'll see the output of each container's STDOUT and STDERR directly in your terminal window.
If you followed the previous steps precisely you must be seeing something like this in your terminal:
PS: Container names must be unique and since I already have a container named mysql on my system I named my MYSQL container as mysql.
To see which containers are running open your terminal and execute
docker ps
You’ll see two containers:
0.0.0.0:8080->8080/tcp This is another important feature of docker and we mentioned it earlier but It's worth repeating. It represents the port mapping. This indicates that port 8080 on the host machine is mapped to port 8080 on the container. This means that any traffic directed to port 8080 on the host machine will be forwarded to the corresponding port on the Docker container. This means when you try to reach to service from your host machine you must use the port shows up in the left side but if another service in the same network tries to reach this service it should use the port in the right side of :. It is simply [hostPort]:[containerPort]
Let’s check if our Keycloak and MySQL integration is working.
Visit localhost:8080 on your web browser. You should see the following screen:
Seems like Keycloak running fine. Let’s login with the credentials we’ve defined in .env file earlier. Click to Administrator console and login with the following credentials:
Username: admin
Password: keycloak
You will see a Keycloak dashboard after successful login
Perfect! Before we proceed further, let me demonstrate how to access container terminals and execute commands within Docker environments.
docker exec -it <container_id_or_name> <command>
This is the syntax of executing a command in a container. For example, if you have a container running a bash shell and its ID is abcdef123456, you can access its terminal like this:
docker exec -it abcdef123456 bash
This command opens an interactive terminal (-it) within the specified container ( abcdef123456) running the bash shell. You can replace bash with any other command you want to execute within the container.
We will be working inside mysql container so it must be:
docker exec -it mysql bash
If you named your containers different you can check the container names executing docker ps command in your terminal.
After executing the bash command user@host indicator will replace with bash-5.1# like this:
This means that you’ve successfully accessed the bash shell of your container. Now Let’s connect to a MySQL database and check tables and records.
mysql -u keycloak -p
after you execute this command in the bash shell it will require you to enter a password which we specified in docker-compose.dev.yml as
MYSQL_USER: keycloak
MYSQL_PASSWORD: password
Let’s check the databases exists in our mysql service.
Execute the following query in MySQL monitor
You will see 3 database record. 2 of them are default special databases of MySQL. The information_schema database provides metadata about MySQL server objects, while the performance_schema database offers insights into server performance metrics.
Important part for us is keycloak database. It is perfectly created just like we defined as MYSQL_DATABASE: keycloak in our compose file.
To be able to see the the contents execute the following commands in order.
use keycloak;
show tables;
This will show us a list of tables which our Keycloak service created automatically.
Execute exit; to exit from the MySQL monitor and execute exit in the bash shell to exit from the MySQL container's bash shell.
👏 Congratulations on our achievements so far! Yet, there’s still more to accomplish!
Before we proceed, I’d like to introduce the concept of Multi-tenancy. Understanding the Multi-tenancy concept will help you to understand and operate Keycloak better!
A “tenant” typically refers to an individual or organization that has its own distinct set of users, data, and configuration settings within the shared software environment.
Multitenancy refers to a software architecture where a single instance of the software serves multiple clients (tenants), keeping their data and configurations separate while sharing the same underlying infrastructure.
I strongly advise you to read more on multi-tenancy and understand the concept and types of it.
In Keycloak’s architecture, Realms essentially serve as tenants.
There is a beautiful explanation of handling multitenant organization with Keycloak in the documentation of cloud-iam.
We will go for second for simplicity and consistency reasons.
Let’s create our first REALM.
Perfect! We just created our first REALM. Let’s create a client for our NextJS application now!
If all goes well you must see the following screen:
Congrats 🥳 We have a Realm and a Client for our NextJS application.
Before we proceed further with Client configuration and Access Settings we will create and containerize a NextJS application! 💃🕺
“If you’re not a disruptor, you will be disrupted.” — John Chambers
So;
“Mr. Gorbachev, tear down this wall!”- Reagan
Open your terminal in the root of our project which is /keycloak-nextjs-docker-tutorial and execute this command:
docker compose -f docker-compose.dev.yml down
This command will stop and remove all of the containers defined in our compose file as well as the docker network.
Did we made all this for nothing !? Of course NO!
Open the project directory in your favorite IDE and take a look at the folder structure of the project.
Docker Compose and our MySQL service left us a little present. Well, actually we wanted this from them…
Do you remember this part of the docker-compose.dev.yml file?
volumes:
- ./mysql_data:/var/lib/mysql
Thanks to this piece of definition whenever we built our service again mysql will mount the latest status and contents of our databases.
We will reunite with out precious data but first Let’s create our NextJS application and containerize it!
NextJS
Open the project directory in your terminal /keycloak-nextjs-docker-tutorial and execute the following command to create a NextJS app:
npx create-next-app@latest
Once again we will open the project folder using our IDE.
Here is our next application. Isn’t it adorable? No! Because it is not containerized. Let’s get to work then!
But before we work on our application we should add the /next-app directory to workplace otherwise ESLint will throw errors.
File->Add Folder to Workspace->(Choose next-app directory) and click add.
Now lets open next.config.mjs file in our /next-app directory
Next we add a property inside of the nextConfig const.
output: "standalone",
This declaration is to create a self-contained build of our application. This can be particularly useful for deploying our Next.js application in environments where we want to avoid installing Node.js dependencies directly on the server, such as when using Docker or deploying to a serverless environment. We will see the benefit of this when we want to write a production environment.
Until now we built and run an existing images by pulling from their resources. This time we will create a docker image for our NextJs Application.
First create a new file as dev.Dockerfile inside the /next-app directory and open the file on IDE.
Before writing anything let’s first understand what is Dockerfile then break it down it together.
Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image. It has a simple syntax and consists of a series of instructions that are executed in order to build a Docker image.
# Base Image: Typically, a Dockerfile starts with a base image upon which
# you build your application.
# This is specified using the FROM instruction.
FROM node:18-alpine
# If you set WORKDIR /app, then any commands or file operations
# will be relative to the /app directory.
WORKDIR /app
# Install dependencies based on the preferred package manager
# Copy Application Files: You copy the application code or files
# into the image using the COPY or ADD instruction.
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [-f yarn.lock]; then yarn --frozen-lockfile; \
elif [-f package-lock.json]; then npm ci; \
elif [-f pnpm-lock.yaml]; then corepack enable pnpm && pnpm i; \
# Allow install without lockfile, so example works
#even without Node.js installed locally
else echo "Warning: Lockfile not found. It is recommended to commit lockfiles to version control." && yarn install; \
fi
COPY . .
# Next.js collects completely anonymous telemetry data about
# general usage. Learn more here: https://nextjs.org/telemetry
# Comment the following line to enable telemetry at run time
ENV NEXT_TELEMETRY_DISABLED 1
# Note: We could expose ports here but instead
# Compose will handle that for us
# Start Next.js in development mode based on the
# preferred package manager
CMD \
if [-f yarn.lock]; then yarn dev; \
elif [-f package-lock.json]; then npm run dev; \
elif [-f pnpm-lock.yaml]; then pnpm dev; \
else npm run dev; \
fi
Beautiful.
Now we will add this Dockerfile to our Compose definitions so that Compose tool will be able to build the image for us just as we defined in our Dockerfile. Add the following block under services in docker-compose.dev.yml.
next-app:
container_name: next-app
build:
context: ./next-app
dockerfile: dev.Dockerfile
restart: unless-stopped
environment:
- NODE_ENV=development
volumes:
- ./next-app:/app
- /app/node_modules
ports:
- 3000:3000
context definition under the build parameter defines the location of our Dockerfile.
Before building and running the containers I’d like to mention one more concept.
Network Isolation: until now we’ve been using the default bridge network which Docker built for us. But let’s think on this for a second.
While working on a multi-service environment docker automatically creates a bridge network and assigns each container within that network with a unique name.
What does this mean?
Does it mean when we add NextJS application in this compose file will mysql can be accessible by NextJS ?
Answer is Yes! But do we want it though? What are we supposed to do with MySQL in a NextJS front-end client? Nothing. Let’s cut the bonds then!
What we have to do is isolating the connectivity between our services like in this diagram by creating custom networks:
open docker-compose.dev.yml once again and go to very bottom of it and add following piece of definition.
networks:
frontend-network:
keycloak-network:
By adding this definition, we’ve created 2 new networks for our environment. As you can see I did not specify any property for our networks because the default network type is bridge.
Bridge type networks allows containers to communicate with each other, and also provides external access if ports are exposed.
and this is exactly what we want.
Adding the previous piece of definition in our compose file only creates the network. To be able to use it we must introduce our containers to our new networks.
version: '3.8'
services:
mysql:
container_name: mysqlk
image: "mysql:${MYSQL_VERSION}"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
environment:
MYSQL_DATABASE: keycloak
MYSQL_USER: keycloak
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
volumes:
- ./mysql_data:/var/lib/mysql
networks:
- keycloak-network
keycloak:
container_name: keycloak
image: "quay.io/keycloak/keycloak:${KC_VERSION}"
command: ["start-dev"]
restart: unless-stopped
depends_on:
- mysql
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080"]
environment:
- KC_DB=mysql
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD=password
- KC_DB_URL=jdbc:mysql://mysqlk:3306/keycloak
- KC_FEATURES=${KC_FEATURES}
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=${KC_PASSWORD}
ports:
- ${KC_PORT}:8080
networks:
- keycloak-network
- frontend-network
next-app:
container_name: next-app
build:
context: ./next-app
dockerfile: dev.Dockerfile
restart: unless-stopped
environment:
- NODE_ENV=development
volumes:
- ./next-app:/app
- /app/node_modules
ports:
- 3000:3000
networks:
- frontend-network
networks:
frontend-network:
keycloak-network:
As you can see in the current version of our compose file, I added the networks to the bottom of each service.
It’s time to see what we’ve done. Open the terminal in the root directory of our project and run the following command to build image.
docker compose -f docker-compose.dev.yml build
This will build the images you defined in the Compose file.
You must re-build the image for any changes in Dockerfile.
It’s time to run our containers.
docker compose -f docker-compose.dev.yml up -d
If you run the up command without using the build command, Compose will check if a pre-built image with the same name exists. If it does, Compose will use that image. If it doesn't, Compose will build the image first and then start the container with it.
It will take a bit more time for Keycloak to start comparing other services. In the meanwhile we can check if our next-app.
Visit localhost:3000 on your browser.
Nice work!
Let’s make sure our hot load works as expected and our container applies the changes we make in our host machine through the volume.
Open /next-app/src/app/page.tsx and replace the content with the following code:
export default function Home() {
return (
<main>
<div>It Works!</div>
</main>
);
}
Visit localhost:3000 and you must see the changes!
Now it’s time to integrate Keycloak authentication for our our NextJS application.
We will be using NextAuth.
Install Next-Auth package:
npm install next-auth
Create a new directory under /next-app directory as types and a file inside types as node-env.d.ts .
// /next-app/types/node-env.d.ts
declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: string
KEYCLOAK_CLIENT_SECRET: string
NEXT_LOCAL_KEYCLOAK_URL: string
NEXT_PUBLIC_KEYCLOAK_REALM: string
NEXT_CONTAINER_KEYCLOAK_ENDPOINT: string
}
}
now it’s time to create authentication routes.
import { AuthOptions } from "next-auth";
import NextAuth from "next-auth/next";
import KeycloakProvider from "next-auth/providers/keycloak";
export const authOptions: AuthOptions = {
providers: [
KeycloakProvider({
jwks_endpoint: `${process.env.NEXT_CONTAINER_KEYCLOAK_ENDPOINT}/realms/myrealm/protocol/openid-connect/certs`,
wellKnown: undefined,
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: `${process.env.NEXT_LOCAL_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}`,
authorization: {
params: {
scope: "openid email profile",
},
url: `${process.env.NEXT_LOCAL_KEYCLOAK_URL}/realms/myrealm/protocol/openid-connect/auth`,
},
token: `${process.env.NEXT_CONTAINER_KEYCLOAK_ENDPOINT}/realms/myrealm/protocol/openid-connect/token`,
userinfo: `${process.env.NEXT_CONTAINER_KEYCLOAK_ENDPOINT}/realms/myrealm/protocol/openid-connect/userinfo`,
}),
],
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Go back to root directory of your next-app and create an environment file as .env.local
NEXT_PUBLIC_KEYCLOAK_REALM=<realm-name>
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=<client-name>
KEYCLOAK_CLIENT_SECRET=<secret-from-keycloak-client>
NEXTAUTH_SECRET=<create-using-openssl>
NEXT_LOCAL_KEYCLOAK_URL="http://localhost:8080"
NEXT_CONTAINER_KEYCLOAK_ENDPOINT="http://keycloak:8080"
For local network connectivity purposes we will use keycloak url and jwks endpoint differently from each other but in most cases in a development environment both should be same.
As I mentioned before Keycloak still has the Realm and the Client we’ve created before.
Visit localhost:8080 and sign in and select the Realm we created earlier:
Go to clients and select the client we created earlier.
Go to Access Settings and define Home URL and Valid redirect URI’s:
Valid URI pattern a browser can redirect to after a successful login or logout. Simple wildcards are allowed such as ‘http://example.com/'. Relative path can be specified too such as /my/relative/path/. Relative paths are relative to the client root URL, or if none is specified the auth server root URL is used. For SAML, you must set valid URI patterns if you are relying on the consumer service URL embedded with the login request.
Home URL: Default URL to use when the auth server needs to redirect or link back to the client.
Save and go to Credentials.
Copy Client secret and paste KEYCLOAK_CLIENT_SECRET value.
KEYCLOAK_CLIENT_SECRET=<secret-from-keycloak-client>
For NEXTAUTH_SECRET create a secret by running the following command and add it to .env.local. This secret is used to sign and encrypt cookies.
openssl rand -base64 32
In my case final .env.local file looks like this:
NEXT_LOCAL_KEYCLOAK_URL="http://localhost:8080"
NEXT_CONTAINER_KEYCLOAK_ENDPOINT="http://keycloak:8080"
NEXT_PUBLIC_KEYCLOAK_REALM="myrealm"
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID="next-app"
KEYCLOAK_CLIENT_SECRET="71ikzeN5p0fEwdHW6Hw5jOmlRvRIEtgO"
NEXTAUTH_SECRET="MdiNiCNlDcBP8fUmANd9ARPIB+tlKV/oy3m88W2bTHk="
Create a new folder under /next-app/src as components and create the following components inside the directory.
//next-app/src/components/Login.tsx
"use client"
import { signIn } from "next-auth/react";
export default function Login() {
return <button onClick={() => signIn("keycloak")}>
Signin with keycloak
</button>
}
//next-app/src/components/Logout.tsx
"use client"
import { signOut } from "next-auth/react";
export default function Logout() {
return <button onClick={() => signOut()}>
Signout of keycloak
</button>
}
Go to page.tsx file /next-app/src/app/page.tsx and replace the content with the following block:
import { getServerSession } from 'next-auth'
import { authOptions } from './api/auth/[...nextauth]/route'
import Login from '../components/Login'
import Logout from '../components/Logout'
export default async function Home() {
const session = await getServerSession(authOptions)
if (session) {
return <div>
<div>Your name is {session.user?.name}</div>
<div><Logout /> </div>
</div>
}
return (
<div>
<Login />
</div>
)
}
We need a user to test our client.
Create the user and go to Credentials section to set a password for the user we just created.
Now its time to test our authentication flow.
Go to our nextjs app localhost:3000
Congratulations we have successfully implemented an enterprise-level authentication in a containerized environment for Next.js 13 front-end.
For federated logout you my want to check this discussion on github: https://github.com/nextauthjs/next-auth/discussions/3938
And here is how you can activate user registration:
I’ll also write how to integrate it with your backend service and how to monitor your application and keycloak using Prometheus and Grafana as soon as possible. It will also cover securing your Next.js routes using Keycloak’s role-based access control.
Thank you, see you soon!
Originally published at https://ulasozdemir.com.tr on June 12, 2024.
Posted on June 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.