auth flow

This commit is contained in:
alma 2025-05-02 12:38:04 +02:00
parent 32f77425de
commit ed199d7a00
4 changed files with 219 additions and 20 deletions

View File

@ -1,11 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { clearAuthCookies } from "@/lib/session";
import Link from "next/link";
export default function LoggedOut() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking');
const iframeRef = useRef<HTMLIFrameElement>(null);
const forceLogout = new URLSearchParams(window.location.search).get('forceLogout') === 'true';
// Listen for any messages from iframes
useEffect(() => {
@ -27,6 +29,18 @@ export default function LoggedOut() {
// Additional browser storage clearing
console.log('Performing complete browser storage cleanup');
// Add a hidden iframe to directly call Keycloak logout endpoint
// This ensures the server-side Keycloak session is properly terminated
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {
console.log('Adding Keycloak logout iframe');
const keycloakBaseUrl = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
const logoutEndpoint = `${keycloakBaseUrl}/protocol/openid-connect/logout`;
if (iframeRef.current) {
iframeRef.current.src = logoutEndpoint;
}
}
// Try to get any user ID from localStorage or sessionStorage for server-side cleanup
let userId = '';
try {
@ -124,6 +138,8 @@ export default function LoggedOut() {
'rc_token',
'rc_uid',
'Meteor.loginToken',
'AUTH_SESSION_ID',
'AUTH_SESSION_ID_LEGACY',
...chunkedCookies
];
@ -136,6 +152,13 @@ export default function LoggedOut() {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${rootDomain}; SameSite=None; Secure;`;
}
// Additional paths that Keycloak might use
['/auth', '/realms'].forEach(path => {
keycloakCookies.forEach(cookieName => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${window.location.hostname}; SameSite=None; Secure;`;
});
});
// Notify any parent windows/iframes
try {
if (window.parent && window.parent !== window) {
@ -165,6 +188,13 @@ export default function LoggedOut() {
backgroundRepeat: 'no-repeat'
}}
>
{/* Hidden iframe for direct Keycloak logout */}
<iframe
ref={iframeRef}
style={{ display: 'none' }}
title="keycloak-logout"
/>
<div className="w-full max-w-md p-8 bg-black/60 backdrop-blur-sm rounded-lg shadow-xl">
<div className="text-center">
<h2 className="text-3xl font-bold text-white mb-4">
@ -192,14 +222,14 @@ export default function LoggedOut() {
<div className="mt-6">
<Link
href="/signin?signedOut=true"
href="/signin?fresh=true"
className="inline-block px-8 py-3 bg-white text-gray-800 rounded hover:bg-gray-100 transition-colors mb-4"
>
Sign In Again
</Link>
<p className="text-white/60 text-sm mt-4">
Note: If you're automatically signed in again, try clearing your browser cookies or restarting your browser.
Note: You'll need to enter your credentials when signing in again.
</p>
</div>
</div>

View File

@ -9,9 +9,57 @@ export default function SignIn() {
const { data: session } = useSession();
const searchParams = useSearchParams();
const signedOut = searchParams.get('signedOut') === 'true';
const forceFreshLogin = searchParams.get('fresh') === 'true';
const [isRedirecting, setIsRedirecting] = useState(false);
const [isFromLogout, setIsFromLogout] = useState(false);
// Clear all Keycloak cookies when the fresh parameter is present
useEffect(() => {
if (forceFreshLogin) {
console.log('Fresh login requested, clearing all Keycloak cookies');
// Clear auth cookies to ensure a fresh login
clearAuthCookies();
// Extra cleanup for Keycloak-specific cookies with different paths
const keycloakCookies = [
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KEYCLOAK_REMEMBER_ME',
'KC_RESTART',
'AUTH_SESSION_ID',
'AUTH_SESSION_ID_LEGACY',
'JSESSIONID'
];
const domains = [
window.location.hostname,
`.${window.location.hostname}`,
window.location.hostname.split('.').slice(-2).join('.'),
`.${window.location.hostname.split('.').slice(-2).join('.')}`
];
// Clear each cookie with various paths and domains
keycloakCookies.forEach(cookieName => {
domains.forEach(domain => {
const paths = ['/', '/auth', '/realms'];
paths.forEach(path => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure`;
});
});
});
// Also check for chunked cookies
const cookies = document.cookie.split(';');
cookies.forEach(cookie => {
const cookieName = cookie.split('=')[0].trim();
if (/\.\d+$/.test(cookieName)) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure`;
}
});
}
}, [forceFreshLogin]);
// If signedOut is true, make sure we clean up any residual sessions
useEffect(() => {
if (signedOut) {
@ -33,8 +81,13 @@ export default function SignIn() {
}, []);
useEffect(() => {
// Only automatically sign in if not explicitly signed out and not coming from logout
if (!signedOut && !isFromLogout && !isRedirecting && !session) {
// Only automatically sign in if:
// - Not explicitly signed out
// - Not coming from logout
// - Not forcing a fresh login
// - Not already redirecting
// - No session exists
if (!signedOut && !isFromLogout && !forceFreshLogin && !isRedirecting && !session) {
setIsRedirecting(true);
console.log('Triggering automatic sign-in');
// Add a small delay to avoid immediate redirect which can cause loops
@ -45,7 +98,7 @@ export default function SignIn() {
return () => clearTimeout(timer);
}
}, [signedOut, isRedirecting, isFromLogout, session]);
}, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, session]);
useEffect(() => {
if (session?.user && !session.user.nextcloudInitialized) {
@ -62,7 +115,14 @@ export default function SignIn() {
}
}, [session]);
const showManualLoginButton = signedOut || isFromLogout || isRedirecting;
const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin;
// Determine the button text based on the context
const buttonText = forceFreshLogin
? 'Sign In (Fresh Login)'
: signedOut
? 'Sign In Again'
: 'Sign In';
return (
<div
@ -91,13 +151,19 @@ export default function SignIn() {
onClick={() => signIn("keycloak", { callbackUrl: "/" })}
className="w-full bg-white text-gray-800 py-3 rounded hover:bg-gray-100 transition-colors"
>
Sign In
{buttonText}
</button>
) : (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
)}
{(signedOut || forceFreshLogin) && (
<p className="text-white/70 text-sm mt-4">
You'll need to enter your credentials again for security reasons
</p>
)}
</div>
</div>
</div>

View File

@ -35,7 +35,7 @@ export function SignOutHandler() {
}
}
// Then, attempt to sign out from NextAuth explicitly
// Then, attempt to sign out from NextAuth explicitly with force option
await signOut({ redirect: false });
// Clear Rocket Chat authentication tokens
@ -67,7 +67,7 @@ export function SignOutHandler() {
// Then clear all auth-related cookies to ensure we break any local sessions
clearAuthCookies();
// Get Keycloak logout URL
// Get Keycloak logout URL with additional parameters to force session expiration
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {
console.log('Preparing complete Keycloak logout');
@ -75,7 +75,7 @@ export function SignOutHandler() {
const keycloakBaseUrl = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
const logoutEndpoint = `${keycloakBaseUrl}/protocol/openid-connect/logout`;
// Create form for POST logout (more reliable)
// Create form for POST logout (more reliable than GET)
const form = document.createElement('form');
form.method = 'POST';
form.action = logoutEndpoint;
@ -100,7 +100,7 @@ export function SignOutHandler() {
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'post_logout_redirect_uri';
redirectInput.value = `${window.location.origin}/loggedout`;
redirectInput.value = `${window.location.origin}/loggedout?forceLogout=true`;
form.appendChild(redirectInput);
// Add logout_hint=server to explicitly request server-side session cleanup
@ -109,6 +109,27 @@ export function SignOutHandler() {
logoutHintInput.name = 'logout_hint';
logoutHintInput.value = 'server';
form.appendChild(logoutHintInput);
// Add state parameter with random value to prevent CSRF
const stateInput = document.createElement('input');
stateInput.type = 'hidden';
stateInput.name = 'state';
stateInput.value = Math.random().toString(36).substring(2);
form.appendChild(stateInput);
// Set initiate_login_uri parameter to force login screen on next login
const initiateLoginInput = document.createElement('input');
initiateLoginInput.type = 'hidden';
initiateLoginInput.name = 'initiate_login_uri';
initiateLoginInput.value = `${window.location.origin}/signin?fresh=true`;
form.appendChild(initiateLoginInput);
// Add UI locales parameter
const uiLocalesInput = document.createElement('input');
uiLocalesInput.type = 'hidden';
uiLocalesInput.name = 'ui_locales';
uiLocalesInput.value = 'fr';
form.appendChild(uiLocalesInput);
// Notify iframe parents before logging out
try {
@ -120,17 +141,46 @@ export function SignOutHandler() {
console.error('Error notifying parent of logout:', e);
}
// Completely remove all Keycloak session cookies before logout
// This helps prevent automatic re-login
const keycloakCookies = [
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KEYCLOAK_REMEMBER_ME',
'KC_RESTART',
'KEYCLOAK_SESSION_LEGACY',
'KEYCLOAK_IDENTITY_LEGACY',
'AUTH_SESSION_ID',
'AUTH_SESSION_ID_LEGACY',
'JSESSIONID'
];
const domains = [
window.location.hostname,
`.${window.location.hostname}`,
window.location.hostname.split('.').slice(-2).join('.'),
`.${window.location.hostname.split('.').slice(-2).join('.')}`
];
keycloakCookies.forEach(cookieName => {
domains.forEach(domain => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; SameSite=None; Secure`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/realms/; domain=${domain}; SameSite=None; Secure`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/auth/; domain=${domain}; SameSite=None; Secure`;
});
});
// Append to body and submit
document.body.appendChild(form);
console.log('Submitting Keycloak logout form with server-side logout');
form.submit();
} else {
console.log('No Keycloak configuration found, performing simple redirect');
window.location.href = '/loggedout';
window.location.href = '/loggedout?forceLogout=true';
}
} catch (error) {
console.error('Error during logout:', error);
window.location.href = '/loggedout';
window.location.href = '/loggedout?forceLogout=true';
}
};

View File

@ -1,27 +1,80 @@
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;
// Maximum cookie size in bytes (even more conservative to prevent chunking issues)
const MAX_COOKIE_SIZE = 3500;
// This middleware runs before any request
export function middleware(request: NextRequest) {
// Force NextAuth environment variables at runtime
// Force NextAuth environment variables at runtime to prevent cookie chunking
process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = MAX_COOKIE_SIZE.toString();
// Set defaults for cookie security
// Disable cookie callbacks which can increase cookie size
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
// Check if this is a logout-related request
const url = request.nextUrl.pathname;
const isLogoutPath = url.includes('/signout') || url.includes('/loggedout');
const isSigninPath = url.includes('/signin');
const hasForceLogoutParam = request.nextUrl.searchParams.has('forceLogout');
if (isLogoutPath || hasForceLogoutParam) {
// On logout pages, we want to ensure cookies are cleaned up
const response = NextResponse.next();
// List of authentication-related cookies to clear on logout
const authCookies = [
'next-auth.session-token',
'next-auth.callback-url',
'next-auth.csrf-token',
'next-auth.pkce.code-verifier',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token'
];
// Clear each cookie with appropriate settings
authCookies.forEach(cookieName => {
// Try to detect and clear chunked cookies too
for (let i = 0; i < 5; i++) {
const chunkName = i > 0 ? `${cookieName}.${i}` : cookieName;
// Clear with domain
if (process.env.NEXTAUTH_COOKIE_DOMAIN) {
response.cookies.delete({
name: chunkName,
domain: process.env.NEXTAUTH_COOKIE_DOMAIN,
path: '/'
});
}
// Also clear without domain
response.cookies.delete({
name: chunkName,
path: '/'
});
}
});
return response;
}
// For the signin page with fresh=true param, pass a header indicating fresh login
if (isSigninPath && request.nextUrl.searchParams.get('fresh') === 'true') {
const response = NextResponse.next();
response.headers.set('X-Force-Fresh-Login', 'true');
return response;
}
// Continue with the request for all other paths
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
// Apply to all routes except static files
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
};