From 7306ad1b2626d94aad01f90343f6e2f8aaafd2cf Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Sat, 21 Feb 2026 23:29:35 -0600 Subject: [PATCH] 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. --- .../envsync/backend/BackendApplication.java | 5 + .../com/envsync/backend/SecurityConfig.java | 26 +++ .../main/java/com/envsync/backend/test.java | 14 ++ .../src/main/resources/application.properties | 31 ++++ docker-compose.yml | 18 +- frontend/README.md | 26 +++ frontend/package-lock.json | 163 ++++++++++++++++++ frontend/package.json | 1 + .../src/app/api/auth/[...nextauth]/route.ts | 7 + frontend/src/app/api/secure/route.ts | 43 +++++ frontend/src/app/client/page.tsx | 35 ++++ frontend/src/app/layout.tsx | 5 +- frontend/src/app/page.tsx | 101 +++++------ frontend/src/app/public/page.tsx | 86 +++++++++ frontend/src/app/server/page.tsx | 57 ++++++ frontend/src/components/session-provider.tsx | 11 ++ frontend/src/lib/auth.ts | 44 +++++ frontend/src/types/next-auth.d.ts | 16 ++ 18 files changed, 623 insertions(+), 66 deletions(-) create mode 100644 backend/src/main/java/com/envsync/backend/SecurityConfig.java create mode 100644 backend/src/main/java/com/envsync/backend/test.java create mode 100644 frontend/src/app/api/auth/[...nextauth]/route.ts create mode 100644 frontend/src/app/api/secure/route.ts create mode 100644 frontend/src/app/client/page.tsx create mode 100644 frontend/src/app/public/page.tsx create mode 100644 frontend/src/app/server/page.tsx create mode 100644 frontend/src/components/session-provider.tsx create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/types/next-auth.d.ts diff --git a/backend/src/main/java/com/envsync/backend/BackendApplication.java b/backend/src/main/java/com/envsync/backend/BackendApplication.java index ad7680c..3e8ac3d 100644 --- a/backend/src/main/java/com/envsync/backend/BackendApplication.java +++ b/backend/src/main/java/com/envsync/backend/BackendApplication.java @@ -8,6 +8,11 @@ public class BackendApplication { public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); + //call the test + test t = new test(); + + } + } diff --git a/backend/src/main/java/com/envsync/backend/SecurityConfig.java b/backend/src/main/java/com/envsync/backend/SecurityConfig.java new file mode 100644 index 0000000..2b5fda1 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/SecurityConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/envsync/backend/test.java b/backend/src/main/java/com/envsync/backend/test.java new file mode 100644 index 0000000..0f60767 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/test.java @@ -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!\"}"; + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3ca17a4..f52e2dd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,32 @@ 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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d48e40c..9247123 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:15-alpine @@ -11,6 +9,11 @@ services: POSTGRES_USER: admin POSTGRES_PASSWORD: admin POSTGRES_DB: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d postgres"] + interval: 5s + timeout: 3s + retries: 10 volumes: - postgres_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d # place init.sql here to create additional databases @@ -49,17 +52,18 @@ services: container_name: keycloak restart: unless-stopped ports: - - "8180:8080" # Host port 8180 → container 8080 (Keycloak default) + - "127.0.0.1:8180:8080" # Local-only for dev; avoids external probes environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: admin KC_DB_PASSWORD: admin - command: start-dev # Use start-dev for development mode + command: start-dev --http-enabled true depends_on: - - postgres + postgres: + condition: service_healthy networks: - backend diff --git a/frontend/README.md b/frontend/README.md index e215bc4..58f15ab 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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/` +- `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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a4a2c3a..89d52b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index dc832c5..7c5fa67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "next": "16.1.6", + "next-auth": "^4.24.13", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..eea7237 --- /dev/null +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/frontend/src/app/api/secure/route.ts b/frontend/src/app/api/secure/route.ts new file mode 100644 index 0000000..4f53b02 --- /dev/null +++ b/frontend/src/app/api/secure/route.ts @@ -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 }); +} diff --git a/frontend/src/app/client/page.tsx b/frontend/src/app/client/page.tsx new file mode 100644 index 0000000..96044fc --- /dev/null +++ b/frontend/src/app/client/page.tsx @@ -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 ( +
+

/client session example

+

This page reads session in the browser with useSession(). Access token and ID token are not exposed here.

+ +
+ {status === "loading" ? ( +

Loading session…

+ ) : session ? ( +
{JSON.stringify(session, null, 2)}
+ ) : ( +

No active session.

+ )} +
+ +
+ + Home + + + Server example + +
+
+ ); +} + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87e..6c452f3 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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({ - {children} + {children} ); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 295f8fd..e08f70f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - +

EnvSync Auth Example

+ + - + Sign in with Keycloak + + + )} +
+ +
+ + /server example + + + /client example + +
-
+ ); } diff --git a/frontend/src/app/public/page.tsx b/frontend/src/app/public/page.tsx new file mode 100644 index 0000000..3b5acd3 --- /dev/null +++ b/frontend/src/app/public/page.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+

/public polling example

+

+ Polling /api/secure every 2 seconds. The API route forwards your access token to the backend. +

+ +
+ {error &&

{error}

} + {!error && !latest &&

Waiting for first response…

} + {latest && ( +
+

+ Status: {latest.ok ? "success" : "failed"} ({latest.status}) +

+

Last updated: {latest.at}

+
{JSON.stringify(latest.body, null, 2)}
+
+ )} +
+ +
+ + Home + + + Server example + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/server/page.tsx b/frontend/src/app/server/page.tsx new file mode 100644 index 0000000..ede5b9b --- /dev/null +++ b/frontend/src/app/server/page.tsx @@ -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 ( +
+

/server session example

+

This page reads session + server-only token state (via getToken()).

+ +
+ {session ? ( +
+
+

Session (safe for client)

+
{JSON.stringify(session, null, 2)}
+
+
+

Server-only token presence

+
{JSON.stringify(serverOnlyData, null, 2)}
+
+
+ ) : ( +

No active session.

+ )} +
+ +
+ + Home + + + Client example + +
+
+ ); +} + diff --git a/frontend/src/components/session-provider.tsx b/frontend/src/components/session-provider.tsx new file mode 100644 index 0000000..f23949a --- /dev/null +++ b/frontend/src/components/session-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +type Props = { + children: React.ReactNode; +}; + +export function AuthSessionProvider({ children }: Props) { + return {children}; +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..a6b3500 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -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; + }, + }, +}; diff --git a/frontend/src/types/next-auth.d.ts b/frontend/src/types/next-auth.d.ts new file mode 100644 index 0000000..155d795 --- /dev/null +++ b/frontend/src/types/next-auth.d.ts @@ -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; + } +}