diff --git a/README.md b/README.md index 2557bf7a..ae7f1939 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,32 @@ ChemistryCafe is a web application built with React, Vite, and TypeScript. The a ## Getting Started +### Backend Environment Variables + +The backend of Chemistry Cafe requires certain secrets that cannot be stored in version control. These secrets are stored in environment variables that are either on the machine or loaded on runtime. Before running the application, make sure to define these variables ahead of time. + +To define required environment variables, a `.env` file should be created with the following schema: + +```py +# Required +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +MYSQL_USER=chemistrycafedev +MYSQL_PASSWORD=chemistrycafe +MYSQL_DATABASE=chemistry_db + +# Optional with defaults +MYSQL_SERVER=localhost +MYSQL_PORT=3306 +``` + +In order to use Google Authentication, a Google Cloud OAuth 2.0 project must be used with a `client id` and `client secret`. When creating the project, `http://localhost:8080/signin-google` should be added to the list of "Authorized redirect URIs" for testing. + +**Note:** + +- When running locally, the `.env` file must be in the `/backend` directory. +- When running with docker, the `.env` file can either be in the root directory *or* `/backend`. If it is in another directory, simply use `docker compose --env-file up` instead of the default. + ### Running Chemistry Cafe with Docker Compose You must have [Docker Desktop](https://www.docker.com/get-started) installed and running. @@ -48,7 +74,6 @@ To view logs for all services: docker compose logs -f ``` - **Note:** To view changes, you must run the docker compose down and then run the project again. ### Local Development (without Docker) diff --git a/backend/.dockerignore b/backend/.dockerignore index f321c59c..ca4889a8 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -6,6 +6,7 @@ !ChemistryCafeAPI.csproj !ChemistryCafeAPI.http !Citation.cff +!client_secrets.json !Controllers !LICENSE !Models @@ -16,4 +17,5 @@ !Startup.cs !appsettings.Development.json !appsettings.Production.json -!appsettings.json \ No newline at end of file +!appsettings.json +!.env diff --git a/backend/.gitignore b/backend/.gitignore index 4514d7e9..226e7c54 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,6 +6,10 @@ # dotenv files .env +# OAuth Credentials +client_secrets.json + + # User-specific files *.rsuser *.suo @@ -34,6 +38,7 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +app/ # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/backend/ChemistryCafeAPI.csproj b/backend/ChemistryCafeAPI.csproj index b9682da2..6b29bae6 100644 --- a/backend/ChemistryCafeAPI.csproj +++ b/backend/ChemistryCafeAPI.csproj @@ -26,7 +26,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + @@ -48,8 +50,7 @@ - + diff --git a/backend/Controllers/GoogleOAuthController.cs b/backend/Controllers/GoogleOAuthController.cs new file mode 100644 index 00000000..02046048 --- /dev/null +++ b/backend/Controllers/GoogleOAuthController.cs @@ -0,0 +1,117 @@ +using System.Security.Claims; +using Chemistry_Cafe_API.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Protocols.Configuration; + +namespace Chemistry_Cafe_API.Controllers +{ + /// + /// Controls routes related to Google OAuth 2.0 authentication + /// + [Route("/auth/google")] + public class GoogleOAuthController : Controller + { + private readonly GoogleOAuthService _googleOAuthService; + private readonly IConfiguration _configuration; + public GoogleOAuthController(IConfiguration configuration, GoogleOAuthService googleOAuthService) + { + _googleOAuthService = googleOAuthService; + _configuration = configuration; + } + + /// + /// Route which the user redirects to a google authentication page + /// + [HttpGet("login")] + public IActionResult LoginRedirect() + { + AuthenticationProperties authProperties = new AuthenticationProperties { RedirectUri = "/auth/google/authenticate" }; + return new ChallengeResult(GoogleDefaults.AuthenticationScheme, authProperties); + } + + /// + /// Route that user will be redirected to after signing in with Google OAuth. + /// This route essentially sets a user's information in a cookie. + /// + [HttpGet("authenticate")] + public async Task GoogleResponse() + { + AuthenticateResult result = await HttpContext.AuthenticateAsync("External"); + if (!result.Succeeded) + { + return BadRequest("Google OAuth Http Response did not succeed"); + } + + ClaimsPrincipal? claimsIdentity = _googleOAuthService.GetUserClaims(result); + if (claimsIdentity == null) + { + return BadRequest("Invalid Credentials Passed"); + } + + await HttpContext.SignInAsync("Application", claimsIdentity); + string redirectUrl = (_configuration["FrontendHost"] ?? throw new InvalidConfigurationException("")) + "/LoggedIn"; + return Redirect(redirectUrl); + } + + /// + /// Removes all authentication cookies and signs a user out of the backend application + /// + [HttpGet("logout")] + public async Task Logout(string? returnUrl) + { + string frontendHost = _configuration["FrontendHost"] ?? throw new InvalidConfigurationException("'FrontendHost' key not set in appsettings"); + // Ensure the redirect url is + if (returnUrl == null || returnUrl.Equals("")) + { + returnUrl = frontendHost; + } + else if (!Url.IsLocalUrl(returnUrl) && !returnUrl.StartsWith(frontendHost)) + { + return BadRequest("Invalid returnUrl argument. Must be within application scope."); + } + + await HttpContext.SignOutAsync("Application"); + + var request = HttpContext.Request; + var cookies = request.Cookies; + if (cookies.Count > 0) + { + foreach (var cookie in cookies) + { + if (cookie.Key.Contains(".AspNetCore.") || cookie.Key.Contains("Microsoft.Authentication")) + { + Response.Cookies.Delete(cookie.Key); + } + } + } + + return Redirect(returnUrl); + } + + /// + /// Gives the user information on themselves + /// + [HttpGet("whoami")] + public UserClaims GetUserClaims() + { + ClaimsIdentity? claimsIdentity = HttpContext.User.Identity as ClaimsIdentity; + if (claimsIdentity == null) + { + return new UserClaims + { + NameIdentifier = null, + EmailClaim = null, + }; + } + + return new UserClaims + { + NameIdentifier = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier)?.Value, + EmailClaim = claimsIdentity.FindFirst(ClaimTypes.Email)?.Value + }; + } + } +} \ No newline at end of file diff --git a/backend/Models/UserClaims.cs b/backend/Models/UserClaims.cs new file mode 100644 index 00000000..f512cee3 --- /dev/null +++ b/backend/Models/UserClaims.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Chemistry_Cafe_API.Models +{ + /// + /// Represents a user's authentication claims. + /// These are set in the user's cookies when the user logs in. + /// + public partial class UserClaims + { + [JsonPropertyName("nameId")] + public string? NameIdentifier { get; set; } + + [JsonPropertyName("email")] + public string? EmailClaim { get; set; } + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index 9252c976..6b3b37c9 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,6 +1,8 @@ using Chemistry_Cafe_API.Services; using MySqlConnector; using Microsoft.AspNetCore.HttpOverrides; +using Chemistry_Cafe_API.Controllers; +using dotenv.net; var builder = WebApplication.CreateBuilder(args); @@ -9,6 +11,9 @@ builder.WebHost.UseUrls("http://0.0.0.0:5000"); } +// Configure Environment +DotEnv.Load(); + // Add services to the container. builder.Services.AddControllers(); @@ -23,6 +28,24 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +string googleClientId = Environment.GetEnvironmentVariable("GOOGLE_CLIENT_ID") ?? throw new InvalidOperationException("GOOGLE_CLIENT_ID environment variable is missing."); +string googleClientSecret = Environment.GetEnvironmentVariable("GOOGLE_CLIENT_SECRET") ?? throw new InvalidOperationException("GOOGLE_CLIENT_SECRET environment variable is missing."); + +builder.Services.AddAuthentication((options) => + { + options.DefaultScheme = "Application"; + options.DefaultSignInScheme = "External"; + }) + .AddCookie("Application") + .AddCookie("External") + .AddGoogle((options) => + { + options.ClientId = googleClientId; + options.ClientSecret = googleClientSecret; + }); + //builder.Services.AddScoped(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -40,18 +63,20 @@ builder.Services.AddCors(options => { - options.AddPolicy("DevelopmentCorsPolicy", builder => + options.AddPolicy("DevelopmentCorsPolicy", policy => { - builder.WithOrigins("http://localhost:5173") + policy.WithOrigins("http://localhost:5173") .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .AllowCredentials(); }); - options.AddPolicy("ProductionCorsPolicy", builder => + options.AddPolicy("ProductionCorsPolicy", policy => { - builder.WithOrigins("https://cafe-deux-devel.acom.ucar.edu") + policy.WithOrigins("https://cafe-deux-devel.acom.ucar.edu") .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .AllowCredentials(); }); }); @@ -72,6 +97,7 @@ }); } +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index d0f8c93b..6da6be2c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,19 +6,28 @@ API for the Chemistry Cafe web application found here https://github.com/NCAR/ch # Getting Started +## Google Cloud + +In order to use Google Authentication, a Google Cloud OAuth 2.0 project must be used for testing. When creating the project, `http://localhost:8080/signin-google` should be added to the list of Authorized redirect URIs. + ## Environment Variables For the backend to connect to the MySQL server, certain environment variables must be specified. The default values can be seen in `docker-compose.yml`. These variables are the following: ```py -MYSQL_SERVER=localhost +# Required +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= MYSQL_USER=chemistrycafedev MYSQL_PASSWORD=chemistrycafe MYSQL_DATABASE=chemistry_db + +# Optional with defaults +MYSQL_SERVER=localhost MYSQL_PORT=3306 ``` -The only ones that are required are `MYSQL_USER`, `MYSQL_PASSWORD`, and `MYSQL_DATABASE`. `MYSQL_SERVER` defaults to "localhost" and `MYSQL_PORT` defaults to "3306". +`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` can be found in a google cloud project. These variables may be set in a `.env` file in the `/backend` directory or created on the machine itself. ## Command line diff --git a/backend/Services/GoogleOAuthService.cs b/backend/Services/GoogleOAuthService.cs new file mode 100644 index 00000000..fdcfe430 --- /dev/null +++ b/backend/Services/GoogleOAuthService.cs @@ -0,0 +1,53 @@ + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace Chemistry_Cafe_API.Controllers +{ + /// + /// Adapted from: https://blog.rashik.com.np/adding-google-authentication-in-net-core-application-without-identity/ + /// + public class GoogleOAuthService + { + /// + /// Parses an OAuth challenge result and turns them into a user's claims + /// + /// Result of Google OAuth Challenge + /// ClaimsPrincipal object which holds the user's auth informations + public ClaimsPrincipal? GetUserClaims(AuthenticateResult authenticateResult) + { + if (authenticateResult.Principal == null) + { + return null; + } + + var identityList = authenticateResult.Principal.Identities.ToList(); + if (authenticateResult.Principal != null && identityList.Count > 0) + { + var identity = identityList[0]; + if (identity.AuthenticationType != null && identity.AuthenticationType.ToLower().Equals("google")) + { + var claimsIdentity = new ClaimsIdentity("Application"); + var nameIdClaim = authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier); // GUID specified by Google + var emailClaim = authenticateResult.Principal.FindFirst(ClaimTypes.Email); + + if (nameIdClaim != null && emailClaim != null) + { + claimsIdentity.AddClaim(nameIdClaim); + claimsIdentity.AddClaim(emailClaim); + } + else + { + return null; + } + return new ClaimsPrincipal(claimsIdentity); + } + else + { + return null; + } + } + return null; + } + } +} \ No newline at end of file diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 680eddbb..547f362a 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -4,5 +4,6 @@ "Default": "Debug", "Microsoft.AspNetCore": "Debug" } - } -} + }, + "FrontendHost": "http://localhost:5173" +} \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index 4d1dc86c..7aef4970 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Debug" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "FrontendHost": "http://localhost:5173" } diff --git a/docker-compose.yml b/docker-compose.yml index d89748ee..439fc07d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,8 +34,8 @@ services: - backend # Ensure the backend service starts before frontend environment: # - ASPNETCORE_ENVIRONMENT=Development - - API_URL=http://backend:8080 # This points to the backend service within the Docker network # - ASPNETCORE_URLS=http://0.0.0.0:8080 + - API_URL=http://backend:8080 # This points to the backend service within the Docker network networks: - app-network # test code by britt to enable live updates to frontend with docker @@ -60,6 +60,8 @@ services: environment: <<: *db-env ASPNETCORE_ENVIRONMENT: Development + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} networks: - app-network command: ["dotnet", "watch", "run", "--project", "ChemistryCafeAPI.csproj"] # Command for development diff --git a/frontend/.env.development b/frontend/.env.development index 9324fc6d..cf77bc4b 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1,2 @@ -VITE_REACT_APP_OAUTH_CLIENT_ID="257697450661-a69l9bv939uuso551n6pcf1gngpv9ql0.apps.googleusercontent.com" -VITE_BASE_URL=http://localhost:8080/api \ No newline at end of file +VITE_BASE_URL=http://localhost:8080/api +VITE_AUTH_URL=http://localhost:8080/auth diff --git a/frontend/.env.production b/frontend/.env.production index a3060845..cf435674 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,2 +1,2 @@ -VITE_REACT_APP_OAUTH_CLIENT_ID="257697450661-a69l9bv939uuso551n6pcf1gngpv9ql0.apps.googleusercontent.com" -VITE_BASE_URL=https://cafe-deux.acom.ucar.edu/api/api \ No newline at end of file +VITE_BASE_URL=https://cafe-deux.acom.ucar.edu/api/api +VITE_AUTH_URL=https://cafe-deux.acom.ucar.edu/api/auth diff --git a/frontend/.env.test b/frontend/.env.test index 9324fc6d..cf77bc4b 100644 --- a/frontend/.env.test +++ b/frontend/.env.test @@ -1,2 +1,2 @@ -VITE_REACT_APP_OAUTH_CLIENT_ID="257697450661-a69l9bv939uuso551n6pcf1gngpv9ql0.apps.googleusercontent.com" -VITE_BASE_URL=http://localhost:8080/api \ No newline at end of file +VITE_BASE_URL=http://localhost:8080/api +VITE_AUTH_URL=http://localhost:8080/auth diff --git a/frontend/src/API/API_GetMethods.tsx b/frontend/src/API/API_GetMethods.tsx index e9830f85..582cf588 100644 --- a/frontend/src/API/API_GetMethods.tsx +++ b/frontend/src/API/API_GetMethods.tsx @@ -11,8 +11,9 @@ import { ReactionSpeciesDto, InitialConditionSpecies, Property, + UserClaims, } from "./API_Interfaces"; -import { BASE_URL } from "./API_config"; +import { AUTH_URL, BASE_URL } from "./API_config"; // Get all families export async function getFamilies(): Promise { @@ -263,6 +264,25 @@ export async function getUserById(id: string): Promise { } } +/** + * Gets the currently logged in user + */ +export async function getGoogleAuthUser(): Promise { + return axios.get( + `${AUTH_URL}/google/whoami`, + { + maxRedirects: 0, + withCredentials: true, + }) + .then((response) => { + return response.data; + }) + .catch((error: any) => { + console.error(`Error fetching current user: ${error}`); + return null; + }); +} + export async function getPropertyById(id: string): Promise { try { const response = await axios.get( diff --git a/frontend/src/API/API_Interfaces.tsx b/frontend/src/API/API_Interfaces.tsx index 5626bfa7..78ffbb28 100644 --- a/frontend/src/API/API_Interfaces.tsx +++ b/frontend/src/API/API_Interfaces.tsx @@ -76,6 +76,11 @@ export interface User { created_date?: string; } +export interface UserClaims { + nameId?: string | null; + email?: string | null; +} + export interface UserMechanism { id?: string; user_id: string; diff --git a/frontend/src/API/API_config.tsx b/frontend/src/API/API_config.tsx index f28e8ba3..77e281ed 100644 --- a/frontend/src/API/API_config.tsx +++ b/frontend/src/API/API_config.tsx @@ -1 +1,2 @@ export const BASE_URL = import.meta.env.VITE_BASE_URL; +export const AUTH_URL = import.meta.env.VITE_AUTH_URL; diff --git a/frontend/src/components/NavDropDown.tsx b/frontend/src/components/NavDropDown.tsx index 8a325573..cd10dd1c 100644 --- a/frontend/src/components/NavDropDown.tsx +++ b/frontend/src/components/NavDropDown.tsx @@ -5,6 +5,7 @@ import ListItem from "@mui/material/ListItem"; import ListItemText from "@mui/material/ListItemText"; import ListItemButton from "@mui/material/ListItemButton"; import { useAuth } from "../pages/AuthContext"; // Import useAuth to get the user data +import { AUTH_URL } from "../API/API_config"; const NavDropDown = () => { const navigate = useNavigate(); @@ -16,7 +17,7 @@ const NavDropDown = () => { const goFamily = () => navigate("/FamilyPage"); const goLogOut = () => { setUser(null); - navigate("/"); + window.location.href = `${AUTH_URL}/google/logout`; }; const goRoles = () => navigate("/Roles"); // Add navigation to Roles page diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a70c2688..81f5d559 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { GoogleOAuthProvider } from "@react-oauth/google"; import App from "./pages/App"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; @@ -8,17 +7,11 @@ import "./index.css"; const rootElement = document.getElementById("root"); const root = createRoot(rootElement!); -const url = import.meta.env.VITE_REACT_APP_OAUTH_CLIENT_ID; root.render( - - - - - + + + , ); diff --git a/frontend/src/pages/AuthContext.tsx b/frontend/src/pages/AuthContext.tsx index 3a73c197..f3c73c5e 100644 --- a/frontend/src/pages/AuthContext.tsx +++ b/frontend/src/pages/AuthContext.tsx @@ -3,9 +3,10 @@ import { useState, useContext, ReactNode, - useEffect, + useLayoutEffect, } from "react"; import { User } from "../API/API_Interfaces"; +import { getGoogleAuthUser, getUserByEmail } from "../API/API_GetMethods"; // Define the shape of the AuthContext interface AuthContextProps { @@ -24,14 +25,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { return storedUser ? JSON.parse(storedUser) : null; }); - useEffect(() => { - // Store user in localStorage whenever it changes - if (user) { - localStorage.setItem("user", JSON.stringify(user)); - } else { - localStorage.removeItem("user"); + useLayoutEffect(() => { + const getUser = async () => { + if (!user) { + const authInfo = await getGoogleAuthUser(); + if (authInfo?.email) { + setUser(await getUserByEmail(authInfo?.email)); + } + } } - }, [user]); + + getUser(); + }, []); return ( diff --git a/frontend/src/pages/logIn.tsx b/frontend/src/pages/logIn.tsx index 9996c4a9..1b1754fb 100644 --- a/frontend/src/pages/logIn.tsx +++ b/frontend/src/pages/logIn.tsx @@ -1,8 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { googleLogout, useGoogleLogin } from "@react-oauth/google"; -import axios from "axios"; import { useAuth } from "../pages/AuthContext"; // Import the AuthContext - import "../styles/logIn.css"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; @@ -12,92 +9,22 @@ import GoogleIcon from "@mui/icons-material/Google"; import NoAccountsIcon from "@mui/icons-material/NoAccounts"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import { Footer, Header } from "../components/HeaderFooter"; -import { getUserByEmail } from "../API/API_GetMethods"; -import { createUser } from "../API/API_CreateMethods"; +import { AUTH_URL } from "../API/API_config"; -interface AuthUser { - access_token: string; -} const LogIn: React.FC = () => { const { setUser, user } = useAuth(); // Get setUser from AuthContext const navigate = useNavigate(); - const setUserInformation = async (user: AuthUser) => { - if (user) { - // Fetch the user profile using the access token - axios - .get( - `https://www.googleapis.com/oauth2/v1/userinfo?access_token=${user.access_token}`, - { - headers: { - Authorization: `Bearer ${user.access_token}`, - Accept: "application/json", - }, - }, - ) - .then(async (res) => { - const profileData = res.data; - - if (!profileData.email) { - console.error("Profile data does not contain an email."); - alert("Profile data is missing email information."); - return; - } - - // Check if the user already exists in the database - try { - const existingUser = await getUserByEmail(profileData.email); - if (existingUser) { - const contextUser = { - id: existingUser.id, - username: existingUser.username, - email: existingUser.email, - role: existingUser.role || "unverified", - }; - setUser(contextUser); - } else { - const newUser = { - username: profileData.name, - email: profileData.email, - role: "unverified", - }; - const createdUser = await createUser(newUser); - - const contextUser = { - id: createdUser.id, - username: createdUser.username, - email: createdUser.email, - role: createdUser.role || "unverified", - }; - - setUser(createdUser); - console.log("Context user ", contextUser); - } - } catch (error) { - console.error("Error checking or creating user:", error); - alert("Error checking or creating user" + error); - } - }) - .catch((error) => { - console.error("Error fetching user profile:", error); - alert("Error fetching profile"); - }); - } + const login = () => { + window.location.href = `${AUTH_URL}/google/login`; }; - const login = useGoogleLogin({ - onSuccess: (codeResponse) => { - setUserInformation(codeResponse).then(() => navigate("/LoggedIn")); - }, - onError: (error) => console.log("Login Failed:", error), - }); - // Log out function to log the user out of Google and set the profile array to null const continueAsGuest = () => { - googleLogout(); setUser(null); // Clear user from AuthContext on logout - navigate("/LoggedIn"); + const returnUrl = `${window.location.protocol}//${window.location.host}/loggedIn`; + window.location.href = encodeURI(`${AUTH_URL}/google/logout?returnUrl=${returnUrl}`); }; return (