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:
1 parent
92971a3983
commit
7306ad1b26
18 files changed
+623
-66
No files matched your search
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
First, run the development server:
|
||||
|
||||
Generated
+163
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
@@ -228,6 +229,15 @@
|
||||
"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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -1226,6 +1236,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2626,6 +2645,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4421,6 +4449,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"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": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -5077,6 +5146,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5087,6 +5162,15 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -5200,6 +5284,48 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -5366,6 +5492,28 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -5376,6 +5524,12 @@
|
||||
"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": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -6417,6 +6571,15 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "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 };
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import { AuthSessionProvider } from "@/components/session-provider";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -27,7 +30,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<AuthSessionProvider>{children}</AuthSessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+43
-58
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<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"
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">EnvSync Auth Example</h1>
|
||||
|
||||
<div className="rounded-xl border border-black/10 p-6 dark:border-white/20">
|
||||
{session ? (
|
||||
<>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">Signed in as</p>
|
||||
<p className="mt-1 text-lg font-medium">{session.user?.email ?? session.user?.name ?? "Keycloak user"}</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/signout"}
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
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"
|
||||
Sign out
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
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]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
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>
|
||||
Sign in with Keycloak
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</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="/server">
|
||||
/server example
|
||||
</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>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user