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) {
|
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: {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}</>;
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user