From 96f23877da9f31fb7d3432e40d2bd607797d0980 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 21:34:16 +0200 Subject: [PATCH] feat(photos): log in to Immich through the stack's OIDC provider When a stacklet that provides OpenID Connect is installed and has registered the photos stacklet, `./stack up photos` turns on "Sign in with family account" in the Immich web UI and the mobile app. Accounts link by email. Without a provider nothing changes. - Immich takes OAuth settings only through its system config. Its config file would replace that config and lock every admin setting, so the settings go in through the admin API instead. - The stacklet owns five OAuth keys: on, issuer, client id, client secret, button text. Claims, auto signup, the password login and all other settings stay as the admin set them. - Without credentials Immich is left alone, so a login set up by hand in the admin UI is kept. After the provider is destroyed its button stays until an admin turns OAuth off. --- stacklets/photos/README.md | 14 ++ stacklets/photos/hooks/on_start_ready.py | 19 +++ stacklets/photos/oauth.py | 98 ++++++++++++ stacklets/photos/stacklet.toml | 17 ++ tests/unit/stacklets/test_photos_oauth.py | 180 ++++++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 stacklets/photos/hooks/on_start_ready.py create mode 100644 stacklets/photos/oauth.py create mode 100644 tests/unit/stacklets/test_photos_oauth.py 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]