diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 30e0081b..c4a3b326 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,8 @@ import NextAuth, { NextAuthOptions } from "next-auth"; +import { JWT } from "next-auth/jwt"; import KeycloakProvider from "next-auth/providers/keycloak"; import { jwtDecode } from "jwt-decode"; +import { DEFAULT_AUTH_COOKIE_OPTIONS } from "@/lib/cookies"; interface KeycloakProfile { sub: string; @@ -63,23 +65,30 @@ function getRequiredEnvVar(name: string): string { async function refreshAccessToken(token: JWT) { try { + console.log('Refreshing access token...'); + const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, body: new URLSearchParams({ client_id: process.env.KEYCLOAK_CLIENT_ID!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, grant_type: "refresh_token", - refresh_token: token.refreshToken, + refresh_token: token.refreshToken!, }), - method: "POST", }); const refreshedTokens = await response.json(); if (!response.ok) { + console.error('Token refresh failed:', refreshedTokens); throw refreshedTokens; } + console.log('Token refreshed successfully'); + return { ...token, accessToken: refreshedTokens.access_token, @@ -108,23 +117,20 @@ export const authOptions: NextAuthOptions = { }, profile(profile) { console.log('Keycloak profile callback:', { - rawProfile: profile, - rawRoles: profile.roles, - realmAccess: profile.realm_access, - groups: profile.groups + email: profile.email, + name: profile.name, + sub: profile.sub, + roles: profile.realm_access?.roles || [] }); // Get roles from realm_access const roles = profile.realm_access?.roles || []; - console.log('Profile callback raw roles:', roles); // Clean up roles by removing ROLE_ prefix and converting to lowercase const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); - console.log('Profile callback cleaned roles:', cleanRoles); - return { id: profile.sub, name: profile.name ?? profile.preferred_username, @@ -141,8 +147,29 @@ export const authOptions: NextAuthOptions = { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + ...DEFAULT_AUTH_COOKIE_OPTIONS, + httpOnly: true + } + }, + callbackUrl: { + name: `next-auth.callback-url`, + options: DEFAULT_AUTH_COOKIE_OPTIONS + }, + csrfToken: { + name: `next-auth.csrf-token`, + options: { + ...DEFAULT_AUTH_COOKIE_OPTIONS, + httpOnly: true + } + } + }, callbacks: { async jwt({ token, account, profile }) { + // Initial sign in if (account && profile) { const keycloakProfile = profile as KeycloakProfile; const roles = keycloakProfile.realm_access?.roles || []; @@ -150,33 +177,25 @@ export const authOptions: NextAuthOptions = { role.replace(/^ROLE_/, '').toLowerCase() ); - token.accessToken = account.access_token ?? ''; - token.refreshToken = account.refresh_token ?? ''; - token.accessTokenExpires = account.expires_at ?? 0; - token.sub = keycloakProfile.sub; - token.role = cleanRoles; - token.username = keycloakProfile.preferred_username ?? ''; - token.first_name = keycloakProfile.given_name ?? ''; - token.last_name = keycloakProfile.family_name ?? ''; - } else if (token.accessToken) { - try { - const decoded = jwtDecode(token.accessToken); - if (decoded.realm_access?.roles) { - const roles = decoded.realm_access.roles; - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); - token.role = cleanRoles; - } - } catch (error) { - console.error('Error decoding token:', error); - } + return { + ...token, + accessToken: account.access_token, + refreshToken: account.refresh_token, + accessTokenExpires: account.expires_at ? account.expires_at * 1000 : 0, + sub: keycloakProfile.sub, + role: cleanRoles, + username: keycloakProfile.preferred_username ?? '', + first_name: keycloakProfile.given_name ?? '', + last_name: keycloakProfile.family_name ?? '', + }; } - if (Date.now() < (token.accessTokenExpires as number) * 1000) { + // Return the previous token if the access token has not expired + if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) { return token; } + // Access token has expired, try to refresh it return refreshAccessToken(token); }, async session({ session, token }) { @@ -206,21 +225,21 @@ export const authOptions: NextAuthOptions = { error: '/signin', }, debug: process.env.NODE_ENV === 'development', + logger: { + error(code, metadata) { + console.error(`Auth error: ${code}`, metadata); + }, + warn(code) { + console.warn(`Auth warning: ${code}`); + }, + debug(code, metadata) { + if (process.env.NODE_ENV === 'development') { + console.debug(`Auth debug: ${code}`, metadata); + } + } + } }; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; -interface JWT { - accessToken: string; - refreshToken: string; - accessTokenExpires: number; -} - -interface Profile { - sub?: string; - email?: string; - name?: string; - roles?: string[]; -} - diff --git a/app/api/keycloak/token/route.ts b/app/api/keycloak/token/route.ts new file mode 100644 index 00000000..41544944 --- /dev/null +++ b/app/api/keycloak/token/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../auth/[...nextauth]/route"; + +/** + * Handler for getting a Keycloak admin token + * This replaces the previous proxy implementation + */ +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + // Check authentication + if (!session || !session.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Check if user has admin role + const isAdmin = session.user.role?.includes('admin'); + if (!isAdmin) { + return NextResponse.json( + { error: "Forbidden: Admin role required" }, + { status: 403 } + ); + } + + try { + // Get admin token + const tokenResponse = await fetch( + `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: process.env.KEYCLOAK_CLIENT_ID!, + client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, + }), + } + ); + + const data = await tokenResponse.json(); + + if (!tokenResponse.ok || !data.access_token) { + console.error("Token Error:", data); + return NextResponse.json( + { error: "Failed to get admin token" }, + { status: 500 } + ); + } + + // Return the token + return NextResponse.json({ + access_token: data.access_token, + expires_in: data.expires_in, + token_type: data.token_type + }); + } catch (error) { + console.error("Token Error:", error); + return NextResponse.json( + { error: "Server error obtaining token" }, + { status: 500 } + ); + } +} + +/** + * Handle refreshing a token + */ +export async function POST(request: NextRequest) { + try { + const { refresh_token } = await request.json(); + + if (!refresh_token) { + return NextResponse.json( + { error: "Refresh token is required" }, + { status: 400 } + ); + } + + const response = await fetch( + `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.KEYCLOAK_CLIENT_ID!, + client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, + grant_type: "refresh_token", + refresh_token: refresh_token, + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json( + { error: "Token refresh failed", details: data }, + { status: response.status } + ); + } + + return NextResponse.json({ + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + token_type: data.token_type + }); + } catch (error) { + console.error("Token refresh error:", error); + return NextResponse.json( + { error: "Server error refreshing token" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/keycloak/users/route.ts b/app/api/keycloak/users/route.ts new file mode 100644 index 00000000..24e4a6cb --- /dev/null +++ b/app/api/keycloak/users/route.ts @@ -0,0 +1,265 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../auth/[...nextauth]/route"; + +/** + * Get an admin token + */ +async function getAdminToken() { + try { + const tokenResponse = await fetch( + `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: process.env.KEYCLOAK_CLIENT_ID!, + client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, + }), + } + ); + + const data = await tokenResponse.json(); + + if (!tokenResponse.ok || !data.access_token) { + console.error("Token Error:", data); + return null; + } + + return data.access_token; + } catch (error) { + console.error("Token Error:", error); + return null; + } +} + +/** + * Get users from Keycloak + */ +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + // Check authentication + if (!session || !session.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Only administrators can list users + const isAdmin = session.user.role?.includes('admin'); + if (!isAdmin) { + return NextResponse.json( + { error: "Forbidden: Admin role required" }, + { status: 403 } + ); + } + + try { + // Get admin token + const token = await getAdminToken(); + if (!token) { + return NextResponse.json( + { error: "Failed to get admin token" }, + { status: 500 } + ); + } + + // Parse search params + const { searchParams } = new URL(request.url); + const query = searchParams.get('query') || ''; + const max = searchParams.get('max') || '100'; + + // Fetch users from Keycloak + const keycloakUrl = `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`; + const queryParams = new URLSearchParams(); + + if (query) { + queryParams.append('search', query); + } + + queryParams.append('max', max); + + const usersResponse = await fetch( + `${keycloakUrl}?${queryParams.toString()}`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ); + + if (!usersResponse.ok) { + const errorData = await usersResponse.json(); + return NextResponse.json( + { error: "Failed to fetch users", details: errorData }, + { status: usersResponse.status } + ); + } + + const users = await usersResponse.json(); + + // Return user data + return NextResponse.json(users); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Server error fetching users" }, + { status: 500 } + ); + } +} + +/** + * Create a new user in Keycloak + */ +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions); + + // Check authentication + if (!session || !session.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Only administrators can create users + const isAdmin = session.user.role?.includes('admin'); + if (!isAdmin) { + return NextResponse.json( + { error: "Forbidden: Admin role required" }, + { status: 403 } + ); + } + + try { + // Get admin token + const token = await getAdminToken(); + if (!token) { + return NextResponse.json( + { error: "Failed to get admin token" }, + { status: 500 } + ); + } + + // Get user data from request + const userData = await request.json(); + + // Validate user data + if (!userData.username || !userData.email) { + return NextResponse.json( + { error: "Username and email are required" }, + { status: 400 } + ); + } + + // Create user in Keycloak + const keycloakUrl = `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`; + const createResponse = await fetch(keycloakUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: userData.username, + email: userData.email, + enabled: true, + emailVerified: true, + firstName: userData.firstName || '', + lastName: userData.lastName || '', + credentials: userData.password ? [ + { + type: "password", + value: userData.password, + temporary: false + } + ] : undefined + }), + }); + + if (!createResponse.ok) { + const errorData = await createResponse.json().catch(() => ({})); + return NextResponse.json( + { error: "Failed to create user", details: errorData }, + { status: createResponse.status } + ); + } + + // If user was created successfully, get the user ID + const userResponse = await fetch( + `${keycloakUrl}?username=${encodeURIComponent(userData.username)}`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ); + + if (!userResponse.ok) { + return NextResponse.json( + { error: "User created but failed to retrieve user details" }, + { status: 207 } // Partial success + ); + } + + const users = await userResponse.json(); + const createdUser = users[0]; + + // If roles are specified, assign them + if (userData.roles && Array.isArray(userData.roles) && userData.roles.length > 0) { + // Get available roles + const rolesResponse = await fetch( + `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ); + + if (rolesResponse.ok) { + const availableRoles = await rolesResponse.json(); + + // Filter valid roles + const validRoles = userData.roles.map((roleName: string) => + availableRoles.find((r: any) => r.name === roleName) + ).filter(Boolean); + + if (validRoles.length > 0) { + // Assign roles to user + await fetch( + `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${createdUser.id}/role-mappings/realm`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(validRoles), + } + ); + } + } + } + + return NextResponse.json({ + success: true, + user: createdUser + }); + } catch (error) { + console.error("Error creating user:", error); + return NextResponse.json( + { error: "Server error creating user" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/proxy/[...path]/route.ts b/app/api/proxy/[...path]/route.ts deleted file mode 100644 index 9dadb016..00000000 --- a/app/api/proxy/[...path]/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/app/api/auth/[...nextauth]/route'; - -// Map of service prefixes to their base URLs -const SERVICE_URLS: Record = { - 'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '', - 'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '', - 'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '', - 'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '', - 'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '', - 'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '', - 'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '', - 'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '', - 'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || '' -}; - -// Check if a service is Rocket.Chat (they require special authentication) -function isRocketChat(serviceName: string): boolean { - return serviceName === 'parole'; // Assuming 'parole' is your Rocket.Chat service -} - -export async function GET( - request: NextRequest, - context: { params: { path: string[] } } -) { - // Get the service prefix (first part of the path) - const paramsObj = await Promise.resolve(context.params); - const pathArray = await Promise.resolve(paramsObj.path); - - const serviceName = pathArray[0]; - const restOfPath = pathArray.slice(1).join('/'); - - // Get the base URL for this service - const baseUrl = SERVICE_URLS[serviceName]; - if (!baseUrl) { - return NextResponse.json({ error: 'Service not found' }, { status: 404 }); - } - - // Get the user's session - const session = await getServerSession(authOptions); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - try { - // Extract search parameters - const searchParams = new URL(request.url).searchParams.toString(); - const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`; - - // Prepare headers based on the service type - const headers: Record = {}; - - if (isRocketChat(serviceName)) { - // For Rocket.Chat, use their specific authentication headers - if (session.rocketChatToken && session.rocketChatUserId) { - console.log('Using Rocket.Chat specific authentication'); - headers['X-Auth-Token'] = session.rocketChatToken; - headers['X-User-Id'] = session.rocketChatUserId; - } else { - console.warn('Rocket.Chat tokens not available in session'); - // Still try with standard authorization if available - if (session.accessToken) { - headers['Authorization'] = `Bearer ${session.accessToken}`; - } - } - } else { - // Standard OAuth Bearer token for other services - if (session.accessToken) { - headers['Authorization'] = `Bearer ${session.accessToken}`; - } - } - - // Add other common headers - headers['Accept'] = 'application/json, text/html, */*'; - - // Forward the request to the target service with the authentication headers - const response = await fetch(targetUrl, { headers }); - - // Get response data - const data = await response.arrayBuffer(); - - // Create response with the same status and headers - const newResponse = new NextResponse(data, { - status: response.status, - statusText: response.statusText, - headers: { - 'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream', - } - }); - - return newResponse; - } catch (error) { - console.error('Proxy error:', error); - return NextResponse.json({ error: 'Proxy error' }, { status: 500 }); - } -} - -export async function POST( - request: NextRequest, - context: { params: { path: string[] } } -) { - // Get the service prefix (first part of the path) - const paramsObj = await Promise.resolve(context.params); - const pathArray = await Promise.resolve(paramsObj.path); - - const serviceName = pathArray[0]; - const restOfPath = pathArray.slice(1).join('/'); - - const baseUrl = SERVICE_URLS[serviceName]; - if (!baseUrl) { - return NextResponse.json({ error: 'Service not found' }, { status: 404 }); - } - - const session = await getServerSession(authOptions); - if (!session) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - try { - const searchParams = new URL(request.url).searchParams.toString(); - const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`; - - // Get the request body - const body = await request.arrayBuffer(); - - // Prepare headers based on the service type - const headers: Record = { - 'Content-Type': request.headers.get('Content-Type') || 'application/json', - }; - - if (isRocketChat(serviceName)) { - // For Rocket.Chat, use their specific authentication headers - if (session.rocketChatToken && session.rocketChatUserId) { - headers['X-Auth-Token'] = session.rocketChatToken; - headers['X-User-Id'] = session.rocketChatUserId; - } else if (session.accessToken) { - headers['Authorization'] = `Bearer ${session.accessToken}`; - } - } else { - // Standard OAuth Bearer token for other services - if (session.accessToken) { - headers['Authorization'] = `Bearer ${session.accessToken}`; - } - } - - const response = await fetch(targetUrl, { - method: 'POST', - headers, - body: body - }); - - const data = await response.arrayBuffer(); - - return new NextResponse(data, { - status: response.status, - statusText: response.statusText, - headers: { - 'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream', - } - }); - } catch (error) { - console.error('Proxy error:', error); - return NextResponse.json({ error: 'Proxy error' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/components/auth/signout-handler.tsx b/components/auth/signout-handler.tsx index b80239c3..80119bbf 100644 --- a/components/auth/signout-handler.tsx +++ b/components/auth/signout-handler.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useSession } from "next-auth/react"; -import { clearAuthCookies } from "@/lib/session"; +import { clearAuthCookiesClient } from "@/lib/cookies"; export function SignOutHandler() { const { data: session } = useSession(); @@ -11,7 +11,7 @@ export function SignOutHandler() { const handleSignOut = async () => { try { // First, clear all auth-related cookies to ensure we break any local sessions - clearAuthCookies(); + clearAuthCookiesClient(); // Get Keycloak logout URL if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) { diff --git a/lib/cookies.ts b/lib/cookies.ts new file mode 100644 index 00000000..067b2193 --- /dev/null +++ b/lib/cookies.ts @@ -0,0 +1,193 @@ +import { serialize, parse } from 'cookie'; +import { IncomingMessage } from 'http'; +import { NextApiRequestCookies } from 'next/dist/server/api-utils'; + +export interface CookieOptions { + maxAge?: number; + expires?: Date; + path?: string; + domain?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: 'strict' | 'lax' | 'none'; +} + +// Default cookie options for auth-related cookies +export const DEFAULT_AUTH_COOKIE_OPTIONS: CookieOptions = { + maxAge: 30 * 24 * 60 * 60, // 30 days + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' +}; + +// Cookie names +export const COOKIE_NAMES = { + // NextAuth cookies + AUTH_TOKEN: 'next-auth.session-token', + AUTH_CSRF_TOKEN: 'next-auth.csrf-token', + AUTH_CALLBACK_URL: 'next-auth.callback-url', + AUTH_PKCE_CODE_CHALLENGE: 'next-auth.pkce.code_challenge', + + // Keycloak cookies + KEYCLOAK_SESSION: 'KEYCLOAK_SESSION', + KEYCLOAK_IDENTITY: 'KEYCLOAK_IDENTITY', + KEYCLOAK_REMEMBER_ME: 'KEYCLOAK_REMEMBER_ME', + KC_RESTART: 'KC_RESTART', + + // Custom cookies + USER_PREFERENCES: 'user-preferences', + THEME: 'theme', + + // Function to create a namespaced cookie name + namespaced: (name: string) => `neah-front9.${name}` +}; + +/** + * Set a cookie with the specified options + */ +export function setCookie( + name: string, + value: string, + options: CookieOptions = {} +): string { + // Merge with default options + const cookieOptions = { + ...DEFAULT_AUTH_COOKIE_OPTIONS, + ...options + }; + + // For security, ensure secure flag is set if sameSite is 'none' + if (cookieOptions.sameSite === 'none' && cookieOptions.secure === undefined) { + cookieOptions.secure = true; + } + + // Create the cookie string + return serialize(name, value, cookieOptions as any); +} + +/** + * Get all cookies from the request + */ +export function getCookies(req: { + headers: { cookie?: string }; +}): Record { + const cookie = req.headers?.cookie; + return parse(cookie || ''); +} + +/** + * Get a specific cookie value + */ +export function getCookie( + req: { headers: { cookie?: string } }, + name: string +): string | undefined { + const cookies = getCookies(req); + return cookies[name]; +} + +/** + * Delete a cookie by setting its expiration to the past + */ +export function deleteCookie( + name: string, + options: CookieOptions = {} +): string { + return setCookie(name, '', { + ...options, + maxAge: 0, + expires: new Date(0) + }); +} + +/** + * Helper to generate SetCookie headers for multiple cookies + */ +export function createCookieHeaders(cookieStrings: string[]): [string, string][] { + return cookieStrings.map(cookie => ['Set-Cookie', cookie]); +} + +/** + * Clear all auth-related cookies + */ +export function getAuthCookieClearingHeaders(): [string, string][] { + const authCookies = [ + COOKIE_NAMES.AUTH_TOKEN, + COOKIE_NAMES.AUTH_CSRF_TOKEN, + COOKIE_NAMES.AUTH_CALLBACK_URL, + COOKIE_NAMES.AUTH_PKCE_CODE_CHALLENGE, + COOKIE_NAMES.KEYCLOAK_SESSION, + COOKIE_NAMES.KEYCLOAK_IDENTITY, + COOKIE_NAMES.KEYCLOAK_REMEMBER_ME, + COOKIE_NAMES.KC_RESTART, + // Also clear secure variants + `__Secure-${COOKIE_NAMES.AUTH_TOKEN}`, + `__Host-${COOKIE_NAMES.AUTH_TOKEN}` + ]; + + // Create clearing headers for root path + const cookieHeaders = authCookies.flatMap(name => { + return [ + deleteCookie(name, { path: '/' }), + deleteCookie(name, { path: '/auth' }), + deleteCookie(name, { path: '/api' }) + ]; + }); + + return createCookieHeaders(cookieHeaders); +} + +/** + * Client-side function to clear all auth cookies + */ +export function clearAuthCookiesClient(): void { + const authCookiePrefixes = [ + 'next-auth.', + '__Secure-next-auth.', + '__Host-next-auth.', + 'KEYCLOAK_', + 'KC_' + ]; + + const specificCookies = [ + COOKIE_NAMES.KEYCLOAK_SESSION, + COOKIE_NAMES.KEYCLOAK_IDENTITY, + COOKIE_NAMES.KEYCLOAK_REMEMBER_ME, + COOKIE_NAMES.KC_RESTART + ]; + + const cookies = document.cookie.split(';'); + + for (const cookie of cookies) { + const [name] = cookie.split('='); + const trimmedName = name.trim(); + + const isAuthCookie = + authCookiePrefixes.some(prefix => trimmedName.startsWith(prefix)) || + specificCookies.includes(trimmedName); + + if (isAuthCookie) { + // Clear cookie for different paths and domains + const paths = ['/', '/auth', '/api']; + + for (const path of paths) { + // Basic deletion + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path};`; + + // With Secure and SameSite + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`; + + // Try with domain + const domain = window.location.hostname; + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain};`; + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure;`; + + // Try with root domain + const rootDomain = `.${domain.split('.').slice(-2).join('.')}`; + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${rootDomain};`; + document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${rootDomain}; SameSite=None; Secure;`; + } + } + } +} \ No newline at end of file diff --git a/lib/redis.ts b/lib/redis.ts index 801581b6..0cc1b415 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -127,7 +127,11 @@ export const KEYS = { EMAIL_LIST: (userId: string, accountId: string, folder: string, page: number, perPage: number) => `email:list:${userId}:${accountId}:${folder}:${page}:${perPage}`, EMAIL_CONTENT: (userId: string, accountId: string, emailId: string) => - `email:content:${userId}:${accountId}:${emailId}` + `email:content:${userId}:${accountId}:${emailId}`, + // Add auth-specific Redis keys + AUTH_STATE: (sessionId: string) => `auth:state:${sessionId}`, + AUTH_REFRESH_TOKEN: (userId: string) => `auth:refresh:${userId}`, + AUTH_SESSION: (sessionId: string) => `auth:session:${sessionId}` }; // TTL constants in seconds @@ -135,7 +139,10 @@ export const TTL = { CREDENTIALS: 60 * 60 * 24, // 24 hours SESSION: 60 * 60 * 4, // 4 hours (increased from 30 minutes) EMAIL_LIST: 60 * 5, // 5 minutes - EMAIL_CONTENT: 60 * 15 // 15 minutes + EMAIL_CONTENT: 60 * 15, // 15 minutes + AUTH_STATE: 60 * 10, // 10 minutes + AUTH_REFRESH_TOKEN: 60 * 60 * 24 * 30, // 30 days + AUTH_SESSION: 60 * 60 * 24 * 7 // 7 days }; interface EmailCredentials { @@ -494,4 +501,81 @@ export async function getCachedEmailCredentials( accountId: string ): Promise { return getEmailCredentials(userId, accountId); +} + +/** + * Cache an auth state for PKCE + */ +export async function cacheAuthState( + sessionId: string, + state: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_STATE(sessionId); + await redis.set(key, state, 'EX', TTL.AUTH_STATE); +} + +/** + * Get a cached auth state + */ +export async function getAuthState( + sessionId: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_STATE(sessionId); + return redis.get(key); +} + +/** + * Delete auth state after use + */ +export async function deleteAuthState( + sessionId: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_STATE(sessionId); + await redis.del(key); +} + +/** + * Cache a refresh token + */ +export async function cacheRefreshToken( + userId: string, + refreshToken: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_REFRESH_TOKEN(userId); + await redis.set(key, encryptData(refreshToken), 'EX', TTL.AUTH_REFRESH_TOKEN); +} + +/** + * Get a cached refresh token + */ +export async function getRefreshToken( + userId: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_REFRESH_TOKEN(userId); + const token = await redis.get(key); + + if (!token) return null; + + try { + return decryptData(token); + } catch (error) { + console.error('Error decrypting refresh token:', error); + return null; + } +} + +/** + * Delete a refresh token + */ +export async function deleteRefreshToken( + userId: string +): Promise { + const redis = getRedisClient(); + const key = KEYS.AUTH_REFRESH_TOKEN(userId); + await redis.del(key); } \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..4ea2a031 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; + +// Define paths that don't require authentication +const publicPaths = [ + '/api/auth', + '/signin', + '/loggedout', + '/register', + '/error', + '/images', + '/fonts', + '/favicon.ico', + '/robots.txt', + '/sitemap.xml', +]; + +// Check if the requested path is public +function isPublicPath(path: string): boolean { + return publicPaths.some(publicPath => path.startsWith(publicPath)); +} + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Check if the path is public (no authentication needed) + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + // For API routes, check for token in the request + if (pathname.startsWith('/api/')) { + try { + // Verify authentication token + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET + }); + + if (!token) { + // No token found, redirect to sign-in page or return 401 for API requests + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // Check if token is expired + if (token.accessTokenExpires && (token.accessTokenExpires as number) < Date.now()) { + return NextResponse.json( + { error: 'Session expired' }, + { status: 401 } + ); + } + + // Token is valid, proceed + return NextResponse.next(); + } catch (error) { + console.error('Auth middleware error:', error); + return NextResponse.json( + { error: 'Authentication error' }, + { status: 401 } + ); + } + } + + // For page routes, verify session + try { + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET + }); + + // If no token found, redirect to sign-in page + if (!token) { + const url = new URL('/signin', request.url); + url.searchParams.set('callbackUrl', encodeURI(request.url)); + return NextResponse.redirect(url); + } + + // Check if token is expired + if (token.accessTokenExpires && (token.accessTokenExpires as number) < Date.now()) { + const url = new URL('/signin', request.url); + url.searchParams.set('callbackUrl', encodeURI(request.url)); + url.searchParams.set('error', 'SessionExpired'); + return NextResponse.redirect(url); + } + + // If authorized, proceed to the requested page + return NextResponse.next(); + } catch (error) { + console.error('Auth middleware error:', error); + const url = new URL('/signin', request.url); + url.searchParams.set('error', 'AuthError'); + return NextResponse.redirect(url); + } +} + +// Only run middleware on matching paths +export const config = { + matcher: [ + /* + * Match all request paths except: + * 1. /_next (Next.js internals) + * 2. /static (static files) + * 3. /images (public images) + * 4. /fonts (public fonts) + * 5. /favicon.ico, /robots.txt, /sitemap.xml (SEO files) + */ + '/((?!_next/|static/|images/|fonts/|favicon.ico|robots.txt|sitemap.xml).*)', + ], +}; \ No newline at end of file diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index bc5a27b0..544e5e59 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -2337,6 +2337,13 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/crypto-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", @@ -3065,9 +3072,9 @@ } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4958,6 +4965,15 @@ } } }, + "node_modules/next-auth/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/next-themes": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz", diff --git a/node_modules/cookie/index.js b/node_modules/cookie/index.js index acd5acd6..03d4c386 100644 --- a/node_modules/cookie/index.js +++ b/node_modules/cookie/index.js @@ -21,69 +21,16 @@ exports.serialize = serialize; */ var __toString = Object.prototype.toString -var __hasOwnProperty = Object.prototype.hasOwnProperty /** - * RegExp to match cookie-name in RFC 6265 sec 4.1.1 - * This refers out to the obsoleted definition of token in RFC 2616 sec 2.2 - * which has been replaced by the token definition in RFC 7230 appendix B. + * RegExp to match field-content in RFC 7230 sec 3.2 * - * cookie-name = token - * token = 1*tchar - * tchar = "!" / "#" / "$" / "%" / "&" / "'" / - * "*" / "+" / "-" / "." / "^" / "_" / - * "`" / "|" / "~" / DIGIT / ALPHA + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + * obs-text = %x80-FF */ -var cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - -/** - * RegExp to match cookie-value in RFC 6265 sec 4.1.1 - * - * cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) - * cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E - * ; US-ASCII characters excluding CTLs, - * ; whitespace DQUOTE, comma, semicolon, - * ; and backslash - */ - -var cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/; - -/** - * RegExp to match domain-value in RFC 6265 sec 4.1.1 - * - * domain-value = - * ; defined in [RFC1034], Section 3.5, as - * ; enhanced by [RFC1123], Section 2.1 - * =