auth flow

This commit is contained in:
alma 2025-05-02 12:52:40 +02:00
parent fa05961404
commit d7922351c0
4 changed files with 158 additions and 56 deletions

View File

@ -63,6 +63,7 @@ function getRequiredEnvVar(name: string): string {
async function refreshAccessToken(token: JWT) { async function refreshAccessToken(token: JWT) {
try { try {
console.log('Attempting to refresh access token');
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ body: new URLSearchParams({
@ -77,14 +78,18 @@ async function refreshAccessToken(token: JWT) {
const refreshedTokens = await response.json(); const refreshedTokens = await response.json();
if (!response.ok) { if (!response.ok) {
console.error('Token refresh failed with status:', response.status);
console.error('Error response:', refreshedTokens);
throw refreshedTokens; throw refreshedTokens;
} }
console.log('Token refresh successful');
return { return {
...token, ...token,
accessToken: refreshedTokens.access_token, accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
error: undefined, // Clear any previous errors
}; };
} catch (error) { } catch (error) {
console.error("Error refreshing access token:", error); console.error("Error refreshing access token:", error);
@ -159,7 +164,7 @@ export const authOptions: NextAuthOptions = {
}, },
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
// Drastically reduce JWT size by only storing essential info // Initial sign in
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 || [];
@ -174,6 +179,7 @@ export const authOptions: NextAuthOptions = {
token.sub = keycloakProfile.sub; token.sub = keycloakProfile.sub;
token.role = cleanRoles; token.role = cleanRoles;
token.username = keycloakProfile.preferred_username ?? ''; token.username = keycloakProfile.preferred_username ?? '';
token.error = undefined; // Clear any errors
// Only store these if they're short // Only store these if they're short
if (keycloakProfile.given_name && keycloakProfile.given_name.length < 30) { if (keycloakProfile.given_name && keycloakProfile.given_name.length < 30) {
@ -182,30 +188,57 @@ export const authOptions: NextAuthOptions = {
if (keycloakProfile.family_name && keycloakProfile.family_name.length < 30) { if (keycloakProfile.family_name && keycloakProfile.family_name.length < 30) {
token.last_name = keycloakProfile.family_name; token.last_name = keycloakProfile.family_name;
} }
} else if (token.accessToken) {
try {
const decoded = jwtDecode<DecodedToken>(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);
}
}
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
return token; return token;
} }
return refreshAccessToken(token as JWT); // Check if token has expired
const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0;
const currentTime = Date.now();
const hasExpired = currentTime >= tokenExpiresAt;
// If the token is still valid, return it
if (!hasExpired) {
return token;
}
// 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"
};
}
// Try to refresh the token
const refreshedToken = await refreshAccessToken(token as JWT);
// 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"
};
}
return refreshedToken;
}, },
async session({ session, token }) { async session({ session, token }) {
if (token.error) { try {
throw new Error(token.error); // 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 ?? ''
}
};
} }
const userRoles = Array.isArray(token.role) ? token.role : []; const userRoles = Array.isArray(token.role) ? token.role : [];
@ -227,6 +260,18 @@ export const authOptions: NextAuthOptions = {
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
return session; 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: { pages: {

View File

@ -10,13 +10,33 @@ export default function SignIn() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const signedOut = searchParams.get('signedOut') === 'true'; const signedOut = searchParams.get('signedOut') === 'true';
const forceFreshLogin = searchParams.get('fresh') === 'true'; const forceFreshLogin = searchParams.get('fresh') === 'true';
const error = searchParams.get('error');
const [isRedirecting, setIsRedirecting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false);
const [isFromLogout, setIsFromLogout] = useState(false); const [isFromLogout, setIsFromLogout] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 // Clear all Keycloak cookies when the fresh parameter is present
useEffect(() => { useEffect(() => {
if (forceFreshLogin) { if (forceFreshLogin || error) {
console.log('Fresh login requested, clearing all Keycloak cookies'); console.log('Fresh login requested or error detected, clearing all cookies');
// Clear auth cookies to ensure a fresh login // Clear auth cookies to ensure a fresh login
clearAuthCookies(); clearAuthCookies();
@ -58,7 +78,7 @@ export default function SignIn() {
} }
}); });
} }
}, [forceFreshLogin]); }, [forceFreshLogin, error]);
// If signedOut is true, make sure we clean up any residual sessions // If signedOut is true, make sure we clean up any residual sessions
useEffect(() => { useEffect(() => {
@ -85,9 +105,10 @@ export default function SignIn() {
// - Not explicitly signed out // - Not explicitly signed out
// - Not coming from logout // - Not coming from logout
// - Not forcing a fresh login // - Not forcing a fresh login
// - No error present
// - Not already redirecting // - Not already redirecting
// - No session exists // - No session exists
if (!signedOut && !isFromLogout && !forceFreshLogin && !isRedirecting && !session) { if (!signedOut && !isFromLogout && !forceFreshLogin && !error && !isRedirecting && !session) {
setIsRedirecting(true); setIsRedirecting(true);
console.log('Triggering automatic sign-in'); console.log('Triggering automatic sign-in');
// Add a small delay to avoid immediate redirect which can cause loops // Add a small delay to avoid immediate redirect which can cause loops
@ -98,7 +119,7 @@ export default function SignIn() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, session]); }, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, error, session]);
useEffect(() => { useEffect(() => {
if (session?.user && !session.user.nextcloudInitialized) { if (session?.user && !session.user.nextcloudInitialized) {
@ -115,10 +136,12 @@ export default function SignIn() {
} }
}, [session]); }, [session]);
const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin; const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin || error;
// Determine the button text based on the context // Determine the button text based on the context
const buttonText = forceFreshLogin const buttonText = error
? 'Sign In Again'
: forceFreshLogin
? 'Sign In (Fresh Login)' ? 'Sign In (Fresh Login)'
: signedOut : signedOut
? 'Sign In Again' ? 'Sign In Again'
@ -137,9 +160,15 @@ export default function SignIn() {
<div className="w-full max-w-md p-8 bg-black/60 backdrop-blur-sm rounded-lg shadow-xl"> <div className="w-full max-w-md p-8 bg-black/60 backdrop-blur-sm rounded-lg shadow-xl">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold text-white mb-6"> <h2 className="text-3xl font-bold text-white mb-6">
{signedOut ? 'You have signed out' : 'Welcome Back'} {error ? 'Session Expired' : signedOut ? 'You have signed out' : 'Welcome Back'}
</h2> </h2>
{errorMessage && (
<div className="rounded bg-red-800/40 p-3 mb-6 text-white">
{errorMessage}
</div>
)}
{isRedirecting && !showManualLoginButton && ( {isRedirecting && !showManualLoginButton && (
<p className="text-white/80 mb-4"> <p className="text-white/80 mb-4">
Redirecting to login... Redirecting to login...
@ -159,7 +188,7 @@ export default function SignIn() {
</div> </div>
)} )}
{(signedOut || forceFreshLogin) && ( {(signedOut || forceFreshLogin || error) && (
<p className="text-white/70 text-sm mt-4"> <p className="text-white/70 text-sm mt-4">
You'll need to enter your credentials again for security reasons You'll need to enter your credentials again for security reasons
</p> </p>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useSession } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
@ -10,18 +10,45 @@ export function AuthCheck({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
useEffect(() => { 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"); 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") { if (status === "loading") {
return <div>Chargement...</div>; return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement...</p>
</div>
</div>
);
} }
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; return null;
} }
// Session has error, don't render children
if (session?.error) {
return null;
}
// Authentication is valid, render children
return <>{children}</>; return <>{children}</>;
} }

View File

@ -4,11 +4,12 @@ import { SessionProvider } from "next-auth/react";
interface ProvidersProps { interface ProvidersProps {
children: React.ReactNode; children: React.ReactNode;
session: any;
} }
export function Providers({ children }: ProvidersProps) { export function Providers({ children, session }: ProvidersProps) {
return ( return (
<SessionProvider> <SessionProvider session={session} refetchInterval={5 * 60}>
{children} {children}
</SessionProvider> </SessionProvider>
); );