Enhance Authentik integration in noble cluster setup by adding support for OAuth2 flow primary keys in configuration. Update README with troubleshooting steps for common API errors and improve deployment reliability with tasks to wait for Authentik worker rollout and API readiness. Adjust Helm chart values for Grafana and Headlamp to accommodate new OIDC settings, ensuring seamless authentication and authorization processes.
This commit is contained in:
@@ -27,3 +27,15 @@ When **`noble_authentik_configure_idp`** is true, Ansible runs **`files/configur
|
||||
|
||||
- Re-run **`configure_authentik.py`** only by executing **`noble.yml`** with **`--tags authentik`** after fixing `.env`.
|
||||
- If Authentik API calls fail, check flows exist (slug **`default-provider-authorization-implicit-consent`**) and TLS reaches **`AUTHENTIK_API_BASE`**.
|
||||
- **`GET …/flows/instances/…` → HTTP 403** with **`Token invalid/expired`**: the bootstrap API token is not accepted yet (common right after install: worker still creating it) or **`NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN`** in `.env` does not match the value Helm applied. Re-run **`--tags authentik`** (the role waits for **`GET …/core/applications/`** to return **200** with your token). If you rotated the token in `.env` only, run the play again so Helm picks up the new value, or mint a new API token for **`akadmin`** in the admin UI.
|
||||
- **`GET …/flows/instances/…` → HTTP 403** with **permission** errors (Authentik **2026+** RBAC): the bearer token’s user must be able to **view flows**. The Helm bootstrap token belongs to **`akadmin`**, which must be in the **`authentik Admins`** group. Add **`akadmin`** to **Directory → Groups → authentik Admins**, or create a new **API token** for `akadmin` after fixing groups, and put that token in **`NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN`**. As a workaround, set **`noble_authentik_oauth_authorization_flow_pk`** and **`noble_authentik_oauth_invalidation_flow_pk`** (both required) to the flows’ **UUID** primary keys from **Admin → Flows** (or `-e` / `group_vars`); the configure script then skips flow list API calls.
|
||||
- **`/if/admin/` redirects to `/if/user/`** (even as **`akadmin`**): the admin UI only loads when **`canAccessAdmin`** is true. That comes from **`user.isSuperuser`** on **`GET /api/v3/core/users/me/`**, which is **not** the Django username — in Authentik **2026.x** it is derived from **membership in a group with the superuser flag** (bootstrap blueprint: **`authentik Admins`**). If **`isSuperuser`** is false in **`/me`**, `akadmin` is missing that membership or the group’s flag is off. Fix in **Directory → Groups** when you can, or run the worker shell below, then **log out** and sign in again.
|
||||
|
||||
### Fix `akadmin` superuser / admin redirect (worker shell)
|
||||
|
||||
```bash
|
||||
kubectl exec -it deploy/authentik-worker -n authentik -- ak shell -c "from authentik.core.models import User, Group; u=User.objects.get(username='akadmin'); adm,_=Group.objects.get_or_create(name='authentik Admins', defaults={'is_superuser': True}); adm.is_superuser=True; adm.save(update_fields=['is_superuser']); adm.users.add(u); u=User.objects.get(pk=u.pk); print('all_groups', list(u.all_groups().values_list('name', flat=True))); print('is_superuser', u.is_superuser)"
|
||||
```
|
||||
|
||||
Then **log out** of Authentik (or use a private window) and sign in again as **`akadmin`**.
|
||||
- **Grafana / Headlamp / ForwardAuth “Unauthorized” or Authentik “Not found”** (Authentik **2026.x**): OAuth endpoints are no longer under **`/application/o/<app>/oauth2/...`**. Use **issuer discovery** (Grafana **`server_url`** at **`…/application/o/<slug>/`**; oauth2-proxy **`oidc-issuer-url`**; Headlamp **`-oidc-idp-issuer-url`**). Re-apply **Traefik** (**`allowCrossNamespace`** so Ingresses can use Middleware in **`oauth2-proxy`**), **kube-prometheus-stack**, and **Headlamp** after updating values (e.g. **`ansible-playbook playbooks/noble.yml --tags authentik`**).
|
||||
|
||||
@@ -32,8 +32,17 @@ noble_authentik_client_secret_headlamp: ""
|
||||
noble_authentik_client_secret_oauth2_proxy: ""
|
||||
noble_authentik_oauth2_proxy_cookie_secret: ""
|
||||
|
||||
# Optional: OAuth2 provider flow PKs (UUID strings). When **both** are set, **configure_authentik.py**
|
||||
# skips **GET /flows/instances/** (avoids 403 if the API token user is not a superuser). See role README.
|
||||
noble_authentik_oauth_authorization_flow_pk: ""
|
||||
noble_authentik_oauth_invalidation_flow_pk: ""
|
||||
|
||||
noble_authentik_helm_wait_timeout: 25m
|
||||
|
||||
# After Helm --wait, the worker still creates the bootstrap API token; poll the public API before configure_authentik.py.
|
||||
noble_authentik_bootstrap_api_wait_retries: 36
|
||||
noble_authentik_bootstrap_api_wait_delay: 5
|
||||
|
||||
# Re-apply the same chart versions as the rest of noble.yml when flipping SSO on.
|
||||
noble_authentik_argocd_chart_version: "9.4.17"
|
||||
noble_authentik_kube_prometheus_chart_version: "82.15.1"
|
||||
|
||||
Binary file not shown.
@@ -3,9 +3,12 @@
|
||||
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)
|
||||
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):
|
||||
AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK
|
||||
AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,6 +20,36 @@ import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
# List endpoints for OAuth2 scope PropertyMapping (Authentik version-dependent).
|
||||
_OAUTH_SCOPE_MAPPING_LIST_PATHS = (
|
||||
"/propertymappings/provider/scope/?page_size=500",
|
||||
"/propertymappings/scope/?page_size=500",
|
||||
"/propertymappings/oauthscope/?page_size=500",
|
||||
)
|
||||
|
||||
|
||||
def primary_key(value) -> int | str:
|
||||
"""Authentik API pk: integer (legacy) or UUID string (2026+)."""
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"invalid pk (bool): {value!r}")
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
raise ValueError(f"invalid pk type {type(value).__name__}: {value!r}")
|
||||
|
||||
|
||||
def dedupe_pks_preserve_order(items: list[int | str]) -> list[int | str]:
|
||||
out: list[int | str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
key = str(item)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
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"}
|
||||
@@ -36,6 +69,29 @@ def req(method: str, url: str, body: dict | None = None) -> tuple[int, dict | li
|
||||
raise RuntimeError(f"{method} {url} -> HTTP {e.code}: {err}") from e
|
||||
|
||||
|
||||
def req_any(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list | str]:
|
||||
"""Like req() but returns HTTP status and body (or error text) instead of raising on HTTP errors."""
|
||||
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")
|
||||
try:
|
||||
return e.code, json.loads(err)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, err
|
||||
|
||||
|
||||
def collect(base: str, path: str) -> list:
|
||||
out: list = []
|
||||
url = base + path
|
||||
@@ -59,13 +115,51 @@ def collect(base: str, path: str) -> list:
|
||||
|
||||
|
||||
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}")
|
||||
"""Resolve a Flow primary key. Prefer detail-by-slug (REST); fall back to list filter."""
|
||||
slug_path = urllib.parse.quote(slug, safe="")
|
||||
attempts = (
|
||||
f"{base}/flows/instances/{slug_path}/",
|
||||
f"{base}/flows/instances/?slug={urllib.parse.quote(slug)}",
|
||||
)
|
||||
errors: list[str] = []
|
||||
for url in attempts:
|
||||
code, payload = req_any("GET", url)
|
||||
if code != 200:
|
||||
errors.append(f"{url} -> HTTP {code}: {payload!r}")
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"{url} -> unexpected body type")
|
||||
continue
|
||||
if "results" in payload:
|
||||
results = payload.get("results") or []
|
||||
if not results:
|
||||
raise RuntimeError(f"flow slug not found: {slug}")
|
||||
return str(results[0]["pk"])
|
||||
if results:
|
||||
return str(primary_key(results[0]["pk"]))
|
||||
elif payload.get("pk") is not None:
|
||||
return str(primary_key(payload["pk"]))
|
||||
errors.append(f"{url} -> empty")
|
||||
joined = "; ".join(errors)
|
||||
extra = ""
|
||||
low = joined.lower()
|
||||
if (
|
||||
"token invalid" in low
|
||||
or "invalid/expired" in low
|
||||
or ("invalid" in low and "expired" in low)
|
||||
):
|
||||
extra = (
|
||||
" The API rejected the bearer token (invalid/expired). After a fresh install, wait until "
|
||||
"the worker has finished bootstrap (re-run this playbook; Ansible now waits for HTTP 200). "
|
||||
"Confirm NOBLE_AUTHENTIK_BOOTSTRAP_TOKEN in .env matches what Helm was given. "
|
||||
"If you changed the token in .env without a matching helm upgrade, run the authentik tag again "
|
||||
"or create a new API token for akadmin."
|
||||
)
|
||||
else:
|
||||
extra = (
|
||||
" Authentik 2026+ RBAC: the API user must be a superuser (member of **authentik Admins**) "
|
||||
"or use a token for **akadmin** created from that account. "
|
||||
"Alternatively set AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK and "
|
||||
"AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK (flow UUIDs from Admin → Flows → open flow → URL / API)."
|
||||
)
|
||||
raise RuntimeError(f"cannot resolve flow slug {slug!r} ({joined}).{extra}")
|
||||
|
||||
|
||||
def find_flow_pk_first(base: str, slugs: list[str]) -> str:
|
||||
@@ -78,27 +172,43 @@ def find_flow_pk_first(base: str, slugs: list[str]) -> str:
|
||||
raise RuntimeError(f"no flow matched {slugs}: {last_err}")
|
||||
|
||||
|
||||
def signing_key_pk(base: str) -> int:
|
||||
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"):
|
||||
raise RuntimeError("no signing certificate keypairs returned")
|
||||
return int(payload["results"][0]["pk"])
|
||||
return payload["results"][0]["pk"]
|
||||
|
||||
|
||||
def scope_mapping_pks(base: str) -> list[int]:
|
||||
mappings = collect(base, "/propertymappings/oauthscope/?page_size=500")
|
||||
def _collect_scope_mappings(base: str) -> list:
|
||||
"""Try known Authentik API paths for OAuth2 scope property mappings (version-dependent)."""
|
||||
last_err: Exception | None = None
|
||||
for path in _OAUTH_SCOPE_MAPPING_LIST_PATHS:
|
||||
try:
|
||||
return collect(base, path)
|
||||
except RuntimeError as exc:
|
||||
err = str(exc)
|
||||
if " 404" in err or "404:" in err:
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError(f"could not list OAuth scope property mappings; last error: {last_err}")
|
||||
|
||||
|
||||
def scope_mapping_pks(base: str) -> list[int | str]:
|
||||
mappings = _collect_scope_mappings(base)
|
||||
want_scopes = {"openid", "email", "profile", "offline_access", "groups"}
|
||||
pks: list[int] = []
|
||||
pks: list[int | str] = []
|
||||
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] = []
|
||||
pks.append(primary_key(m["pk"]))
|
||||
# de-dupe preserve order (str key: int 1 vs str "1" unlikely in one payload)
|
||||
seen: set[str] = set()
|
||||
uniq: list[int | str] = []
|
||||
for p in pks:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
key = str(p)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
uniq.append(p)
|
||||
if len(uniq) >= 3:
|
||||
return uniq
|
||||
@@ -106,15 +216,40 @@ def scope_mapping_pks(base: str) -> list[int]:
|
||||
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)
|
||||
pk_val = primary_key(m["pk"])
|
||||
key = str(pk_val)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
uniq.append(pk_val)
|
||||
if len(uniq) < 3:
|
||||
raise RuntimeError(f"too few oauth scope mappings resolved ({uniq}); inspect /propertymappings/oauthscope/")
|
||||
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}")
|
||||
return uniq
|
||||
|
||||
|
||||
def find_oauth2_provider_by_client_id(base: str, client_id: str) -> dict | None:
|
||||
"""OAuth2 provider list has no reliable **slug** filter; match on **client_id**."""
|
||||
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)
|
||||
if code != 200 or not isinstance(payload, dict):
|
||||
raise RuntimeError(f"provider list failed: GET {url} -> {code}")
|
||||
for row in payload.get("results") or []:
|
||||
if row.get("client_id") == client_id:
|
||||
return row
|
||||
nxt = payload.get("next") or ""
|
||||
if not nxt:
|
||||
return None
|
||||
if nxt.startswith("http"):
|
||||
url = nxt
|
||||
else:
|
||||
url = origin + nxt
|
||||
return None
|
||||
|
||||
|
||||
def upsert_oauth_provider(
|
||||
base: str,
|
||||
slug: str,
|
||||
@@ -124,68 +259,80 @@ def upsert_oauth_provider(
|
||||
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 []
|
||||
signing_key,
|
||||
property_mappings: list[int | str],
|
||||
) -> int | str:
|
||||
# Authentik 2025+ expects redirect_uris as structured JSON (not newline-separated text).
|
||||
redirect_uris = [
|
||||
{
|
||||
"matching_mode": "strict",
|
||||
"url": redirect_uri.strip(),
|
||||
"redirect_uri_type": "authorization",
|
||||
}
|
||||
]
|
||||
existing = find_oauth2_provider_by_client_id(base, client_id)
|
||||
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",
|
||||
"redirect_uris": redirect_uris,
|
||||
"signing_key": signing_key,
|
||||
"grant_types": ["authorization_code"],
|
||||
}
|
||||
if results:
|
||||
pk = int(results[0]["pk"])
|
||||
if existing:
|
||||
pk = primary_key(existing["pk"])
|
||||
code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body)
|
||||
if code2 not in (200, 204):
|
||||
raise RuntimeError(f"PATCH provider {slug} -> {code2}")
|
||||
raise RuntimeError(f"PATCH provider client_id={client_id} -> {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"])
|
||||
raise RuntimeError(f"POST provider client_id={client_id} -> {code3} {created}")
|
||||
return primary_key(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)
|
||||
def upsert_application(base: str, slug: str, name: str, provider_pk: int | str) -> None:
|
||||
# Detail URL is /core/applications/{slug}/ (lookup_field slug, 2026+). Do not trust list
|
||||
# ordering: ?slug= filters can still return unrelated rows first → PATCH wrong app + 400.
|
||||
slug = str(slug).strip()
|
||||
seg = urllib.parse.quote(slug, safe="")
|
||||
detail = f"{base}/core/applications/{seg}/"
|
||||
code, payload = req_any("GET", detail)
|
||||
if code == 200:
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"GET application {slug}: unexpected response")
|
||||
# Omit slug on update — sending the same slug/provider can trip UniqueValidator on PATCH.
|
||||
code2, err = req_any("PATCH", detail, {"name": name, "provider": provider_pk})
|
||||
if code2 not in (200, 204):
|
||||
raise RuntimeError(f"PATCH application {slug} -> {code2}")
|
||||
raise RuntimeError(f"PATCH application {slug} -> {code2}: {err!r}")
|
||||
return
|
||||
code3, _ = req("POST", f"{base}/core/applications/", body)
|
||||
if code == 404:
|
||||
body = {"name": name, "slug": slug, "provider": provider_pk}
|
||||
code3, err = req_any("POST", f"{base}/core/applications/", body)
|
||||
if code3 not in (200, 201):
|
||||
raise RuntimeError(f"POST application {slug} -> {code3}")
|
||||
raise RuntimeError(f"POST application {slug} -> {code3}: {err!r}")
|
||||
return
|
||||
raise RuntimeError(f"GET application {slug} -> {code}: {payload!r}")
|
||||
|
||||
|
||||
def ensure_group(base: str, name: str) -> int:
|
||||
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):
|
||||
raise RuntimeError("group list failed")
|
||||
results = payload.get("results") or []
|
||||
if results:
|
||||
return int(results[0]["pk"])
|
||||
return primary_key(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"])
|
||||
return primary_key(created["pk"])
|
||||
|
||||
|
||||
def add_user_to_groups(base: str, email: str, group_pks: list[int]) -> None:
|
||||
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):
|
||||
raise RuntimeError("user list failed")
|
||||
@@ -194,10 +341,14 @@ def add_user_to_groups(base: str, email: str, group_pks: list[int]) -> None:
|
||||
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)):
|
||||
upk = primary_key(user["pk"])
|
||||
existing = [
|
||||
primary_key(g["pk"])
|
||||
for g in user.get("groups", [])
|
||||
if isinstance(g, dict) and "pk" in g
|
||||
]
|
||||
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})
|
||||
if code2 not in (200, 204):
|
||||
@@ -218,6 +369,18 @@ def main() -> int:
|
||||
print("CLIENT_JSON must be an object keyed by provider slug", 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,
|
||||
@@ -230,6 +393,7 @@ def main() -> int:
|
||||
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"]
|
||||
@@ -253,6 +417,12 @@ def main() -> int:
|
||||
add_user_to_groups(base, email, [admins_pk, editors_pk])
|
||||
print("authentik: providers + applications configured", flush=True)
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -90,6 +90,38 @@
|
||||
KUBECONFIG: "{{ noble_kubeconfig }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for authentik worker rollout
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- kubectl
|
||||
- rollout
|
||||
- status
|
||||
- deployment/authentik-worker
|
||||
- -n
|
||||
- authentik
|
||||
- --timeout=15m
|
||||
environment:
|
||||
KUBECONFIG: "{{ noble_kubeconfig }}"
|
||||
changed_when: false
|
||||
when: noble_authentik_configure_idp | default(true) | bool
|
||||
|
||||
- name: Wait until Authentik API accepts bootstrap token (worker finished bootstrap)
|
||||
ansible.builtin.uri:
|
||||
url: "{{ noble_authentik_api_base }}/core/applications/?page_size=1"
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: "Bearer {{ noble_authentik_bootstrap_token }}"
|
||||
Accept: application/json
|
||||
status_code: [200, 401, 403, 500, 502, 503]
|
||||
timeout: 30
|
||||
register: noble_authentik_api_bootstrap_ready
|
||||
until: noble_authentik_api_bootstrap_ready.status == 200
|
||||
retries: "{{ noble_authentik_bootstrap_api_wait_retries }}"
|
||||
delay: "{{ noble_authentik_bootstrap_api_wait_delay }}"
|
||||
when: noble_authentik_configure_idp | default(true) | bool
|
||||
changed_when: false
|
||||
no_log: true
|
||||
|
||||
- name: Render Authentik API client descriptor (JSON)
|
||||
ansible.builtin.template:
|
||||
src: authentik-clients.json.j2
|
||||
@@ -107,9 +139,10 @@
|
||||
AUTHENTIK_TOKEN: "{{ noble_authentik_bootstrap_token }}"
|
||||
BOOTSTRAP_EMAIL: "{{ noble_authentik_bootstrap_email }}"
|
||||
CLIENT_JSON: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json"
|
||||
AUTHENTIK_OAUTH_AUTHORIZATION_FLOW_PK: "{{ noble_authentik_oauth_authorization_flow_pk | default('') }}"
|
||||
AUTHENTIK_OAUTH_INVALIDATION_FLOW_PK: "{{ noble_authentik_oauth_invalidation_flow_pk | default('') }}"
|
||||
when: noble_authentik_configure_idp | default(true) | bool
|
||||
changed_when: true
|
||||
no_log: true
|
||||
|
||||
- name: Create argocd namespace Secret for OIDC client (Argo CD $authentik-oidc:clientSecret)
|
||||
ansible.builtin.shell: |
|
||||
|
||||
@@ -16,6 +16,7 @@ spec:
|
||||
releaseName: headlamp
|
||||
valueFiles:
|
||||
- $values/clusters/noble/bootstrap/headlamp/values.yaml
|
||||
- $values/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml
|
||||
- repoURL: https://gitea.pcenicni.ca/gsdavidp/home-server.git
|
||||
targetRevision: HEAD
|
||||
path: clusters/noble/bootstrap/headlamp
|
||||
|
||||
@@ -17,6 +17,7 @@ spec:
|
||||
releaseName: kube-prometheus
|
||||
valueFiles:
|
||||
- $values/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml
|
||||
- $values/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml
|
||||
- repoURL: https://gitea.pcenicni.ca/gsdavidp/home-server.git
|
||||
targetRevision: HEAD
|
||||
ref: values
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# OIDC with Authentik — credentials live in Secret **headlamp-oidc** (envFrom), created by **noble_authentik**.
|
||||
#
|
||||
# With **externalSecret**, the Headlamp chart only adds **-oidc-callback-url** / **-oidc-use-pkce** args when these
|
||||
# values are set here (or under **env:**). The Secret alone is not enough — without them, login can fail or Authentik returns errors.
|
||||
|
||||
config:
|
||||
oidc:
|
||||
@@ -7,3 +10,5 @@ config:
|
||||
externalSecret:
|
||||
enabled: true
|
||||
name: headlamp-oidc
|
||||
callbackURL: "https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback"
|
||||
usePKCE: true
|
||||
|
||||
@@ -11,9 +11,9 @@ alertmanager:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd
|
||||
|
||||
grafana:
|
||||
env:
|
||||
# Grafana chart maps plain strings under **env** only. Use **envValueFrom** for secretKeyRef.
|
||||
envValueFrom:
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-grafana-oauth
|
||||
key: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
|
||||
@@ -27,7 +27,7 @@ grafana:
|
||||
client_id: grafana
|
||||
scopes: openid profile email groups
|
||||
use_pkce: true
|
||||
auth_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/authorize/
|
||||
token_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/token/
|
||||
api_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/userinfo/
|
||||
# Authentik 2026.x: OAuth endpoints live under /application/o/authorize|token|userinfo/ (no …/oauth2/… per app).
|
||||
# Use issuer discovery like Argo CD — do not hardcode legacy /application/o/<slug>/oauth2/* URLs (they 404).
|
||||
server_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/
|
||||
role_attribute_path: "contains(groups[*], 'noble-admins') && 'Admin' || contains(groups[*], 'noble-editors') && 'Editor' || 'Viewer'"
|
||||
|
||||
@@ -27,3 +27,8 @@ gatewayClass:
|
||||
|
||||
deployment:
|
||||
replicas: 1
|
||||
|
||||
# Ingresses in **monitoring** / **longhorn** reference Middleware **forward-auth** in **oauth2-proxy** (ForwardAuth).
|
||||
providers:
|
||||
kubernetesCRD:
|
||||
allowCrossNamespace: true
|
||||
|
||||
Reference in New Issue
Block a user