Skip to content
Merged
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
14 changes: 14 additions & 0 deletions stacklets/photos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down
19 changes: 19 additions & 0 deletions stacklets/photos/hooks/on_start_ready.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 98 additions & 0 deletions stacklets/photos/oauth.py
Original file line number Diff line number Diff line change
@@ -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}")
17 changes: 17 additions & 0 deletions stacklets/photos/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions tests/unit/stacklets/test_photos_oauth.py
Original file line number Diff line number Diff line change
@@ -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]
Loading