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) {
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<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);
}
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: {

View File

@ -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<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
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 (
<div
@ -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="text-center">
<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>
{errorMessage && (
<div className="rounded bg-red-800/40 p-3 mb-6 text-white">
{errorMessage}
</div>
)}
{isRedirecting && !showManualLoginButton && (
<p className="text-white/80 mb-4">
Redirecting to login...
@ -159,7 +188,7 @@ export default function SignIn() {
</div>
)}
{(signedOut || forceFreshLogin) && (
{(signedOut || forceFreshLogin || error) && (
<p className="text-white/70 text-sm mt-4">
You'll need to enter your credentials again for security reasons
</p>

View File

@ -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 <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;
}
// Session has error, don't render children
if (session?.error) {
return null;
}
// Authentication is valid, render children
return <>{children}</>;
}

View File

@ -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 (
<SessionProvider>
<SessionProvider session={session} refetchInterval={5 * 60}>
{children}
</SessionProvider>
);