Complete TB-006 batch 1 SSO so admin apps use Authentik without double login.
Deploy to valhalla / deploy (push) Has been cancelled

Native OAuth/OIDC for Homarr, BookStack, Gitea, and MinIO console; forward_auth with local auth disabled for code-server, uptime, and kopia; Caddy and Authentik scripts updated to match.
This commit is contained in:
ginnoir
2026-06-10 23:52:14 -05:00
parent 109cc0d5cb
commit 63889fc150
19 changed files with 424 additions and 94 deletions
@@ -0,0 +1,15 @@
#!/bin/bash
set -e
docker cp /tmp/disable-uptime-auth.py uptime_kuma:/tmp/disable-uptime-auth.py
docker exec uptime_kuma python3 /tmp/disable-uptime-auth.py /app/data/kuma.db
docker exec -u git gitea gitea admin auth add-oauth \
--name Authentik \
--provider openidConnect \
--key y7rEvQjZ2J6sGuLndPZalGXn4jx0Tus601U9USOS \
--secret FCbRN0YUh5K6rGhirX5HlrSumYgmKPGi6xBPQY5YvpJIPGi9st4g8FZN2kyJ7IzDXbj0FGmE3Q2qysNcZYePc7mofq6k98fz9Ue97Tjj4y3ZPNXAeAqlJrclv9DzR6eo \
--auto-discover-url https://auth.ginnoir.com/application/o/gitea/.well-known/openid-configuration \
--scopes "openid profile email" || echo "gitea oauth may already exist"
echo done
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Apply TB-006 batch 1 proxy providers via authentik ORM (run: ak shell < script)."""
"""Apply TB-006 batch 1 forward_auth proxy providers (forward_auth-only sites).
Native OAuth/OIDC apps (bookstack, gitea, portainer, minio, plane, homarr) use
setup-batch1-oidc.py instead. Run this via: ak shell -c exec(open(...).read())
"""
from authentik.core.models import Application
from authentik.flows.models import Flow
from authentik.outposts.models import Outpost, OutpostType
@@ -8,33 +12,38 @@ from authentik.providers.proxy.models import ProxyMode, ProxyProvider
AUTHZ = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
INVALID = Flow.objects.get(slug="default-provider-invalidation-flow")
UPTIME_UNAUTH = """^/status/.*
^/assets/.*
^/api/push/.*
^/api/badge/.*
^/api/status-page/heartbeat/.*
^/icon.svg
^/upload/.*"""
SITES = [
("homelab-portainer", "portainer", "Portainer", "https://portainer.ginnoir.com"),
("homelab-code", "code", "code-server", "https://code.ginnoir.com"),
("homelab-registry-ui", "registry-ui", "Registry UI", "https://registry-ui.ginnoir.com"),
("homelab-vault", "vault", "Vault", "https://vault.ginnoir.com"),
("homelab-minio", "minio", "MinIO Console", "https://minio.ginnoir.com"),
("homelab-homarr", "homarr", "Homarr", "https://homarr.ginnoir.com"),
("homelab-uptime", "uptime", "Uptime Kuma", "https://uptime.ginnoir.com"),
("homelab-backup", "backup", "Kopia", "https://backup.ginnoir.com"),
("homelab-gitea", "gitea", "Gitea", "https://gitea.ginnoir.com"),
("homelab-dbx", "dbx", "DBX", "https://dbx.ginnoir.com"),
("homelab-bookstack", "bookstack", "BookStack", "https://docs.ginnoir.com"),
("homelab-plane", "plane", "Plane", "https://plane.ginnoir.com"),
("homelab-code", "code", "code-server", "https://code.ginnoir.com", None, None),
("homelab-registry-ui", "registry-ui", "Registry UI", "https://registry-ui.ginnoir.com", None, None),
("homelab-vault", "vault", "Vault", "https://vault.ginnoir.com", None, None),
("homelab-uptime", "uptime", "Uptime Kuma", "https://uptime.ginnoir.com", "http://uptime-kuma:3001", UPTIME_UNAUTH),
("homelab-backup", "backup", "Kopia", "https://backup.ginnoir.com", None, None),
("homelab-dbx", "dbx", "DBX", "https://dbx.ginnoir.com", None, None),
]
providers = []
for pname, slug, aname, host in SITES:
provider, _ = ProxyProvider.objects.update_or_create(
name=pname,
defaults={
"mode": ProxyMode.FORWARD_SINGLE,
"external_host": host,
"authorization_flow": AUTHZ,
"invalidation_flow": INVALID,
"intercept_header_auth": True,
},
)
for pname, slug, aname, host, internal, unauth in SITES:
defaults = {
"mode": ProxyMode.FORWARD_SINGLE,
"external_host": host,
"authorization_flow": AUTHZ,
"invalidation_flow": INVALID,
"intercept_header_auth": True,
}
if internal:
defaults["internal_host"] = internal
if unauth:
defaults["skip_path_regex"] = unauth
provider, _ = ProxyProvider.objects.update_or_create(name=pname, defaults=defaults)
Application.objects.update_or_create(
slug=slug,
defaults={
@@ -0,0 +1,45 @@
"""Configure Portainer OAuth via API (run on valhalla with admin JWT)."""
import json
import sys
import urllib.error
import urllib.request
JWT = sys.argv[1] if len(sys.argv) > 1 else ""
CLIENT_ID = sys.argv[2] if len(sys.argv) > 2 else ""
CLIENT_SECRET = sys.argv[3] if len(sys.argv) > 3 else ""
if not JWT or not CLIENT_ID or not CLIENT_SECRET:
print("usage: configure-portainer-oauth.py <admin_jwt> <client_id> <client_secret>")
sys.exit(1)
BASE = "http://localhost:9100/api"
AUTH = "https://auth.ginnoir.com/application/o"
payload = {
"AuthenticationMethod": 3,
"OAuthSettings": {
"ClientID": CLIENT_ID,
"ClientSecret": CLIENT_SECRET,
"AuthorizationURI": f"{AUTH}/authorize/",
"AccessTokenURI": f"{AUTH}/token/",
"ResourceURI": f"{AUTH}/userinfo/",
"RedirectURI": "https://portainer.ginnoir.com/",
"LogoutURI": f"{AUTH}/portainer/end-session/",
"UserIdentifier": "email",
"Scopes": "openid email profile",
"OAuthAutoCreateUsers": True,
"DefaultTeamID": 0,
},
}
req = urllib.request.Request(
f"{BASE}/settings",
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {JWT}", "Content-Type": "application/json"},
method="PUT",
)
try:
with urllib.request.urlopen(req) as resp:
print("portainer oauth configured:", resp.status)
except urllib.error.HTTPError as e:
print("error:", e.code, e.read().decode())
sys.exit(1)
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
docker cp /tmp/disable-uptime-auth.py uptime_kuma:/tmp/disable-uptime-auth.py
docker exec uptime_kuma python3 /tmp/disable-uptime-auth.py /app/data/kuma.db
docker exec -u git gitea gitea admin auth add-oauth \
--name Authentik \
--provider openidConnect \
--key y7rEvQjZ2J6sGuLndPZalGXn4jx0Tus601U9USOS \
--secret FCbRN0YUh5K6rGhirX5HlrSumYgmKPGi6xBPQY5YvpJIPGi9st4g8FZN2kyJ7IzDXbj0FGmE3Q2qysNcZYePc7mofq6k98fz9Ue97Tjj4y3ZPNXAeAqlJrclv9DzR6eo \
--auto-discover-url https://auth.ginnoir.com/application/o/gitea/.well-known/openid-configuration \
--scopes "openid profile email" 2>&1 || echo "gitea oauth may already exist"
cp /tmp/init-kopia.sh /config/backup/init-kopia.sh
chmod +x /config/backup/init-kopia.sh
mkdir -p /config/code-server/.config/code-server
cp /tmp/code-server.yaml /config/code-server/code-server.yaml
docker compose -p dev -f /tmp/batch1-deploy/dev/docker-compose.yml --env-file /tmp/batch1-deploy/dev/stack.env up -d --force-recreate bookstack code-server gitea
docker compose -p resume -f /tmp/batch1-deploy/resume/docker-compose.yml --env-file /tmp/batch1-deploy/resume/stack.env up -d --force-recreate resume-minio
docker compose -p backup -f /data/compose/29/stacks/backup/docker-compose.yml up -d --force-recreate kopia
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
echo batch1 live deploy done
@@ -0,0 +1,15 @@
"""Disable Uptime Kuma local auth (forward_auth is the gate)."""
import sqlite3
import sys
DB = sys.argv[1] if len(sys.argv) > 1 else "/config/uptime-kuma/kuma.db"
conn = sqlite3.connect(DB)
cur = conn.cursor()
cur.execute(
"INSERT INTO setting (key, value) VALUES ('disableAuth', '1') "
"ON CONFLICT(key) DO UPDATE SET value='1'"
)
conn.commit()
conn.close()
print(f"disableAuth=1 in {DB}")
@@ -0,0 +1,77 @@
"""Create OAuth2 providers for batch-1 apps with native SSO (ak shell: exec(open(...).read()))."""
from authentik.core.models import Application
from authentik.flows.models import Flow
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.proxy.models import ProxyProvider
AUTHZ = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
INVALID = Flow.objects.get(slug="default-provider-invalidation-flow")
DEFAULT_MAPPINGS = OAuth2Provider.objects.get(name="famapp").property_mappings.all()
# slug, provider name, redirect URI(s)
OIDC_APPS = [
("bookstack", "bookstack", ["https://docs.ginnoir.com/oidc/callback"]),
("gitea", "gitea", ["https://gitea.ginnoir.com/user/oauth2/Authentik/callback"]),
("portainer", "portainer", ["https://portainer.ginnoir.com/"]),
("minio", "minio-console", ["https://minio.ginnoir.com/oauth_callback"]),
("plane", "plane", ["https://plane.ginnoir.com/auth/oidc/callback/"]),
]
native_slugs = []
for slug, pname, redirects in OIDC_APPS:
provider, created = OAuth2Provider.objects.update_or_create(
name=pname,
defaults={
"authorization_flow": AUTHZ,
"invalidation_flow": INVALID,
"redirect_uris": [
RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url=url)
for url in redirects
],
"access_code_validity": "minutes=1",
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
},
)
provider.property_mappings.set(DEFAULT_MAPPINGS)
provider.save()
app, _ = Application.objects.update_or_create(
slug=slug,
defaults={
"name": pname.replace("-", " ").title(),
"provider": provider,
"meta_launch_url": redirects[0].split("/oidc")[0].split("/user/oauth2")[0].rstrip("/"),
"policy_engine_mode": "any",
},
)
app.provider = provider
app.save()
native_slugs.append(slug)
print(f"oauth2 {slug} created={created} client_id={provider.client_id}")
print(f" {slug.upper()}_OIDC_CLIENT_ID={provider.client_id}")
print(f" {slug.upper()}_OIDC_CLIENT_SECRET={provider.client_secret}")
# Drop native-OIDC apps from the embedded proxy outpost (avoid double auth).
PROXY_NAMES = {
"bookstack": "homelab-bookstack",
"gitea": "homelab-gitea",
"portainer": "homelab-portainer",
"minio": "homelab-minio",
"plane": "homelab-plane",
}
outpost = Outpost.objects.get(name="authentik Embedded Outpost")
for slug in native_slugs:
pname = PROXY_NAMES.get(slug)
if not pname:
continue
try:
proxy = ProxyProvider.objects.get(name=pname)
outpost.providers.remove(proxy)
print(f"removed proxy {pname} from embedded outpost")
except ProxyProvider.DoesNotExist:
print(f"proxy {pname} not found (ok)")
print("done — configure each app with printed client_id/secret")
@@ -0,0 +1,59 @@
"""Update proxy providers for forward_auth-only batch-1 apps (ak shell: exec(open(...).read()))."""
from authentik.core.models import Application
from authentik.flows.models import Flow
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
AUTHZ = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
INVALID = Flow.objects.get(slug="default-provider-invalidation-flow")
UPTIME_UNAUTH = """^/status/.*
^/assets/.*
^/api/push/.*
^/api/badge/.*
^/api/status-page/heartbeat/.*
^/icon.svg
^/upload/.*"""
# Proxy-only sites (native OIDC apps managed by setup-batch1-oidc.py).
SITES = [
("homelab-code", "code", "code-server", "https://code.ginnoir.com", None, None),
("homelab-registry-ui", "registry-ui", "Registry UI", "https://registry-ui.ginnoir.com", None, None),
("homelab-vault", "vault", "Vault", "https://vault.ginnoir.com", None, None),
("homelab-uptime", "uptime", "Uptime Kuma", "https://uptime.ginnoir.com", "http://uptime-kuma:3001", UPTIME_UNAUTH),
("homelab-backup", "backup", "Kopia", "https://backup.ginnoir.com", None, None),
("homelab-dbx", "dbx", "DBX", "https://dbx.ginnoir.com", None, None),
]
providers = []
for pname, slug, aname, host, internal, unauth in SITES:
defaults = {
"mode": ProxyMode.FORWARD_SINGLE,
"external_host": host,
"authorization_flow": AUTHZ,
"invalidation_flow": INVALID,
"intercept_header_auth": True,
}
if internal:
defaults["internal_host"] = internal
if unauth:
defaults["skip_path_regex"] = unauth
provider, _ = ProxyProvider.objects.update_or_create(name=pname, defaults=defaults)
Application.objects.update_or_create(
slug=slug,
defaults={
"name": aname,
"provider": provider,
"meta_launch_url": host,
"policy_engine_mode": "any",
},
)
providers.append(provider)
print(f"ok {slug} -> {host}")
outpost = Outpost.objects.get(name="authentik Embedded Outpost")
outpost.type = OutpostType.PROXY
outpost.providers.set(providers)
outpost.save()
print(f"outpost providers: {outpost.providers.count()}")
@@ -0,0 +1,24 @@
"""Register Authentik as Gitea OAuth source (run on host: docker exec gitea ...)."""
import subprocess
import sys
CLIENT_ID = sys.argv[1] if len(sys.argv) > 1 else ""
CLIENT_SECRET = sys.argv[2] if len(sys.argv) > 2 else ""
if not CLIENT_ID or not CLIENT_SECRET:
print("usage: setup-gitea-oauth.py <client_id> <client_secret>")
sys.exit(1)
DISCOVERY = "https://auth.ginnoir.com/application/o/gitea/.well-known/openid-configuration"
cmd = [
"docker", "exec", "-u", "git", "gitea", "gitea", "admin", "auth", "add-oauth",
"--name", "Authentik",
"--provider", "openidConnect",
"--key", CLIENT_ID,
"--secret", CLIENT_SECRET,
"--auto-discover-url", DISCOVERY,
"--scopes", "openid profile email",
]
print("running:", " ".join(cmd[:8]), "...")
subprocess.run(cmd, check=True)
print("gitea oauth source Authentik registered")
@@ -0,0 +1,47 @@
"""Switch homarr from forward-auth proxy provider to native OIDC (run: ak shell -c exec(open(...).read()))."""
from authentik.core.models import Application
from authentik.flows.models import Flow
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.proxy.models import ProxyProvider
AUTHZ = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
INVALID = Flow.objects.get(slug="default-provider-invalidation-flow")
REDIRECT = "https://homarr.ginnoir.com/api/auth/callback/oidc"
provider, created = OAuth2Provider.objects.update_or_create(
name="homarr",
defaults={
"authorization_flow": AUTHZ,
"invalidation_flow": INVALID,
"redirect_uris": [
RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url=REDIRECT),
],
"access_code_validity": "minutes=1",
"access_token_validity": "hours=24",
"refresh_token_validity": "days=30",
},
)
# Required for Homarr profile parsing — without these, userinfo lacks name/email claims.
default_mappings = OAuth2Provider.objects.get(name="famapp").property_mappings.all()
provider.property_mappings.set(default_mappings)
provider.save()
print(f"oauth2 provider homarr created={created} client_id={provider.client_id}")
app = Application.objects.get(slug="homarr")
app.provider = provider
app.save()
print(f"application homarr -> OAuth2Provider homarr")
# Drop homarr from the embedded proxy outpost (native OIDC replaces forward_auth here).
try:
proxy = ProxyProvider.objects.get(name="homelab-homarr")
outpost = Outpost.objects.get(name="authentik Embedded Outpost")
outpost.providers.remove(proxy)
print("removed homelab-homarr from embedded outpost")
except ProxyProvider.DoesNotExist:
print("homelab-homarr proxy provider not found (ok)")
print(f"AUTH_OIDC_CLIENT_ID={provider.client_id}")
print(f"AUTH_OIDC_CLIENT_SECRET={provider.client_secret}")