Tested KeyCloak validation - AUTH

Test Keycloak JWT validation against access token issued via NextAuth; ensure Spring Boot resource server correctly validates token signature, expiration, issuer, and optional audience.
This commit is contained in:
Aryan Singh committed 2026-02-21 23:29:35 -06:00
1 parent 92971a3983
commit 7306ad1b26
18 files changed
+621 -64

No files matched your search

@@ -8,6 +8,11 @@ public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args); SpringApplication.run(BackendApplication.class, args);
//call the test
test t = new test();
} }
} }
@@ -0,0 +1,26 @@
package com.envsync.backend;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/secure").authenticated()
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()) // uses issuer-uri/jwk-set-uri
);
return http.build();
}
}
@@ -0,0 +1,14 @@
package com.envsync.backend;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class test {
@GetMapping("/api/secure")
public String secure() {
//send json response
return "{\"message\": \"This is a secure endpoint!\"}";
}
}
@@ -1 +1,32 @@
spring.application.name=backend spring.application.name=backend
# ----- Postgres -----
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=admin
spring.datasource.password=admin
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# ----- Redis -----
spring.data.redis.host=localhost
spring.data.redis.port=6379
# ----- MinIO -----
minio.url=http://localhost:9000
minio.access-key=minioadmin
minio.secret-key=minioadmin
minio.bucket=envsync-backups
# ----- Keycloak -----
# Set audience to your Keycloak client-id to enforce aud validation (optional).
app.security.jwt.audience=envsync-app
# Allow clock skew in seconds for exp/nbf validation.
app.security.jwt.clock-skew-seconds=60
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8180/realms/envsync
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8180/realms/envsync/protocol/openid-connect/certs
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security.oauth2=DEBUG
# ----- Server port -----
server.port=8081
+11 -7
View File
@@ -1,5 +1,3 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
@@ -11,6 +9,11 @@ services:
POSTGRES_USER: admin POSTGRES_USER: admin
POSTGRES_PASSWORD: admin POSTGRES_PASSWORD: admin
POSTGRES_DB: postgres POSTGRES_DB: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d postgres"]
interval: 5s
timeout: 3s
retries: 10
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d # place init.sql here to create additional databases - ./init:/docker-entrypoint-initdb.d # place init.sql here to create additional databases
@@ -49,17 +52,18 @@ services:
container_name: keycloak container_name: keycloak
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8180:8080" # Host port 8180 → container 8080 (Keycloak default) - "127.0.0.1:8180:8080" # Local-only for dev; avoids external probes
environment: environment:
KEYCLOAK_ADMIN: admin KC_BOOTSTRAP_ADMIN_USERNAME: admin
KEYCLOAK_ADMIN_PASSWORD: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: postgres KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: admin KC_DB_USERNAME: admin
KC_DB_PASSWORD: admin KC_DB_PASSWORD: admin
command: start-dev # Use start-dev for development mode command: start-dev --http-enabled true
depends_on: depends_on:
- postgres postgres:
condition: service_healthy
networks: networks:
- backend - backend
+26
View File
@@ -1,5 +1,31 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## NextAuth + Keycloak setup
1. Copy environment variables:
```bash
cp .env.example .env.local
```
2. Set values in `.env.local`:
- `NEXTAUTH_SECRET`: generate one with `openssl rand -base64 32`
- `KEYCLOAK_ISSUER`: e.g. `http://localhost:8080/realms/<your-realm>`
- `KEYCLOAK_CLIENT_ID` and `KEYCLOAK_CLIENT_SECRET`: from your Keycloak client
3. In Keycloak client settings, add callback URL:
- `http://localhost:3000/api/auth/callback/keycloak`
4. Start the app and test sign-in:
```bash
npm run dev
```
Open `http://localhost:3000` and click **Sign in with Keycloak**.
## Getting Started ## Getting Started
First, run the development server: First, run the development server:
+163
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
@@ -228,6 +229,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1226,6 +1236,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2626,6 +2645,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4421,6 +4449,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5023,6 +5060,38 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "4.24.13",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
"integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.3",
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
"nodemailer": "^7.0.7",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5077,6 +5146,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5087,6 +5162,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5200,6 +5284,48 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5366,6 +5492,28 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.28.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5376,6 +5524,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6417,6 +6571,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
@@ -0,0 +1,7 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+43
View File
@@ -0,0 +1,43 @@
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const BACKEND_SECURE_URL = "http://localhost:8081/api/secure";
export async function GET(request: NextRequest) {
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
if (!token || typeof token.accessToken !== "string") {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const backendResponse = await fetch(BACKEND_SECURE_URL, {
headers: {
Authorization: `Bearer ${token.accessToken}`,
Accept: "application/json",
},
cache: "no-store",
});
const contentType = backendResponse.headers.get("content-type") ?? "";
if (!backendResponse.ok) {
const body = await backendResponse.text();
return NextResponse.json(
{
error: "Backend request failed",
status: backendResponse.status,
body,
},
{ status: 502 }
);
}
if (contentType.includes("application/json")) {
const data = await backendResponse.json();
return NextResponse.json(data);
}
const text = await backendResponse.text();
return NextResponse.json({ data: text });
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { useSession } from "next-auth/react";
export default function ClientSessionPage() {
const { data: session, status } = useSession();
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-6 py-20">
<h1 className="text-3xl font-semibold tracking-tight">/client session example</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-300">This page reads session in the browser with useSession(). Access token and ID token are not exposed here.</p>
<div className="rounded-xl border border-black/10 p-6 dark:border-white/20">
{status === "loading" ? (
<p className="text-sm">Loading session…</p>
) : session ? (
<pre className="overflow-x-auto text-sm">{JSON.stringify(session, null, 2)}</pre>
) : (
<p className="text-sm">No active session.</p>
)}
</div>
<div className="flex gap-3">
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/">
Home
</Link>
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/server">
Server example
</Link>
</div>
</main>
);
}
+4 -1
View File
@@ -1,5 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { AuthSessionProvider } from "@/components/session-provider";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -27,7 +30,7 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
{children} <AuthSessionProvider>{children}</AuthSessionProvider>
</body> </body>
</html> </html>
); );
+41 -56
View File
@@ -1,65 +1,50 @@
import Image from "next/image"; import { getServerSession } from "next-auth";
import Link from "next/link";
import { authOptions } from "@/lib/auth";
import { signOut } from "next-auth/react";
export default async function Home() {
const session = await getServerSession(authOptions);
export default function Home() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <h1 className="text-3xl font-semibold tracking-tight">EnvSync Auth Example</h1>
<Image
className="dark:invert" <div className="rounded-xl border border-black/10 p-6 dark:border-white/20">
src="/next.svg" {session ? (
alt="Next.js logo" <>
width={100} <p className="text-sm text-zinc-600 dark:text-zinc-300">Signed in as</p>
height={20} <p className="mt-1 text-lg font-medium">{session.user?.email ?? session.user?.name ?? "Keycloak user"}</p>
priority <Link
/> className="mt-4 inline-flex h-10 items-center rounded-md bg-black px-4 text-sm font-medium text-white dark:bg-white dark:text-black"
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> href={"/api/auth/signout"}
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
> >
Templates Sign out
</a>{" "} </Link>
or the{" "} </>
<a ) : (
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <>
className="font-medium text-zinc-950 dark:text-zinc-50" <p className="text-sm text-zinc-600 dark:text-zinc-300">You are not signed in.</p>
<Link
className="mt-4 inline-flex h-10 items-center rounded-md bg-black px-4 text-sm font-medium text-white dark:bg-white dark:text-black"
href="/api/auth/signin/keycloak"
> >
Learning Sign in with Keycloak
</a>{" "} </Link>
center. </>
</p> )}
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a <div className="flex gap-3">
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/server">
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" /server example
target="_blank" </Link>
rel="noopener noreferrer" <Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/client">
> /client example
<Image </Link>
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main> </main>
</div>
); );
} }
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type PollResult = {
ok: boolean;
status: number;
body: unknown;
at: string;
};
export default function PublicPage() {
const [latest, setLatest] = useState<PollResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
const run = async () => {
try {
const response = await fetch("/api/secure", { cache: "no-store" });
const body = await response.json().catch(() => null);
if (!active) {
return;
}
setLatest({
ok: response.ok,
status: response.status,
body,
at: new Date().toISOString(),
});
setError(null);
} catch (err) {
if (!active) {
return;
}
const message = err instanceof Error ? err.message : "Request failed";
setError(message);
}
};
run();
const interval = setInterval(run, 2000);
return () => {
active = false;
clearInterval(interval);
};
}, []);
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-6 py-20">
<h1 className="text-3xl font-semibold tracking-tight">/public polling example</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-300">
Polling /api/secure every 2 seconds. The API route forwards your access token to the backend.
</p>
<div className="rounded-xl border border-black/10 p-6 dark:border-white/20">
{error && <p className="text-sm text-red-600">{error}</p>}
{!error && !latest && <p className="text-sm">Waiting for first response…</p>}
{latest && (
<div className="space-y-3">
<p className="text-sm">
Status: <span className="font-medium">{latest.ok ? "success" : "failed"}</span> ({latest.status})
</p>
<p className="text-xs text-zinc-500">Last updated: {latest.at}</p>
<pre className="overflow-x-auto text-sm">{JSON.stringify(latest.body, null, 2)}</pre>
</div>
)}
</div>
<div className="flex gap-3">
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/">
Home
</Link>
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/server">
Server example
</Link>
</div>
</main>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { getServerSession } from "next-auth";
import { getToken } from "next-auth/jwt";
import Link from "next/link";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
export default async function ServerSessionPage() {
const session = await getServerSession(authOptions);
const requestHeaders = await headers();
const request = new NextRequest("http://localhost:3000/server", {
headers: requestHeaders,
});
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
console.log("ServerSessionPage token:", token); // Debug log to check token contents
const serverOnlyData = {
hasAccessToken: typeof token?.accessToken === "string",
hasIdToken: typeof token?.idToken === "string",
preferredUsername: typeof token?.preferredUsername === "string" ? token.preferredUsername : null,
};
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-6 py-20">
<h1 className="text-3xl font-semibold tracking-tight">/server session example</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-300">This page reads session + server-only token state (via getToken()).</p>
<div className="rounded-xl border border-black/10 p-6 dark:border-white/20">
{session ? (
<div className="space-y-4">
<div>
<p className="mb-2 text-sm font-medium">Session (safe for client)</p>
<pre className="overflow-x-auto text-sm">{JSON.stringify(session, null, 2)}</pre>
</div>
<div>
<p className="mb-2 text-sm font-medium">Server-only token presence</p>
<pre className="overflow-x-auto text-sm">{JSON.stringify(serverOnlyData, null, 2)}</pre>
</div>
</div>
) : (
<p className="text-sm">No active session.</p>
)}
</div>
<div className="flex gap-3">
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/">
Home
</Link>
<Link className="rounded-md border border-black/10 px-4 py-2 text-sm dark:border-white/20" href="/client">
Client example
</Link>
</div>
</main>
);
}
@@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
type Props = {
children: React.ReactNode;
};
export function AuthSessionProvider({ children }: Props) {
return <SessionProvider>{children}</SessionProvider>;
}
+44
View File
@@ -0,0 +1,44 @@
import type { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: requiredEnv("KEYCLOAK_CLIENT_ID"),
clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
issuer: requiredEnv("KEYCLOAK_ISSUER"),
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.idToken = account.id_token;
}
if (profile && typeof profile === "object" && "preferred_username" in profile) {
token.preferredUsername = String(profile.preferred_username);
}
return token;
},
async session({ session, token }) {
session.preferredUsername = typeof token.preferredUsername === "string" ? token.preferredUsername : undefined;
return session;
},
},
};
+16
View File
@@ -0,0 +1,16 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
preferredUsername?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
idToken?: string;
preferredUsername?: string;
}
}