Commit 9a99e6f6 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: send signed-out users straight to Keycloak

parent 65c11098
Loading
Loading
Loading
Loading
+56 −3
Changes for portal-gui/src/app/guards/protected-route.tsx: 56 added lines, 3 removed lines.
Original line number Diff line number Diff line
import { Navigate, Outlet } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/features/auth/auth-context';
import { LoadingState } from '@/components/common/loading-state';
import { ROUTES } from '../config/route';
import { AuthPending } from './auth-pending';

const LAST_REDIRECT_KEY = 'portal:login-redirect-at';
// Coming back signed out sooner than this means the login did not stick, e.g. the
// session cookie is refused, and redirecting again would loop through Keycloak.
const REDIRECT_COOLDOWN_MS = 10_000;

function redirectedRecently(): boolean {
  try {
    const last = Number(sessionStorage.getItem(LAST_REDIRECT_KEY));
    return Date.now() - last < REDIRECT_COOLDOWN_MS;
  } catch {
    return false;
  }
}

function markRedirect(at: number | null) {
  try {
    if (at === null) {
      sessionStorage.removeItem(LAST_REDIRECT_KEY);
    } else {
      sessionStorage.setItem(LAST_REDIRECT_KEY, String(at));
    }
  } catch {
    // Without storage the guard is off; the callback's ?error still stops a loop.
  }
}

export function ProtectedRoute() {
  const { status, isAuthenticated } = useAuth();
  const { status, isAuthenticated, login } = useAuth();
  const location = useLocation();
  // Read once, on arrival: a page load straight after our own redirect is the
  // browser coming back from Keycloak.
  const [justRedirected] = useState(redirectedRecently);
  // Set by the login callback when it failed; redirecting again would repeat it.
  const error = new URLSearchParams(location.search).get('error');
  const signedOut = status === 'anonymous';
  const stuck = signedOut && !error && justRedirected;
  const shouldRedirect = signedOut && !error && !stuck;
  const returnTo = location.pathname + location.search + location.hash;

  useEffect(() => {
    if (isAuthenticated) {
      // The login stuck, so a quick logout after it is not a loop.
      markRedirect(null);
    } else if (shouldRedirect) {
      markRedirect(Date.now());
      login(returnTo);
    }
  }, [isAuthenticated, shouldRedirect, login, returnTo]);

  if (status === 'loading' || status === 'unavailable') {
    return <AuthPending status={status} />;
  }
  if (!isAuthenticated) {
    return <Navigate to={ROUTES.LOGIN} replace />;
    if (error || stuck) {
      const reason = new URLSearchParams({ error: error ?? 'failed' });
      return <Navigate to={`${ROUTES.LOGIN}?${reason}`} replace />;
    }
    return <LoadingState message="Redirecting to sign in…" />;
  }

  return <Outlet />;
+10 −4
Changes for portal-gui/src/features/auth/auth-provider.tsx: 10 added lines, 4 removed lines.
Original line number Diff line number Diff line
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AuthContext } from './auth-context';
import type {
  AuthStatus,
@@ -66,8 +66,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
    };
  }, []);

  const login = () => window.location.assign(`${AUTH_API}/login`);
  const register = () => window.location.assign(`${AUTH_API}/register`);
  // Stable, so a guard can call it from an effect without re-running it.
  const login = useCallback((returnTo?: string) => {
    const query = returnTo
      ? `?${new URLSearchParams({ return_to: returnTo })}`
      : '';
    // replace, not assign: Back from Keycloak must not return to a page that
    // sends the browser straight back to Keycloak.
    window.location.replace(`${AUTH_API}/login${query}`);
  }, []);

  const logout = async () => {
    let logoutUrl: string | null = null;
@@ -88,7 +95,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
        status,
        user,
        login,
        register,
        logout,
        isAuthenticated: status === 'authenticated',
      }}
+45 −16
Changes for portal-gui/src/features/auth/login.tsx: 45 added lines, 16 removed lines.
Original line number Diff line number Diff line
import { useSearchParams } from 'react-router-dom';

import { Button } from '@/components/ui/button';

import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';

import { useAuth } from './auth-context';

type Message = { title: string; description: string };

const SIGNED_OUT: Message = {
  title: "You're signed out",
  description: 'Sign in to access the OpenOP.',
};

const FAILED: Message = {
  title: "Couldn't sign you in",
  description:
    'Try again. If this keeps happening, contact your administrator.',
};

// The reasons GET /api/auth/callback sends back, and what the person can do about them.
const LOGIN_ERRORS: Record<string, Message> = {
  cancelled: {
    title: 'Sign-in cancelled',
    description: 'You cancelled signing in.',
  },
  expired: {
    title: 'Sign-in timed out',
    description: 'Signing in took too long. Please try again.',
  },
  failed: FAILED,
  unavailable: {
    title: 'Sign-in unavailable',
    description: "The sign-in service isn't responding. Try again in a moment.",
  },
};

// Only reached when sending the browser to Keycloak straight away would be wrong:
// after a failed sign-in, or a logout that could not end the Keycloak session.
// It never redirects on its own, so a sign-in that keeps failing cannot loop.
export function LoginPage() {
  const { login, register } = useAuth();
  const { login } = useAuth();
  const [searchParams] = useSearchParams();
  const error = searchParams.get('error');
  const { title, description } = error
    ? (LOGIN_ERRORS[error] ?? FAILED)
    : SIGNED_OUT;

  return (
    <div className="space-y-6">
      <CardHeader className="space-y-2 px-0">
        <CardTitle className="text-2xl">Login</CardTitle>
        <CardDescription>Sign in to access the OpenOP.</CardDescription>
        <CardTitle className="text-2xl">{title}</CardTitle>
        <CardDescription>{description}</CardDescription>
      </CardHeader>

      <Button className="w-full" onClick={login}>
        Sign In
      <Button className="w-full" onClick={() => login()}>
        Sign in
      </Button>

      <div className="text-center text-sm text-muted-foreground">
        Don&apos;t have an account?{' '}
        <button
          type="button"
          className="text-primary hover:underline"
          onClick={register}
        >
          Create account
        </button>
      </div>
    </div>
  );
}
+1 −2
Changes for portal-gui/src/types/authentication.ts: 1 added line, 2 removed lines.
Original line number Diff line number Diff line
@@ -30,8 +30,7 @@ export type AuthStatus =
export type AuthContextType = {
  status: AuthStatus;
  user: CurrentUser | null;
  login: () => void;
  register: () => void;
  login: (returnTo?: string) => void;
  logout: () => Promise<void>;
  isAuthenticated: boolean;
};
+0 −2
Changes for portal-gui/src/types/ui.ts: 0 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,5 +9,3 @@ export type ActionSettingsBtn =
  (typeof ActionSettingsBtn)[keyof typeof ActionSettingsBtn];

export type MyApplicationsTabKey = 'profile' | 'deployments';

export type LoginTabKey = 'login' | 'register';
Loading