diff --git a/stacklets/photos/README.md b/stacklets/photos/README.md index 0c896719..2c8f0996 100644 --- a/stacklets/photos/README.md +++ b/stacklets/photos/README.md @@ -36,6 +36,20 @@ stack photos seed This creates accounts for everyone in `users.toml`. +## Single sign-on + +With an OIDC provider installed (see `docs/stack-reference.md`, "Single +Sign-On (OIDC)"), `stack up photos` turns on "Sign in with family +account" on the login page and in the mobile app. Accounts link by +email: the address at the provider must equal the one on the Immich +account. + +`hooks/on_start_ready.py` writes five keys of Immich's OAuth settings +through the admin API: on, issuer, client id, client secret, button +text. Every other setting stays as set in the admin UI, and the +password login stays on. Without a provider nothing is written, so a +login set up by hand in the admin UI is kept. + ## Data Stored in `~/famstack-data/photos/`: diff --git a/stacklets/photos/hooks/on_start_ready.py b/stacklets/photos/hooks/on_start_ready.py new file mode 100644 index 00000000..7415fb11 --- /dev/null +++ b/stacklets/photos/hooks/on_start_ready.py @@ -0,0 +1,19 @@ +"""Runs after the health check on every 'stack up photos'. + +Keeps Immich's login through the stack's OIDC provider in line with the +credentials the provider stored. Without a provider it does nothing. +See oauth.py. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from oauth import sync + + +def run(ctx): + from stack.users import TECH_ADMIN_EMAIL + + sync("http://localhost:42010", TECH_ADMIN_EMAIL, + ctx.secret("ADMIN_PASSWORD") or "", ctx.env, ctx.step) diff --git a/stacklets/photos/oauth.py b/stacklets/photos/oauth.py new file mode 100644 index 00000000..44fe2286 --- /dev/null +++ b/stacklets/photos/oauth.py @@ -0,0 +1,98 @@ +"""Immich login through the stack's OIDC provider. + +Immich keeps its OAuth settings in its system config, in the database, +and does not read them from the environment. Its config file +(IMMICH_CONFIG_FILE) is no alternative: it replaces the whole system +config and makes every setting in the admin UI read-only. So the +settings go in through the admin API, `GET` and `PUT /api/system-config`. + +The stacklet owns five keys of the `oauth` section: whether it is on, +the issuer, the client credentials and the button text. Scope, claims, +auto signup and everything outside `oauth` stay as Immich defaults them +or as the admin set them. The password login is never touched, so an +account that is not linked yet keeps working. + +Without credentials nothing is written. That keeps a login an admin set +up by hand in the Immich UI. The cost: after `stack destroy` of the +provider, Immich keeps its OAuth settings and shows a button for an +issuer that is gone, until an admin turns it off. Once the credentials +are gone, the stacklet cannot tell its own settings from hand-made +ones, and switching off an admin's setup would be worse. + +Stdlib only, like every host-side hook. +""" + +import json +import urllib.error +import urllib.request + +BUTTON = "Sign in with family account" + + +# ── What the section should say ─────────────────────────────────────── + +def desired(env: dict) -> dict | None: + """The OAuth keys this stacklet owns, or None without credentials.""" + issuer = env.get("OIDC_ISSUER", "") + client_id = env.get("OIDC_CLIENT_ID", "") + secret = env.get("OIDC_CLIENT_SECRET", "") + if not (issuer and client_id and secret): + return None + return { + "enabled": True, + "issuerUrl": issuer, + "clientId": client_id, + "clientSecret": secret, + "buttonText": BUTTON, + } + + +def changed_keys(current: dict, want: dict) -> list[str]: + """Names of the owned keys whose value differs. Names only: one of + them is the client secret, and this list is printed.""" + return [k for k, v in want.items() if current.get(k) != v] + + +# ── Talking to Immich ───────────────────────────────────────────────── + +def _call(base_url, method, path, body=None, token=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request( + f"{base_url}{path}", method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read() or b"null") + + +def sync(base_url: str, admin_email: str, admin_password: str, env: dict, step) -> None: + """Bring Immich's OAuth section in line with the provider's credentials. + + Prints one line when something changed and nothing otherwise, since + this runs on every `stack up photos`. Errors are reported, not + raised: a failed sync must not stop the stacklet, Immich and its + password login still work, and the next run tries again. + """ + want = desired(env) + if want is None: + return + try: + # The same login setup.py uses. The config endpoints need an + # admin session, and the tech admin is the one account the + # stack always has the password for. + token = _call(base_url, "POST", "/api/auth/login", + {"email": admin_email, "password": admin_password})["accessToken"] + config = _call(base_url, "GET", "/api/system-config", token=token) + keys = changed_keys(config.get("oauth", {}), want) + if not keys: + return + # PUT takes the whole config: send back what Immich returned, + # with only the owned keys replaced. + config["oauth"] = {**config.get("oauth", {}), **want} + _call(base_url, "PUT", "/api/system-config", config, token=token) + step(f"Immich login through the OIDC provider: {', '.join(keys)} updated") + except urllib.error.HTTPError as e: + step(f"Immich OAuth settings not updated: {e.code} {e.read().decode()[:160]}") + except urllib.error.URLError as e: + step(f"Immich OAuth settings not updated: {e.reason}") diff --git a/stacklets/photos/stacklet.toml b/stacklets/photos/stacklet.toml index 203a02b3..f477d38e 100644 --- a/stacklets/photos/stacklet.toml +++ b/stacklets/photos/stacklet.toml @@ -46,6 +46,23 @@ DB_DATABASE_NAME = "immich" DB_HOSTNAME = "stack-photos-db" REDIS_HOSTNAME = "stack-photos-redis" IMMICH_VERSION = "v3" +# Single sign-on through the stack's OIDC provider, if one is installed. +# Empty until the provider has registered this stacklet. Immich does not +# read these; hooks/on_start_ready.py writes them into its system config. +OIDC_ISSUER = "{oidc_issuer}" +OIDC_CLIENT_ID = "{oidc_client_id}" +OIDC_CLIENT_SECRET = "{oidc_client_secret}" + +# Declares this stacklet as an OpenID Connect client. Immich redirects to +# /auth/login and /user-settings in the browser, and the mobile app to +# app.immich:///oauth-callback. +[oidc] +name = "Photos" +callbacks = [ + "{url}/auth/login", + "{url}/user-settings", + "app.immich:///oauth-callback", +] # stack status uses this to confirm the service is actually responding, # not just that the container is running. diff --git a/tests/unit/stacklets/test_photos_oauth.py b/tests/unit/stacklets/test_photos_oauth.py new file mode 100644 index 00000000..7f6018f2 --- /dev/null +++ b/tests/unit/stacklets/test_photos_oauth.py @@ -0,0 +1,180 @@ +"""Immich login through the stack's OIDC provider. + +The photos stacklet is an OIDC client. Immich does not read its OAuth +settings from the environment, and its config file (IMMICH_CONFIG_FILE) +replaces the whole system config and locks every setting in the admin +UI. So the stacklet writes the OAuth section through Immich's admin +API, and only the keys it owns: whether OAuth is on, the issuer, the +client credentials and the button text. Everything else in the system +config, OAuth claims included, stays as the admin set it. + +Immich's `PUT /api/system-config` takes the whole config, so the fake +here stores and returns the whole config as Immich does. +""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from stack import Stack # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "photos_oauth", REPO / "stacklets" / "photos" / "oauth.py") +oauth = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(oauth) + + +# ── The manifest side: what the provider registers ────────────────────── + +@pytest.fixture +def instance(tmp_path): + """The repo's stacklets plus a provider extension, in domain mode.""" + (tmp_path / "stack.toml").write_text( + '[core]\ndomain = "home.example.family"\n' + f'extension_dirs = ["{tmp_path / "ext"}"]\n') + (tmp_path / ".stack").mkdir() + (tmp_path / "ext" / "idp").mkdir(parents=True) + (tmp_path / "ext" / "idp" / "stacklet.toml").write_text( + 'id = "idp"\nport = 42100\n[oidc_provider]\n') + return Stack(root=REPO, data=tmp_path / "data", instance_dir=tmp_path) + + +def test_the_provider_registers_the_web_and_the_mobile_callbacks(instance): + """Immich redirects to /auth/login and /user-settings in the browser, + and the mobile app to app.immich:///oauth-callback. A callback the + provider does not know fails at login time with a provider error.""" + [photos] = [c for c in instance.oidc_clients() if c["stacklet"] == "photos"] + assert sorted(photos["callbacks"]) == sorted([ + "http://photos.home.example.family/auth/login", + "http://photos.home.example.family/user-settings", + "app.immich:///oauth-callback", + ]) + + +def test_registered_credentials_reach_the_hook(instance): + instance.store_oidc_client("photos", client_id="c-1", client_secret="s-1") + env = instance.env("photos") + assert (env["OIDC_ISSUER"], env["OIDC_CLIENT_ID"], env["OIDC_CLIENT_SECRET"]) == ( + "http://idp.home.example.family", "c-1", "s-1") + + +# ── The Immich side: what the hook writes ──────────────────────────────── + +ADMIN = ("stackadmin@home.local", "admin-pass") +ENV = {"OIDC_ISSUER": "https://id.home.example.family", + "OIDC_CLIENT_ID": "c-1", "OIDC_CLIENT_SECRET": "s-1"} + +# A slice of Immich's system config with settings an admin changed. +ADMIN_CONFIG = { + "oauth": {"enabled": False, "issuerUrl": "", "clientId": "", + "clientSecret": "", "buttonText": "Login with OAuth", + "storageLabelClaim": "email", "autoRegister": False}, + "passwordLogin": {"enabled": True}, + "server": {"externalDomain": "https://photos.home.example.family"}, +} + + +class FakeImmich: + """Immich's login and system-config endpoints, holding one config.""" + + def __init__(self, httpserver, config): + self.config = copy.deepcopy(config) + self.puts = 0 + httpserver.expect_request("/api/auth/login", method="POST").respond_with_handler(self._login) + httpserver.expect_request("/api/system-config", method="GET").respond_with_handler(self._get) + httpserver.expect_request("/api/system-config", method="PUT").respond_with_handler(self._put) + self.url = httpserver.url_for("").rstrip("/") + + def _login(self, request): + from werkzeug import Response + body = json.loads(request.data) + if (body["email"], body["password"]) != ADMIN: + return Response(json.dumps({"message": "Incorrect email or password"}), 401) + return Response(json.dumps({"accessToken": "t-1"}), 201, + content_type="application/json") + + def _authorized(self, request): + return request.headers.get("Authorization") == "Bearer t-1" + + def _get(self, request): + from werkzeug import Response + if not self._authorized(request): + return Response("{}", 401) + return Response(json.dumps(self.config), 200, content_type="application/json") + + def _put(self, request): + from werkzeug import Response + if not self._authorized(request): + return Response("{}", 401) + self.puts += 1 + self.config = json.loads(request.data) + return Response(json.dumps(self.config), 200, content_type="application/json") + + +def _sync(immich, env=ENV, password=ADMIN[1]): + steps = [] + oauth.sync(immich.url, ADMIN[0], password, env, steps.append) + return steps + + +def test_turns_the_login_on_with_the_provider(httpserver): + immich = FakeImmich(httpserver, ADMIN_CONFIG) + _sync(immich) + got = immich.config["oauth"] + assert (got["enabled"], got["issuerUrl"], got["clientId"], got["clientSecret"]) == ( + True, "https://id.home.example.family", "c-1", "s-1") + assert got["buttonText"] == "Sign in with family account" + + +def test_leaves_every_setting_it_does_not_own(httpserver): + """The admin chose the email as storage label and no auto signup; + the password login and the server settings are theirs too.""" + immich = FakeImmich(httpserver, ADMIN_CONFIG) + _sync(immich) + assert immich.config["oauth"]["storageLabelClaim"] == "email" + assert immich.config["oauth"]["autoRegister"] is False + assert immich.config["passwordLogin"] == ADMIN_CONFIG["passwordLogin"] + assert immich.config["server"] == ADMIN_CONFIG["server"] + + +def test_says_what_changed_and_never_the_secret(httpserver): + immich = FakeImmich(httpserver, ADMIN_CONFIG) + steps = _sync(immich) + assert len(steps) == 1 + assert "clientSecret" in steps[0] and "s-1" not in steps[0] + + +def test_a_second_run_writes_nothing_and_says_nothing(httpserver): + immich = FakeImmich(httpserver, ADMIN_CONFIG) + _sync(immich) + steps = _sync(immich) + assert immich.puts == 1 + assert steps == [] + + +def test_without_credentials_immich_is_not_touched(httpserver): + """No provider, or not registered yet: a login an admin set up by + hand in the Immich UI must survive `stack up photos`.""" + immich = FakeImmich(httpserver, ADMIN_CONFIG) + steps = _sync(immich, env={"OIDC_ISSUER": "", "OIDC_CLIENT_ID": "", "OIDC_CLIENT_SECRET": ""}) + assert immich.puts == 0 + assert steps == [] + assert httpserver.log == [] + + +def test_a_refused_login_is_reported_not_raised(httpserver): + """A failed sync must not stop `stack up photos`: Immich and its + password login still work, and the next run tries again.""" + immich = FakeImmich(httpserver, ADMIN_CONFIG) + steps = _sync(immich, password="wrong") + assert immich.puts == 0 + assert len(steps) == 1 and "401" in steps[0]