auth flow

This commit is contained in:
alma 2025-05-02 12:33:43 +02:00
parent 90867df452
commit 32f77425de
9 changed files with 500 additions and 30 deletions

View File

@ -153,9 +153,13 @@ export const authOptions: NextAuthOptions = {
},
},
},
jwt: {
// Add explicit max size to prevent chunking
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, account, profile }) {
// Only include essential data in the JWT to reduce size
// Drastically reduce JWT size by only storing essential info
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
@ -163,14 +167,21 @@ export const authOptions: NextAuthOptions = {
role.replace(/^ROLE_/, '').toLowerCase()
);
// Store minimal data in the token
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 ?? '';
// Only store these if they're short
if (keycloakProfile.given_name && keycloakProfile.given_name.length < 30) {
token.first_name = keycloakProfile.given_name;
}
if (keycloakProfile.family_name && keycloakProfile.family_name.length < 30) {
token.last_name = keycloakProfile.family_name;
}
} else if (token.accessToken) {
try {
const decoded = jwtDecode<DecodedToken>(token.accessToken);
@ -199,7 +210,7 @@ export const authOptions: NextAuthOptions = {
const userRoles = Array.isArray(token.role) ? token.role : [];
// Only include essential user data
// Create a minimal user object
session.user = {
id: token.sub ?? '',
email: token.email ?? null,
@ -212,7 +223,7 @@ export const authOptions: NextAuthOptions = {
nextcloudInitialized: false,
};
// Only pass the access token, not the entire token
// Only store access token, not the entire token
session.accessToken = token.accessToken;
return session;

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { cleanupUserSessions } from '@/lib/redis';
import { closeUserImapConnections } from '@/lib/services/email-service';
/**
* API endpoint to clean up user sessions and invalidate cached data
* Called during logout to ensure proper cleanup of all connections
*/
export async function POST(request: NextRequest) {
try {
// Get the user ID either from the session or request body
const session = await getServerSession(authOptions);
const body = await request.json().catch(() => ({}));
// Get user ID from session or from request body
const userId = session?.user?.id || body.userId;
if (!userId) {
return NextResponse.json({
success: false,
error: 'No user ID provided or user not authenticated'
}, { status: 400 });
}
console.log(`Processing session cleanup for user ${userId}`);
// 1. Close any active IMAP connections using the dedicated function
const closedConnections = await closeUserImapConnections(userId);
// 2. Clean up Redis data
await cleanupUserSessions(userId);
// 3. Return success response with details
return NextResponse.json({
success: true,
message: `Session cleanup completed for user ${userId}`,
details: {
closedConnections,
redisCleanupPerformed: true
}
});
} catch (error) {
console.error('Error in session cleanup:', error);
return NextResponse.json({
success: false,
error: 'Session cleanup failed',
details: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}

View File

@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
interface ResponsiveIframeProps {
src: string;
@ -12,11 +13,49 @@ interface ResponsiveIframeProps {
export function ResponsiveIframe({ src, className = '', allow, style, token }: ResponsiveIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { data: session } = useSession();
const [authError, setAuthError] = useState<string | null>(null);
// Add token parameter only if token is provided
const fullSrc = token ?
`${src}${src.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` :
src;
// Handle silent authentication refresh
useEffect(() => {
let silentRefreshTimer: NodeJS.Timeout;
// Set up periodic silent refresh (every 15 minutes)
const startSilentRefresh = () => {
silentRefreshTimer = setInterval(() => {
console.log('Performing silent authentication check for iframes');
// Create a hidden iframe for silent authentication
const refreshFrame = document.createElement('iframe');
refreshFrame.style.display = 'none';
refreshFrame.src = '/silent-refresh';
document.body.appendChild(refreshFrame);
// Remove iframe after it has loaded (5 seconds timeout)
setTimeout(() => {
if (refreshFrame && refreshFrame.parentNode) {
refreshFrame.parentNode.removeChild(refreshFrame);
}
}, 5000);
}, 15 * 60 * 1000); // 15 minutes
};
if (session) {
startSilentRefresh();
}
return () => {
if (silentRefreshTimer) {
clearInterval(silentRefreshTimer);
}
};
}, [session]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
@ -44,16 +83,25 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
// Handle authentication messages from iframe
const handleMessage = (event: MessageEvent) => {
// Only accept messages from our iframe
if (iframe.contentWindow !== event.source) return;
// Accept messages from our iframe or from silent auth iframe
if (event.source !== iframe.contentWindow &&
!event.data?.type?.startsWith('SILENT_AUTH_')) return;
const { type, data } = event.data || {};
const { type, data, error } = event.data || {};
// Handle auth related messages
// Handle auth-related messages
if (type === 'AUTH_ERROR' || type === 'SESSION_EXPIRED') {
console.log('Auth error in iframe:', data);
// Optionally redirect to login page
// window.location.href = '/signin';
console.log('Auth error in iframe:', data || error);
setAuthError(error || 'Authentication error');
} else if (type === 'SILENT_AUTH_SUCCESS') {
console.log('Silent authentication successful');
setAuthError(null);
} else if (type === 'SILENT_AUTH_FAILURE') {
console.log('Silent authentication failed:', error);
// Only set error if it's persistent
if (error !== 'loading') {
setAuthError('Session expired');
}
}
};
@ -77,19 +125,33 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
}, []);
return (
<iframe
ref={iframeRef}
id="myFrame"
src={fullSrc}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
<>
{authError && (
<div
style={{
backgroundColor: 'rgba(255, 0, 0, 0.1)',
padding: '10px',
borderRadius: '4px',
marginBottom: '10px'
}}
>
Authentication error: {authError}. The service might not work correctly.
</div>
)}
<iframe
ref={iframeRef}
id="myFrame"
src={fullSrc}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
</>
);
}

View File

@ -27,6 +27,59 @@ export default function LoggedOut() {
// Additional browser storage clearing
console.log('Performing complete browser storage cleanup');
// Try to get any user ID from localStorage or sessionStorage for server-side cleanup
let userId = '';
try {
// Check standard localStorage locations for userId
const possibleUserIdKeys = [
'userId',
'user_id',
'currentUser',
'user',
'keycloak.userId',
'auth.userId'
];
for (const key of possibleUserIdKeys) {
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
if (value) {
try {
// It might be a JSON object
const parsed = JSON.parse(value);
userId = parsed.id || parsed.userId || parsed.user_id || parsed.sub || '';
if (userId) break;
} catch {
// Or it might be a plain string
userId = value;
break;
}
}
}
console.log('Found user ID for server cleanup:', userId || 'None found');
} catch (e) {
console.error('Error getting user ID from storage:', e);
}
// Call the server-side cleanup if we have a user ID
if (userId) {
try {
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId }),
credentials: 'include'
});
const cleanupResult = await cleanupResponse.json();
console.log('Server-side cleanup result:', cleanupResult);
} catch (e) {
console.error('Error calling server-side cleanup:', e);
}
}
// Clear cookies
clearAuthCookies();
@ -40,7 +93,7 @@ export default function LoggedOut() {
// Clear local storage items related to auth
try {
const authLocalStoragePrefixes = ['token', 'auth', 'session', 'keycloak', 'kc', 'oidc', 'user'];
const authLocalStoragePrefixes = ['token', 'auth', 'session', 'keycloak', 'kc', 'oidc', 'user', 'meteor'];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@ -68,6 +121,9 @@ export default function LoggedOut() {
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART',
'rc_token',
'rc_uid',
'Meteor.loginToken',
...chunkedCookies
];

View File

@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
export default function SilentRefresh() {
const { data: session, status } = useSession();
const [message, setMessage] = useState('Checking authentication...');
useEffect(() => {
// Notify parent window of authentication state
const notifyParent = () => {
try {
if (window.parent && window.parent !== window) {
if (status === 'authenticated' && session) {
window.parent.postMessage({
type: 'SILENT_AUTH_SUCCESS',
session: {
authenticated: true,
userId: session.user.id,
username: session.user.username,
roles: session.user.role
}
}, '*');
setMessage('Authentication successful. You can close this window.');
} else if (status === 'unauthenticated') {
window.parent.postMessage({
type: 'SILENT_AUTH_FAILURE',
error: 'Not authenticated'
}, '*');
setMessage('Not authenticated. You may need to log in again.');
}
}
} catch (e) {
console.error('Error notifying parent window:', e);
setMessage('Error communicating with parent window.');
}
};
if (status !== 'loading') {
notifyParent();
}
}, [session, status]);
// This page is meant to be loaded in an iframe, so keep it minimal
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif', color: '#666' }}>
{message}
</div>
);
}

View File

@ -10,9 +10,60 @@ export function SignOutHandler() {
useEffect(() => {
const handleSignOut = async () => {
try {
// First, attempt to sign out from NextAuth explicitly
// Store the user ID before signout clears the session
const userId = session?.user?.id;
console.log('Starting comprehensive logout process');
// First trigger server-side session cleanup
if (userId) {
console.log('Triggering server-side session cleanup');
try {
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId }),
credentials: 'include'
});
const cleanupResult = await cleanupResponse.json();
console.log('Server cleanup result:', cleanupResult);
} catch (cleanupError) {
console.error('Error during server-side cleanup:', cleanupError);
// Continue with logout even if cleanup fails
}
}
// Then, attempt to sign out from NextAuth explicitly
await signOut({ redirect: false });
// Clear Rocket Chat authentication tokens
try {
console.log('Clearing Rocket Chat tokens');
// Remove cookies
document.cookie = `rc_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
document.cookie = `rc_uid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
// Remove localStorage items
localStorage.removeItem('Meteor.loginToken');
localStorage.removeItem('Meteor.userId');
// Try to send logout to Rocket Chat server
const rocketChatBaseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (rocketChatBaseUrl) {
// This is a best-effort logout - we don't wait for it to complete
fetch(`${rocketChatBaseUrl}/api/v1/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).catch(e => console.error('Failed to notify Rocket Chat server about logout:', e));
}
} catch (e) {
console.error('Error clearing Rocket Chat tokens:', e);
}
// Then clear all auth-related cookies to ensure we break any local sessions
clearAuthCookies();

View File

@ -494,4 +494,112 @@ export async function getCachedEmailCredentials(
accountId: string
): Promise<EmailCredentials | null> {
return getEmailCredentials(userId, accountId);
}
/**
* Cleans up all Redis data related to a user during logout
* This is critical to ensure proper session cleanup and prevent auth conflicts
*/
export async function cleanupUserSessions(userId: string): Promise<void> {
if (!userId) {
console.error('Cannot cleanup sessions: Missing userId');
return;
}
console.log(`Performing complete Redis cleanup for user ${userId}`);
const redis = getRedisClient();
try {
// 1. First get all the user's email accounts to ensure we clean everything
const userAccountPattern = `email:credentials:${userId}:*`;
const accountKeys: string[] = [];
// Use SCAN to find all account keys
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', userAccountPattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
accountKeys.push(...keys);
}
} while (cursor !== '0');
console.log(`Found ${accountKeys.length} email accounts for user ${userId}`);
// Extract accountIds from the keys
const accountIds = accountKeys.map(key => {
const parts = key.split(':');
return parts[3]; // Format is email:credentials:userId:accountId
});
// 2. Remove email content and email list caches for each account
for (const accountId of accountIds) {
// Delete email lists
const emailListPattern = `email:list:${userId}:${accountId}:*`;
let listCursor = '0';
let deletedEmailLists = 0;
do {
const [nextCursor, keys] = await redis.scan(listCursor, 'MATCH', emailListPattern, 'COUNT', 100);
listCursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedEmailLists += keys.length;
}
} while (listCursor !== '0');
// Delete email content
const emailContentPattern = `email:content:${userId}:${accountId}:*`;
let contentCursor = '0';
let deletedEmailContent = 0;
do {
const [nextCursor, keys] = await redis.scan(contentCursor, 'MATCH', emailContentPattern, 'COUNT', 100);
contentCursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedEmailContent += keys.length;
}
} while (contentCursor !== '0');
console.log(`Cleaned up ${deletedEmailLists} email lists and ${deletedEmailContent} email content items for account ${accountId}`);
}
// 3. Remove credential entries
if (accountKeys.length > 0) {
await redis.del(...accountKeys);
console.log(`Removed ${accountKeys.length} credential entries`);
}
// 4. Remove IMAP session
const sessionKey = KEYS.SESSION(userId);
await redis.del(sessionKey);
console.log(`Removed IMAP session data for user ${userId}`);
// 5. Remove any other user-specific data that might be present
const otherUserDataPattern = `*:${userId}:*`;
let otherCursor = '0';
let deletedOtherData = 0;
do {
const [nextCursor, keys] = await redis.scan(otherCursor, 'MATCH', otherUserDataPattern, 'COUNT', 100);
otherCursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedOtherData += keys.length;
}
} while (otherCursor !== '0');
if (deletedOtherData > 0) {
console.log(`Removed ${deletedOtherData} additional user-specific entries`);
}
console.log(`Successfully completed Redis cleanup for user ${userId}`);
} catch (error) {
console.error(`Error during Redis cleanup for user ${userId}:`, error);
}
}

View File

@ -1379,4 +1379,56 @@ export async function testEmailConnection(credentials: EmailCredentials): Promis
error: `IMAP connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Close all IMAP connections for a user during logout
*/
export async function closeUserImapConnections(userId: string): Promise<number> {
if (!userId) {
console.error('Cannot close IMAP connections: Missing userId');
return 0;
}
console.log(`Closing all IMAP connections for user ${userId}`);
let closedCount = 0;
// Get all connection keys that start with this userId
const userConnectionKeys = Object.keys(connectionPool).filter(key =>
key.startsWith(`${userId}:`)
);
if (userConnectionKeys.length === 0) {
console.log(`No active IMAP connections found for user ${userId}`);
return 0;
}
console.log(`Found ${userConnectionKeys.length} active IMAP connections to close`);
// Close each connection
for (const key of userConnectionKeys) {
try {
const connection = connectionPool[key];
if (connection.isConnecting) {
console.log(`Connection ${key} is still being established, marking for removal`);
} else if (connection.client && connection.client.usable) {
// Make a best-effort attempt to log out properly
try {
await connection.client.logout();
console.log(`Successfully closed IMAP connection ${key}`);
} catch (logoutError) {
console.error(`Error during IMAP logout for ${key}:`, logoutError);
}
}
// Remove from the pool regardless of logout success
delete connectionPool[key];
closedCount++;
} catch (error) {
console.error(`Error closing IMAP connection ${key}:`, error);
}
}
console.log(`Closed ${closedCount} IMAP connections for user ${userId}`);
return closedCount;
}

27
middleware.ts Normal file
View File

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Maximum cookie size in bytes (a bit less than 4KB to be safe)
const MAX_COOKIE_SIZE = 3800;
// This middleware runs before any request
export function middleware(request: NextRequest) {
// Force NextAuth environment variables at runtime
process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = MAX_COOKIE_SIZE.toString();
// Set defaults for cookie security
process.env.NEXTAUTH_CALLBACK = 'false';
process.env.NEXTAUTH_SESSION_STORE_SESSION_TOKEN = 'false';
process.env.NEXTAUTH_JWT_STORE_RAW_TOKEN = 'false';
// Continue with the request
return NextResponse.next();
}
// Configure the middleware to run on specific paths
export const config = {
matcher: [
// Apply to all routes except static files and api routes that aren't auth
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
};