auth flow

This commit is contained in:
alma 2025-05-02 11:48:24 +02:00
parent 6c28c51373
commit 500d7de548
9 changed files with 215 additions and 847 deletions

View File

@ -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<DecodedToken>(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[];
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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<string, string> = {
'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<string, string> = {};
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<string, string> = {
'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 });
}
}

View File

@ -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) {

View File

@ -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<string, string> {
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;`;
}
}
}
}

View File

@ -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<EmailCredentials | null> {
return getEmailCredentials(userId, accountId);
}
/**
* Cache an auth state for PKCE
*/
export async function cacheAuthState(
sessionId: string,
state: string
): Promise<void> {
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<string | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
const redis = getRedisClient();
const key = KEYS.AUTH_REFRESH_TOKEN(userId);
await redis.del(key);
}

View File

@ -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).*)',
],
};

View File

@ -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",