Enhance Authentik role in noble cluster setup by adding support for resolving OAuth2 flow, signing key, and scope mapping UUIDs from the worker database, improving API access under 2026+ RBAC. Update README with troubleshooting steps for common OAuth2 provider issues and adjust default variables for better configuration management. Ensure seamless integration with oauth2-proxy by allowing unverified email handling in development environments.

This commit is contained in:
Nikholas Pcenicni
2026-05-14 14:26:43 -04:00
parent c392ce1e5a
commit 5e5c6ef671
24 changed files with 868 additions and 99 deletions

View File

@@ -6,9 +6,17 @@ Environment:
AUTHENTIK_TOKEN bootstrap API token (Bearer) for user **akadmin** (see README if flows return 403)
BOOTSTRAP_EMAIL initial admin email (added to noble-admins)
CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... }
Optional (skip flow API discovery; Authentik 2026+ flow **pk** UUID strings):
Optional (skip API discovery; Authentik 2026+ RBAC — use UUID strings from worker shell or Admin UI):
AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK
AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK
AUTHENTIK_OAUTH_SIGNING_KEY_PK
AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS comma-separated ScopeMapping UUIDs (openid,email,profile,offline_access[,groups])
AUTHENTIK_NOBLE_ADMINS_GROUP_PK noble-admins Group UUID (skip GET /core/groups/)
AUTHENTIK_NOBLE_EDITORS_GROUP_PK noble-editors Group UUID
AUTHENTIK_SKIP_OIDC_REST if 1/true/yes, skip OAuth2 provider + application REST calls (Ansible
uses **worker_upsert_oauth_oidc.py** when bootstrap tokens cannot **GET …/providers/oauth2/**)
AUTHENTIK_SKIP_USER_GROUP_REST if 1/true/yes, skip **GET/PATCH …/core/users/** for bootstrap group membership (Ansible uses
**worker_add_bootstrap_user_groups.py** when the token cannot **view_user**)
"""
from __future__ import annotations
@@ -173,10 +181,19 @@ def find_flow_pk_first(base: str, slugs: list[str]) -> str:
def signing_key_pk(base: str):
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"):
raw = (os.environ.get("AUTHENTIK_OAUTH_SIGNING_KEY_PK") or "").strip()
if raw:
return primary_key(raw)
code, payload = req_any("GET", f"{base}/crypto/certificatekeypairs/?ordering=pk&page_size=1")
if code != 200:
raise RuntimeError(
f"GET certificatekeypairs -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SIGNING_KEY_PK (CertificateKeyPair UUID) or let "
"Ansible resolve it from the worker DB (noble_authentik_oauth_signing_key_pk)."
)
if not isinstance(payload, dict) or not payload.get("results"):
raise RuntimeError("no signing certificate keypairs returned")
return payload["results"][0]["pk"]
return primary_key(payload["results"][0]["pk"])
def _collect_scope_mappings(base: str) -> list:
@@ -187,7 +204,7 @@ def _collect_scope_mappings(base: str) -> list:
return collect(base, path)
except RuntimeError as exc:
err = str(exc)
if " 404" in err or "404:" in err:
if " 404" in err or "404:" in err or " 403" in err or "403:" in err:
last_err = exc
continue
raise
@@ -195,6 +212,17 @@ def _collect_scope_mappings(base: str) -> list:
def scope_mapping_pks(base: str) -> list[int | str]:
raw = (os.environ.get("AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS") or "").strip()
if raw:
parts = [p.strip() for p in raw.split(",") if p.strip()]
uniq = dedupe_pks_preserve_order([primary_key(p) for p in parts])
if len(uniq) < 3:
raise RuntimeError(
f"AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS must list at least 3 UUIDs (got {len(uniq)}); "
"use comma-separated ScopeMapping primary keys in order "
"openid,email,profile,offline_access,groups from the worker helper or Admin UI."
)
return uniq
mappings = _collect_scope_mappings(base)
want_scopes = {"openid", "email", "profile", "offline_access", "groups"}
pks: list[int | str] = []
@@ -225,7 +253,11 @@ def scope_mapping_pks(base: str) -> list[int | str]:
hints = ", ".join(
f"GET {base}{p.split('?', 1)[0]}/" for p in _OAUTH_SCOPE_MAPPING_LIST_PATHS
)
raise RuntimeError(f"too few oauth scope mappings resolved ({uniq}); inspect one of: {hints}")
raise RuntimeError(
f"too few oauth scope mappings resolved ({uniq}); inspect one of: {hints}. "
"Authentik 2026+ RBAC: set AUTHENTIK_OAUTH_SCOPE_MAPPING_PKS (comma-separated UUIDs) or "
"let Ansible resolve them from the worker DB."
)
return uniq
@@ -234,9 +266,13 @@ def find_oauth2_provider_by_client_id(base: str, client_id: str) -> dict | None:
url = f"{base}/providers/oauth2/?page_size=100"
origin = base.split("/api/v3", 1)[0] if "/api/v3" in base else base
while url:
code, payload = req("GET", url)
code, payload = req_any("GET", url)
if code != 200 or not isinstance(payload, dict):
raise RuntimeError(f"provider list failed: GET {url} -> {code}")
raise RuntimeError(
f"provider list failed: GET {url} -> {code}: {payload!r}. "
"Authentik 2026+ RBAC: the token may not list OAuth2 providers; use an **akadmin** API token "
"with provider permissions, or grant **view_oauth2provider** (see Admin → Roles)."
)
for row in payload.get("results") or []:
if row.get("client_id") == client_id:
return row
@@ -319,22 +355,52 @@ def upsert_application(base: str, slug: str, name: str, provider_pk: int | str)
raise RuntimeError(f"GET application {slug} -> {code}: {payload!r}")
def _group_pk_from_env(name: str) -> str | None:
env_keys = {
"noble-admins": "AUTHENTIK_NOBLE_ADMINS_GROUP_PK",
"noble-editors": "AUTHENTIK_NOBLE_EDITORS_GROUP_PK",
}
key = env_keys.get(name)
if not key:
return None
raw = (os.environ.get(key) or "").strip()
return raw or None
def ensure_group(base: str, name: str) -> int | str:
code, payload = req("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}")
if code != 200 or not isinstance(payload, dict):
env_pk = _group_pk_from_env(name)
if env_pk:
return primary_key(env_pk)
code, payload = req_any("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}")
if code != 200:
raise RuntimeError(
f"GET groups name={name!r} -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: set AUTHENTIK_NOBLE_ADMINS_GROUP_PK and "
"AUTHENTIK_NOBLE_EDITORS_GROUP_PK (Group UUIDs), or let Ansible resolve/create them via the worker DB."
)
if not isinstance(payload, dict):
raise RuntimeError("group list failed")
results = payload.get("results") or []
if results:
return primary_key(results[0]["pk"])
code2, created = req("POST", f"{base}/core/groups/", {"name": name})
code2, created = req_any("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}")
raise RuntimeError(
f"POST group {name} -> {code2}: {created!r}. "
"If the API user cannot create groups, run the noble_authentik worker helper or create the group in Admin."
)
return primary_key(created["pk"])
def add_user_to_groups(base: str, email: str, group_pks: list[int | str]) -> None:
code, payload = req("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}")
if code != 200 or not isinstance(payload, dict):
code, payload = req_any("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}")
if code != 200:
raise RuntimeError(
f"GET users email={email!r} -> HTTP {code}: {payload!r}. "
"Authentik 2026+ RBAC: the token may not list users; add the bootstrap user to **noble-admins** / "
"**noble-editors** in Admin, or use a token with **view_user** permission."
)
if not isinstance(payload, dict):
raise RuntimeError("user list failed")
results = payload.get("results") or []
if not results:
@@ -350,9 +416,13 @@ def add_user_to_groups(base: str, email: str, group_pks: list[int | str]) -> Non
merged = dedupe_pks_preserve_order([*existing, *group_pks])
if {str(x) for x in merged} == {str(x) for x in existing}:
return
code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged})
code2, err = req_any("PATCH", f"{base}/core/users/{upk}/", {"groups": merged})
if code2 not in (200, 204):
raise RuntimeError(f"PATCH user groups -> {code2}")
raise RuntimeError(f"PATCH user groups -> {code2}: {err!r}")
def _truthy_env(name: str) -> bool:
return (os.environ.get(name) or "").strip().lower() in ("1", "true", "yes")
def main() -> int:
@@ -360,62 +430,83 @@ def main() -> int:
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)
skip_oidc_rest = _truthy_env("AUTHENTIK_SKIP_OIDC_REST")
skip_user_group_rest = _truthy_env("AUTHENTIK_SKIP_USER_GROUP_REST")
if not base or not tok:
print("AUTHENTIK_API_BASE and AUTHENTIK_TOKEN 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)
if not skip_oidc_rest and not cfg_path:
print("CLIENT_JSON required unless AUTHENTIK_SKIP_OIDC_REST is set", file=sys.stderr)
return 2
auth_pk = (os.environ.get("AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK") or "").strip()
inv_pk = (os.environ.get("AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK") or "").strip()
if auth_pk and inv_pk:
auth_flow, invalidation_flow = auth_pk, inv_pk
elif auth_pk or inv_pk:
print(
"Set both AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK "
"or neither",
file=sys.stderr,
)
return 2
else:
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"],
)
clients: dict = {}
if not skip_oidc_rest:
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
signing_key = signing_key_pk(base)
pmap = scope_mapping_pks(base)
auth_flow: str | None = None
invalidation_flow: str | None = None
signing_key = None
pmap: list[int | str] = []
if not skip_oidc_rest:
auth_pk = (os.environ.get("AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK") or "").strip()
inv_pk = (os.environ.get("AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK") or "").strip()
if auth_pk and inv_pk:
auth_flow, invalidation_flow = auth_pk, inv_pk
elif auth_pk or inv_pk:
print(
"Set both AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK "
"or neither",
file=sys.stderr,
)
return 2
else:
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")
try:
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 not skip_oidc_rest:
assert auth_flow is not None and invalidation_flow is not None
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:
if email and not skip_user_group_rest:
add_user_to_groups(base, email, [admins_pk, editors_pk])
print("authentik: providers + applications configured", flush=True)
if skip_oidc_rest and skip_user_group_rest:
print("authentik: noble groups verified; OAuth2 apps + bootstrap group membership via worker", flush=True)
elif skip_oidc_rest:
print("authentik: groups verified; OAuth2 apps via worker (user membership via API)", flush=True)
else:
print("authentik: providers + applications configured", flush=True)
return 0
except RuntimeError as exc:
print(str(exc), file=sys.stderr)