diff --git a/shard_core/data_model/app_meta.py b/shard_core/data_model/app_meta.py index 44cbf5e..34d5108 100644 --- a/shard_core/data_model/app_meta.py +++ b/shard_core/data_model/app_meta.py @@ -88,6 +88,13 @@ class Entrypoint(BaseModel): entrypoint_port: EntrypointPort +class OidcMeta(BaseModel): + # entries may reference {{ portal.domain }}, rendered at client registration + redirect_uris: List[str] + public_client: bool = False + scope: str = "openid profile email" + + class Lifecycle(BaseModel): always_on: bool = False skip_pause: bool = False @@ -136,6 +143,7 @@ class AppMeta(BaseModel): lifecycle: Lifecycle = Lifecycle() minimum_portal_size: VMSize = VMSize.XS store_info: Optional[StoreInfo] = None + oidc: Optional[OidcMeta] = None @model_validator(mode="before") @classmethod diff --git a/shard_core/database/oidc.py b/shard_core/database/oidc.py index 6cf75e8..39d8939 100644 --- a/shard_core/database/oidc.py +++ b/shard_core/database/oidc.py @@ -8,13 +8,14 @@ async def upsert_client(conn: AsyncConnection, client: dict) -> dict: + """Insert the client, or refresh its configuration if the app already has + one — existing client_id/client_secret are preserved so reinstalls and + re-renders keep working credentials.""" sql: LiteralString = """INSERT INTO oidc_clients (client_id, client_secret, app_name, redirect_uris, scope, token_endpoint_auth_method) VALUES (%(client_id)s, %(client_secret)s, %(app_name)s, %(redirect_uris)s, %(scope)s, %(token_endpoint_auth_method)s) ON CONFLICT (app_name) DO UPDATE SET - client_id = EXCLUDED.client_id, - client_secret = EXCLUDED.client_secret, redirect_uris = EXCLUDED.redirect_uris, scope = EXCLUDED.scope, token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method @@ -33,6 +34,18 @@ async def get_client(conn: AsyncConnection, client_id: str) -> dict | None: return await cur.fetchone() +async def get_client_by_app_name(conn: AsyncConnection, app_name: str) -> dict | None: + sql: LiteralString = "SELECT * FROM oidc_clients WHERE app_name = %s" + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute(sql, (app_name,)) + return await cur.fetchone() + + +async def remove_client_by_app_name(conn: AsyncConnection, app_name: str): + sql: LiteralString = "DELETE FROM oidc_clients WHERE app_name = %s" + await conn.execute(sql, (app_name,)) + + # --- authorization codes ------------------------------------------------------ diff --git a/shard_core/service/app_installation/util.py b/shard_core/service/app_installation/util.py index a616746..08af0dc 100644 --- a/shard_core/service/app_installation/util.py +++ b/shard_core/service/app_installation/util.py @@ -12,6 +12,7 @@ from shard_core.database import identities as db_identities from shard_core.data_model.app_meta import Status, InstalledApp from shard_core.data_model.identity import Identity, SafeIdentity +from shard_core.service import oidc_provider from shard_core.service.app_installation.exceptions import AppInIllegalStatus from shard_core.service.app_tools import get_installed_apps_path, get_app_metadata from shard_core.settings import settings @@ -100,12 +101,18 @@ async def render_docker_compose_template(app: InstalledApp): default_identity = Identity(**default_row) portal = SafeIdentity.from_identity(default_identity) + oidc = None + app_meta = get_app_metadata(app.name) + if app_meta.oidc: + oidc = await oidc_provider.ensure_app_client(app.name, app_meta.oidc, portal) + app_dir = get_installed_apps_path() / app.name template = jinja2.Template((app_dir / "docker-compose.yml.template").read_text()) (app_dir / "docker-compose.yml").write_text( template.render( fs=fs, portal=portal, + oidc=oidc, ) ) diff --git a/shard_core/service/app_installation/worker.py b/shard_core/service/app_installation/worker.py index ae329c9..c078ef7 100644 --- a/shard_core/service/app_installation/worker.py +++ b/shard_core/service/app_installation/worker.py @@ -10,6 +10,7 @@ from shard_core.database.connection import db_conn from shard_core.database import installed_apps as db_installed_apps +from shard_core.database import oidc as db_oidc from shard_core.data_model.app_meta import Status from shard_core.service.app_tools import ( app_op_lock, @@ -151,6 +152,8 @@ async def _uninstall_app(app_name: str): log.debug(f"removing app {app_name} from database") async with db_conn() as conn: await db_installed_apps.remove(conn, app_name) + # cascades to the app's codes and tokens + await db_oidc.remove_client_by_app_name(conn, app_name) await write_traefik_dyn_config() await signals.on_apps_update.send_async() log.info(f"uninstalled {app_name}") diff --git a/shard_core/service/oidc_provider.py b/shard_core/service/oidc_provider.py index 57bb503..d00014e 100644 --- a/shard_core/service/oidc_provider.py +++ b/shard_core/service/oidc_provider.py @@ -22,6 +22,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone +import jinja2 from authlib.oauth2.rfc6749 import ( AuthorizationServer, ClientMixin, @@ -34,6 +35,7 @@ from joserfc.jwk import RSAKey from shard_core.database import kv_store +from shard_core.settings import settings from shard_core.database import oidc as db_oidc from shard_core.database import users as db_users from shard_core.database.connection import db_conn @@ -441,7 +443,8 @@ async def register_client( public_client: bool = False, scope: str = "openid profile email", ) -> dict: - """Create (or replace) the OIDC client for an installed app. + """Create the OIDC client for an installed app, or refresh its + configuration — credentials are preserved if the app already has one. The client secret is stored plaintext: docker-compose templates consume it on every startup re-render, and it sits in the app's compose env on the @@ -459,9 +462,36 @@ async def register_client( ), } async with db_conn() as conn: - await db_oidc.upsert_client(conn, row) + stored = await db_oidc.upsert_client(conn, row) log.info(f"registered OIDC client for app {app_name}") - return row + return stored + + +def issuer_for_domain(domain: str, disable_ssl: bool = False) -> str: + protocol = "http" if disable_ssl else "https" + # Traefik routes /core/* to shard_core with /core/ stripped + return f"{protocol}://{domain}/core/public/oidc" + + +async def ensure_app_client(app_name: str, oidc_meta, portal) -> dict: + """Register/refresh the app's OIDC client and return the docker-compose + template context: client_id, client_secret, issuer.""" + redirect_uris = [ + jinja2.Template(uri).render(portal=portal) for uri in oidc_meta.redirect_uris + ] + client = await register_client( + app_name, + redirect_uris, + public_client=oidc_meta.public_client, + scope=oidc_meta.scope, + ) + return { + "client_id": client["client_id"], + "client_secret": client["client_secret"], + "issuer": issuer_for_domain( + portal.domain, disable_ssl=settings().traefik.disable_ssl + ), + } async def userinfo_for_access_token(access_token: str) -> dict | None: diff --git a/tests/mock_app_store/oidc_app/app_meta.json b/tests/mock_app_store/oidc_app/app_meta.json new file mode 100644 index 0000000..4ab29b6 --- /dev/null +++ b/tests/mock_app_store/oidc_app/app_meta.json @@ -0,0 +1,28 @@ +{ + "v": "1.0", + "app_version": "0.1.0", + "name": "oidc_app", + "icon": "icon.svg", + "entrypoints": [ + { + "container_name": "oidc_app", + "container_port": 80, + "entrypoint_port": "http" + } + ], + "paths": { + "": { + "access": "private" + } + }, + "oidc": { + "redirect_uris": ["https://oidc_app.{{ portal.domain }}/callback"] + }, + "lifecycle": { + "always_on": false, + "idle_time_for_shutdown": 3600 + }, + "store_info": { + "description_short": "an oidc-enabled app for testing" + } +} diff --git a/tests/mock_app_store/oidc_app/docker-compose.yml.template b/tests/mock_app_store/oidc_app/docker-compose.yml.template new file mode 100644 index 0000000..f262e46 --- /dev/null +++ b/tests/mock_app_store/oidc_app/docker-compose.yml.template @@ -0,0 +1,15 @@ +networks: + portal: + external: true + +services: + oidc_app: + restart: always + image: nginx:alpine + container_name: oidc_app + environment: + - OIDC_CLIENT_ID={{ oidc.client_id }} + - OIDC_CLIENT_SECRET={{ oidc.client_secret }} + - OIDC_ISSUER={{ oidc.issuer }} + networks: + - portal diff --git a/tests/mock_app_store/oidc_app/icon.svg b/tests/mock_app_store/oidc_app/icon.svg new file mode 100644 index 0000000..5e78ecc --- /dev/null +++ b/tests/mock_app_store/oidc_app/icon.svg @@ -0,0 +1,147 @@ + +image/svg+xml + + + + + \ No newline at end of file diff --git a/tests/mock_app_store/oidc_app/oidc_app.zip b/tests/mock_app_store/oidc_app/oidc_app.zip new file mode 100644 index 0000000..d8b0d7c Binary files /dev/null and b/tests/mock_app_store/oidc_app/oidc_app.zip differ diff --git a/tests/test_oidc_app_install.py b/tests/test_oidc_app_install.py new file mode 100644 index 0000000..73dbb31 --- /dev/null +++ b/tests/test_oidc_app_install.py @@ -0,0 +1,65 @@ +"""OIDC client registration during app installation. + +Uses the oidc_app from the mock app store, whose app_meta.json declares an +oidc section and whose compose template consumes {{ oidc.* }} variables. +""" + +from httpx import AsyncClient + +from shard_core.database.connection import db_conn +from shard_core.database import oidc as db_oidc +from shard_core.service.app_tools import get_installed_apps_path +from tests.util import wait_until_app_installed, wait_until_app_uninstalled + +APP_NAME = "oidc_app" + + +async def _get_client_row() -> dict | None: + async with db_conn() as conn: + return await db_oidc.get_client_by_app_name(conn, APP_NAME) + + +async def _install(api_client: AsyncClient): + response = await api_client.post(f"protected/apps/{APP_NAME}") + assert response.status_code == 201 + await wait_until_app_installed(api_client, APP_NAME) + + +async def test_install_registers_client_and_renders_creds(api_client: AsyncClient): + await _install(api_client) + + row = await _get_client_row() + assert row is not None + assert row["client_secret"] + + domain = (await api_client.get("public/meta/whoareyou")).json()["domain"] + assert row["redirect_uris"] == [f"https://{APP_NAME}.{domain}/callback"] + + compose = (get_installed_apps_path() / APP_NAME / "docker-compose.yml").read_text() + assert f"OIDC_CLIENT_ID={row['client_id']}" in compose + assert f"OIDC_CLIENT_SECRET={row['client_secret']}" in compose + assert f"OIDC_ISSUER=https://{domain}/core/public/oidc" in compose + + +async def test_reinstall_keeps_client_credentials(api_client: AsyncClient): + await _install(api_client) + before = await _get_client_row() + + response = await api_client.post(f"protected/apps/{APP_NAME}/reinstall") + assert response.status_code == 201 + await wait_until_app_installed(api_client, APP_NAME) + + after = await _get_client_row() + assert after["client_id"] == before["client_id"] + assert after["client_secret"] == before["client_secret"] + + +async def test_uninstall_removes_client(api_client: AsyncClient): + await _install(api_client) + assert await _get_client_row() is not None + + response = await api_client.delete(f"protected/apps/{APP_NAME}") + assert response.status_code in (200, 204) + await wait_until_app_uninstalled(api_client, APP_NAME) + + assert await _get_client_row() is None