Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions shard_core/data_model/app_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions shard_core/database/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ------------------------------------------------------


Expand Down
7 changes: 7 additions & 0 deletions shard_core/service/app_installation/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
)

Expand Down
3 changes: 3 additions & 0 deletions shard_core/service/app_installation/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down
36 changes: 33 additions & 3 deletions shard_core/service/oidc_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone

import jinja2
from authlib.oauth2.rfc6749 import (
AuthorizationServer,
ClientMixin,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <domain>/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:
Expand Down
28 changes: 28 additions & 0 deletions tests/mock_app_store/oidc_app/app_meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions tests/mock_app_store/oidc_app/docker-compose.yml.template
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions tests/mock_app_store/oidc_app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/mock_app_store/oidc_app/oidc_app.zip
Binary file not shown.
Loading
Loading