From 67b80815f8260506276dbb3e747f0958410d8533 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Fri, 10 Jul 2026 22:11:14 +0000 Subject: [PATCH] feat(oidc): phase-2b client registration at app install + {{ oidc.* }} template vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app_meta.json gains an optional oidc section (redirect_uris — may reference {{ portal.domain }} —, public_client, scope). Compose-template rendering registers/refreshes the app's OIDC client and passes oidc.client_id, oidc.client_secret and oidc.issuer into the template. Registration lives in the render step because rendering runs at install, reinstall and every startup — redirect URIs heal automatically after a domain change with no extra hook. The client upsert preserves client_id/client_secret on conflict so reinstalls and re-renders never invalidate an app's configured credentials. Uninstalling an app removes its client row, cascading its codes and tokens. Apps without an oidc section are unaffected (oidc=None in the context). Phase 2b of the multi-user identity rollout; companion app-repository PR adds the first catalog app (paperless-ngx) on this mechanism. Co-Authored-By: Claude Fable 5 --- shard_core/data_model/app_meta.py | 8 + shard_core/database/oidc.py | 17 +- shard_core/service/app_installation/util.py | 7 + shard_core/service/app_installation/worker.py | 3 + shard_core/service/oidc_provider.py | 36 ++++- tests/mock_app_store/oidc_app/app_meta.json | 28 ++++ .../oidc_app/docker-compose.yml.template | 15 ++ tests/mock_app_store/oidc_app/icon.svg | 147 ++++++++++++++++++ tests/mock_app_store/oidc_app/oidc_app.zip | Bin 0 -> 2737 bytes tests/test_oidc_app_install.py | 65 ++++++++ 10 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 tests/mock_app_store/oidc_app/app_meta.json create mode 100644 tests/mock_app_store/oidc_app/docker-compose.yml.template create mode 100644 tests/mock_app_store/oidc_app/icon.svg create mode 100644 tests/mock_app_store/oidc_app/oidc_app.zip create mode 100644 tests/test_oidc_app_install.py diff --git a/shard_core/data_model/app_meta.py b/shard_core/data_model/app_meta.py index 44cbf5e0..34d51085 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 6cf75e8e..39d89396 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 a616746d..08af0dcf 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 ae329c98..c078ef7c 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 57bb5036..d00014e8 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 00000000..4ab29b6b --- /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 00000000..f262e467 --- /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 00000000..5e78eccf --- /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 0000000000000000000000000000000000000000..d8b0d7c293c0ba6a75fc7e48178ee83863ba220a GIT binary patch literal 2737 zcmaKuc{J4h8o+1F(8w~@?8Xwt9@)3X`W9h`m=Llv!`LSKzPpSl8cQYFrJH3W+h7`! zh{&#F$z?22?}U~+-h0k{JH79{=X;*-`ThQRp6~NH&pFQrX#oQB00008V4{A*`EWiU zi5Ca}a4`S?Yye?^I~MDT@d|TS@(&FPw6|jhfD$}y{Es6%f&~BsuY&-9KQ4^Tm_J#J zBWBbgV-%xQi5&W7uI*{9SyX9eN()KJi+@ynm7!9ZGCVvo(r6)>Qo4c~Y$FyZuR@KG z+!T{V-p+vQl->-+JeWaW76NSw_KU#%Z z?%rr#Yo2TG|9W94QG0Oc(Lv_~D=-4S(AFK&b4$FHVy7h_VM&UZXlx~db<8CNvuaQa zl9Q4CJz)lAcY5c%IHg#o-+WOy!M7PT?zYAIsjZXRT^KddOpLgl97`&-Tet?*SkkCI z?O~&mQxgTV7u-gFt;kqB_cT!RB2`i@r0bk8o3w~5Hm=W^)*+6)pAu_&!Xevi<&}gG z3x&KA=miXD);Nd!FWX5v+kI#FlP7f2u5`Xabhe&B9syn#wFFU%j6E-D;RsV z`rZ9FE`tiR z_B(kRax%-=0u66O*cO)E|FKA@glFbo(+!q0E}bl$SuLF*pvEhv+V+#?*Xu?swQ)+gmxJ|W%4m@q zI5*e>w2SL+;kOAq{c5t!SdI?#OHJ?tr!vGL=Wg>S@_D2n;Ke+Q44N|cd= zSmaAtjz8XxdjUZV?kxrJImlF~iG25~!~3SdCS<6SfxA_LRcAFbg`|0;ZyUdNpi$=AWU?uB5*+z=>pRFprvXZh>;w8 zikV*YMH#kgUcE8@uey4d^PFb{y$+vlM=U$)$Y?-+BT6lC?4zk}qr0!4F5&>>gIPgp zPjq}-@Zs90*rI13xTV?mUM7v0xbV#QAKTXq(`^I?wKm+Bm^$?Ai+uDIHqMBqf1I?3 zatl$Z)EH=mi1aNTHcx1QNEJB}8@||YmtN-Ld}sUNbKQ%OxRKG>z^;)=agj3um#jJj zU`{9s~}nVc}7lns&xHgk!w^iR)LfQ96Zz1=x{*%;^(5b$s+uB{t( znDd>yuw%JKXiB;4CI=1KmG&#VwapbBy57p!`R0pH$+U`hOc8Uqw2 zTRRTf#B6{PPbr?~AQRi@EWl_2aZ`j}U=Ejcmk8-{Ok_ov@A=^&9R4f>;8_9q16s3Ti8dou})9YAi*2g1-ZZeb#i0Ph)N!Q+<193wXZBRM$@ zg1e*s=@plnqc@g~H0xB7Z0;t?6fuK`#~Ts!N!g#d4X<34-R-kV{|w?gm6WVf$PF@T z;8n3re9T8=2qQ808gZ)VvONP9$nd-dVY=Il^&l>?lJn&VAdG$SBp*9$x=3g|Z5{{e zu+-?q_(%If1kqd!nw4jn7@dOhwLE#h2OWhDX4R&nP&)_W^2D{@wJuxNEGv6SNe~CJ zXpSF`e$U$4;q6Gj718(ct7$73e?oPo3s-kVTKKRo{blRci6QS2OeGPDMFTe+VI0=$ zAgG=7=yidNpq(Slx~~IeCl^$PB-?M$U={DzRSrqH>0LFc$TD{{%tG|rJw_QsfFiSC zg`(a{1|-Z^(A^VnP?%9|S8a9kPE4VF*sdI6t3IL6Q0(3SX<6C)NDgp3w@+eu0n8eg z(-eh?Z`^eSdh>7rJ(8iU2!Lkdmv+`%uGAw6;g%LuigGHLe~tj}rI_FL2)^ly=lUR- zyIHP3^sSBkASEzC`NpOXZ+z4qI_1Y*?E@&%0>~f=`d@0sPZtRQfNm0imc-+)Uqp@Z zKce=_0{uDVUybDFl+N_b|JP6G1^&iOelGCWE&p=?Ke}N4x%27S{$}4JEg0#B004mK Nhk^be0`u|TzX9xMw3q+@ literal 0 HcmV?d00001 diff --git a/tests/test_oidc_app_install.py b/tests/test_oidc_app_install.py new file mode 100644 index 00000000..73dbb313 --- /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