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:
259
ansible/roles/noble_authentik/files/configure_authentik.py
Normal file
259
ansible/roles/noble_authentik/files/configure_authentik.py
Normal 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())
|
||||
Reference in New Issue
Block a user