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:
@@ -0,0 +1,8 @@
|
||||
name = "immich"
|
||||
|
||||
[dependencies]
|
||||
requires = ["authentik"]
|
||||
|
||||
[sso]
|
||||
provider = "authentik"
|
||||
setup_script = "setup/sso_integration.py"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Executable
+28
@@ -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()
|
||||
Executable
+96
@@ -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()
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user