Add Authentik and oauth2-proxy support to noble cluster setup, including environment variables, playbook tags, and landing URLs. Update README and kustomization.yaml to reflect new OIDC integration, enhancing security and user authentication capabilities.

This commit is contained in:
Nikholas Pcenicni
2026-05-14 00:23:48 -04:00
parent 2bf7277917
commit 78b524a044
25 changed files with 1125 additions and 7 deletions

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
Create / update Authentik OAuth2/OpenID providers + applications + groups (stdlib only).
Environment:
AUTHENTIK_API_BASE e.g. https://auth.apps.example.com/api/v3
AUTHENTIK_TOKEN bootstrap token (Bearer)
BOOTSTRAP_EMAIL initial admin email (added to noble-admins)
CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... }
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
def req(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list]:
data = None
headers = {"Authorization": f"Bearer {os.environ['AUTHENTIK_TOKEN']}", "Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=120) as resp:
raw = resp.read().decode("utf-8")
code = resp.getcode()
if not raw:
return code, {}
return code, json.loads(raw)
except urllib.error.HTTPError as e:
err = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {url} -> HTTP {e.code}: {err}") from e
def collect(base: str, path: str) -> list:
out: list = []
url = base + path
origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base
while url:
code, payload = req("GET", url)
if code != 200:
raise RuntimeError(f"GET {url} unexpected {code}")
if isinstance(payload, dict) and "results" in payload:
out.extend(payload["results"])
nxt = payload.get("next") or ""
if not nxt:
url = ""
elif nxt.startswith("http"):
url = nxt
else:
url = origin + nxt
else:
break
return out
def find_flow_pk(base: str, slug: str) -> str:
code, payload = req("GET", f"{base}/flows/instances/?slug={urllib.parse.quote(slug)}")
if code != 200 or not isinstance(payload, dict):
raise RuntimeError(f"flow lookup failed for {slug}")
results = payload.get("results") or []
if not results:
raise RuntimeError(f"flow slug not found: {slug}")
return str(results[0]["pk"])
def find_flow_pk_first(base: str, slugs: list[str]) -> str:
last_err: Exception | None = None
for s in slugs:
try:
return find_flow_pk(base, s)
except RuntimeError as e:
last_err = e
raise RuntimeError(f"no flow matched {slugs}: {last_err}")
def signing_key_pk(base: str) -> int:
code, payload = req("GET", f"{base}/crypto/certificatekeypairs/?ordering=pk&page_size=1")
if code != 200 or not isinstance(payload, dict) or not payload.get("results"):
raise RuntimeError("no signing certificate keypairs returned")
return int(payload["results"][0]["pk"])
def scope_mapping_pks(base: str) -> list[int]:
mappings = collect(base, "/propertymappings/oauthscope/?page_size=500")
want_scopes = {"openid", "email", "profile", "offline_access", "groups"}
pks: list[int] = []
for m in mappings:
sn = (m.get("scope_name") or "").strip()
if sn in want_scopes:
pks.append(int(m["pk"]))
# de-dupe preserve order
seen: set[int] = set()
uniq: list[int] = []
for p in pks:
if p not in seen:
seen.add(p)
uniq.append(p)
if len(uniq) >= 3:
return uniq
# Fallback: include managed OpenID mappings by name heuristics
for m in mappings:
name = (m.get("name") or "").lower()
if "openid" in name and any(x in name for x in ("openid", "email", "profile", "groups", "offline")):
pk = int(m["pk"])
if pk not in seen:
seen.add(pk)
uniq.append(pk)
if len(uniq) < 3:
raise RuntimeError(f"too few oauth scope mappings resolved ({uniq}); inspect /propertymappings/oauthscope/")
return uniq
def upsert_oauth_provider(
base: str,
slug: str,
name: str,
client_id: str,
client_secret: str,
redirect_uri: str,
auth_flow: str,
invalidation_flow: str,
signing_key: int,
property_mappings: list[int],
) -> int:
code, payload = req("GET", f"{base}/providers/oauth2/?slug={urllib.parse.quote(slug)}")
if code != 200 or not isinstance(payload, dict):
raise RuntimeError("provider list failed")
results = payload.get("results") or []
body = {
"name": name,
"slug": slug,
"authorization_flow": auth_flow,
"invalidation_flow": invalidation_flow,
"property_mappings": property_mappings,
"client_type": "confidential",
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": redirect_uri.strip() + "\n",
"signing_key": signing_key,
}
if results:
pk = int(results[0]["pk"])
code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body)
if code2 not in (200, 204):
raise RuntimeError(f"PATCH provider {slug} -> {code2}")
return pk
code3, created = req("POST", f"{base}/providers/oauth2/", body)
if code3 not in (200, 201) or not isinstance(created, dict):
raise RuntimeError(f"POST provider {slug} -> {code3} {created}")
return int(created["pk"])
def upsert_application(base: str, slug: str, name: str, provider_pk: int) -> None:
code, payload = req("GET", f"{base}/core/applications/?slug={urllib.parse.quote(slug)}")
if code != 200 or not isinstance(payload, dict):
raise RuntimeError("application list failed")
results = payload.get("results") or []
body = {"name": name, "slug": slug, "provider": provider_pk}
if results:
pk = int(results[0]["pk"])
code2, _ = req("PATCH", f"{base}/core/applications/{pk}/", body)
if code2 not in (200, 204):
raise RuntimeError(f"PATCH application {slug} -> {code2}")
return
code3, _ = req("POST", f"{base}/core/applications/", body)
if code3 not in (200, 201):
raise RuntimeError(f"POST application {slug} -> {code3}")
def ensure_group(base: str, name: str) -> int:
code, payload = req("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}")
if code != 200 or not isinstance(payload, dict):
raise RuntimeError("group list failed")
results = payload.get("results") or []
if results:
return int(results[0]["pk"])
code2, created = req("POST", f"{base}/core/groups/", {"name": name})
if code2 not in (200, 201) or not isinstance(created, dict):
raise RuntimeError(f"POST group {name} -> {code2}")
return int(created["pk"])
def add_user_to_groups(base: str, email: str, group_pks: list[int]) -> None:
code, payload = req("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}")
if code != 200 or not isinstance(payload, dict):
raise RuntimeError("user list failed")
results = payload.get("results") or []
if not results:
print(f"WARN: no user with email {email}; skip group membership", file=sys.stderr)
return
user = results[0]
upk = int(user["pk"])
existing = [int(g["pk"]) for g in user.get("groups", []) if isinstance(g, dict) and "pk" in g]
merged = sorted(set(existing + group_pks))
if merged == sorted(set(existing)):
return
code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged})
if code2 not in (200, 204):
raise RuntimeError(f"PATCH user groups -> {code2}")
def main() -> int:
base = os.environ.get("AUTHENTIK_API_BASE", "").rstrip("/")
tok = os.environ.get("AUTHENTIK_TOKEN", "")
cfg_path = os.environ.get("CLIENT_JSON", "")
email = os.environ.get("BOOTSTRAP_EMAIL", "")
if not base or not tok or not cfg_path:
print("AUTHENTIK_API_BASE, AUTHENTIK_TOKEN, CLIENT_JSON required", file=sys.stderr)
return 2
with open(cfg_path, encoding="utf-8") as f:
clients = json.load(f)
if not isinstance(clients, dict):
print("CLIENT_JSON must be an object keyed by provider slug", file=sys.stderr)
return 2
auth_flow = find_flow_pk(base, "default-provider-authorization-implicit-consent")
invalidation_flow = find_flow_pk_first(
base,
["default-invalidation-flow", "default-provider-invalidation-flow"],
)
signing_key = signing_key_pk(base)
pmap = scope_mapping_pks(base)
admins_pk = ensure_group(base, "noble-admins")
editors_pk = ensure_group(base, "noble-editors")
for slug, meta in clients.items():
name = meta.get("name") or slug
cid = meta["client_id"]
csec = meta["client_secret"]
redir = meta["redirect_uri"]
ppk = upsert_oauth_provider(
base,
slug,
name,
cid,
csec,
redir,
auth_flow,
invalidation_flow,
signing_key,
pmap,
)
upsert_application(base, slug, name, ppk)
if email:
add_user_to_groups(base, email, [admins_pk, editors_pk])
print("authentik: providers + applications configured", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())