From d7922351c0857ec2a47360e5495bf64dc8e60ee5 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 2 May 2025 12:52:40 +0200 Subject: [PATCH] auth flow --- app/api/auth/[...nextauth]/route.ts | 117 +++++++++++++++++++--------- app/signin/page.tsx | 55 +++++++++---- components/auth/auth-check.tsx | 37 +++++++-- components/providers.tsx | 5 +- 4 files changed, 158 insertions(+), 56 deletions(-) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 8302d22a..bf076e42 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -63,6 +63,7 @@ function getRequiredEnvVar(name: string): string { async function refreshAccessToken(token: JWT) { try { + console.log('Attempting to refresh access token'); const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -77,14 +78,18 @@ async function refreshAccessToken(token: JWT) { const refreshedTokens = await response.json(); if (!response.ok) { + console.error('Token refresh failed with status:', response.status); + console.error('Error response:', refreshedTokens); throw refreshedTokens; } + console.log('Token refresh successful'); return { ...token, accessToken: refreshedTokens.access_token, refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + error: undefined, // Clear any previous errors }; } catch (error) { console.error("Error refreshing access token:", error); @@ -159,7 +164,7 @@ export const authOptions: NextAuthOptions = { }, callbacks: { async jwt({ token, account, profile }) { - // Drastically reduce JWT size by only storing essential info + // Initial sign in if (account && profile) { const keycloakProfile = profile as KeycloakProfile; const roles = keycloakProfile.realm_access?.roles || []; @@ -174,6 +179,7 @@ export const authOptions: NextAuthOptions = { token.sub = keycloakProfile.sub; token.role = cleanRoles; token.username = keycloakProfile.preferred_username ?? ''; + token.error = undefined; // Clear any errors // Only store these if they're short if (keycloakProfile.given_name && keycloakProfile.given_name.length < 30) { @@ -182,51 +188,90 @@ export const authOptions: NextAuthOptions = { if (keycloakProfile.family_name && keycloakProfile.family_name.length < 30) { token.last_name = keycloakProfile.family_name; } - } else if (token.accessToken) { - try { - const decoded = jwtDecode(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 token; } + + // Check if token has expired + const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0; + const currentTime = Date.now(); + const hasExpired = currentTime >= tokenExpiresAt; - if (Date.now() < (token.accessTokenExpires as number) * 1000) { + // If the token is still valid, return it + if (!hasExpired) { return token; } - return refreshAccessToken(token as JWT); - }, - async session({ session, token }) { - if (token.error) { - throw new Error(token.error); + // If refresh token is missing, force sign in + if (!token.refreshToken) { + console.warn('No refresh token available, session cannot be refreshed'); + return { + ...token, + error: "RefreshAccessTokenError" + }; } - const userRoles = Array.isArray(token.role) ? token.role : []; + // Try to refresh the token + const refreshedToken = await refreshAccessToken(token as JWT); - // Create a minimal user object - session.user = { - id: token.sub ?? '', - email: token.email ?? null, - name: token.name ?? null, - image: null, - username: token.username ?? '', - first_name: token.first_name ?? '', - last_name: token.last_name ?? '', - role: userRoles, - nextcloudInitialized: false, - }; + // If there was an error refreshing, mark token for re-authentication + if (refreshedToken.error) { + console.warn('Token refresh failed, user will need to reauthenticate'); + return { + ...refreshedToken, + error: "RefreshAccessTokenError" + }; + } - // Only store access token, not the entire token - session.accessToken = token.accessToken; + return refreshedToken; + }, + async session({ session, token }) { + try { + // Handle the error from jwt callback + if (token.error === "RefreshAccessTokenError") { + console.warn("Session encountered a refresh token error, redirecting to login"); + // Return minimal session with error flag that will trigger re-auth in client + return { + ...session, + error: "RefreshTokenError", + user: { + ...session.user, + id: token.sub ?? '' + } + }; + } - return session; + const userRoles = Array.isArray(token.role) ? token.role : []; + + // Create a minimal user object + session.user = { + id: token.sub ?? '', + email: token.email ?? null, + name: token.name ?? null, + image: null, + username: token.username ?? '', + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + role: userRoles, + nextcloudInitialized: false, + }; + + // Only store access token, not the entire token + session.accessToken = token.accessToken; + + return session; + } catch (error) { + console.error("Error in session callback:", error); + // Return minimal session with error flag + return { + ...session, + error: "SessionError", + user: { + ...session.user, + id: token.sub ?? '' + } + }; + } } }, pages: { diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 7a6cda00..0eb1c9bc 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -10,13 +10,33 @@ export default function SignIn() { const searchParams = useSearchParams(); const signedOut = searchParams.get('signedOut') === 'true'; const forceFreshLogin = searchParams.get('fresh') === 'true'; + const error = searchParams.get('error'); const [isRedirecting, setIsRedirecting] = useState(false); const [isFromLogout, setIsFromLogout] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + // Set error message based on the error param + useEffect(() => { + if (error) { + let message = "An authentication error occurred"; + + if (error === "RefreshTokenError") { + message = "Your session has expired. Please sign in again."; + } else if (error === "SessionError") { + message = "There was a problem with your session. Please sign in again."; + } else if (error === "invalid_grant") { + message = "Your authentication has expired. Please sign in again."; + } + + setErrorMessage(message); + console.log(`Authentication error: ${error}`); + } + }, [error]); // Clear all Keycloak cookies when the fresh parameter is present useEffect(() => { - if (forceFreshLogin) { - console.log('Fresh login requested, clearing all Keycloak cookies'); + if (forceFreshLogin || error) { + console.log('Fresh login requested or error detected, clearing all cookies'); // Clear auth cookies to ensure a fresh login clearAuthCookies(); @@ -58,7 +78,7 @@ export default function SignIn() { } }); } - }, [forceFreshLogin]); + }, [forceFreshLogin, error]); // If signedOut is true, make sure we clean up any residual sessions useEffect(() => { @@ -85,9 +105,10 @@ export default function SignIn() { // - Not explicitly signed out // - Not coming from logout // - Not forcing a fresh login + // - No error present // - Not already redirecting // - No session exists - if (!signedOut && !isFromLogout && !forceFreshLogin && !isRedirecting && !session) { + if (!signedOut && !isFromLogout && !forceFreshLogin && !error && !isRedirecting && !session) { setIsRedirecting(true); console.log('Triggering automatic sign-in'); // Add a small delay to avoid immediate redirect which can cause loops @@ -98,7 +119,7 @@ export default function SignIn() { return () => clearTimeout(timer); } - }, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, session]); + }, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, error, session]); useEffect(() => { if (session?.user && !session.user.nextcloudInitialized) { @@ -115,14 +136,16 @@ export default function SignIn() { } }, [session]); - const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin; + const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin || error; // Determine the button text based on the context - const buttonText = forceFreshLogin - ? 'Sign In (Fresh Login)' - : signedOut - ? 'Sign In Again' - : 'Sign In'; + const buttonText = error + ? 'Sign In Again' + : forceFreshLogin + ? 'Sign In (Fresh Login)' + : signedOut + ? 'Sign In Again' + : 'Sign In'; return (

- {signedOut ? 'You have signed out' : 'Welcome Back'} + {error ? 'Session Expired' : signedOut ? 'You have signed out' : 'Welcome Back'}

+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {isRedirecting && !showManualLoginButton && (

Redirecting to login... @@ -159,7 +188,7 @@ export default function SignIn() {

)} - {(signedOut || forceFreshLogin) && ( + {(signedOut || forceFreshLogin || error) && (

You'll need to enter your credentials again for security reasons

diff --git a/components/auth/auth-check.tsx b/components/auth/auth-check.tsx index c8e0c21e..eaaa04bb 100644 --- a/components/auth/auth-check.tsx +++ b/components/auth/auth-check.tsx @@ -1,6 +1,6 @@ "use client"; -import { useSession } from "next-auth/react"; +import { useSession, signOut } from "next-auth/react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -10,18 +10,45 @@ export function AuthCheck({ children }: { children: React.ReactNode }) { const router = useRouter(); useEffect(() => { - if (status === "unauthenticated" && pathname !== "/signin") { + // Handle authentication status changes + if (status === "unauthenticated" && !pathname.includes("/signin")) { + console.log("User is not authenticated, redirecting to signin page"); router.push("/signin"); } - }, [status, router, pathname]); + + // Handle session errors (like refresh token failures) + if (session?.error) { + console.log(`Session error detected: ${session.error}, signing out`); + // Force a clean sign out + signOut({ + callbackUrl: `/signin?error=${encodeURIComponent(session.error)}`, + redirect: true + }); + } + }, [status, session, router, pathname]); + // Show loading state if (status === "loading") { - return
Chargement...
; + return ( +
+
+
+

Chargement...

+
+
+ ); } - if (status === "unauthenticated" && pathname !== "/signin") { + // If not authenticated and not on signin page, don't render children + if (status === "unauthenticated" && !pathname.includes("/signin")) { return null; } + // Session has error, don't render children + if (session?.error) { + return null; + } + + // Authentication is valid, render children return <>{children}; } \ No newline at end of file diff --git a/components/providers.tsx b/components/providers.tsx index 987b2ac0..f69519c6 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -4,11 +4,12 @@ import { SessionProvider } from "next-auth/react"; interface ProvidersProps { children: React.ReactNode; + session: any; } -export function Providers({ children }: ProvidersProps) { +export function Providers({ children, session }: ProvidersProps) { return ( - + {children} );