diff --git a/ansible/roles/noble_authentik/README.md b/ansible/roles/noble_authentik/README.md index 64e5b4b..8d6dbae 100644 --- a/ansible/roles/noble_authentik/README.md +++ b/ansible/roles/noble_authentik/README.md @@ -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//oauth2/...`**. Use **issuer discovery** (Grafana **`server_url`** at **`…/application/o//`**; 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`**). diff --git a/ansible/roles/noble_authentik/defaults/main.yml b/ansible/roles/noble_authentik/defaults/main.yml index 7ee1e2d..08ee577 100644 --- a/ansible/roles/noble_authentik/defaults/main.yml +++ b/ansible/roles/noble_authentik/defaults/main.yml @@ -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" diff --git a/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc b/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc index f309206..ffaa034 100644 Binary files a/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc and b/ansible/roles/noble_authentik/files/__pycache__/configure_authentik.cpython-314.pyc differ diff --git a/ansible/roles/noble_authentik/files/configure_authentik.py b/ansible/roles/noble_authentik/files/configure_authentik.py index ccf7469..10db75a 100644 --- a/ansible/roles/noble_authentik/files/configure_authentik.py +++ b/ansible/roles/noble_authentik/files/configure_authentik.py @@ -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}") - results = payload.get("results") or [] - if not results: - raise RuntimeError(f"flow slug not found: {slug}") - return str(results[0]["pk"]) + """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 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 code3 not in (200, 201): - raise RuntimeError(f"POST application {slug} -> {code3}") + 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}: {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,11 +369,23 @@ def main() -> int: 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"], - ) + 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) @@ -230,29 +393,36 @@ def main() -> int: 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) + 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 email: - add_user_to_groups(base, email, [admins_pk, editors_pk]) - print("authentik: providers + applications configured", flush=True) - return 0 + if email: + 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__": diff --git a/ansible/roles/noble_authentik/tasks/main.yml b/ansible/roles/noble_authentik/tasks/main.yml index 94a9418..13fb0e3 100644 --- a/ansible/roles/noble_authentik/tasks/main.yml +++ b/ansible/roles/noble_authentik/tasks/main.yml @@ -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: | diff --git a/clusters/noble/bootstrap/argocd/app-of-apps/headlamp-application.yaml b/clusters/noble/bootstrap/argocd/app-of-apps/headlamp-application.yaml index 2fb3ec4..056c9b2 100644 --- a/clusters/noble/bootstrap/argocd/app-of-apps/headlamp-application.yaml +++ b/clusters/noble/bootstrap/argocd/app-of-apps/headlamp-application.yaml @@ -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 diff --git a/clusters/noble/bootstrap/argocd/app-of-apps/kube-prometheus-application.yaml b/clusters/noble/bootstrap/argocd/app-of-apps/kube-prometheus-application.yaml index 21085f4..06fac48 100644 --- a/clusters/noble/bootstrap/argocd/app-of-apps/kube-prometheus-application.yaml +++ b/clusters/noble/bootstrap/argocd/app-of-apps/kube-prometheus-application.yaml @@ -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 diff --git a/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml b/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml index c4d8c6e..5b84ecf 100644 --- a/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml +++ b/clusters/noble/bootstrap/headlamp/values-authentik-oidc.yaml @@ -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 diff --git a/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml b/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml index 573d523..03f06ec 100644 --- a/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml +++ b/clusters/noble/bootstrap/kube-prometheus-stack/values-authentik-oidc.yaml @@ -11,12 +11,12 @@ 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 + secretKeyRef: + name: authentik-grafana-oauth + key: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET grafana.ini: auth: disable_login_form: "false" @@ -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//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'" diff --git a/clusters/noble/bootstrap/traefik/values.yaml b/clusters/noble/bootstrap/traefik/values.yaml index 05f55fd..c4c2368 100644 --- a/clusters/noble/bootstrap/traefik/values.yaml +++ b/clusters/noble/bootstrap/traefik/values.yaml @@ -27,3 +27,8 @@ gatewayClass: deployment: replicas: 1 + +# Ingresses in **monitoring** / **longhorn** reference Middleware **forward-auth** in **oauth2-proxy** (ForwardAuth). +providers: + kubernetesCRD: + allowCrossNamespace: true