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 logo +

-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 +

+ +

+ Latest release + License +

+ +--- ## 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")