auth flow

This commit is contained in:
alma 2025-05-02 12:08:25 +02:00
parent 500d7de548
commit 645907b38b
6 changed files with 140 additions and 33 deletions

View File

@ -141,8 +141,21 @@ 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: {
httpOnly: true,
sameSite: 'none',
path: '/',
secure: true,
domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined,
},
},
},
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
// Only include essential data in the JWT to reduce size
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,7 +190,7 @@ export const authOptions: NextAuthOptions = {
return token; return token;
} }
return refreshAccessToken(token); return refreshAccessToken(token as JWT);
}, },
async session({ session, token }) { async session({ session, token }) {
if (token.error) { if (token.error) {
@ -185,6 +198,8 @@ export const authOptions: NextAuthOptions = {
} }
const userRoles = Array.isArray(token.role) ? token.role : []; const userRoles = Array.isArray(token.role) ? token.role : [];
// Only include essential user data
session.user = { session.user = {
id: token.sub ?? '', id: token.sub ?? '',
email: token.email ?? null, email: token.email ?? null,
@ -196,6 +211,8 @@ export const authOptions: NextAuthOptions = {
role: userRoles, role: userRoles,
nextcloudInitialized: false, nextcloudInitialized: false,
}; };
// Only pass the access token, not the entire token
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
return session; return session;

View File

@ -41,6 +41,21 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
iframe.src = iframeURL.toString(); iframe.src = iframeURL.toString();
} }
}; };
// Handle authentication messages from iframe
const handleMessage = (event: MessageEvent) => {
// Only accept messages from our iframe
if (iframe.contentWindow !== event.source) return;
const { type, data } = event.data || {};
// 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';
}
};
// Initial setup // Initial setup
calculateHeight(); calculateHeight();
@ -49,12 +64,14 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
// Event listeners // Event listeners
window.addEventListener('resize', calculateHeight); window.addEventListener('resize', calculateHeight);
window.addEventListener('hashchange', handleHashChange); window.addEventListener('hashchange', handleHashChange);
window.addEventListener('message', handleMessage);
iframe.addEventListener('load', calculateHeight); iframe.addEventListener('load', calculateHeight);
// Cleanup // Cleanup
return () => { return () => {
window.removeEventListener('resize', calculateHeight); window.removeEventListener('resize', calculateHeight);
window.removeEventListener('hashchange', handleHashChange); window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('message', handleMessage);
iframe.removeEventListener('load', calculateHeight); iframe.removeEventListener('load', calculateHeight);
}; };
}, []); }, []);

View File

