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:
Nikholas Pcenicni
2026-05-14 01:29:49 -04:00
parent 15d0e120d3
commit c392ce1e5a
10 changed files with 331 additions and 95 deletions

View File

@@ -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`. - 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`**. - 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 tokens 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 groups 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`**).

View File

@@ -32,8 +32,17 @@ noble_authentik_client_secret_headlamp: ""
noble_authentik_client_secret_oauth2_proxy: "" noble_authentik_client_secret_oauth2_proxy: ""
noble_authentik_oauth2_proxy_cookie_secret: "" 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 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. # 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_argocd_chart_version: "9.4.17"
noble_authentik_kube_prometheus_chart_version: "82.15.1" noble_authentik_kube_prometheus_chart_version: "82.15.1"

View File

@@ -3,9 +3,12 @@
Create / update Authentik OAuth2/OpenID providers + applications + groups (stdlib only). Create / update Authentik OAuth2/OpenID providers + applications + groups (stdlib only).
Environment: Environment:
AUTHENTIK_API_BASE e.g. https://auth.apps.example.com/api/v3 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) BOOTSTRAP_EMAIL initial admin email (added to noble-admins)
CLIENT_JSON path to JSON file: { "argocd": {"client_id","client_secret","redirect_uri"}, ... } 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 from __future__ import annotations
@@ -17,6 +20,36 @@ import urllib.parse
import urllib.request 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]: def req(method: str, url: str, body: dict | None = None) -> tuple[int, dict | list]:
data = None data = None
headers = {"Authorization": f"Bearer {os.environ['AUTHENTIK_TOKEN']}", "Accept": "application/json"} 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 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: def collect(base: str, path: str) -> list:
out: list = [] out: list = []
url = base + path url = base + path
@@ -59,13 +115,51 @@ def collect(base: str, path: str) -> list:
def find_flow_pk(base: str, slug: str) -> str: def find_flow_pk(base: str, slug: str) -> str:
code, payload = req("GET", f"{base}/flows/instances/?slug={urllib.parse.quote(slug)}") """Resolve a Flow primary key. Prefer detail-by-slug (REST); fall back to list filter."""
if code != 200 or not isinstance(payload, dict): slug_path = urllib.parse.quote(slug, safe="")
raise RuntimeError(f"flow lookup failed for {slug}") 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 [] results = payload.get("results") or []
if not results: if results:
raise RuntimeError(f"flow slug not found: {slug}") return str(primary_key(results[0]["pk"]))
return str(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: 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}") 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") 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"): if code != 200 or not isinstance(payload, dict) or not payload.get("results"):
raise RuntimeError("no signing certificate keypairs returned") 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]: def _collect_scope_mappings(base: str) -> list:
mappings = collect(base, "/propertymappings/oauthscope/?page_size=500") """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"} want_scopes = {"openid", "email", "profile", "offline_access", "groups"}
pks: list[int] = [] pks: list[int | str] = []
for m in mappings: for m in mappings:
sn = (m.get("scope_name") or "").strip() sn = (m.get("scope_name") or "").strip()
if sn in want_scopes: if sn in want_scopes:
pks.append(int(m["pk"])) pks.append(primary_key(m["pk"]))
# de-dupe preserve order # de-dupe preserve order (str key: int 1 vs str "1" unlikely in one payload)
seen: set[int] = set() seen: set[str] = set()
uniq: list[int] = [] uniq: list[int | str] = []
for p in pks: for p in pks:
if p not in seen: key = str(p)
seen.add(p) if key not in seen:
seen.add(key)
uniq.append(p) uniq.append(p)
if len(uniq) >= 3: if len(uniq) >= 3:
return uniq return uniq
@@ -106,15 +216,40 @@ def scope_mapping_pks(base: str) -> list[int]:
for m in mappings: for m in mappings:
name = (m.get("name") or "").lower() name = (m.get("name") or "").lower()
if "openid" in name and any(x in name for x in ("openid", "email", "profile", "groups", "offline")): if "openid" in name and any(x in name for x in ("openid", "email", "profile", "groups", "offline")):
pk = int(m["pk"]) pk_val = primary_key(m["pk"])
if pk not in seen: key = str(pk_val)
seen.add(pk) if key not in seen:
uniq.append(pk) seen.add(key)
uniq.append(pk_val)
if len(uniq) < 3: 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 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( def upsert_oauth_provider(
base: str, base: str,
slug: str, slug: str,
@@ -124,68 +259,80 @@ def upsert_oauth_provider(
redirect_uri: str, redirect_uri: str,
auth_flow: str, auth_flow: str,
invalidation_flow: str, invalidation_flow: str,
signing_key: int, signing_key,
property_mappings: list[int], property_mappings: list[int | str],
) -> int: ) -> int | str:
code, payload = req("GET", f"{base}/providers/oauth2/?slug={urllib.parse.quote(slug)}") # Authentik 2025+ expects redirect_uris as structured JSON (not newline-separated text).
if code != 200 or not isinstance(payload, dict): redirect_uris = [
raise RuntimeError("provider list failed") {
results = payload.get("results") or [] "matching_mode": "strict",
"url": redirect_uri.strip(),
"redirect_uri_type": "authorization",
}
]
existing = find_oauth2_provider_by_client_id(base, client_id)
body = { body = {
"name": name, "name": name,
"slug": slug,
"authorization_flow": auth_flow, "authorization_flow": auth_flow,
"invalidation_flow": invalidation_flow, "invalidation_flow": invalidation_flow,
"property_mappings": property_mappings, "property_mappings": property_mappings,
"client_type": "confidential", "client_type": "confidential",
"client_id": client_id, "client_id": client_id,
"client_secret": client_secret, "client_secret": client_secret,
"redirect_uris": redirect_uri.strip() + "\n", "redirect_uris": redirect_uris,
"signing_key": signing_key, "signing_key": signing_key,
"grant_types": ["authorization_code"],
} }
if results: if existing:
pk = int(results[0]["pk"]) pk = primary_key(existing["pk"])
code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body) code2, _ = req("PATCH", f"{base}/providers/oauth2/{pk}/", body)
if code2 not in (200, 204): 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 return pk
code3, created = req("POST", f"{base}/providers/oauth2/", body) code3, created = req("POST", f"{base}/providers/oauth2/", body)
if code3 not in (200, 201) or not isinstance(created, dict): if code3 not in (200, 201) or not isinstance(created, dict):
raise RuntimeError(f"POST provider {slug} -> {code3} {created}") raise RuntimeError(f"POST provider client_id={client_id} -> {code3} {created}")
return int(created["pk"]) return primary_key(created["pk"])
def upsert_application(base: str, slug: str, name: str, provider_pk: int) -> None: def upsert_application(base: str, slug: str, name: str, provider_pk: int | str) -> None:
code, payload = req("GET", f"{base}/core/applications/?slug={urllib.parse.quote(slug)}") # Detail URL is /core/applications/{slug}/ (lookup_field slug, 2026+). Do not trust list
if code != 200 or not isinstance(payload, dict): # ordering: ?slug= filters can still return unrelated rows first → PATCH wrong app + 400.
raise RuntimeError("application list failed") slug = str(slug).strip()
results = payload.get("results") or [] seg = urllib.parse.quote(slug, safe="")
body = {"name": name, "slug": slug, "provider": provider_pk} detail = f"{base}/core/applications/{seg}/"
if results: code, payload = req_any("GET", detail)
pk = int(results[0]["pk"]) if code == 200:
code2, _ = req("PATCH", f"{base}/core/applications/{pk}/", body) 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): if code2 not in (200, 204):
raise RuntimeError(f"PATCH application {slug} -> {code2}") raise RuntimeError(f"PATCH application {slug} -> {code2}: {err!r}")
return 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): 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)}") code, payload = req("GET", f"{base}/core/groups/?name={urllib.parse.quote(name)}")
if code != 200 or not isinstance(payload, dict): if code != 200 or not isinstance(payload, dict):
raise RuntimeError("group list failed") raise RuntimeError("group list failed")
results = payload.get("results") or [] results = payload.get("results") or []
if results: if results:
return int(results[0]["pk"]) return primary_key(results[0]["pk"])
code2, created = req("POST", f"{base}/core/groups/", {"name": name}) code2, created = req("POST", f"{base}/core/groups/", {"name": name})
if code2 not in (200, 201) or not isinstance(created, dict): 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}")
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)}") code, payload = req("GET", f"{base}/core/users/?email={urllib.parse.quote(email)}")
if code != 200 or not isinstance(payload, dict): if code != 200 or not isinstance(payload, dict):
raise RuntimeError("user list failed") 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) print(f"WARN: no user with email {email}; skip group membership", file=sys.stderr)
return return
user = results[0] user = results[0]
upk = int(user["pk"]) upk = primary_key(user["pk"])
existing = [int(g["pk"]) for g in user.get("groups", []) if isinstance(g, dict) and "pk" in g] existing = [
merged = sorted(set(existing + group_pks)) primary_key(g["pk"])
if merged == sorted(set(existing)): 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 return
code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged}) code2, _ = req("PATCH", f"{base}/core/users/{upk}/", {"groups": merged})
if code2 not in (200, 204): 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) print("CLIENT_JSON must be an object keyed by provider slug", file=sys.stderr)
return 2 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") auth_flow = find_flow_pk(base, "default-provider-authorization-implicit-consent")
invalidation_flow = find_flow_pk_first( invalidation_flow = find_flow_pk_first(
base, base,
@@ -230,6 +393,7 @@ def main() -> int:
admins_pk = ensure_group(base, "noble-admins") admins_pk = ensure_group(base, "noble-admins")
editors_pk = ensure_group(base, "noble-editors") editors_pk = ensure_group(base, "noble-editors")
try:
for slug, meta in clients.items(): for slug, meta in clients.items():
name = meta.get("name") or slug name = meta.get("name") or slug
cid = meta["client_id"] cid = meta["client_id"]
@@ -253,6 +417,12 @@ def main() -> int:
add_user_to_groups(base, email, [admins_pk, editors_pk]) add_user_to_groups(base, email, [admins_pk, editors_pk])
print("authentik: providers + applications configured", flush=True) print("authentik: providers + applications configured", flush=True)
return 0 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__": if __name__ == "__main__":

View File

@@ -90,6 +90,38 @@
KUBECONFIG: "{{ noble_kubeconfig }}" KUBECONFIG: "{{ noble_kubeconfig }}"
changed_when: false 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) - name: Render Authentik API client descriptor (JSON)
ansible.builtin.template: ansible.builtin.template:
src: authentik-clients.json.j2 src: authentik-clients.json.j2
@@ -107,9 +139,10 @@
AUTHENTIK_TOKEN: "{{ noble_authentik_bootstrap_token }}" AUTHENTIK_TOKEN: "{{ noble_authentik_bootstrap_token }}"
BOOTSTRAP_EMAIL: "{{ noble_authentik_bootstrap_email }}" BOOTSTRAP_EMAIL: "{{ noble_authentik_bootstrap_email }}"
CLIENT_JSON: "{{ noble_repo_root }}/ansible/.ansible-tmp/authentik-clients.json" 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 when: noble_authentik_configure_idp | default(true) | bool
changed_when: true changed_when: true
no_log: true
- name: Create argocd namespace Secret for OIDC client (Argo CD $authentik-oidc:clientSecret) - name: Create argocd namespace Secret for OIDC client (Argo CD $authentik-oidc:clientSecret)
ansible.builtin.shell: | ansible.builtin.shell: |

View File

@@ -16,6 +16,7 @@ spec:
releaseName: headlamp releaseName: headlamp
valueFiles: valueFiles:
- $values/clusters/noble/bootstrap/headlamp/values.yaml - $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 - repoURL: https://gitea.pcenicni.ca/gsdavidp/home-server.git
targetRevision: HEAD targetRevision: HEAD
path: clusters/noble/bootstrap/headlamp path: clusters/noble/bootstrap/headlamp

View File

@@ -17,6 +17,7 @@ spec:
releaseName: kube-prometheus releaseName: kube-prometheus
valueFiles: valueFiles:
- $values/clusters/noble/bootstrap/kube-prometheus-stack/values.yaml - $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 - repoURL: https://gitea.pcenicni.ca/gsdavidp/home-server.git
targetRevision: HEAD targetRevision: HEAD
ref: values ref: values

View File

@@ -1,4 +1,7 @@
# OIDC with Authentik — credentials live in Secret **headlamp-oidc** (envFrom), created by **noble_authentik**. # 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: config:
oidc: oidc:
@@ -7,3 +10,5 @@ config:
externalSecret: externalSecret:
enabled: true enabled: true
name: headlamp-oidc name: headlamp-oidc
callbackURL: "https://headlamp.apps.noble.lab.pcenicni.dev/oidc-callback"
usePKCE: true

View File

@@ -11,9 +11,9 @@ alertmanager:
traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: oauth2-proxy-forward-auth@kubernetescrd
grafana: grafana:
env: # Grafana chart maps plain strings under **env** only. Use **envValueFrom** for secretKeyRef.
envValueFrom:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:
valueFrom:
secretKeyRef: secretKeyRef:
name: authentik-grafana-oauth name: authentik-grafana-oauth
key: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET key: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
@@ -27,7 +27,7 @@ grafana:
client_id: grafana client_id: grafana
scopes: openid profile email groups scopes: openid profile email groups
use_pkce: true use_pkce: true
auth_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/authorize/ # Authentik 2026.x: OAuth endpoints live under /application/o/authorize|token|userinfo/ (no …/oauth2/… per app).
token_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/oauth2/token/ # Use issuer discovery like Argo CD — do not hardcode legacy /application/o/<slug>/oauth2/* URLs (they 404).
api_url: https://auth.apps.noble.lab.pcenicni.dev/application/o/grafana/userinfo/ 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'" role_attribute_path: "contains(groups[*], 'noble-admins') && 'Admin' || contains(groups[*], 'noble-editors') && 'Editor' || 'Viewer'"

View File

@@ -27,3 +27,8 @@ gatewayClass:
deployment: deployment:
replicas: 1 replicas: 1
# Ingresses in **monitoring** / **longhorn** reference Middleware **forward-auth** in **oauth2-proxy** (ForwardAuth).
providers:
kubernetesCRD:
allowCrossNamespace: true