From 1932ead08feb1441e11d80985681d96b08ffb0ca Mon Sep 17 00:00:00 2001
From: lilly
Date: Sun, 31 May 2026 22:02:39 +0200
Subject: [PATCH] api: implement automatic ssh key fetching from keycloak
---
.dev.env | 1 +
.gitignore | 1 +
api/src/dooris_api/__init__.py | 13 ++++++
api/src/dooris_api/app.py | 14 +++++-
api/src/dooris_api/ccujack.py | 3 ++
api/src/dooris_api/keycloak.py | 83 ++++++++++++++++++++++++++++++++++
6 files changed, 113 insertions(+), 2 deletions(-)
create mode 100644 api/src/dooris_api/keycloak.py
diff --git a/.dev.env b/.dev.env
index eb77b35..59f4383 100644
--- a/.dev.env
+++ b/.dev.env
@@ -3,3 +3,4 @@ DOORIS_OPENID_CLIENT_ID=dooris
DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8
DOORIS_BASE_URL=http://localhost:8000
DOORIS_CCUJACK_USER=dooris
+DOORIS_AUTHORIZED_KEYS_FILE=./authorized_keys
diff --git a/.gitignore b/.gitignore
index 4ad5c33..7b869c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
.env
**/__pycache__
+**/authorized_keys
api/dist
diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py
index 203d379..10d1ff5 100644
--- a/api/src/dooris_api/__init__.py
+++ b/api/src/dooris_api/__init__.py
@@ -1,5 +1,6 @@
import os
import logging
+from pathlib import Path
from contextvars import ContextVar
from argparse import ArgumentParser, Namespace
@@ -80,6 +81,18 @@ def main():
action="append",
default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)],
)
+ argp.add_argument(
+ "--kc-ssh-attr-group",
+ required=False,
+ default=os.environ.get("DOORIS_KC_SSH_ATTR_GROUP", "dooris-ssh-keys"),
+ )
+ argp.add_argument(
+ "--authorized-keys-file",
+ required="DOORIS_AUTHORIZED_KEYS_FILE" not in os.environ,
+ default=os.environ.get("DOORIS_AUTHORIZED_KEYS_FILE", None),
+ type=Path,
+ help="A file to which ssh authorized keys fetched from keycloak will be written",
+ )
args = argp.parse_args()
# setup logging
diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py
index 0a33704..d485a3b 100644
--- a/api/src/dooris_api/app.py
+++ b/api/src/dooris_api/app.py
@@ -13,6 +13,7 @@ from aiohttp import BasicAuth
from dooris_api import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient
+from dooris_api.keycloak import KeycloakClient
logger = logging.getLogger(__name__)
@@ -35,11 +36,20 @@ async def lifespan(app: FastAPI):
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password),
mqtt_conn=app_cfg.ccujack_mqtt,
)
- await app.extra["ccujack"].start()
+ # await app.extra["ccujack"].start()
+
+ app.extra["keycloak"] = KeycloakClient(
+ base_uri=app_cfg.openid_issuer,
+ oidc_client=app.extra["oidc_client"],
+ keycloak_attribute_group=app_cfg.kc_ssh_attr_group,
+ authorized_keys_file=app_cfg.authorized_keys_file,
+ )
+ app.extra["keycloak"].start()
yield
- await app.extra["ccujack"].close_connections()
+ # await app.extra["ccujack"].close_connections()
+ await app.extra["keycloak"].stop()
app = FastAPI(
diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py
index de08bbf..8cf767f 100644
--- a/api/src/dooris_api/ccujack.py
+++ b/api/src/dooris_api/ccujack.py
@@ -166,6 +166,9 @@ class CCUJackClient:
await self.find_locks()
+ except asyncio.CancelledError:
+ logger.info("CCUJack task cron stopped")
+ raise
except Exception as e:
logger.exception(f"Error in CCUJack cron task: {e}")
finally:
diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py
new file mode 100644
index 0000000..ea614d1
--- /dev/null
+++ b/api/src/dooris_api/keycloak.py
@@ -0,0 +1,83 @@
+from typing import List
+from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse
+import asyncio
+import logging
+from pathlib import Path
+from simple_openid_connect.client import OpenidClient
+from simple_openid_connect.data import TokenErrorResponse
+
+
+logger = logging.getLogger(__name__)
+
+
+class ClientCredentialsAuth:
+ def __init__(self, oidc_client: OpenidClient):
+ self.oidc_client = oidc_client
+
+ async def __call__(self, req: ClientRequest, handler: ClientHandlerType) -> ClientResponse:
+ resp = self.oidc_client.client_credentials_grant.authenticate()
+ if isinstance(resp, TokenErrorResponse):
+ raise Exception(f"Could not authenticate against keycloak with client-credentials-grant: {resp.error} ({resp.error_description})")
+
+ req.headers["Authorization"] = f"Bearer {resp.access_token}"
+ return await handler(req)
+
+
+class KeycloakClient:
+ keycloak_attribute_group: str
+ authorized_keys_file: Path
+ ssh_keys: List[str]
+ data_updated: asyncio.Event
+ task_cron = asyncio.Task
+
+ def __init__(self, base_uri: str, oidc_client: OpenidClient, keycloak_attribute_group: str, authorized_keys_file: Path):
+ self.base_uri: str
+ self.keycloak_attribute_group = keycloak_attribute_group
+ self.ssh_keys = []
+ self.data_updated = asyncio.Event()
+ self.authorized_keys_file = authorized_keys_file
+ self.http = ClientSession(
+ base_url=base_uri,
+ middlewares=[ClientCredentialsAuth(oidc_client)],
+ raise_for_status=True,
+ )
+
+ def start(self):
+ self.task_cron = asyncio.get_running_loop().create_task(self.cron(), name="keycloak-cron")
+
+ async def stop(self):
+ await self.http.close()
+ self.task_cron.cancel()
+ self.task_cron = None
+
+ async def cron(self):
+ while True:
+ try:
+
+ logger.info("Running keycloak cron")
+ await self.fetch_ssh_keys()
+ await self.write_authorized_keys()
+
+ except asyncio.CancelledError:
+ logger.info("keycloak cron stopped")
+ raise
+ except Exception as e:
+ logger.exception(f"Error in Keycloak cron task: {e}")
+ finally:
+ await asyncio.sleep(15 * 60) # 15 minutes
+
+ async def fetch_ssh_keys(self):
+ logger.info("Fetching ssh keys from keycloak")
+ async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp:
+ keys = await resp.json()
+ assert isinstance(keys, list)
+ self.ssh_keys = keys
+ self.data_updated.set()
+ self.data_updated.clear()
+
+ async def write_authorized_keys(self):
+ logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}")
+ with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f:
+ f.writelines(self.ssh_keys)
+
+