@ -7,6 +7,19 @@ import Link from "next/link";
export default function LoggedOut() { export default function LoggedOut() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking'); const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking');
// Listen for any messages from iframes
useEffect(() => {
const messageHandler = (event: MessageEvent) => {
// Handle any auth-related messages from iframes
if (event.data && event.data.type === 'AUTH_ERROR') {
console.log('Received auth error from iframe:', event.data);
}
};
window.addEventListener('message', messageHandler);
return () => window.removeEventListener('message', messageHandler);
}, []);
// Clear auth cookies again on this page as an extra precaution // Clear auth cookies again on this page as an extra precaution
useEffect(() => { useEffect(() => {
const checkAndClearSessions = async () => { const checkAndClearSessions = async () => {
@ -44,11 +57,36 @@ export default function LoggedOut() {
console.error('Error clearing localStorage:', e); console.error('Error clearing localStorage:', e);
} }
// Double check for Keycloak specific cookies // Double check for Keycloak specific cookies and chunked cookies
const keycloakCookies = ['KEYCLOAK_SESSION', 'KEYCLOAK_IDENTITY', 'KC_RESTART']; const cookies = document.cookie.split(';');
const cookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
// Look for chunked cookies
const chunkedCookies = cookieNames.filter(name => /\.\d+$/.test(name));
const keycloakCookies = [
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART',
...chunkedCookies
];
for (const cookieName of keycloakCookies) { for (const cookieName of keycloakCookies) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure;`; document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure;`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// Also try with root domain
const rootDomain = window.location.hostname.split('.').slice(-2).join('.');
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${rootDomain}; SameSite=None; Secure;`;
}
// Notify any parent windows/iframes
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'SESSION_CLEARED' }, '*');
}
} catch (e) {
console.error('Error notifying parent window:', e);
} }
setSessionStatus('cleared'); setSessionStatus('cleared');
@ -98,7 +136,7 @@ export default function LoggedOut() {
<div className="mt-6"> <div className="mt-6">
<Link <Link
href="/signin" href="/signin?signedOut=true"
className="inline-block px-8 py-3 bg-white text-gray-800 rounded hover:bg-gray-100 transition-colors mb-4" className="inline-block px-8 py-3 bg-white text-gray-800 rounded hover:bg-gray-100 transition-colors mb-4"
> >
Sign In Again Sign In Again

View File

@ -3,6 +3,7 @@
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { clearAuthCookies } from "@/lib/session";
export default function SignIn() { export default function SignIn() {
const { data: session } = useSession(); const { data: session } = useSession();
@ -11,6 +12,14 @@ export default function SignIn() {
const [isRedirecting, setIsRedirecting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false);
const [isFromLogout, setIsFromLogout] = useState(false); const [isFromLogout, setIsFromLogout] = useState(false);
// If signedOut is true, make sure we clean up any residual sessions
useEffect(() => {
if (signedOut) {
console.log('User explicitly signed out, clearing any residual session data');
clearAuthCookies();
}
}, [signedOut]);
// Check if we came from the loggedout page // Check if we came from the loggedout page
useEffect(() => { useEffect(() => {
const referrer = document.referrer; const referrer = document.referrer;
@ -65,32 +74,29 @@ export default function SignIn() {
backgroundRepeat: 'no-repeat' backgroundRepeat: 'no-repeat'
}} }}
> >
<div className="w-full max-w-md space-y-8"> <div className="w-full max-w-md p-8 bg-black/60 backdrop-blur-sm rounded-lg shadow-xl">
<div> <div className="text-center">
{showManualLoginButton ? ( <h2 className="text-3xl font-bold text-white mb-6">
<> {signedOut ? 'You have signed out' : 'Welcome Back'}
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-white"> </h2>
{signedOut || isFromLogout ? 'You have been signed out' : 'Welcome Back'}
</h2> {isRedirecting && !showManualLoginButton && (
<p className="mt-2 text-center text-lg text-white/80"> <p className="text-white/80 mb-4">
Click below to sign in
</p>
<div className="mt-6 flex justify-center">
<button
onClick={() => {
setIsRedirecting(true);
signIn("keycloak", { callbackUrl: "/" });
}}
className="px-8 py-3 bg-white text-gray-800 rounded hover:bg-gray-100 transition-colors"
>
Sign In
</button>
</div>
</>
) : (
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-white">
Redirecting to login... Redirecting to login...
</h2> </p>
)}
{showManualLoginButton ? (
<button
onClick={() => signIn("keycloak", { callbackUrl: "/" })}
className="w-full bg-white text-gray-800 py-3 rounded hover:bg-gray-100 transition-colors"
>
Sign In
</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>
)} )}
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { clearAuthCookies } from "@/lib/session"; import { clearAuthCookies } from "@/lib/session";
export function SignOutHandler() { export function SignOutHandler() {
@ -10,7 +10,10 @@ export function SignOutHandler() {
useEffect(() => { useEffect(() => {
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
// First, clear all auth-related cookies to ensure we break any local sessions // First, attempt to sign out from NextAuth explicitly
await signOut({ redirect: false });
// Then clear all auth-related cookies to ensure we break any local sessions
clearAuthCookies(); clearAuthCookies();
// Get Keycloak logout URL // Get Keycloak logout URL
@ -56,6 +59,16 @@ export function SignOutHandler() {
logoutHintInput.value = 'server'; logoutHintInput.value = 'server';
form.appendChild(logoutHintInput); form.appendChild(logoutHintInput);
// Notify iframe parents before logging out
try {
// Attempt to notify any iframes that might be using this authentication
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'LOGOUT_EVENT' }, '*');
}
} catch (e) {
console.error('Error notifying parent of logout:', e);
}
// Append to body and submit // Append to body and submit
document.body.appendChild(form); document.body.appendChild(form);
console.log('Submitting Keycloak logout form with server-side logout'); console.log('Submitting Keycloak logout form with server-side logout');

View File

@ -124,6 +124,21 @@ export function clearAuthCookies() {
console.log(`Processing ${cookies.length} cookies`); console.log(`Processing ${cookies.length} cookies`);
// Get all cookie names to detect chunks (like next-auth.session-token.0)
const allCookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
// Find any chunked cookies
const chunkedCookies = allCookieNames.filter(name => {
return /\.\d+$/.test(name) && authCookiePrefixes.some(prefix => name.startsWith(prefix));
});
if (chunkedCookies.length > 0) {
console.log(`Found ${chunkedCookies.length} chunked cookies:`, chunkedCookies);
}
// Add detected chunked cookies to our specific cookies list
specificCookies.push(...chunkedCookies);
for (const cookie of cookies) { for (const cookie of cookies) {
const [name] = cookie.split('='); const [name] = cookie.split('=');
const trimmedName = name.trim(); const trimmedName = name.trim();
@ -145,11 +160,12 @@ export function clearAuthCookies() {
console.log(`Clearing cookie: ${trimmedName}`); console.log(`Clearing cookie: ${trimmedName}`);
// Try different combinations to ensure the cookie is cleared // Try different combinations to ensure the cookie is cleared
const paths = ['/', '/auth', '/realms', '/admin']; const paths = ['/', '/auth', '/realms', '/admin', '/api'];
const domains = [ const domains = [
window.location.hostname, // Exact domain window.location.hostname, // Exact domain
`.${window.location.hostname}`, // Domain with leading dot `.${window.location.hostname}`, // Domain with leading dot
window.location.hostname.split('.').slice(-2).join('.') // Root domain window.location.hostname.split('.').slice(-2).join('.'), // Root domain
`.${window.location.hostname.split('.').slice(-2).join('.')}` // Root domain with leading dot
]; ];
// Try each combination of path and domain // Try each combination of path and domain