auth flow
This commit is contained in:
parent
fa05961404
commit
d7922351c0
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user