diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index c4a3b326..30e0081b 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,6 @@ 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; @@ -65,30 +63,23 @@ 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`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, + 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, @@ -117,20 +108,23 @@ export const authOptions: NextAuthOptions = { }, profile(profile) { console.log('Keycloak profile callback:', { - email: profile.email, - name: profile.name, - sub: profile.sub, - roles: profile.realm_access?.roles || [] + rawProfile: profile, + rawRoles: profile.roles, + realmAccess: profile.realm_access, + groups: profile.groups }); // 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, @@ -147,29 +141,8 @@ 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 || []; @@ -177,25 +150,33 @@ export const authOptions: NextAuthOptions = { role.replace(/^ROLE_/, '').toLowerCase() ); - 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 ?? '', - }; + 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 the previous token if the access token has not expired - if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) { + if (Date.now() < (token.accessTokenExpires as number) * 1000) { return token; } - // Access token has expired, try to refresh it return refreshAccessToken(token); }, async session({ session, token }) { @@ -225,21 +206,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 deleted file mode 100644 index 41544944..00000000 --- a/app/api/keycloak/token/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 24e4a6cb..00000000 --- a/app/api/keycloak/users/route.ts +++ /dev/null @@ -1,265 +0,0 @@ -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 new file mode 100644 index 00000000..9dadb016 --- /dev/null +++ b/app/api/proxy/[...path]/route.ts @@ -0,0 +1,166 @@ +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 80119bbf..b80239c3 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 { clearAuthCookiesClient } from "@/lib/cookies"; +import { clearAuthCookies } from "@/lib/session"; 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 - clearAuthCookiesClient(); + clearAuthCookies(); // Get Keycloak logout URL if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) { diff --git a/lib/cookies.ts b/lib/cookies.ts deleted file mode 100644 index 067b2193..00000000 --- a/lib/cookies.ts +++ /dev/null @@ -1,193 +0,0 @@ -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 0cc1b415..801581b6 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -127,11 +127,7 @@ 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}`, - // 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}` + `email:content:${userId}:${accountId}:${emailId}` }; // TTL constants in seconds @@ -139,10 +135,7 @@ 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 - AUTH_STATE: 60 * 10, // 10 minutes - AUTH_REFRESH_TOKEN: 60 * 60 * 24 * 30, // 30 days - AUTH_SESSION: 60 * 60 * 24 * 7 // 7 days + EMAIL_CONTENT: 60 * 15 // 15 minutes }; interface EmailCredentials { @@ -501,81 +494,4 @@ 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 deleted file mode 100644 index 4ea2a031..00000000 --- a/middleware.ts +++ /dev/null @@ -1,113 +0,0 @@ -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/package.json b/package.json index 6d89a7f1..99134e40 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", - "cookie": "^0.6.0", "cookies-next": "^5.1.0", "crypto-js": "^4.2.0", "date-fns": "^3.6.0",