auth flow
This commit is contained in:
parent
6c28c51373
commit
500d7de548
@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
app/api/proxy/[...path]/route.ts
Normal file
166
app/api/proxy/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
193
lib/cookies.ts
193
lib/cookies.ts
@ -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;`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
lib/redis.ts
88
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<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);
|
||||
}
|
||||
113
middleware.ts
113
middleware.ts
@ -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).*)',
|
||||
],
|
||||
};
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user