A Keycloak SDK for Next.js
A TypeScript SDK wrapping the Keycloak OIDC login flow for Next.js: hand-rolled PKCE on Web Crypto, token auto-refresh, and a demo site built on it.
- TypeScript
- Keycloak
- Next.js
- OIDC
- tsup
Three repositories for one problem: a self-hosted Keycloak server, a TypeScript SDK that wraps the OIDC login flow, and a Next.js site that uses that SDK as its test subject.
Why write it instead of using keycloak-js#
No commit records a technical reason, so I will say it plainly: this was an exercise to
understand OIDC from the inside. The evidence left in the repo is clear enough —
keycloak-js is still listed in the demo's dependencies but no file imports it;
every call goes through the local package linked with file:../sdk. What I got out of it
is knowing exactly what every parameter in the Authorization Code + PKCE flow is for,
where NextAuth would have folded most of that away behind a ready-made provider.
The self-hosted Keycloak server#
The server repo is a Keycloak 26.4.0 distribution running on Quarkus, started with
bin/kc.sh start --optimized. It uses hostname v2 with hostname-strict=false so local
runs still work, plus proxy-headers=xforwarded because it sits behind a reverse proxy;
health and metrics are both enabled. The providers/ folder carries three external SPI
jars: two-factor authentication over email, email verification by code, and trusted-device
remembering. The database is still the default H2 — fine for a learning environment, not
something to put in production.
Hand-rolled PKCE and a deliberately small API#
This is a public client: the browser holds no client secret, so PKCE is not optional. The
SDK generates a 32-byte random code_verifier, hashes it with SHA-256 through Web Crypto,
and sends the base64url form of the digest.
/* toBase64Url: btoa(...), swap "+/" for "-_", drop "=" — spelled out inline in the source. */
async function generateCodeChallenge() {
const codeVerifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
return { codeVerifier, codeChallenge: toBase64Url(new Uint8Array(digest)) };
}
export async function login(config: KeycloakConfig) {
const { codeVerifier, codeChallenge } = await generateCodeChallenge();
/* The verifier has to survive leaving the page for Keycloak and coming back. */
LocalStorageService.setCodeVerifier(codeVerifier);
window.location.href =
`${getKeycloakUrl(config)}/auth?client_id=${encodeURIComponent(config.clientId)}` +
`&redirect_uri=${encodeURIComponent(config.redirectUri)}` +
`&response_type=code&scope=openid profile email` +
`&code_challenge=${codeChallenge}&code_challenge_method=S256`;
}Everything the SDK exposes fits in one line: login, logout, exchangeCodeForToken,
refreshTokens, getUserInfo, isTokenValid, startAutoRefresh, plus the
AuthProvider / useAuth pair and a KeycloakConfig type with four fields. tsup builds
one entry into both ESM and CJS with .d.ts alongside, so the Next.js app can import it
either way.
Tokens live in localStorage#
Once the code is exchanged, the SDK stores the whole token bundle in localStorage with an
expires_at computed from expires_in. startAutoRefresh sets a 60-second setInterval:
each tick checks expiry, refreshes when it has lapsed, and logs out via id_token_hint if
the refresh fails.
The middleware only guards navigation#
Middleware runs before React gets to render and cannot see localStorage, so the callback page has to copy the login state into a cookie for it to read.
const publicPaths = ['/', '/login', '/register', '/api/auth', '/auth/callback', '/dashboard'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (publicPaths.some((path) => pathname.startsWith(path))) return NextResponse.next();
/* Running at the edge with no localStorage: a cookie is all there is to look at. */
const kcSession =
request.cookies.get('kc_session')?.value || request.cookies.get('kc_tokens')?.value;
if (!kcSession) return NextResponse.redirect(new URL('/login', request.url));
return NextResponse.next();
}This is where the cost of the earlier decision shows up most clearly: the middleware only asks whether the cookie exists, never verifying a signature or asking Keycloak. Anyone with devtools open can set that cookie and walk through. It is a navigation experience — don't show someone a blank page before bouncing them to login — not a security boundary; the real boundary belongs on the API side, where the access token is actually checked. The demo uses the same cookie trick for its two wallet-gated routes: missing flag, back to the dashboard.
Outcome#
- The full round trip works: login → callback → code exchange → userinfo → auto refresh
→ logout with
id_token_hint - The SDK builds to ESM + CJS + types and installs into the demo as a real package via
file:../sdk keycloak-jsis still in the dependencies although nothing imports it — a leftover from the change of direction, and something to clean up- The weak spots I am not hiding: tokens in localStorage, and a middleware trusting an unsigned cookie. Making this real means moving tokens server-side first
- The token path is still littered with
console.log— handy while learning, but it all has to go before calling this a shared library