Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ src/frontend/dist/
src/backend/main/resources/META-INF/resources/app/
.m2/
tmp/
.env
.env

# keycloak secrets
.env-keycloak
keycloak-realm.json
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Jesper Pedersen <jesper.pedersen@mnemosyne-systems.ai>
Ahmed Mordi <ahmed.m.hamada2003@gmail.com>
Hamza Azeem <hamzaalsherif9@gmail.com>
Shashank Singh <shashanksgh3@gmail.com>
Omar Goher <omargoher59@gmail.com>
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ roles to navigate and get their work done quicker.
## Features

- Support ticket system with 5 roles (User, Superuser, TAM, Support, Admin)
- Keycloak Single Sign-On (SSO) authentication
- Branding
- Public and private messages
- Search across ticket numbers and messages
Expand All @@ -36,6 +37,7 @@ roles to navigate and get their work done quicker.
- [Developer guide](https://github.com/mnemosyne-systems/billetsys/blob/main/doc/DEVELOPERS.md)
- [Build and run guide](https://github.com/mnemosyne-systems/billetsys/blob/main/doc/BUILDING.md)
- [Frontend guide](https://github.com/mnemosyne-systems/billetsys/blob/main/src/frontend/README.md)
- [Keycloak integration guide](https://github.com/mnemosyne-systems/billetsys/blob/main/contrib/keycloak/README.md)

See the [releases page](https://github.com/mnemosyne-systems/billetsys/releases)
for downloads and release notes.
Expand All @@ -45,6 +47,7 @@ for downloads and release notes.
**billetsys** is built with

- [Quarkus](https://quarkus.io/) on [Java 25](https://openjdk.org/) for the backend
- [Keycloak](https://www.keycloak.org/) for identity management and Single Sign-On (SSO)
- [PostgreSQL](https://www.postgresql.org) for storage
- [React](https://react.dev/) and [TypeScript](https://www.typescriptlang.org/)
on the frontend, bundled with [Vite](https://vitejs.dev/)
Expand Down Expand Up @@ -79,7 +82,15 @@ Copy the example environment file and fill in the secrets:
cp .env.example .env
```

Start the support services (CAP + Valkey) with compose:
Set up Keycloak environment and generate the realm file:
```sh
cd contrib/keycloak
cp .env-keycloak.example .env-keycloak
python generate_realm.py
cd ../..
```

Start the support services (CAP + Valkey + keycloak) with compose:

```sh
make platform
Expand Down
30 changes: 30 additions & 0 deletions contrib/keycloak/.env-keycloak.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
KEYCLOAK_REALM_NAME=billetsys
KEYCLOAK_SERVER_URL=http://localhost:8180

KEYCLOAK_ADMIN_USER=Admin
KEYCLOAK_ADMIN_EMAIL=admin@admin.com
KEYCLOAK_ADMIN_FIRSTNAME=admin
KEYCLOAK_ADMIN_LASTNAME=admin
KEYCLOAK_ADMIN_PASSWORD=admin

KEYCLOAK_ENABLE_REGISTRATION=true

# if enable reset password & verify email should configure SMTP keycloak mail
KEYCLOAK_ENABLE_VERIFY_EMAIL=true
KEYCLOAK_ENABLE_RESET_PASSWORD=true

KEYCLOAK_BACKEND_CLIENT_ID=billetsys-backend
KEYCLOAK_BACKEND_CLIENT_SECRET=123456
BACKEND_URL=http://localhost:8080

KEYCLOAK_FRONTEND_CLIENT_ID=billetsys-frontend
FRONTEND_URL=http://localhost:8080

KEYCLOAK_MAIL_HOST=smtp.example.com
KEYCLOAK_MAIL_PORT=587
KEYCLOAK_MAIL_FROM=example@example.com
KEYCLOAK_MAIL_FROM_NAME=example
KEYCLOAK_MAIL_USERNAME=example@example.com
KEYCLOAK_MAIL_PASSWORD=change-me
KEYCLOAK_MAIL_START_TLS=true
KEYCLOAK_MAIL_SSL=false
156 changes: 156 additions & 0 deletions contrib/keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Keycloak

## Authentication Flow

```
[ User Browser ]
|
| 1. Accesses Frontend App
v
[ React App (keycloak-js) ]
|
| 2. Check Authentication (check-sso)
+---> [ Not Authenticated ] ---> Redirect to Keycloak Login Screen
| (Registration / Reset allowed if enabled)
|
| 3. User Logged In
v
[ Keycloak Issues JWT Token ]
|
| 4. Frontend receives Access Token
| 5. Global fetch interceptor injects header on /api/*:
| Authorization: Bearer <keycloak.token>
v
[ Quarkus Backend (quarkus-oidc) ]
|
| 6. Validates JWT Signature & Claims
| 7. JIT Provisioning & Role Mapping via UserProvisioningService
v
[ PostgreSQL DB (users table) ]
```

### Detailed Flow Steps:

1. **Accessing the App**: The user accesses the React frontend application URL.
2. **SSO Check**: The React app initializes `keycloak-js` on load using `onLoad: "check-sso"`.
3. **Redirection to Keycloak**: If the user is not authenticated, visiting `/login` or protected areas triggers `keycloak.login()`, redirecting them to Keycloak. If user registration is enabled in Keycloak realm settings, users can also register a new account on this page.
4. **Token Issuance**: After successful authentication, Keycloak redirects the user back to the React app with a valid JWT access token.
5. **Automatic Token Attachment**: In `AuthProvider.tsx`, a global `window.fetch` interceptor automatically appends the Bearer token to all local API requests:
```typescript
headers.set("Authorization", `Bearer ${keycloak.token}`);
```
6. **Background Token Refresh**: A 15-second interval loop continuously runs `keycloak.updateToken(30)` to refresh tokens before expiration without disturbing user experience.

---

## User Synchronization & Just-In-Time (JIT) Provisioning

### The Challenge
When a user registers or logs in via Keycloak, their account exists in Keycloak's database, but does not yet exist in the application's PostgreSQL `users` table.

### The Solution: Just-In-Time (JIT) Provisioning

#### **In Frontend**
On each session load, the frontend fetches application state and session data:
```typescript
GET /api/app/session
Headers: {
Authorization: "Bearer " + keycloak.token
}
```

#### **In Backend**
1. **Token Extraction**: Quarkus `quarkus-oidc` validates the incoming Bearer JWT token.
2. **Context Resolution (`CurrentUser.java`)**: The JAX-RS endpoint invokes `@Inject CurrentUser currentUser`.
3. **JIT Synchronization (`UserProvisioningService.java`)**:
* Reads JWT claims: `sub` (`keycloakId`), `email`, `preferred_username`, `name` (`fullName`), and `realm_access.roles`.
* Searches the database for an existing user matching `keycloakId` (or `email`).
* **If the user exists**: Updates/syncs their profile fields and maps Keycloak realm roles (`admin`, `support`, `tam`, `superuser`, `user`) to internal application types (`User.type`).
* **If the user does NOT exist**: Automatically provisions and persists a new `User` record in PostgreSQL.
4. **Request Caching**: The `@RequestScoped` `CurrentUser` bean caches the resolved user for the lifetime of the request, eliminating duplicate database lookups.

---

## Configuration

Authentication behavior can be customized via Keycloak realm settings in `contrib/keycloak/.env-keycloak`:

### 1. Realm Feature Options
* **User Registration**: `KEYCLOAK_ENABLE_REGISTRATION=true` (Enables or disables self-registration on Keycloak's login page).
* **Email Verification**: `KEYCLOAK_ENABLE_VERIFY_EMAIL=true`.
* **Password Reset**: `KEYCLOAK_ENABLE_RESET_PASSWORD=true`.

> [!NOTE]
> Enabling **Email Verification** or **Password Reset** requires configuring valid SMTP server credentials in `.env-keycloak`:
> ```env
> KEYCLOAK_MAIL_HOST=smtp.example.com
> KEYCLOAK_MAIL_PORT=587
> KEYCLOAK_MAIL_FROM=no-reply@example.com
> KEYCLOAK_MAIL_USERNAME=example@example.com
> KEYCLOAK_MAIL_PASSWORD="your-password"
> KEYCLOAK_MAIL_START_TLS=true
> ```

### 2. Backend OIDC Properties (`src/backend/main/resources/application.properties`)
```properties
quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://localhost:8180}/realms/${KEYCLOAK_REALM_NAME:billetsys}
quarkus.oidc.client-id=${KEYCLOAK_BACKEND_CLIENT_ID:billetsys-backend}
quarkus.oidc.credentials.secret=${KEYCLOAK_BACKEND_CLIENT_SECRET:123456}
quarkus.oidc.application-type=service
quarkus.oidc.roles.role-claim-path=realm_access/roles
```

### 3. Frontend Client Configuration (`src/frontend/src/auth/keycloak.ts`)
```typescript
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL || "http://localhost:8180",
realm: import.meta.env.VITE_KEYCLOAK_REALM || "billetsys",
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || "billetsys-frontend",
});
```

---

## Running the keycloak

### 1. Set up Environment & Generate Realm
```bash
cd contrib/keycloak
cp .env-keycloak.example .env-keycloak
python generate_realm.py
cd ../..
```

### 2. Start Services via Docker Compose
```bash
docker compose up -d keycloak
```

Access keycloak at `http://localhost:8180`.

---

## Helper Scripts & Testing

```bash
# 1. Create a Keycloak user
./contrib/keycloak/create-user.sh <username> <email> <password> <role> <firstName> <lastName>

# 2. Get a JWT token
./contrib/keycloak/login.sh <username> <password>

# 3. Call REST API endpoint with Bearer token
TOKEN=$(./contrib/keycloak/login.sh user1 user1)
curl -s "http://localhost:8080/api/user/tickets" -H "Authorization: Bearer $TOKEN" | jq .
```

### Role → Endpoint Mapping Reference

| Role | Access Level | Example Protected Endpoint |
| :--- | :--- | :--- |
| **`admin`** | Administrator | `GET /api/admin/users`, `GET /api/companies` |
| **`support`** | Support Engineer | `GET /api/support/tickets`, `GET /api/support/users` |
| **`superuser`** | Superuser | `GET /api/superuser/tickets`, `GET /api/superuser/users` |
| **`tam`** | Technical Account Manager | `GET /api/user/tickets`, `GET /api/tam/users` |
| **`user`** | Standard User | `GET /api/user/tickets`, `GET /api/user/externals` |
| **`any / none`** | Public | `GET /api/app/session`, `GET /health` |
70 changes: 70 additions & 0 deletions contrib/keycloak/create-user.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Usage:
# ./create-user.sh <username> <email> <password> <role> <firstName> <lastName>
#
# Example:
# ./create-user.sh user1 user1@mnemosyne-systems.ai User@123 user John Doe

USERNAME=$1
EMAIL=$2
PASSWORD=$3
ROLE=$4
FIRST_NAME=$5
LAST_NAME=$6

# get keycloak admin token
KEYCLOAK_TOKEN=$(curl -s -X POST \
"http://localhost:8180/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=admin" | jq -r .access_token)


# create user
curl -s -X POST \
"http://localhost:8180/admin/realms/billetsys/users" \
-H "Authorization: Bearer $KEYCLOAK_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"username\": \"$USERNAME\",
\"email\": \"$EMAIL\",
\"firstName\": \"$FIRST_NAME\",
\"lastName\": \"$LAST_NAME\",
\"enabled\": true,
\"emailVerified\": true,
\"requiredActions\": [],
\"credentials\": [{
\"type\": \"password\",
\"value\": \"$PASSWORD\",
\"temporary\": false
}]
}"


# get user id
USER_ID=$(curl -s \
"http://localhost:8180/admin/realms/billetsys/users?username=$USERNAME" \
-H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.[0].id')

# get role id
ROLE_ID=$(curl -s \
"http://localhost:8180/admin/realms/billetsys/roles/$ROLE" \
-H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.id')

# assign role
curl -s -X POST \
"http://localhost:8180/admin/realms/billetsys/users/$USER_ID/role-mappings/realm" \
-H "Authorization: Bearer $KEYCLOAK_TOKEN" \
-H "Content-Type: application/json" \
-d "[{\"id\": \"$ROLE_ID\", \"name\": \"$ROLE\"}]"


echo ""
echo "Created user:"
echo " Username: $USERNAME"
echo " Email: $EMAIL"
echo " First Name: $FIRST_NAME"
echo " Last Name: $LAST_NAME"
echo " Role: $ROLE"
echo " User ID: $USER_ID"
59 changes: 59 additions & 0 deletions contrib/keycloak/generate_realm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3

from pathlib import Path
import re

BASE_DIR = Path(__file__).parent

ENV_FILE = BASE_DIR / ".env-keycloak"
TEMPLATE_FILE = BASE_DIR / "keycloak-realm.template.json"
OUTPUT_FILE = BASE_DIR / "keycloak-realm.json"


def load_env_file(env_path):
values = {}

for line in Path(env_path).read_text().splitlines():
line = line.strip()

# Skip comments and empty lines
if not line or line.startswith("#"):
continue

key, value = line.split("=", 1)

# Remove surrounding quotes
value = value.strip().strip('"').strip("'")

values[key.strip()] = value

return values


def replace_placeholders(template, values):
pattern = re.compile(r"\$\{([A-Z0-9_]+)\}")

def replacer(match):
key = match.group(1)
return values.get(key, match.group(0))

return pattern.sub(replacer, template)


def main():
env_values = load_env_file(ENV_FILE)

template_content = Path(TEMPLATE_FILE).read_text()

output_content = replace_placeholders(
template_content,
env_values
)

Path(OUTPUT_FILE).write_text(output_content)

print(f"Generated {OUTPUT_FILE} successfully.")


if __name__ == "__main__":
main()
Loading
Loading