recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)

Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
name = "immich"
[dependencies]
requires = ["authentik"]
[sso]
provider = "authentik"
setup_script = "setup/sso_integration.py"
+38
View File
@@ -0,0 +1,38 @@
# Immich — First-Time Setup
## Prerequisites
- DNS: `immich.<domain_suffix>` must resolve to the server
- **Authentik** must be deployed and running (dependency)
## Steps
1. **Create the app:**
```bash
abra app new immich --server <SERVER> --domain immich.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate immich.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/immich.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy immich.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Authentik SSO integration:**
```bash
python3 recipe-info/immich/setup_authentik_integration.py
```
This creates an OAuth2 provider and application in Authentik, creates a test user, creates an Immich admin account via the API, and configures Immich's OAuth settings via the Immich system API.
5. **Verify:** curl `https://immich.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- Credentials are saved to `recipe-info/immich/authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
- Unlike other recipes, Immich's OAuth is configured via its admin API (not env vars), so no redeploy is needed after SSO setup.
- OIDC test user: `testuser` / `testpass123`.
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Setup Authentik OIDC integration for Immich.
Creates an OAuth2 provider, application, and test user in Authentik,
then creates an Immich admin account and configures OAuth via the
Immich API.
"""
import json
import os
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.authentik import AuthentikAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
PROVIDER_NAME = "immich"
APP_SLUG = "immich"
CLIENT_ID = "immich"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
IMMICH_ADMIN_EMAIL = "admin@immich.test"
IMMICH_ADMIN_PASS = "adminpass123"
IMMICH_ADMIN_NAME = "Admin"
def _immich_request(method, url, data=None, headers=None, timeout=10):
"""Make an HTTP request to the Immich API."""
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return resp.getcode(), None
return resp.getcode(), json.loads(raw)
except urllib.error.HTTPError as e:
try:
raw = e.read().decode(errors="replace")
return e.code, json.loads(raw) if raw.strip() else None
except Exception:
return e.code, None
def main():
inst = load_default_instance()
immich_domain = inst.default_domain("immich")
immich_url = f"https://{immich_domain}"
ak_domain = inst.default_domain("authentik")
ak_url = f"https://{ak_domain}"
# Get Authentik admin token from synced secrets
ak_secrets = load_secrets(ak_domain)
ak_token = ak_secrets["admin_token"]
ak = AuthentikAdmin(ak_url, ak_token)
# Resolve Authentik UUIDs
uuids = ak.resolve_uuids()
# Step 1: Create OAuth2 provider
provider_pk, client_secret = ak.ensure_provider(
PROVIDER_NAME, CLIENT_ID,
redirect_uris=[
{"matching_mode": "strict", "url": f"{immich_url}/auth/login"},
{"matching_mode": "strict", "url": f"{immich_url}/user-settings"},
{"matching_mode": "strict", "url": "app.immich:///oauth-callback"},
],
uuids=uuids,
)
# Step 2: Create application
ak.ensure_application("Immich", APP_SLUG, provider_pk, immich_url)
# Step 3: Ensure test user with APP_PASSWORD
user_pk = ak.ensure_user(TEST_USER, TEST_EMAIL, TEST_PASS)
app_password = ak.ensure_app_password(user_pk)
# Step 4: Configure Immich OAuth via API
print("=== Configure Immich OAuth via API ===", flush=True)
# Create admin account (skip if already exists)
print(" Creating Immich admin account ...", flush=True)
status, resp = _immich_request("POST", f"{immich_url}/api/auth/admin-sign-up", {
"email": IMMICH_ADMIN_EMAIL,
"password": IMMICH_ADMIN_PASS,
"name": IMMICH_ADMIN_NAME,
})
if resp and ("error" in resp or "message" in resp):
msg = resp.get("error", resp.get("message", ""))
if "admin" in msg.lower():
print(" Admin account already exists, continuing", flush=True)
else:
print(f" Admin signup response: {msg}", flush=True)
else:
print(" Admin account created (or already existed)", flush=True)
# Login to get access token
print(" Logging in as Immich admin ...", flush=True)
status, resp = _immich_request("POST", f"{immich_url}/api/auth/login", {
"email": IMMICH_ADMIN_EMAIL,
"password": IMMICH_ADMIN_PASS,
})
if not resp or "accessToken" not in resp:
print(f" FAIL: Could not login to Immich. Response: {resp}", flush=True)
sys.exit(1)
access_token = resp["accessToken"]
print(f" Logged in (token: {access_token[:10]}...)", flush=True)
auth_headers = {"Authorization": f"Bearer {access_token}"}
# Get current system config
print(" Fetching current Immich system config ...", flush=True)
_, config = _immich_request("GET", f"{immich_url}/api/system-config",
headers=auth_headers)
# Merge OAuth settings
print(" Updating OAuth settings ...", flush=True)
oidc_issuer = f"{ak_url}/application/o/{APP_SLUG}/.well-known/openid-configuration"
oauth = config.get("oauth", {})
oauth.update({
"enabled": True,
"issuerUrl": oidc_issuer,
"clientId": CLIENT_ID,
"clientSecret": client_secret,
"scope": "openid email profile",
"autoRegister": True,
"autoLaunch": False,
"buttonText": "Login with Authentik",
"tokenEndpointAuthMethod": "client_secret_post",
"timeout": 30000,
"mobileOverrideEnabled": False,
"mobileRedirectUri": "",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "",
"defaultStorageQuota": 0,
"profileSigningAlgorithm": "none",
"roleClaim": "immich_role",
})
config["oauth"] = oauth
_, put_resp = _immich_request("PUT", f"{immich_url}/api/system-config",
data=config, headers=auth_headers)
oauth_enabled = (put_resp or {}).get("oauth", {}).get("enabled", False)
if oauth_enabled:
print(" OAuth enabled successfully", flush=True)
else:
print(f" WARNING: Could not confirm OAuth enabled. Response: {put_resp}",
flush=True)
# Step 5: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"authentik-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Authentik OIDC credentials for Immich test instance\n')
f.write(f'#\n')
f.write(f'# Authentik instance: {ak_domain}\n')
f.write(f'# Application slug: {APP_SLUG}\n')
f.write(f'# Created by: setup_authentik_integration.py\n')
f.write(f'\n')
f.write(f'# Authentik admin\n')
f.write(f'ak_token = "{ak_token}"\n')
f.write(f'\n')
f.write(f'# OIDC provider\n')
f.write(f'ak_app_slug = "{APP_SLUG}"\n')
f.write(f'ak_client_id = "{CLIENT_ID}"\n')
f.write(f'ak_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Authentik OIDC endpoints\n')
f.write(f'ak_token_endpoint = "https://{ak_domain}/application/o/token/"\n')
f.write(f'ak_userinfo_endpoint = "https://{ak_domain}/application/o/userinfo/"\n')
f.write(f'ak_discovery_endpoint = "https://{ak_domain}/application/o/{APP_SLUG}/.well-known/openid-configuration"\n')
f.write(f'\n')
f.write(f'# Test user (password for browser login, app_password for password grant)\n')
f.write(f'ak_test_user = "{TEST_USER}"\n')
f.write(f'ak_test_pass = "{TEST_PASS}"\n')
f.write(f'ak_test_app_password = "{app_password}"\n')
f.write(f'ak_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# Immich instance\n')
f.write(f'immich_domain = "{immich_domain}"\n')
f.write(f'immich_admin_email = "{IMMICH_ADMIN_EMAIL}"\n')
f.write(f'immich_admin_pass = "{IMMICH_ADMIN_PASS}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Authentik OIDC integration setup for Immich complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Run OIDC test: python3 recipe-info/immich/tests/oidc_login.py", flush=True)
print(f" 2. Manual: open {immich_url} and click 'Login with Authentik'", flush=True)
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
# Immich Tests
## Target
- **URL:** https://immich.<DOMAIN_SUFFIX>
## Dependencies
- **Authentik** (`authentik.<DOMAIN_SUFFIX>`) — required for SSO/OIDC testing
## Test Setup
Before running all tests, the following must be in place:
### 1. Deploy authentik
```bash
abra app deploy authentik.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 2. Deploy Immich
```bash
abra app deploy immich.<DOMAIN_SUFFIX> --chaos --force --no-input
```
### 3. Run the Authentik integration setup
```bash
python3 recipe-info/immich/setup_authentik_integration.py
```
This configures authentik as the OAuth provider for Immich:
1. Creates an OAuth2 provider (`immich`) via the authentik REST API
2. Creates an Application linked to the provider
3. Creates a test user (`testuser` / `testpass123`) with an APP_PASSWORD token
4. Creates an Immich admin account via the Immich API
5. Configures Immich's OAuth settings via the Immich system config API
6. Writes credentials to `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`
**Important:** The APP_PASSWORD token becomes invalid if authentik is redeployed. If the `oidc_login.py` test fails with "invalid, expired, revoked" token errors, re-run this setup script.
### 4. Verify
No redeploy needed — Immich's OAuth is configured via its API, not env vars.
## Automated Tests
- `tests/health_check.py` — Confirms the instance is reachable and returns HTTP 200.
- `tests/oidc_login.py` — Tests SSO/OIDC integration with Authentik. Checks OIDC discovery, APP_PASSWORD token grant, and Immich API authentication.
### Credentials
| Key | Description |
|-----|-------------|
| `ak_client_id` / `ak_client_secret` | OIDC client ID and secret |
| `ak_test_user` / `ak_test_pass` | Test user credentials (password for browser login) |
| `ak_test_app_password` | APP_PASSWORD token for password grant (authentik requires this instead of regular passwords) |
| `ak_test_email` | Test user email |
| `ak_discovery_endpoint` | Authentik OIDC discovery URL |
Stored in `authentik-test-credentials.<DOMAIN_SUFFIX>.toml`.
## Manual Verification
1. Open https://immich.<DOMAIN_SUFFIX> in a browser.
2. Confirm the Immich web interface loads without errors.
3. Confirm the "Login with Authentik" button appears on the login page.
4. Click it and verify redirect to Authentik for authentication.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for Immich."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('immich')
url = f"https://{domain}"
print(f"Checking Immich at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: Immich returned HTTP {status}")
else:
print(f"FAIL: Immich returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Immich OIDC integration test."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
if os.environ.get('SKIP_INTEGRATION') == '1':
print("SKIP: OIDC integration test (SKIP_INTEGRATION=1)")
return
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('immich')
url = f"https://{domain}"
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'authentik')
if creds is None:
print("FAIL: Credentials file not found: authentik-test-credentials.<domain_suffix>.toml")
print("Run setup_authentik_integration.py first.")
sys.exit(1)
print("=== Immich OIDC Integration Test ===")
print()
# Step 1: Check Immich is deployed
print("Step 1: Checking Immich is deployed ...")
status, _ = http_get(url)
if status == 0:
print(f" FAIL: Immich is not reachable at {url}")
sys.exit(1)
elif status >= 500:
print(f" FAIL: Immich returned HTTP {status}")
sys.exit(1)
print(f" OK: Immich is reachable (HTTP {status})")
# Step 2: Verify Authentik OIDC discovery
print("Step 2: Checking Authentik OIDC discovery ...")
discovery_url = creds["ak_discovery_endpoint"]
status, _ = http_get(discovery_url)
if status != 200:
print(f" FAIL: OIDC discovery returned HTTP {status}")
print(f" URL: {discovery_url}")
sys.exit(1)
print(f" PASS: OIDC discovery endpoint OK (app '{creds['ak_app_slug']}')")
# Step 3: Obtain token from Authentik
print("Step 3: Obtaining token from Authentik for test user ...")
print(" Using APP_PASSWORD for password grant (authentik requirement)")
status, data = http_post(
creds["ak_token_endpoint"],
data={
"grant_type": "password",
"client_id": creds["ak_client_id"],
"client_secret": creds["ak_client_secret"],
"username": creds["ak_test_user"],
"password": creds["ak_test_app_password"],
"scope": "openid email profile",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Token request failed: {error}")
sys.exit(1)
print(f" PASS: Got access token ({len(access_token)} chars)")
# Step 4: Verify Immich OAuth endpoint
print("Step 4: Checking Immich OAuth endpoint ...")
status, body = http_post(
f"{url}/api/oauth/authorize",
data={"redirectUri": f"{url}/auth/login"},
)
oauth_url = (body or {}).get("url", "")
if not oauth_url:
error = (body or {}).get("message", (body or {}).get("error", "unknown"))
print(f" FAIL: OAuth authorize endpoint failed: {error}")
sys.exit(1)
print(" PASS: OAuth authorize returned redirect URL")
print()
print("PASS: Immich OIDC integration test passed")
print(" Authentik OIDC discovery OK, token grant OK, Immich OAuth endpoint active.")
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
# Immich Upstream
## Main Project
- **Repository:** https://github.com/immich-app/immich
- **Releases:** https://github.com/immich-app/immich/releases
- **Website:** https://immich.app
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `ghcr.io/immich-app/immich-server` | https://github.com/immich-app/immich/releases |
| immich-machine-learning | `ghcr.io/immich-app/immich-machine-learning` | https://github.com/immich-app/immich/releases |
| redis | `docker.io/valkey/valkey` | https://github.com/valkey-io/valkey/releases |
| database | `ghcr.io/immich-app/postgres` | https://github.com/immich-app/immich/releases |