diff --git a/.DS_Store b/.DS_Store index 6ddb0d0..6934b8f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..f953888 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/api/.DS_Store b/app/api/.DS_Store new file mode 100644 index 0000000..37ec536 Binary files /dev/null and b/app/api/.DS_Store differ diff --git a/app/api/auth/.DS_Store b/app/api/auth/.DS_Store new file mode 100644 index 0000000..6839642 Binary files /dev/null and b/app/api/auth/.DS_Store differ diff --git a/app/api/auth/[...nextauth]/route 2.ts b/app/api/auth/[...nextauth]/route 2.ts new file mode 100644 index 0000000..c7850b3 --- /dev/null +++ b/app/api/auth/[...nextauth]/route 2.ts @@ -0,0 +1,144 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + username: string; + first_name: string; + last_name: string; + role: string[]; + }; + accessToken: string; + } + + interface JWT { + accessToken: string; + refreshToken: string; + accessTokenExpires: number; + role: string[]; + username: string; + first_name: string; + last_name: string; + } +} + +function getRequiredEnvVar(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: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + profile(profile) { + return { + id: profile.sub, + name: profile.name ?? profile.preferred_username, + email: profile.email, + first_name: profile.given_name ?? '', + last_name: profile.family_name ?? '', + username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', + role: profile.groups ?? [], + } + }, + }), + ], + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + callbacks: { + async jwt({ token, account, profile }) { + if (account && profile) { + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.accessTokenExpires = account.expires_at! * 1000; + token.role = (profile as any).groups ?? []; + token.username = (profile as any).preferred_username ?? profile.email?.split('@')[0] ?? ''; + token.first_name = (profile as any).given_name ?? ''; + token.last_name = (profile as any).family_name ?? ''; + } + + // Return previous token if not expired + if (Date.now() < (token.accessTokenExpires as number)) { + return token; + } + + try { + const clientId = getRequiredEnvVar("KEYCLOAK_CLIENT_ID"); + const clientSecret = getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"); + + const response = await fetch( + `${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + client_secret: clientSecret, + refresh_token: token.refreshToken as string, + }), + } + ); + + const tokens = await response.json(); + + if (!response.ok) { + throw new Error("RefreshAccessTokenError"); + } + + return { + ...token, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? token.refreshToken, + accessTokenExpires: Date.now() + tokens.expires_in * 1000, + }; + } catch (error) { + return { + ...token, + error: "RefreshAccessTokenError", + }; + } + }, + async session({ session, token }) { + if (token.error) { + throw new Error("RefreshAccessTokenError"); + } + + session.accessToken = token.accessToken; + session.user = { + ...session.user, + id: token.sub as string, + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + username: token.username ?? '', + role: token.role ?? [], + }; + + return session; + } + }, + pages: { + signIn: '/signin', + error: '/signin', + }, + debug: process.env.NODE_ENV === 'development', +}; + +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; + diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index d48bd2e..c7850b3 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,5 @@ -import NextAuth from "next-auth"; -import { authOptions } from '@/lib/auth'; +import NextAuth, { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; declare module "next-auth" { interface Session { @@ -27,6 +27,118 @@ declare module "next-auth" { } } +function getRequiredEnvVar(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: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + profile(profile) { + return { + id: profile.sub, + name: profile.name ?? profile.preferred_username, + email: profile.email, + first_name: profile.given_name ?? '', + last_name: profile.family_name ?? '', + username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', + role: profile.groups ?? [], + } + }, + }), + ], + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + callbacks: { + async jwt({ token, account, profile }) { + if (account && profile) { + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.accessTokenExpires = account.expires_at! * 1000; + token.role = (profile as any).groups ?? []; + token.username = (profile as any).preferred_username ?? profile.email?.split('@')[0] ?? ''; + token.first_name = (profile as any).given_name ?? ''; + token.last_name = (profile as any).family_name ?? ''; + } + + // Return previous token if not expired + if (Date.now() < (token.accessTokenExpires as number)) { + return token; + } + + try { + const clientId = getRequiredEnvVar("KEYCLOAK_CLIENT_ID"); + const clientSecret = getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"); + + const response = await fetch( + `${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + client_secret: clientSecret, + refresh_token: token.refreshToken as string, + }), + } + ); + + const tokens = await response.json(); + + if (!response.ok) { + throw new Error("RefreshAccessTokenError"); + } + + return { + ...token, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? token.refreshToken, + accessTokenExpires: Date.now() + tokens.expires_in * 1000, + }; + } catch (error) { + return { + ...token, + error: "RefreshAccessTokenError", + }; + } + }, + async session({ session, token }) { + if (token.error) { + throw new Error("RefreshAccessTokenError"); + } + + session.accessToken = token.accessToken; + session.user = { + ...session.user, + id: token.sub as string, + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + username: token.username ?? '', + role: token.role ?? [], + }; + + return session; + } + }, + pages: { + signIn: '/signin', + error: '/signin', + }, + debug: process.env.NODE_ENV === 'development', +}; + const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; diff --git a/app/api/calendars/route.ts b/app/api/calendars/route.ts index 39b55c8..e0fd46d 100644 --- a/app/api/calendars/route.ts +++ b/app/api/calendars/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; /** diff --git a/app/api/leantime/tasks/route.ts b/app/api/leantime/tasks/route.ts index a99c051..fea2b82 100644 --- a/app/api/leantime/tasks/route.ts +++ b/app/api/leantime/tasks/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { authOptions } from "@/lib/auth"; interface Task { id: string; diff --git a/app/api/rocket-chat/messages/route.ts b/app/api/rocket-chat/messages/route.ts index e11b27a..329466a 100644 --- a/app/api/rocket-chat/messages/route.ts +++ b/app/api/rocket-chat/messages/route.ts @@ -1,5 +1,5 @@ import { getServerSession } from "next-auth"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { authOptions } from "@/lib/auth"; import { NextResponse } from "next/server"; // Helper function to get user token using admin credentials diff --git a/app/layout.tsx b/app/layout.tsx index cbf2d06..d0f16cf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { headers } from "next/headers"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { authOptions } from "@/lib/auth"; import { Providers } from "@/components/providers"; import { LayoutWrapper } from "@/components/layout/layout-wrapper";