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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user