auth flow
This commit is contained in:
parent
6c28c51373
commit
500d7de548
@ -1,8 +1,6 @@
|
|||||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||||
import { JWT } from "next-auth/jwt";
|
|
||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { DEFAULT_AUTH_COOKIE_OPTIONS } from "@/lib/cookies";
|
|
||||||
|
|
||||||
interface KeycloakProfile {
|
interface KeycloakProfile {
|
||||||
sub: string;
|
sub: string;
|
||||||
@ -65,30 +63,23 @@ function getRequiredEnvVar(name: string): string {
|
|||||||
|
|
||||||
async function refreshAccessToken(token: JWT) {
|
async function refreshAccessToken(token: JWT) {
|
||||||
try {
|
try {
|
||||||
console.log('Refreshing access token...');
|
|
||||||
|
|
||||||
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/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({
|
body: new URLSearchParams({
|
||||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: token.refreshToken!,
|
refresh_token: token.refreshToken,
|
||||||
}),
|
}),
|
||||||
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshedTokens = await response.json();
|
const refreshedTokens = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Token refresh failed:', refreshedTokens);
|
|
||||||
throw refreshedTokens;
|
throw refreshedTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Token refreshed successfully');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
accessToken: refreshedTokens.access_token,
|
accessToken: refreshedTokens.access_token,
|
||||||
@ -117,20 +108,23 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
console.log('Keycloak profile callback:', {
|
console.log('Keycloak profile callback:', {
|
||||||
email: profile.email,
|
rawProfile: profile,
|
||||||
name: profile.name,
|
rawRoles: profile.roles,
|
||||||
sub: profile.sub,
|
realmAccess: profile.realm_access,
|
||||||
roles: profile.realm_access?.roles || []
|
groups: profile.groups
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get roles from realm_access
|
// Get roles from realm_access
|
||||||
const roles = profile.realm_access?.roles || [];
|
const roles = profile.realm_access?.roles || [];
|
||||||
|
console.log('Profile callback raw roles:', roles);
|
||||||
|
|
||||||
// Clean up roles by removing ROLE_ prefix and converting to lowercase
|
// Clean up roles by removing ROLE_ prefix and converting to lowercase
|
||||||
const cleanRoles = roles.map((role: string) =>
|
const cleanRoles = roles.map((role: string) =>
|
||||||
role.replace(/^ROLE_/, '').toLowerCase()
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('Profile callback cleaned roles:', cleanRoles);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
name: profile.name ?? profile.preferred_username,
|
name: profile.name ?? profile.preferred_username,
|
||||||
@ -147,29 +141,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile }) {
|
||||||
// Initial sign in
|
|
||||||
if (account && profile) {
|
if (account && profile) {
|
||||||
const keycloakProfile = profile as KeycloakProfile;
|
const keycloakProfile = profile as KeycloakProfile;
|
||||||
const roles = keycloakProfile.realm_access?.roles || [];
|
const roles = keycloakProfile.realm_access?.roles || [];
|
||||||
@ -177,25 +150,33 @@ export const authOptions: NextAuthOptions = {
|
|||||||
role.replace(/^ROLE_/, '').toLowerCase()
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
token.accessToken = account.access_token ?? '';
|
||||||
...token,
|
token.refreshToken = account.refresh_token ?? '';
|
||||||
accessToken: account.access_token,
|
token.accessTokenExpires = account.expires_at ?? 0;
|
||||||
refreshToken: account.refresh_token,
|
token.sub = keycloakProfile.sub;
|
||||||
accessTokenExpires: account.expires_at ? account.expires_at * 1000 : 0,
|
token.role = cleanRoles;
|
||||||
sub: keycloakProfile.sub,
|
token.username = keycloakProfile.preferred_username ?? '';
|
||||||
role: cleanRoles,
|
token.first_name = keycloakProfile.given_name ?? '';
|
||||||
username: keycloakProfile.preferred_username ?? '',
|
token.last_name = keycloakProfile.family_name ?? '';
|
||||||
first_name: keycloakProfile.given_name ?? '',
|
} else if (token.accessToken) {
|
||||||
last_name: keycloakProfile.family_name ?? '',
|
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 (Date.now() < (token.accessTokenExpires as number) * 1000) {
|
||||||
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access token has expired, try to refresh it
|
|
||||||
return refreshAccessToken(token);
|
return refreshAccessToken(token);
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
@ -225,21 +206,21 @@ export const authOptions: NextAuthOptions = {
|
|||||||
error: '/signin',
|
error: '/signin',
|
||||||
},
|
},
|
||||||
debug: process.env.NODE_ENV === 'development',
|
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);
|
const handler = NextAuth(authOptions);
|
||||||
export { handler as GET, handler as POST };
|
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 { useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { clearAuthCookiesClient } from "@/lib/cookies";
|
import { clearAuthCookies } from "@/lib/session";
|
||||||
|
|
||||||
export function SignOutHandler() {
|
export function SignOutHandler() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@ -11,7 +11,7 @@ export function SignOutHandler() {
|
|||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
// First, clear all auth-related cookies to ensure we break any local sessions
|
// First, clear all auth-related cookies to ensure we break any local sessions
|
||||||
clearAuthCookiesClient();
|
clearAuthCookies();
|
||||||
|
|
||||||
// Get Keycloak logout URL
|
// Get Keycloak logout URL
|
||||||
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {
|
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: string, accountId: string, folder: string, page: number, perPage: number) =>
|
||||||
`email:list:${userId}:${accountId}:${folder}:${page}:${perPage}`,
|
`email:list:${userId}:${accountId}:${folder}:${page}:${perPage}`,
|
||||||
EMAIL_CONTENT: (userId: string, accountId: string, emailId: string) =>
|
EMAIL_CONTENT: (userId: string, accountId: string, emailId: string) =>
|
||||||
`email:content:${userId}:${accountId}:${emailId}`,
|
`email:content:${userId}:${accountId}:${emailId}`
|
||||||
// Add auth-specific Redis keys
|
|
||||||
AUTH_STATE: (sessionId: string) => `auth:state:${sessionId}`,
|
|
||||||
AUTH_REFRESH_TOKEN: (userId: string) => `auth:refresh:${userId}`,
|
|
||||||
AUTH_SESSION: (sessionId: string) => `auth:session:${sessionId}`
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// TTL constants in seconds
|
// TTL constants in seconds
|
||||||
@ -139,10 +135,7 @@ export const TTL = {
|
|||||||
CREDENTIALS: 60 * 60 * 24, // 24 hours
|
CREDENTIALS: 60 * 60 * 24, // 24 hours
|
||||||
SESSION: 60 * 60 * 4, // 4 hours (increased from 30 minutes)
|
SESSION: 60 * 60 * 4, // 4 hours (increased from 30 minutes)
|
||||||
EMAIL_LIST: 60 * 5, // 5 minutes
|
EMAIL_LIST: 60 * 5, // 5 minutes
|
||||||
EMAIL_CONTENT: 60 * 15, // 15 minutes
|
EMAIL_CONTENT: 60 * 15 // 15 minutes
|
||||||
AUTH_STATE: 60 * 10, // 10 minutes
|
|
||||||
AUTH_REFRESH_TOKEN: 60 * 60 * 24 * 30, // 30 days
|
|
||||||
AUTH_SESSION: 60 * 60 * 24 * 7 // 7 days
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EmailCredentials {
|
interface EmailCredentials {
|
||||||
@ -502,80 +495,3 @@ export async function getCachedEmailCredentials(
|
|||||||
): Promise<EmailCredentials | null> {
|
): Promise<EmailCredentials | null> {
|
||||||
return getEmailCredentials(userId, accountId);
|
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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"cookie": "^0.6.0",
|
|
||||||
"cookies-next": "^5.1.0",
|
"cookies-next": "^5.1.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user