params = new HashMap<>();
+ params.put("grant_type", "password");
+ params.put("client_id", CLIENT_ID);
+ params.put("username", username);
+ params.put("password", password);
+ params.put("client_secret", SECRET);
+
+
+ Response response = RestAssured.given()
+ .contentType(ContentType.URLENC)
+ .formParams(params)
+ .post(KEYCLOAK_URL);
+
+ if (response.getStatusCode() != 200) {
+ throw new RuntimeException("Failed to get token: " + response.getBody().asString());
+ }
+
+ return response.jsonPath().getString("access_token");
+ }
+}
\ No newline at end of file
diff --git a/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java b/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java
new file mode 100644
index 0000000..44359be
--- /dev/null
+++ b/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java
@@ -0,0 +1,11 @@
+package org.example.app.task.service;
+
+import io.quarkus.test.junit.QuarkusTestProfile;
+
+public class IntegrationTestProfile implements QuarkusTestProfile {
+
+ @Override
+ public String getConfigProfile() {
+ return "test";
+ }
+}
\ No newline at end of file
diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java
new file mode 100644
index 0000000..672950b
--- /dev/null
+++ b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java
@@ -0,0 +1,39 @@
+
+package org.example.app.task.service;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.not;
+
+import io.quarkus.test.junit.TestProfile;
+import org.example.app.task.resource.KeycloakTokenProvider;
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.restassured.http.ContentType;
+import io.restassured.response.Response;
+
+/**
+ * E2E black-box test of the To-Do service only via its public REST resource.
+ */
+@QuarkusIntegrationTest
+@TestMethodOrder(OrderAnnotation.class)
+@TestProfile(IntegrationTestProfile.class)
+@TestInstance(Lifecycle.PER_CLASS)
+class TaskServiceIT {
+
+ private Integer taskListId;
+
+ private Integer taskItemId;
+
+ private String token;
+
+ @BeforeAll
+ void getJwt() {
+ token = KeycloakTokenProvider.getAccessTokenWithAdmin();
+ }
+
+}
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 0621cb7..a96be22 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -35,6 +35,19 @@ services:
networks:
- quarkus
+ keycloak:
+ image: quay.io/keycloak/keycloak:26.1.4
+ ports:
+ - "8180:8080"
+ environment:
+ KC_BOOTSTRAP_ADMIN_USERNAME: admin
+ KC_BOOTSTRAP_ADMIN_PASSWORD: admin
+ volumes:
+ - ./keycloak/quarkus-realm.json:/opt/keycloak/data/import/realm-config.json # Mount realm config
+ command: -v start-dev --import-realm
+ networks:
+ - quarkus
+
ollama:
image: ollama/ollama:0.5.13
container_name: ollama
diff --git a/documentation/diagrams/keycloak-with-session.drawio b/documentation/diagrams/keycloak-with-session.drawio
new file mode 100644
index 0000000..8b4dbcc
--- /dev/null
+++ b/documentation/diagrams/keycloak-with-session.drawio
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/documentation/diagrams/keycloak-with-session.png b/documentation/diagrams/keycloak-with-session.png
new file mode 100644
index 0000000..9bdbea2
Binary files /dev/null and b/documentation/diagrams/keycloak-with-session.png differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0b2b42e..3e181e3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,12 +1,14 @@
import { Snackbar } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
-import { useContext } from "react";
+import { useContext, useEffect } from "react";
import { Route } from "wouter";
import CalendarView from "./components/calendar";
import Header from "./components/misc/header";
import Sidebar from "./components/misc/sidebar";
-import Todos from "./components/todos/todos";
import { MainContext } from "./provider/mainProvider";
+import Todos from "./components/todos/Todos";
+
+const COOKIE_EXPIRATION_TIME = 3600 * 1000; // 1 hour in milliseconds
function App() {
const {
@@ -17,6 +19,46 @@ function App() {
showCalendar,
} = useContext(MainContext)!;
+ // Function to generate a new session ID
+ const generateSessionId = () => {
+ return crypto.randomUUID();
+ };
+
+ // Function to set a session cookie
+ const setSessionCookie = () => {
+ const newSessionId = generateSessionId();
+ document.cookie = `SESSION_ID=${newSessionId}; path=/; max-age=3600; Secure; SameSite=None`;
+ console.log("New SESSION_ID set:", newSessionId);
+ };
+
+ // Function to get an existing cookie
+ const getCookie = (name: string) => {
+ const cookies = document.cookie.split("; ");
+ for (let cookie of cookies) {
+ const [key, value] = cookie.split("=");
+ if (key === name) {
+ return value;
+ }
+ }
+ return null;
+ };
+
+ // Runs once on app load
+ useEffect(() => {
+ // If SESSION_ID doesn't exist, create one
+ if (!getCookie("SESSION_ID")) {
+ setSessionCookie();
+ }
+
+ // Set an interval to renew the session cookie when it expires
+ const interval = setInterval(() => {
+ console.log("Refreshing session cookie...");
+ setSessionCookie();
+ }, COOKIE_EXPIRATION_TIME);
+
+ return () => clearInterval(interval); // Cleanup interval on unmount
+ }, []);
+
return (
diff --git a/frontend/src/provider/todoListProvider.tsx b/frontend/src/provider/todoListProvider.tsx
index 80b75e2..6691683 100644
--- a/frontend/src/provider/todoListProvider.tsx
+++ b/frontend/src/provider/todoListProvider.tsx
@@ -14,18 +14,25 @@ export const TodoListProvider = ({ children }: PropsI) => {
const [taskLists, setTaskLists] = useState([]);
useEffect(() => {
- fetch(`/api/task/lists`, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- })
- .then((response) => response.json())
- .then((json) => setTaskLists(json))
- .catch((error) => {
- console.error(error);
- setErrorAlert("List could not be loaded!");
- });
+ const fetchData = async () => {
+ try {
+ const response = await fetch(`/api/task/lists`, {
+ method: 'GET',
+ credentials: 'include',
+ });
+ if (response.status === 401) {
+ const data = await response.json();
+ window.location.href = data.redirectUrl; // Redirect to Keycloak
+ } else {
+ const json = await response.json();
+ setTaskLists(json);
+ }
+ } catch (error) {
+ console.error('Error fetching data:', error);
+ setErrorAlert("List could not be loaded!"); // Set error alert in case of failure
+ }
+ };
+ fetchData();
}, [setErrorAlert]);
function editTodoList(newTitle: string) {
@@ -41,8 +48,7 @@ export const TodoListProvider = ({ children }: PropsI) => {
fetch("/api/task/list", {
method: "POST",
headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
+ 'Content-Type': 'application/json', // Ensure that the correct content type is set
},
body: JSON.stringify(taskList),
})
@@ -70,8 +76,7 @@ export const TodoListProvider = ({ children }: PropsI) => {
fetch("/api/task/list", {
method: "POST",
headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
+ 'Content-Type': 'application/json', // Ensure that the correct content type is set
},
body: JSON.stringify(taskList), // body data type must match "Content-Type" header
})
@@ -93,7 +98,11 @@ export const TodoListProvider = ({ children }: PropsI) => {
fetch(`/api/task/list/${encodeURIComponent(id)}`, {
method: "DELETE",
})
- .then(() => {
+ .then((res) => {
+ if(res.status === 403){
+ setErrorAlert("List could not be deleted (No permissions)!");
+ return;
+ }
setTaskLists(taskLists.filter((taskList) => taskList.id !== id));
if (undefined !== listId && id === +listId) {
navigate("/");
diff --git a/frontend/src/provider/todoProvider.tsx b/frontend/src/provider/todoProvider.tsx
index ac03bc2..42caf5e 100644
--- a/frontend/src/provider/todoProvider.tsx
+++ b/frontend/src/provider/todoProvider.tsx
@@ -143,7 +143,11 @@ export const TodoProvider = ({ children }: PropsI) => {
fetch(`/api/task/item/${encodeURIComponent(id)}`, {
method: "DELETE",
})
- .then(() => {
+ .then((res) => {
+ if(res.status === 403){
+ setErrorAlert("Item could not be deleted (No permissions)!");
+ return;
+ }
setTodos(todos.filter((todo) => todo.id !== id));
setSuccessAlert("Item deleted!");
})
diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs
index 235ac62..1b4c116 100644
--- a/frontend/vite.config.mjs
+++ b/frontend/vite.config.mjs
@@ -13,7 +13,7 @@ export default defineConfig(() => {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
- rewrite: (path) => path.replace("/api", ""),
+ rewrite: (path) => path.replace("/api", "")
}
}
}
diff --git a/keycloak/quarkus-realm.json b/keycloak/quarkus-realm.json
new file mode 100644
index 0000000..85d69b6
--- /dev/null
+++ b/keycloak/quarkus-realm.json
@@ -0,0 +1,70 @@
+
+{
+ "realm": "quarkus",
+ "enabled": true,
+ "clients": [
+ {
+ "clientId": "backend-service",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "rootUrl": "http://localhost:8080",
+ "adminUrl": "http://localhost:8080",
+ "baseUrl": "http://localhost:8080",
+ "redirectUris": [
+ "http://localhost:8080/*"
+ ],
+ "publicClient": true,
+ "implicitFlowEnabled": true,
+ "webOrigins": [
+ "*"
+ ]
+ }
+ ],
+ "roles": {
+ "realm": [
+ {
+ "name": "admin",
+ "description": "Administrator role"
+ },
+ {
+ "name": "user",
+ "description": "User role"
+ }
+ ]
+ },
+ "users": [
+ {
+ "username": "alice",
+ "enabled": true,
+ "firstName": "Alice",
+ "lastName": "Smith",
+ "email": "alice@example.com",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "alice"
+ }
+ ],
+ "realmRoles": [
+ "admin",
+ "user"
+ ]
+ },
+ {
+ "username": "bob",
+ "enabled": true,
+ "firstName": "Bob",
+ "lastName": "Johnson",
+ "email": "bob@example.com",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "bob"
+ }
+ ],
+ "realmRoles": [
+ "user"
+ ]
+ }
+ ]
+}
\ No newline at end of file