diff --git a/Dockerfile b/Dockerfile
index 9264aaf..9e8b2ec 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,29 @@
-FROM python:3.11-slim AS python-builder
+# Keycloak ExApp for Nextcloud
+# Wraps Keycloak identity management with AppAPI integration
+#
+# Build: docker build -t ghcr.io/conductionnl/keycloak-nextcloud:latest .
-WORKDIR /build
-COPY requirements.txt .
-RUN pip install --no-cache-dir --prefix=/python-packages -r requirements.txt
-
-
-FROM quay.io/keycloak/keycloak:26.5.4
+# Stage 1: Get Keycloak distribution from official image
+FROM quay.io/keycloak/keycloak:26.5.4 AS keycloak
-USER root
+# Stage 2: Runtime with Python + Java + Keycloak
+FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
-# Install Python runtime (UBI9-based image)
-RUN microdnf install -y python3.11 python3.11-pip && microdnf clean all
+# Install Java (same JDK 21 that Keycloak expects) and Python
+RUN microdnf install -y \
+ java-21-openjdk-headless \
+ python3.11 \
+ python3.11-pip \
+ && microdnf clean all \
+ && ln -sf /usr/bin/python3.11 /usr/bin/python3
-# Copy pre-built Python packages from builder
-COPY --from=python-builder /python-packages/lib/python3.11/site-packages/ /usr/lib/python3.11/site-packages/
-COPY --from=python-builder /python-packages/bin/ /usr/local/bin/
+# Copy Keycloak from the official image
+COPY --from=keycloak /opt/keycloak /opt/keycloak
-# Create app directory
+# Install Python packages
WORKDIR /app
+COPY requirements.txt .
+RUN python3 -m pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY ex_app/ ex_app/
@@ -28,4 +34,8 @@ RUN chmod +x entrypoint.sh
# Persistent data directory
VOLUME /data
+# Keycloak environment
+ENV KC_HOME="/opt/keycloak"
+ENV PATH="/opt/keycloak/bin:${PATH}"
+
ENTRYPOINT ["./entrypoint.sh"]
diff --git a/README.md b/README.md
index 88bd075..25b185e 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,52 @@
-# Keycloak Nextcloud ExApp
+
+
+
-Keycloak identity and access management as a Nextcloud External Application (ExApp).
+Keycloak Nextcloud ExApp
+
+
+ Keycloak identity and access management as a Nextcloud External Application -- shared OIDC provider for Common Ground ExApps
+
+
+
+
+
+
+
+---
## Overview
-This ExApp wraps [Keycloak](https://www.keycloak.org/) as a Nextcloud sidecar, providing:
+This ExApp wraps [Keycloak](https://www.keycloak.org/) as a Nextcloud sidecar, providing a centralized OIDC identity provider for all Common Ground ExApps. It automatically syncs Nextcloud users to Keycloak and provides a token API for server-side authentication, enabling seamless SSO without browser-side OIDC redirects.
+
+### Key Features
+
+- **Automatic user sync** -- Nextcloud users are synced to Keycloak on startup, on-demand, and on user changes
+- **Token API** -- Consumer ExApps (OpenTalk, OpenZaak, Valtimo) request Keycloak tokens server-side via a shared secret
+- **Realm management** -- Auto-creates the `commonground` realm and configures OIDC clients
+- **Direct access grant** -- Gets tokens for users without browser interaction
+- **Admin console** -- Keycloak admin UI accessible from Nextcloud top menu
+
+### Consumer ExApps
-- Single Sign-On (SSO) via OpenID Connect
-- User federation and identity brokering
-- Fine-grained authorization
-- Admin console accessible from Nextcloud
+The Keycloak ExApp serves as the shared identity provider for:
-Serves as the shared OIDC identity provider for Common Ground ExApps (OpenZaak, OpenKlant, OpenTalk, Valtimo).
+| ExApp | Usage |
+|-------|-------|
+| [OpenTalk](https://github.com/ConductionNL/opentalk) | Video conferencing SSO -- iframe-embedded with pre-loaded tokens |
+| [OpenZaak](https://github.com/ConductionNL/openzaak) | ZGW case management authentication |
+| [Valtimo](https://github.com/ConductionNL/valtimo) | BPM and case management SSO |
+| [OpenKlant](https://github.com/ConductionNL/openklant) | Customer interaction registry auth |
## Requirements
-- Nextcloud 30+
-- AppAPI app installed and configured
-- PostgreSQL database
+| Dependency | Version | Notes |
+|-----------|---------|-------|
+| Nextcloud | 30+ | |
+| [AppAPI](https://apps.nextcloud.com/apps/app_api) | latest | Must be installed and configured with a deploy daemon |
+| Docker | -- | Required for ExApp container deployment |
+| PostgreSQL | 14+ | Keycloak database backend |
+| Java 21 | -- | Bundled in the Docker image |
## Quick Start
@@ -27,21 +56,189 @@ The ExApp is included in the OpenRegister docker-compose setup:
docker compose -f openregister/docker-compose.yml --profile commonground up -d
```
-Default admin credentials: `admin` / `admin`
+Default Keycloak admin credentials: `admin` / `admin`
+
+## Configuration
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `KEYCLOAK_REALM` | Realm name for user sync and token issuance | `commonground` |
+| `KC_BOOTSTRAP_ADMIN_USERNAME` | Keycloak admin username | `admin` |
+| `KC_BOOTSTRAP_ADMIN_PASSWORD` | Keycloak admin password | `admin` |
+| `KC_HOSTNAME` | Hostname in issued tokens (must match consumer expectations) | -- |
+| `KEYCLOAK_API_SECRET` | Shared secret for ExApp-to-ExApp token API calls | `keycloak-exapp-internal-secret` |
+
+## Architecture
+
+### Component Overview
+
+The Keycloak ExApp container bundles two processes and connects to a shared PostgreSQL database:
+
+| Component | Image / Technology | Role | Port |
+|-----------|-------------------|------|------|
+| **FastAPI Wrapper** | Python / nc_py_api | AppAPI lifecycle, user sync, token API, Keycloak proxy | 23002 (ExApp) |
+| **Keycloak Server** | `quay.io/keycloak/keycloak:26.5` | OIDC identity provider, realm/client management, admin console | 8080 (internal), 8180 (host) |
+| **PostgreSQL** | Shared with Nextcloud | Persistent storage for realms, users, clients, sessions | 5432 |
+
+### Infrastructure Diagram
+
+```mermaid
+graph TB
+ subgraph "Nextcloud Server"
+ NC["Nextcloud + AppAPI"]
+ end
+
+ subgraph "Consumer ExApps"
+ OT["OpenTalk ExApp
Video conferencing"]
+ OZ["OpenZaak ExApp
Case management"]
+ VL["Valtimo ExApp
BPM platform"]
+ OK["OpenKlant ExApp
Customer registry"]
+ end
+
+ subgraph "Keycloak ExApp Container"
+ FW["FastAPI Wrapper
Port 23002
User sync, token API,
AppAPI lifecycle"]
+ KS["Keycloak Server
Port 8080
OIDC provider,
admin console"]
+ end
+
+ PG["PostgreSQL
Port 5432
Realms, users,
clients, sessions"]
+
+ NC -->|"AUTHORIZATION-APP-API
User management events"| FW
+ OT -->|"POST /api/token
X-API-SECRET + X-NC-USER-ID"| FW
+ OZ -->|"POST /api/token"| FW
+ VL -->|"POST /api/token"| FW
+ OK -->|"POST /api/token"| FW
+ FW -->|"Admin REST API
User CRUD, realm mgmt"| KS
+ FW -->|"Direct access grant
password → tokens"| KS
+ KS -->|"Identity DB"| PG
+ FW -->|"OCS API
List/get NC users"| NC
+
+ style FW fill:#c63,stroke:#333,color:#fff
+ style KS fill:#e74,stroke:#333,color:#fff
+ style PG fill:#36a,stroke:#333,color:#fff
+ style OT fill:#369,stroke:#333,color:#fff
+ style OZ fill:#369,stroke:#333,color:#fff
+ style VL fill:#369,stroke:#333,color:#fff
+ style OK fill:#369,stroke:#333,color:#fff
+```
+
+### Component Details
+
+#### FastAPI Wrapper (`ex_app/lib/main.py`)
+
+The Python wrapper manages the Keycloak process, syncs users, and exposes the token API for consumer ExApps.
+
+| Endpoint | Method | Auth | Purpose |
+|----------|--------|------|---------|
+| `/heartbeat` | GET | None | AppAPI health check -- probes Keycloak management port |
+| `/init` | POST | AppAPI | Starts Keycloak, creates realm, syncs all Nextcloud users |
+| `/enabled` | PUT | AppAPI | Starts or stops the Keycloak process |
+| `/api/token` | POST | Shared secret / AppAPI | Gets a Keycloak token for a Nextcloud user via direct access grant |
+| `/api/sync-user` | POST | Shared secret / AppAPI | Syncs a single Nextcloud user to Keycloak |
+| `/api/sync-all` | POST | Shared secret / AppAPI | Syncs all Nextcloud users to Keycloak |
+| `/api/delete-user` | POST | Shared secret / AppAPI | Removes a user from Keycloak |
+| `/*` | ALL | AppAPI | Proxied to Keycloak (admin console, well-known endpoints) |
+
+#### Keycloak Server
+
+[Keycloak](https://www.keycloak.org/) (v26.5) runs as a child process inside the container. It provides:
+
+- **OIDC / OAuth2 provider** -- Issues JWT access tokens, refresh tokens, and ID tokens
+- **Realm management** -- The `commonground` realm is auto-created on first start
+- **Client registration** -- OIDC clients for each consumer ExApp (e.g., `opentalk`, `opentalk-controller`)
+- **Direct access grant** -- Allows the wrapper to get tokens for users with known credentials (no browser needed)
+- **Admin console** -- Full Keycloak admin UI accessible via the Nextcloud proxy
+- **`KC_HOSTNAME`** -- Controls the `iss` claim in tokens; must match what consumers expect (e.g., `http://localhost:8180`)
+
+#### PostgreSQL
+
+Shared database with Nextcloud. Keycloak uses its own schema (`keycloak` database) for:
+- Realm and client configuration
+- User accounts (synced from Nextcloud)
+- Active sessions and tokens
+- Audit events
+
+### Token API Flow
+
+Consumer ExApps call the `/api/token` endpoint to get Keycloak tokens for Nextcloud users without any browser interaction:
+
+```mermaid
+sequenceDiagram
+ participant C as Consumer ExApp
(e.g. OpenTalk)
+ participant FW as FastAPI Wrapper
+ participant KS as Keycloak Server
+ participant PW as Password Store
(in-memory)
+
+ C->>FW: POST /api/token
X-API-SECRET:
X-NC-USER-ID: admin
?client_id=opentalk
+
+ FW->>PW: Lookup password for "admin"
+
+ alt Password found
+ FW->>KS: POST /realms/commonground/protocol/openid-connect/token
grant_type=password&username=admin&password=
+ else Password not found (first request)
+ FW->>KS: Reset user password via Admin API
+ KS-->>FW: OK
+ FW->>PW: Store new password
+ FW->>KS: POST /token (grant_type=password)
+ end
+
+ alt Token success
+ KS-->>FW: {access_token, refresh_token, id_token, expires_in}
+ FW-->>C: 200 {access_token, refresh_token, id_token, expires_in}
+ else 401 (stale password)
+ FW->>KS: Reset password + retry
+ KS-->>FW: tokens
+ FW-->>C: 200 tokens
+ end
+```
+
+### User Sync
+
+Users are synced from Nextcloud to Keycloak at three trigger points:
+
+1. **On init** -- All Nextcloud users are synced when the ExApp starts
+2. **On demand** -- When a token is requested for a user that doesn't exist in Keycloak yet
+3. **Via API** -- `POST /api/sync-user` or `POST /api/sync-all` for manual sync
+
+For each user, the sync process:
+- Fetches user details from Nextcloud's OCS provisioning API
+- Creates or updates the user in Keycloak's `commonground` realm
+- Sets `firstName`, `lastName`, `email` from the Nextcloud profile
+- Generates a random password and stores it in memory
+- Enables the `direct access grant` on OIDC clients so tokens can be obtained server-side
+
+### Authentication
+
+The `/api/*` endpoints are excluded from Nextcloud's AppAPI middleware (`disable_for=["api/*"]`) and use two auth methods:
+
+1. **Shared secret** (`X-API-SECRET` header) -- For direct ExApp-to-ExApp container calls. The secret is configured via the `KEYCLOAK_API_SECRET` environment variable and must match across all consumer ExApps.
+2. **AppAPI auth** (`authorization-app-api` header) -- For requests proxied through Nextcloud. The user ID is decoded from the base64-encoded `userId:appSecret` value.
## Development
```bash
# Build Docker image
-make build
-
-# Run locally
-make run
+docker build -t ghcr.io/conductionnl/keycloak-nextcloud:latest .
-# Code quality
-make check-strict
+# Copy changes to running container
+docker cp ex_app/lib/main.py openregister-exapp-keycloak:/app/ex_app/lib/main.py
+docker restart openregister-exapp-keycloak
```
+## Links
+
+| Resource | URL |
+|----------|-----|
+| Keycloak | [keycloak.org](https://www.keycloak.org/) |
+| This ExApp (GitHub) | [ConductionNL/keycloak-nextcloud](https://github.com/ConductionNL/keycloak-nextcloud) |
+| OpenTalk ExApp | [ConductionNL/opentalk](https://github.com/ConductionNL/opentalk) |
+| Nextcloud AppAPI | [GitHub](https://github.com/nextcloud/app_api) / [Docs](https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/) |
+
## License
-EUPL-1.2
+EUPL-1.2 -- See [LICENSE](LICENSE) for details.
+
+This license applies to the **Nextcloud ExApp wrapper only**. Keycloak is licensed under the [Apache License 2.0](https://github.com/keycloak/keycloak/blob/main/LICENSE.txt).
+
+## Authors
+
+Built by [Conduction B.V.](https://conduction.nl) -- open-source software for Dutch government and public sector organizations.
diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py
index 62fef16..4c9113c 100644
--- a/ex_app/lib/main.py
+++ b/ex_app/lib/main.py
@@ -3,6 +3,7 @@
import asyncio
import logging
import os
+import secrets
import subprocess
import threading
import typing
@@ -21,7 +22,7 @@
from nc_py_api.ex_app.integration_fastapi import AppAPIAuthMiddleware
-# ── Logging ─────────────────────────────────────────────────────────
+# -- Logging -----------------------------------------------------------------
logging.basicConfig(
level=logging.WARNING,
format="[%(funcName)s]: %(message)s",
@@ -31,13 +32,17 @@
LOGGER.setLevel(logging.DEBUG)
-# ── Configuration ───────────────────────────────────────────────────
+# -- Configuration -----------------------------------------------------------
KEYCLOAK_PORT = 8080
KEYCLOAK_MGMT_PORT = 9000
KEYCLOAK_URL = f"http://localhost:{KEYCLOAK_PORT}"
KEYCLOAK_MGMT_URL = f"http://localhost:{KEYCLOAK_MGMT_PORT}"
KEYCLOAK_PROCESS = None
+KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "commonground")
+KEYCLOAK_ADMIN_USER = os.environ.get("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
+KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
+
# Detect HaRP mode and set proxy prefix accordingly
APP_ID = os.environ.get("APP_ID", "keycloak")
HARP_ENABLED = bool(os.environ.get("HP_SHARED_KEY"))
@@ -46,8 +51,16 @@
else:
PROXY_PREFIX = f"/index.php/apps/app_api/proxy/{APP_ID}"
+# In-memory store for Keycloak passwords (keyed by NC user ID).
+# On restart, users are re-synced and new passwords are generated.
+_USER_PASSWORDS: dict[str, str] = {}
+
+# Keycloak admin token cache
+_ADMIN_TOKEN: str = ""
+_ADMIN_TOKEN_EXPIRES: float = 0
+
-# ── Keycloak Process Management ──────────────────────────────────────
+# -- Keycloak Process Management ---------------------------------------------
def start_keycloak():
"""Start the Keycloak subprocess."""
global KEYCLOAK_PROCESS
@@ -109,9 +122,323 @@ async def wait_for_keycloak(timeout: int = 120) -> bool:
return False
-# ── Path Rewriting ─────────────────────────────────────────────────
-# Keycloak's admin console uses absolute paths that need rewriting
-# when accessed through the ExApp proxy prefix.
+# -- Keycloak Admin API Client -----------------------------------------------
+async def get_admin_token() -> str:
+ """Get a Keycloak admin access token, refreshing if expired."""
+ import time
+
+ global _ADMIN_TOKEN, _ADMIN_TOKEN_EXPIRES
+
+ if _ADMIN_TOKEN and time.time() < _ADMIN_TOKEN_EXPIRES - 10:
+ return _ADMIN_TOKEN
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": KEYCLOAK_ADMIN_USER,
+ "password": KEYCLOAK_ADMIN_PASSWORD,
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ _ADMIN_TOKEN = data["access_token"]
+ _ADMIN_TOKEN_EXPIRES = time.time() + data.get("expires_in", 300)
+ return _ADMIN_TOKEN
+
+
+async def ensure_realm_exists() -> None:
+ """Ensure the target realm exists in Keycloak."""
+ token = await get_admin_token()
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ LOGGER.info("Realm '%s' already exists", KEYCLOAK_REALM)
+ return
+
+ LOGGER.info("Creating realm '%s'", KEYCLOAK_REALM)
+ resp = await client.post(
+ f"{KEYCLOAK_URL}/admin/realms",
+ headers={"Authorization": f"Bearer {token}"},
+ json={
+ "realm": KEYCLOAK_REALM,
+ "enabled": True,
+ "registrationAllowed": False,
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ LOGGER.info("Realm '%s' created", KEYCLOAK_REALM)
+
+
+async def get_keycloak_user(username: str) -> dict | None:
+ """Look up a user in Keycloak by username."""
+ token = await get_admin_token()
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/users",
+ headers={"Authorization": f"Bearer {token}"},
+ params={"username": username, "exact": "true"},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ users = resp.json()
+ return users[0] if users else None
+
+
+async def create_keycloak_user(
+ username: str,
+ email: str = "",
+ first_name: str = "",
+ last_name: str = "",
+) -> str:
+ """Create a user in Keycloak and return their generated password."""
+ password = secrets.token_urlsafe(32)
+ token = await get_admin_token()
+
+ user_payload = {
+ "username": username,
+ "enabled": True,
+ "emailVerified": True,
+ "credentials": [
+ {
+ "type": "password",
+ "value": password,
+ "temporary": False,
+ }
+ ],
+ }
+ if email:
+ user_payload["email"] = email
+ if first_name:
+ user_payload["firstName"] = first_name
+ if last_name:
+ user_payload["lastName"] = last_name
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/users",
+ headers={"Authorization": f"Bearer {token}"},
+ json=user_payload,
+ timeout=10,
+ )
+ if resp.status_code == 409:
+ # User already exists — reset their password instead
+ return await reset_keycloak_user_password(username)
+ resp.raise_for_status()
+
+ _USER_PASSWORDS[username] = password
+ LOGGER.info("Created Keycloak user: %s", username)
+ return password
+
+
+async def reset_keycloak_user_password(username: str) -> str:
+ """Reset a Keycloak user's password and return the new password."""
+ kc_user = await get_keycloak_user(username)
+ if not kc_user:
+ return await create_keycloak_user(username)
+
+ password = secrets.token_urlsafe(32)
+ token = await get_admin_token()
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/users/{kc_user['id']}/reset-password",
+ headers={"Authorization": f"Bearer {token}"},
+ json={
+ "type": "password",
+ "value": password,
+ "temporary": False,
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+
+ _USER_PASSWORDS[username] = password
+ LOGGER.info("Reset password for Keycloak user: %s", username)
+ return password
+
+
+async def update_keycloak_user(
+ username: str,
+ email: str = "",
+ first_name: str = "",
+ last_name: str = "",
+) -> None:
+ """Update an existing Keycloak user's profile."""
+ kc_user = await get_keycloak_user(username)
+ if not kc_user:
+ await create_keycloak_user(username, email, first_name, last_name)
+ return
+
+ token = await get_admin_token()
+ update_payload: dict[str, str] = {}
+ if email:
+ update_payload["email"] = email
+ if first_name:
+ update_payload["firstName"] = first_name
+ if last_name:
+ update_payload["lastName"] = last_name
+
+ if not update_payload:
+ return
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/users/{kc_user['id']}",
+ headers={"Authorization": f"Bearer {token}"},
+ json=update_payload,
+ timeout=10,
+ )
+ resp.raise_for_status()
+ LOGGER.info("Updated Keycloak user: %s", username)
+
+
+async def delete_keycloak_user(username: str) -> None:
+ """Delete a user from Keycloak."""
+ kc_user = await get_keycloak_user(username)
+ if not kc_user:
+ return
+
+ token = await get_admin_token()
+ async with httpx.AsyncClient() as client:
+ resp = await client.delete(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/users/{kc_user['id']}",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=10,
+ )
+ resp.raise_for_status()
+
+ _USER_PASSWORDS.pop(username, None)
+ LOGGER.info("Deleted Keycloak user: %s", username)
+
+
+async def get_user_token(username: str, client_id: str = "opentalk") -> dict:
+ """Get a Keycloak token for a user using the direct access grant.
+
+ Returns dict with access_token, refresh_token, expires_in etc.
+ """
+ password = _USER_PASSWORDS.get(username)
+ if not password:
+ # User not synced yet — sync on demand
+ password = await reset_keycloak_user_password(username)
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": client_id,
+ "username": username,
+ "password": password,
+ "scope": "openid profile email",
+ },
+ timeout=10,
+ )
+ if resp.status_code == 401:
+ # Password may be stale — reset and retry once
+ password = await reset_keycloak_user_password(username)
+ resp = await client.post(
+ f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": client_id,
+ "username": username,
+ "password": password,
+ "scope": "openid profile email",
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+# -- User Sync ---------------------------------------------------------------
+async def sync_all_users(nc: NextcloudApp) -> int:
+ """Sync all Nextcloud users to Keycloak. Returns count of synced users."""
+ await ensure_realm_exists()
+
+ # Use nc_py_api's built-in OCS calls (handles AppAPI auth automatically)
+ nextcloud_url = os.environ.get("NEXTCLOUD_URL", "http://nextcloud")
+
+ # List users via OCS provisioning API
+ try:
+ resp = nc.ocs("GET", "/ocs/v1.php/cloud/users")
+ users = resp.get("users", [])
+ except Exception as e:
+ LOGGER.error("Failed to list NC users: %s", str(e))
+ return 0
+
+ count = 0
+ for uid in users:
+ try:
+ # Get user details
+ try:
+ user_data = nc.ocs("GET", f"/ocs/v1.php/cloud/users/{uid}")
+ email = user_data.get("email", "")
+ display_name = user_data.get("displayname", "")
+ parts = display_name.split(" ", 1) if display_name else [""]
+ first_name = parts[0]
+ last_name = parts[1] if len(parts) > 1 else ""
+ except Exception:
+ email = ""
+ first_name = ""
+ last_name = ""
+
+ existing = await get_keycloak_user(uid)
+ if existing:
+ await update_keycloak_user(uid, email, first_name, last_name)
+ # Reset password so we have it in memory
+ await reset_keycloak_user_password(uid)
+ else:
+ await create_keycloak_user(uid, email, first_name, last_name)
+ count += 1
+ except Exception as e:
+ LOGGER.error("Failed to sync user %s: %s", uid, str(e))
+
+ LOGGER.info("Synced %d users to Keycloak", count)
+ return count
+
+
+# -- Ensure Direct Access Grant on Clients -----------------------------------
+async def ensure_direct_access_grant(client_id: str) -> None:
+ """Ensure a Keycloak client has direct access grants enabled."""
+ token = await get_admin_token()
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/clients",
+ headers={"Authorization": f"Bearer {token}"},
+ params={"clientId": client_id},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ clients = resp.json()
+ if not clients:
+ LOGGER.warning("Client '%s' not found in realm '%s'", client_id, KEYCLOAK_REALM)
+ return
+
+ kc_client = clients[0]
+ if kc_client.get("directAccessGrantsEnabled"):
+ return
+
+ LOGGER.info("Enabling direct access grants on client '%s'", client_id)
+ resp = await client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{kc_client['id']}",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"directAccessGrantsEnabled": True},
+ timeout=10,
+ )
+ resp.raise_for_status()
+
+
+# -- Path Rewriting ----------------------------------------------------------
_REWRITE_PREFIXES = (
"/js/",
"/resources/",
@@ -135,7 +462,7 @@ def rewrite_content(content: bytes, content_type: str) -> bytes:
return text.encode("utf-8")
-# ── Lifespan ────────────────────────────────────────────────────────
+# -- Lifespan ----------------------------------------------------------------
@asynccontextmanager
async def lifespan(_app: FastAPI):
setup_nextcloud_logging("keycloak", logging_level=logging.WARNING)
@@ -147,12 +474,17 @@ async def lifespan(_app: FastAPI):
LOGGER.info("Keycloak ExApp shutdown complete")
-# ── FastAPI App ─────────────────────────────────────────────────────
+# -- FastAPI App -------------------------------------------------------------
+# Internal API secret for ExApp-to-ExApp calls (bypasses AppAPI middleware)
+INTERNAL_API_SECRET = os.environ.get("KEYCLOAK_API_SECRET", "keycloak-exapp-internal-secret")
+
APP = FastAPI(lifespan=lifespan)
-APP.add_middleware(AppAPIAuthMiddleware)
+# Disable AppAPIAuthMiddleware for /api/ routes (they use shared secret auth)
+# Note: fnmatch matches against path WITHOUT leading slash
+APP.add_middleware(AppAPIAuthMiddleware, disable_for=["api/*"])
-# ── Inline iframe loader JS ────────────────────────────────────────
+# -- Inline iframe loader JS ------------------------------------------------
IFRAME_LOADER_JS = f"""
(function() {{
var style = document.createElement('style');
@@ -195,7 +527,7 @@ async def iframe_loader():
)
-# ── Enabled Handler ────────────────────────────────────────────────
+# -- Enabled Handler ---------------------------------------------------------
def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
"""Handle app enable/disable events."""
if enabled:
@@ -211,7 +543,7 @@ def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
return ""
-# ── Required Endpoints ──────────────────────────────────────────────
+# -- Required Endpoints ------------------------------------------------------
@APP.get("/heartbeat")
async def heartbeat_callback():
"""Heartbeat endpoint for AppAPI health checks."""
@@ -246,6 +578,19 @@ async def init_keycloak_task(nc: NextcloudApp):
nc.set_init_status(20)
if await wait_for_keycloak():
+ nc.set_init_status(40)
+
+ # Ensure realm exists and sync users
+ try:
+ await ensure_realm_exists()
+ nc.set_init_status(50)
+ await sync_all_users(nc)
+ nc.set_init_status(70)
+ # Ensure direct access grant is enabled on the opentalk client
+ await ensure_direct_access_grant("opentalk")
+ except Exception as e:
+ LOGGER.error("User sync failed during init: %s", str(e))
+
nc.set_init_status(80)
nc.ui.resources.set_script("top_menu", "keycloak", "js/keycloak-iframe-loader")
nc.ui.top_menu.register("keycloak", "Keycloak", "ex_app/img/app.svg", True)
@@ -255,7 +600,157 @@ async def init_keycloak_task(nc: NextcloudApp):
LOGGER.error("Keycloak failed to start within timeout")
-# ── Catch-All Proxy ────────────────────────────────────────────────
+# -- Token API (for consumer ExApps like OpenTalk) ---------------------------
+@APP.post("/api/token")
+async def token_endpoint(request: Request):
+ """Get a Keycloak token for a Nextcloud user.
+
+ Called by consumer ExApps (e.g. OpenTalk) to get a token for server-side auth.
+ Authentication: shared secret via X-API-SECRET header, or AppAPI proxy auth.
+
+ Headers:
+ X-NC-USER-ID: Nextcloud user ID (required for direct ExApp-to-ExApp calls)
+ X-API-SECRET: Shared secret for authentication
+
+ Query params:
+ client_id: Keycloak client ID to get the token for (default: opentalk)
+ """
+ import base64
+
+ # Authenticate: check shared secret or AppAPI auth
+ api_secret = request.headers.get("X-API-SECRET", "")
+ if api_secret != INTERNAL_API_SECRET:
+ # Try AppAPI auth as fallback (when called via Nextcloud proxy)
+ auth_header = request.headers.get("authorization-app-api", "")
+ if not auth_header:
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
+
+ # Get user ID from headers
+ nc_user_id = request.headers.get("X-NC-USER-ID", "")
+ if not nc_user_id:
+ # Fallback: decode from AppAPI authorization header
+ auth_header = request.headers.get("authorization-app-api", "")
+ if auth_header:
+ try:
+ decoded = base64.b64decode(auth_header).decode("utf-8")
+ nc_user_id = decoded.split(":")[0]
+ except Exception:
+ pass
+ LOGGER.info("Token request for NC user: %s", nc_user_id)
+
+ if not nc_user_id:
+ return JSONResponse(
+ {"error": "No Nextcloud user context found"},
+ status_code=401,
+ )
+
+ client_id = request.query_params.get("client_id", "opentalk")
+
+ try:
+ token_data = await get_user_token(nc_user_id, client_id)
+ return JSONResponse({
+ "access_token": token_data["access_token"],
+ "refresh_token": token_data.get("refresh_token", ""),
+ "id_token": token_data.get("id_token", ""),
+ "expires_in": token_data.get("expires_in", 300),
+ "token_type": token_data.get("token_type", "Bearer"),
+ })
+ except httpx.HTTPStatusError as e:
+ LOGGER.error("Token request failed for user %s: %s", nc_user_id, str(e))
+ return JSONResponse(
+ {"error": f"Failed to get token: {e.response.status_code}"},
+ status_code=502,
+ )
+ except Exception as e:
+ LOGGER.error("Token request error for user %s: %s", nc_user_id, str(e))
+ return JSONResponse(
+ {"error": f"Token error: {str(e)}"},
+ status_code=500,
+ )
+
+
+# -- User Sync API (for admin / event-triggered sync) -----------------------
+@APP.post("/api/sync-user")
+async def sync_user_endpoint(request: Request):
+ """Sync a single Nextcloud user to Keycloak.
+
+ Called by event listeners when a user is created or modified.
+
+ Body JSON: {"user_id": "...", "email": "...", "display_name": "..."}
+ """
+ api_secret = request.headers.get("X-API-SECRET", "")
+ auth_header = request.headers.get("authorization-app-api", "")
+ if api_secret != INTERNAL_API_SECRET and not auth_header:
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ user_id = body.get("user_id", "")
+ if not user_id:
+ return JSONResponse({"error": "user_id is required"}, status_code=400)
+
+ email = body.get("email", "")
+ display_name = body.get("display_name", "")
+ parts = display_name.split(" ", 1) if display_name else [""]
+ first_name = parts[0]
+ last_name = parts[1] if len(parts) > 1 else ""
+
+ try:
+ existing = await get_keycloak_user(user_id)
+ if existing:
+ await update_keycloak_user(user_id, email, first_name, last_name)
+ await reset_keycloak_user_password(user_id)
+ else:
+ await create_keycloak_user(user_id, email, first_name, last_name)
+
+ return JSONResponse({"status": "ok", "user_id": user_id})
+ except Exception as e:
+ LOGGER.error("User sync failed for %s: %s", user_id, str(e))
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@APP.post("/api/delete-user")
+async def delete_user_endpoint(request: Request):
+ """Delete a user from Keycloak when they are removed from Nextcloud."""
+ api_secret = request.headers.get("X-API-SECRET", "")
+ auth_header = request.headers.get("authorization-app-api", "")
+ if api_secret != INTERNAL_API_SECRET and not auth_header:
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ user_id = body.get("user_id", "")
+ if not user_id:
+ return JSONResponse({"error": "user_id is required"}, status_code=400)
+
+ try:
+ await delete_keycloak_user(user_id)
+ return JSONResponse({"status": "ok", "user_id": user_id})
+ except Exception as e:
+ LOGGER.error("User deletion failed for %s: %s", user_id, str(e))
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@APP.post("/api/sync-all")
+async def sync_all_endpoint(
+ nc: typing.Annotated[NextcloudApp, Depends(nc_app)],
+):
+ """Trigger a full user sync from Nextcloud to Keycloak."""
+ try:
+ count = await sync_all_users(nc)
+ return JSONResponse({"status": "ok", "synced": count})
+ except Exception as e:
+ LOGGER.error("Full sync failed: %s", str(e))
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+# -- Catch-All Proxy ---------------------------------------------------------
@APP.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
@@ -326,7 +821,7 @@ async def proxy(request: Request, path: str):
)
-# ── Entry Point ─────────────────────────────────────────────────────
+# -- Entry Point -------------------------------------------------------------
if __name__ == "__main__":
os.chdir(Path(__file__).parent)
run_app(APP, log_level="info")