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",
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: {
async jwt({ token, account, profile }) {
// Only include essential data in the JWT to reduce size
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
@ -177,7 +190,7 @@ export const authOptions: NextAuthOptions = {
return token;
}
return refreshAccessToken(token);
return refreshAccessToken(token as JWT);
},
async session({ session, token }) {
if (token.error) {
@ -185,6 +198,8 @@ export const authOptions: NextAuthOptions = {
}
const userRoles = Array.isArray(token.role) ? token.role : [];
// Only include essential user data
session.user = {
id: token.sub ?? '',
email: token.email ?? null,
@ -196,6 +211,8 @@ export const authOptions: NextAuthOptions = {
role: userRoles,
nextcloudInitialized: false,
};
// Only pass the access token, not the entire token
session.accessToken = token.accessToken;
return session;

View File

@ -41,6 +41,21 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
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
calculateHeight();
@ -49,12 +64,14 @@ export function ResponsiveIframe({ src, className = '', allow, style, token }: R
// Event listeners
window.addEventListener('resize', calculateHeight);
window.addEventListener('hashchange', handleHashChange);
window.addEventListener('message', handleMessage);
iframe.addEventListener('load', calculateHeight);
// Cleanup
return () => {
window.removeEventListener('resize', calculateHeight);
window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('message', handleMessage);
iframe.removeEventListener('load', calculateHeight);
};
}, []);

View File

@ -7,6 +7,19 @@ import Link from "next/link";
export default function LoggedOut() {
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
useEffect(() => {
const checkAndClearSessions = async () => {
@ -44,11 +57,36 @@ export default function LoggedOut() {
console.error('Error clearing localStorage:', e);
}
// Double check for Keycloak specific cookies
const keycloakCookies = ['KEYCLOAK_SESSION', 'KEYCLOAK_IDENTITY', 'KC_RESTART'];
// Double check for Keycloak specific cookies and chunked cookies
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) {
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=/;`;
// 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');
@ -98,7 +136,7 @@ export default function LoggedOut() {
<div className="mt-6">
<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"
>
Sign In Again

View File

@ -3,6 +3,7 @@
import { signIn, useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { clearAuthCookies } from "@/lib/session";
export default function SignIn() {
const { data: session } = useSession();
@ -11,6 +12,14 @@ export default function SignIn() {
const [isRedirecting, setIsRedirecting] = 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
useEffect(() => {
const referrer = document.referrer;
@ -65,32 +74,29 @@ export default function SignIn() {
backgroundRepeat: 'no-repeat'
}}
>
<div className="w-full max-w-md space-y-8">
<div>
{showManualLoginButton ? (
<>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-white">
{signedOut || isFromLogout ? 'You have been signed out' : 'Welcome Back'}
</h2>
<p className="mt-2 text-center text-lg text-white/80">
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">
<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-6">
{signedOut ? 'You have signed out' : 'Welcome Back'}
</h2>
{isRedirecting && !showManualLoginButton && (
<p className="text-white/80 mb-4">
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>

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import { useSession, signOut } from "next-auth/react";
import { clearAuthCookies } from "@/lib/session";
export function SignOutHandler() {
@ -10,7 +10,10 @@ export function SignOutHandler() {
useEffect(() => {
const handleSignOut = async () => {
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();
// Get Keycloak logout URL
@ -56,6 +59,16 @@ export function SignOutHandler() {
logoutHintInput.value = 'server';
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
document.body.appendChild(form);
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`);
// 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) {
const [name] = cookie.split('=');
const trimmedName = name.trim();
@ -145,11 +160,12 @@ export function clearAuthCookies() {
console.log(`Clearing cookie: ${trimmedName}`);
// Try different combinations to ensure the cookie is cleared
const paths = ['/', '/auth', '/realms', '/admin'];
const paths = ['/', '/auth', '/realms', '/admin', '/api'];
const domains = [
window.location.hostname, // Exact domain
`.${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