From 8739efe026a1452e3d3e223d123d5a22dda9b84c Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Sun, 1 Nov 2026 09:22:25 +0000 Subject: [PATCH 1/3] feat(authentik-gate-sync): OIDC clients from auth.renner.uno/oidc-client An Ingress annotated auth.renner.uno/oidc-client: gets a confidential OAuth2/OpenID provider (client ID = slug, secret generated by Authentik), an application with that slug and, with auth.renner.uno/group, the same group expression policy as gates. auth.renner.uno/oidc-redirect-uri pins strict redirect URIs on the Ingress host; default is any path on it. Same fail-closed and ownership rules as gates: owned clients are found via the OAuth2 provider list and meta_publisher and swept when their Ingress goes. The service-account role gains the OAuth2 provider permissions plus view on scope mappings and certificate key pairs. So apps that log in through Authentik via OIDC (first: YouTrack, #1534) need no blueprint entries; the groups stay managed in Authentik. --- ci/images/authentik-gate-sync/controller.py | 196 ++++++++++++++++-- .../authentik-gate-sync/test_controller.py | 132 +++++++++++- .../apps/authentik/blueprints-configmap.yaml | 6 + 3 files changed, 312 insertions(+), 22 deletions(-) diff --git a/ci/images/authentik-gate-sync/controller.py b/ci/images/authentik-gate-sync/controller.py index feddb0b7..b4db0568 100644 --- a/ci/images/authentik-gate-sync/controller.py +++ b/ci/images/authentik-gate-sync/controller.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""authentik-gate-sync: the Authentik side of the gate Middleware. +"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients. An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares annotation names the gate Middleware (authentik/gate, a ForwardAuth against the @@ -18,6 +18,15 @@ Annotations (all optional): auth.renner.uno/authorization-flow authorization flow slug (default below) auth.renner.uno/skip-path provider skip_path_regex +OIDC clients: an Ingress with auth.renner.uno/oidc-client: (it need not +be gated) gets a confidential OAuth2/OpenID provider (client ID = slug, secret +generated by Authentik) and an application with that slug, so the issuer is +/application/o//. It shares auth.renner.uno/group and +auth.renner.uno/authorization-flow with gates, plus: + + auth.renner.uno/oidc-redirect-uri strict redirect URI(s), comma-separated; + none = any path on the Ingress's host + Fail closed: until the provider exists, or after it is removed (e.g. the named group is gone), the embedded outpost has no provider for the host and rejects the request; Traefik answers 500 when the outpost is unreachable. @@ -30,6 +39,7 @@ import hashlib import json import logging import os +import re import sys import time import urllib.error @@ -46,6 +56,10 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow") COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno") AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/") +SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate") +OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email")) +OIDC_GRANTS = ["authorization_code", "refresh_token"] +SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$") INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60")) HTTP_TIMEOUT = 20 @@ -234,6 +248,64 @@ def provider_body(gate, flows): } +def is_oidc(ing): + return bool(((ing.get("metadata") or {}).get("annotations") or {}).get(ANN + "oidc-client")) + + +def oidc_slug(ing): + return ing["metadata"]["annotations"][ANN + "oidc-client"].strip() + + +def oidc_spec(ing): + """Translate an Ingress into the OIDC client it asks for. Raises GateError.""" + meta = ing["metadata"] + ann = meta.get("annotations") or {} + slug = oidc_slug(ing) + if not SLUG_RE.match(slug): + raise GateError(f"oidc-client {slug!r} is not a slug (a-z, 0-9, -; max 50)") + rules = ing.get("spec", {}).get("rules") or [] + hosts = sorted({r.get("host") for r in rules if r.get("host")}) + if len(hosts) != 1: + raise GateError(f"oidc-client needs exactly one host, has {hosts}") + host = hosts[0] + if host.startswith("*"): + raise GateError(f"wildcard host {host} cannot be an OIDC client") + tls_hosts = {h for t in ing.get("spec", {}).get("tls") or [] for h in t.get("hosts") or []} + scheme = "https" if host in tls_hosts else "http" + strict = [u.strip() for u in ann.get(ANN + "oidc-redirect-uri", "").split(",") if u.strip()] + for u in strict: + if not u.startswith(f"{scheme}://{host}/"): + raise GateError(f"redirect URI {u} is not on {scheme}://{host}/") + redirects = [{"matching_mode": "strict", "url": u} for u in strict] or \ + [{"matching_mode": "regex", "url": f"^{scheme}://{re.escape(host)}/.*$"}] + return { + # name: provider and policy name; slug: application slug and client ID. + "name": f"oidc-{slug}", + "slug": slug, + "namespace": meta["namespace"], + "ingress": meta["name"], + "host": host, + "scheme": scheme, + "group": ann.get(ANN + "group") or None, + "authorization_flow": ann.get(ANN + "authorization-flow") or DEFAULT_AUTHZ_FLOW, + "redirect_uris": redirects, + } + + +def oauth2_body(spec, flows, refs): + return { + "name": spec["name"], + "authorization_flow": flows[spec["authorization_flow"]], + "invalidation_flow": flows[INVALIDATION_FLOW], + "client_type": "confidential", + "client_id": spec["slug"], + "grant_types": OIDC_GRANTS, + "signing_key": refs["signing_key"], + "property_mappings": sorted(refs["scopes"]), + "redirect_uris": spec["redirect_uris"], + } + + def drift(current, desired): return {k: v for k, v in desired.items() if current.get(k) != v} @@ -447,17 +519,79 @@ def ensure_access(ak, gate, target): ak.req("DELETE", f"policies/expression/{stale['pk']}/") +def oidc_refs(ak): + """Signing key and scope mappings every OIDC client uses; matched here, not + by server-side filters, so an ignored filter cannot pick another object.""" + keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY] + if len(keys) != 1: + raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}") + scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/") + if m.get("managed") in OIDC_SCOPES} + missing = [s for s in OIDC_SCOPES if s not in scopes] + if missing: + raise ApiError(f"scope mappings not found: {missing}") + return {"signing_key": keys[0], "scopes": list(scopes.values())} + + +def ensure_oidc(ak, spec, flows, refs): + name, slug = spec["name"], spec["slug"] + if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None: + raise GateError(f"group {spec['group']!r} not found in Authentik") + + app = get_application(ak, slug) + if app is not None and app.get("meta_publisher") != MANAGER: + raise GateError(f"application {slug} exists and is not managed by {MANAGER}") + + want = oauth2_body(spec, flows, refs) + provider = next((p for p in ak.list("providers/oauth2/") if p.get("name", "").lower() == name), None) + if provider is None: + # No client_secret: Authentik generates it (generate_client_secret). + provider = ak.req("POST", "providers/oauth2/", want) + LOG.info("%s: created OAuth2 provider %s", name, provider["pk"]) + elif provider.get("assigned_application_slug") not in (None, slug) or \ + (app is not None and app.get("provider") != provider["pk"]): + raise GateError(f"OAuth2 provider {name} belongs to another application") + else: + current = dict(provider, property_mappings=sorted(provider.get("property_mappings") or [])) + if drift(current, want): + ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want)) + LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want))) + + launch = f"{spec['scheme']}://{spec['host']}" + want_app = {"name": f"{spec['host']} (oidc)", "slug": slug, "provider": provider["pk"], + "meta_launch_url": launch, "meta_publisher": MANAGER, + "meta_description": f"OIDC client for Ingress {spec['namespace']}/{spec['ingress']}"} + if app is None: + app = ak.req("POST", "core/applications/", want_app) + LOG.info("%s: created application %s", name, slug) + elif drift(app, want_app): + ak.req("PATCH", f"core/applications/{slug}/", drift(app, want_app)) + LOG.info("%s: updated application %s", name, sorted(drift(app, want_app))) + + ensure_access(ak, spec, app["pbm_uuid"]) + + +PROVIDER_PATHS = ("providers/proxy/", "providers/oauth2/") + + +def policy_name(app): + return f"oidc-{app['slug']}" if app.get("_provider_path") == "providers/oauth2/" else app["slug"] + + def remove_gate(ak, app): - """Delete an owned gate: the application (bindings cascade; its provider is - PROTECTed while referenced), then the provider (which leaves the embedded - outpost with it), the policy. Ownership is found through the application, - so until it is gone a failed step is retried on the next pass.""" + """Delete an owned gate or OIDC client: the application (bindings cascade; + its provider is PROTECTed while referenced), then the provider (a proxy + provider leaves the embedded outpost with it), the policy. Ownership is + found through the application, so until it is gone a failed step is + retried on the next pass.""" name, provider = app["slug"], app.get("provider") - remove_dedicated_outposts(ak, name, provider) + path = app.get("_provider_path", "providers/proxy/") + if path == "providers/proxy/": + remove_dedicated_outposts(ak, name, provider) ak.req("DELETE", f"core/applications/{name}/") if provider: - ak.req("DELETE", f"providers/proxy/{provider}/") - policy = ak.one("policies/expression/", name=name) + ak.req("DELETE", f"{path}{provider}/") + policy = ak.one("policies/expression/", name=policy_name(app)) if policy is not None: ak.req("DELETE", f"policies/expression/{policy['pk']}/") LOG.info("%s: removed gate from Authentik", name) @@ -465,14 +599,15 @@ def remove_gate(ak, app): def owned_applications(ak): """Applications this controller owns, found through the (unfiltered) proxy - provider list and confirmed one by one.""" - for p in ak.list("providers/proxy/"): - slug = p.get("assigned_application_slug") - if not slug: - continue - app = get_application(ak, slug) - if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]: - yield app + and OAuth2 provider lists and confirmed one by one.""" + for path in PROVIDER_PATHS: + for p in ak.list(path): + slug = p.get("assigned_application_slug") + if not slug: + continue + app = get_application(ak, slug) + if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]: + yield dict(app, _provider_path=path) # --- reconcile -------------------------------------------------------------- @@ -482,7 +617,9 @@ def reconcile(): with open(AK_TOKEN_FILE) as f: ak = Authentik(f.read().strip()) - sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)] + ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) + sources = [i for i in ingresses if is_gated(i)] + clients = [i for i in ingresses if is_oidc(i)] shared = shared_hosts(sources) flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")} ensure_embedded_config(ak) @@ -526,6 +663,28 @@ def reconcile(): held.add(gate_name(ing)) failed += 1 + slugs = [oidc_slug(i) for i in clients] + refs = None + for ing in sorted(clients, key=oidc_slug): + ref, slug = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}', oidc_slug(ing) + try: + spec = oidc_spec(ing) + if slugs.count(slug) > 1: + raise GateError(f"oidc-client {slug} is claimed by more than one Ingress") + if spec["authorization_flow"] not in flows or INVALIDATION_FLOW not in flows: + raise GateError(f"flow {spec['authorization_flow']!r} or {INVALIDATION_FLOW!r} not found") + gates.add(slug) + refs = refs or oidc_refs(ak) + ensure_oidc(ak, spec, flows, refs) + except GateError as e: + LOG.error("%s: %s", ref, e) + failed += 1 + gates.discard(slug) + except Exception as e: + LOG.error("%s: %s", ref, e) + gates.add(slug) + failed += 1 + removed = 0 for obj in managed_objects(k8s): key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]) @@ -540,7 +699,8 @@ def reconcile(): remove_gate(ak, app) removed += 1 - LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed) + LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed", + len(sources), len(clients), failed, removed) def main(): diff --git a/ci/images/authentik-gate-sync/test_controller.py b/ci/images/authentik-gate-sync/test_controller.py index 038db4f6..04a68372 100644 --- a/ci/images/authentik-gate-sync/test_controller.py +++ b/ci/images/authentik-gate-sync/test_controller.py @@ -176,8 +176,10 @@ class FakeAuthentik: """In-memory stand-in for the endpoints the controller uses; records calls.""" def __init__(self): - self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {}, - "policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}} + self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {}, + "outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {}, + "core/groups/": {}, "crypto/certificatekeypairs/": {}, + "propertymappings/provider/scope/": {}} self.calls = [] self.next = 1 @@ -194,6 +196,9 @@ class FakeAuthentik: return coll, rest or None raise AssertionError(path) + def _provider(self, pk): + return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk] + def _find(self, coll, key): if coll == "core/applications/": return next((a for a in self.db[coll].values() if a["slug"] == key), None) @@ -226,7 +231,7 @@ class FakeAuthentik: obj = self.add(coll, dict(body)) if coll == "core/applications/": obj["pbm_uuid"] = f"pbm-{obj['pk']}" - self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"] + self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"] return copy.deepcopy(obj) obj = self._find(coll, key) if method == "PATCH": @@ -239,7 +244,7 @@ class FakeAuthentik: if method == "DELETE": del self.db[coll][obj["pk"]] if coll == "core/applications/": - self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None + self._provider(obj["provider"])["assigned_application_slug"] = None for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]: del self.db["policies/bindings/"][b["pk"]] return None @@ -382,5 +387,124 @@ class Reconcile(unittest.TestCase): self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"]) +YOUTRACK = { + "metadata": { + "name": "youtrack", "namespace": "youtrack", "uid": "u-2", + "annotations": {"auth.renner.uno/oidc-client": "youtrack", "auth.renner.uno/group": "YouTrack Users"}, + }, + "spec": { + "tls": [{"hosts": ["youtrack.renner.uno"], "secretName": "youtrack-tls"}], + "rules": [{"host": "youtrack.renner.uno", "http": {"paths": [ + {"path": "/", "pathType": "Prefix", + "backend": {"service": {"name": "youtrack", "port": {"number": 8080}}}}]}}], + }, +} + + +class OidcSpec(unittest.TestCase): + def test_youtrack(self): + s = c.oidc_spec(YOUTRACK) + self.assertTrue(c.is_oidc(YOUTRACK)) + self.assertFalse(c.is_gated(YOUTRACK)) + self.assertEqual((s["name"], s["slug"], s["host"], s["scheme"], s["group"]), + ("oidc-youtrack", "youtrack", "youtrack.renner.uno", "https", "YouTrack Users")) + self.assertEqual(s["redirect_uris"], + [{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}]) + + def test_strict_redirect(self): + ing = copy.deepcopy(YOUTRACK) + ing["metadata"]["annotations"]["auth.renner.uno/oidc-redirect-uri"] = \ + "https://youtrack.renner.uno/hub/a, https://youtrack.renner.uno/hub/b" + self.assertEqual([u["url"] for u in c.oidc_spec(ing)["redirect_uris"]], + ["https://youtrack.renner.uno/hub/a", "https://youtrack.renner.uno/hub/b"]) + + def test_rejects_foreign_redirect(self): + ing = copy.deepcopy(YOUTRACK) + ing["metadata"]["annotations"]["auth.renner.uno/oidc-redirect-uri"] = "https://evil.example/cb" + with self.assertRaises(c.GateError): + c.oidc_spec(ing) + + def test_rejects_bad_slug(self): + ing = copy.deepcopy(YOUTRACK) + ing["metadata"]["annotations"]["auth.renner.uno/oidc-client"] = "You Track" + with self.assertRaises(c.GateError): + c.oidc_spec(ing) + + def test_rejects_two_hosts(self): + ing = copy.deepcopy(YOUTRACK) + ing["spec"]["rules"].append(dict(ing["spec"]["rules"][0], host="other.renner.uno")) + with self.assertRaises(c.GateError): + c.oidc_spec(ing) + + +class ReconcileOidc(unittest.TestCase): + def setUp(self): + self.ak = FakeAuthentik() + self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"}) + self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"}) + for i, scope in enumerate(c.OIDC_SCOPES): + self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope}) + self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"}) + self.ak.add("core/groups/", {"name": "YouTrack Users"}) + self.s = c.oidc_spec(YOUTRACK) + + def run_once(self): + c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak)) + + def test_creates_owned_client(self): + self.run_once() + app = self.ak.get("core/applications/youtrack/") + self.assertEqual(app["meta_publisher"], c.MANAGER) + p = self.ak.db["providers/oauth2/"][app["provider"]] + self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]), + ("oidc-youtrack", "youtrack", "confidential", "key-1")) + self.assertNotIn("client_secret", p) + self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"]) + self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"]) + policy = self.ak.one("policies/expression/", name="oidc-youtrack") + self.assertIn("'YouTrack Users'", policy["expression"]) + self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]]) + + def test_second_pass_is_a_noop(self): + self.run_once() + before = len(self.ak.calls) + self.run_once() + self.assertEqual(self.ak.calls[before:], []) + + def test_missing_group_is_a_gate_error(self): + self.ak.db["core/groups/"].clear() + with self.assertRaises(c.GateError): + self.run_once() + self.assertEqual(self.ak.list("providers/oauth2/"), []) + + def test_refuses_unowned_application(self): + self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": "", "provider": None}) + with self.assertRaises(c.GateError): + self.run_once() + + def test_redirect_change_patches_provider(self): + self.run_once() + self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb"}]) + self.run_once() + p = next(iter(self.ak.db["providers/oauth2/"].values())) + self.assertEqual(p["redirect_uris"], self.s["redirect_uris"]) + + def test_missing_signing_key_is_transient(self): + self.ak.db["crypto/certificatekeypairs/"].clear() + with self.assertRaises(c.ApiError): + c.oidc_refs(self.ak) + + def test_ownership_scan_and_removal(self): + foreign = self.ak.add("providers/oauth2/", {"name": "gitea", "assigned_application_slug": "gitea"}) + self.ak.add("core/applications/", {"slug": "gitea", "meta_publisher": "", "provider": foreign["pk"]}) + self.run_once() + owned = list(c.owned_applications(self.ak)) + self.assertEqual([a["slug"] for a in owned], ["youtrack"]) + c.remove_gate(self.ak, owned[0]) + self.assertIsNone(self.ak.get("core/applications/youtrack/")) + self.assertEqual([p["name"] for p in self.ak.list("providers/oauth2/")], ["gitea"]) + self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack")) + + if __name__ == "__main__": unittest.main() diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 684f6b43..e0704a76 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1262,6 +1262,12 @@ data: - authentik_policies_expression.add_expressionpolicy - authentik_policies_expression.change_expressionpolicy - authentik_policies_expression.delete_expressionpolicy + - authentik_providers_oauth2.view_oauth2provider + - authentik_providers_oauth2.add_oauth2provider + - authentik_providers_oauth2.change_oauth2provider + - authentik_providers_oauth2.delete_oauth2provider + - authentik_providers_oauth2.view_scopemapping + - authentik_crypto.view_certificatekeypair - authentik_core.view_group - authentik_flows.view_flow - authentik_outposts.view_outpostserviceconnection -- 2.54.0 From 3e0557cfe812d377c831ad1e1a61d4485014afa5 Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Sun, 1 Nov 2026 09:31:43 +0000 Subject: [PATCH 2/3] feat(authentik-gate-sync): OIDC clients as OIDCClient resources, not Ingress annotations OIDC is not part of the Ingress. The client is now declared by an OIDCClient (auth.renner.uno/v1alpha1, CRD in infrastructure/authentik-gate-sync-crds, like kuma-sync-crds) next to the app: launchURL, redirectURIs, optional slug, group, authorizationFlow. The controller writes a Ready condition and the issuer to its status. The auth.renner.uno/oidc-* Ingress annotations are gone; RBAC gains get/list/watch on oidcclients and patch on their status. --- ci/images/authentik-gate-sync/controller.py | 130 +++++++++++------- .../authentik-gate-sync/test_controller.py | 66 +++++---- kubernetes/apps/authentik/gate-sync.yaml | 6 + .../crd-oidcclients.yaml | 99 +++++++++++++ .../kustomization.yaml | 4 + kubernetes/infrastructure/kustomization.yaml | 1 + 6 files changed, 225 insertions(+), 81 deletions(-) create mode 100644 kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml create mode 100644 kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml diff --git a/ci/images/authentik-gate-sync/controller.py b/ci/images/authentik-gate-sync/controller.py index b4db0568..4b598c94 100644 --- a/ci/images/authentik-gate-sync/controller.py +++ b/ci/images/authentik-gate-sync/controller.py @@ -18,14 +18,12 @@ Annotations (all optional): auth.renner.uno/authorization-flow authorization flow slug (default below) auth.renner.uno/skip-path provider skip_path_regex -OIDC clients: an Ingress with auth.renner.uno/oidc-client: (it need not -be gated) gets a confidential OAuth2/OpenID provider (client ID = slug, secret -generated by Authentik) and an application with that slug, so the issuer is -/application/o//. It shares auth.renner.uno/group and -auth.renner.uno/authorization-flow with gates, plus: - - auth.renner.uno/oidc-redirect-uri strict redirect URI(s), comma-separated; - none = any path on the Ingress's host +OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in +infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID +provider oidc- (client ID = slug, secret generated by Authentik) and an +application with that slug, so the issuer is /application/o//; +with spec.group the same group policy as gates. Its status carries Ready and +the issuer. Fail closed: until the provider exists, or after it is removed (e.g. the named group is gone), the embedded outpost has no provider for the host and rejects @@ -248,50 +246,59 @@ def provider_body(gate, flows): } -def is_oidc(ing): - return bool(((ing.get("metadata") or {}).get("annotations") or {}).get(ANN + "oidc-client")) +OIDC_API = "/apis/auth.renner.uno/v1alpha1" -def oidc_slug(ing): - return ing["metadata"]["annotations"][ANN + "oidc-client"].strip() +def oidc_slug(cr): + return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip() -def oidc_spec(ing): - """Translate an Ingress into the OIDC client it asks for. Raises GateError.""" - meta = ing["metadata"] - ann = meta.get("annotations") or {} - slug = oidc_slug(ing) +def oidc_spec(cr): + """Translate an OIDCClient into the client it asks for. Raises GateError.""" + meta, spec = cr["metadata"], cr.get("spec") or {} + slug = oidc_slug(cr) if not SLUG_RE.match(slug): - raise GateError(f"oidc-client {slug!r} is not a slug (a-z, 0-9, -; max 50)") - rules = ing.get("spec", {}).get("rules") or [] - hosts = sorted({r.get("host") for r in rules if r.get("host")}) - if len(hosts) != 1: - raise GateError(f"oidc-client needs exactly one host, has {hosts}") - host = hosts[0] - if host.startswith("*"): - raise GateError(f"wildcard host {host} cannot be an OIDC client") - tls_hosts = {h for t in ing.get("spec", {}).get("tls") or [] for h in t.get("hosts") or []} - scheme = "https" if host in tls_hosts else "http" - strict = [u.strip() for u in ann.get(ANN + "oidc-redirect-uri", "").split(",") if u.strip()] - for u in strict: - if not u.startswith(f"{scheme}://{host}/"): - raise GateError(f"redirect URI {u} is not on {scheme}://{host}/") - redirects = [{"matching_mode": "strict", "url": u} for u in strict] or \ - [{"matching_mode": "regex", "url": f"^{scheme}://{re.escape(host)}/.*$"}] + raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)") + launch = spec.get("launchURL", "") + if not launch.startswith(("https://", "http://")): + raise GateError(f"launchURL {launch!r} is not an http(s) URL") + redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", "")} + for r in spec.get("redirectURIs") or []] + if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects): + raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex") + for r in redirects: + if r["matching_mode"] == "regex": + try: + re.compile(r["url"]) + except re.error as e: + raise GateError(f"redirect regex {r['url']!r}: {e}") from None return { # name: provider and policy name; slug: application slug and client ID. "name": f"oidc-{slug}", "slug": slug, "namespace": meta["namespace"], - "ingress": meta["name"], - "host": host, - "scheme": scheme, - "group": ann.get(ANN + "group") or None, - "authorization_flow": ann.get(ANN + "authorization-flow") or DEFAULT_AUTHZ_FLOW, + "resource": meta["name"], + "launch_url": launch, + "group": spec.get("group") or None, + "authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW, "redirect_uris": redirects, } +def oidc_status(cr, ok, message): + cond = {"type": "Ready", "status": "True" if ok else "False", + "reason": "Reconciled" if ok else "Failed", "message": message[:1000], + "observedGeneration": cr["metadata"].get("generation", 0)} + old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {}) + same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration")) + cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \ + else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]} + if ok: + status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/" + return status + + def oauth2_body(spec, flows, refs): return { "name": spec["name"], @@ -336,6 +343,18 @@ class K8s: return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE, content_type="application/apply-patch+yaml") + def patch_status(self, path, status): + return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(), + cafile=K8S_CA_FILE, content_type="application/merge-patch+json") + + def oidc_clients(self): + try: + return self.get(f"{OIDC_API}/oidcclients").get("items", []) + except ApiError as e: + if e.code == 404: # CRD not installed: no clients, gates unaffected + return [] + raise + def delete(self, obj): try: http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE) @@ -557,10 +576,9 @@ def ensure_oidc(ak, spec, flows, refs): ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want)) LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want))) - launch = f"{spec['scheme']}://{spec['host']}" - want_app = {"name": f"{spec['host']} (oidc)", "slug": slug, "provider": provider["pk"], - "meta_launch_url": launch, "meta_publisher": MANAGER, - "meta_description": f"OIDC client for Ingress {spec['namespace']}/{spec['ingress']}"} + want_app = {"name": f"{slug} (oidc)", "slug": slug, "provider": provider["pk"], + "meta_launch_url": spec["launch_url"], "meta_publisher": MANAGER, + "meta_description": f"OIDCClient {spec['namespace']}/{spec['resource']}"} if app is None: app = ak.req("POST", "core/applications/", want_app) LOG.info("%s: created application %s", name, slug) @@ -619,7 +637,7 @@ def reconcile(): ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) sources = [i for i in ingresses if is_gated(i)] - clients = [i for i in ingresses if is_oidc(i)] + clients = k8s.oidc_clients() shared = shared_hosts(sources) flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")} ensure_embedded_config(ak) @@ -665,25 +683,35 @@ def reconcile(): slugs = [oidc_slug(i) for i in clients] refs = None - for ing in sorted(clients, key=oidc_slug): - ref, slug = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}', oidc_slug(ing) + for cr in sorted(clients, key=oidc_slug): + ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr) + ok, message = False, "" try: - spec = oidc_spec(ing) + spec = oidc_spec(cr) if slugs.count(slug) > 1: - raise GateError(f"oidc-client {slug} is claimed by more than one Ingress") - if spec["authorization_flow"] not in flows or INVALIDATION_FLOW not in flows: - raise GateError(f"flow {spec['authorization_flow']!r} or {INVALIDATION_FLOW!r} not found") + raise GateError(f"slug {slug} is claimed by more than one OIDCClient") + for flow in (spec["authorization_flow"], INVALIDATION_FLOW): + if flow not in flows: + raise GateError(f"flow {flow!r} not found in Authentik") gates.add(slug) refs = refs or oidc_refs(ak) ensure_oidc(ak, spec, flows, refs) + ok, message = True, f"provider {spec['name']}, application {slug}" except GateError as e: - LOG.error("%s: %s", ref, e) + LOG.error("OIDCClient %s: %s", ref, e) failed += 1 gates.discard(slug) + message = str(e) except Exception as e: - LOG.error("%s: %s", ref, e) + LOG.error("OIDCClient %s: %s", ref, e) gates.add(slug) failed += 1 + message = f"transient: {e}" + try: + path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}' + k8s.patch_status(path, oidc_status(cr, ok, message)) + except ApiError as e: + LOG.error("OIDCClient %s: status: %s", ref, e) removed = 0 for obj in managed_objects(k8s): diff --git a/ci/images/authentik-gate-sync/test_controller.py b/ci/images/authentik-gate-sync/test_controller.py index 04a68372..4b6d82f1 100644 --- a/ci/images/authentik-gate-sync/test_controller.py +++ b/ci/images/authentik-gate-sync/test_controller.py @@ -388,15 +388,11 @@ class Reconcile(unittest.TestCase): YOUTRACK = { - "metadata": { - "name": "youtrack", "namespace": "youtrack", "uid": "u-2", - "annotations": {"auth.renner.uno/oidc-client": "youtrack", "auth.renner.uno/group": "YouTrack Users"}, - }, + "metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3}, "spec": { - "tls": [{"hosts": ["youtrack.renner.uno"], "secretName": "youtrack-tls"}], - "rules": [{"host": "youtrack.renner.uno", "http": {"paths": [ - {"path": "/", "pathType": "Prefix", - "backend": {"service": {"name": "youtrack", "port": {"number": 8080}}}}]}}], + "launchURL": "https://youtrack.renner.uno", + "group": "YouTrack Users", + "redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}], }, } @@ -404,37 +400,47 @@ YOUTRACK = { class OidcSpec(unittest.TestCase): def test_youtrack(self): s = c.oidc_spec(YOUTRACK) - self.assertTrue(c.is_oidc(YOUTRACK)) - self.assertFalse(c.is_gated(YOUTRACK)) - self.assertEqual((s["name"], s["slug"], s["host"], s["scheme"], s["group"]), - ("oidc-youtrack", "youtrack", "youtrack.renner.uno", "https", "YouTrack Users")) + self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]), + ("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users")) self.assertEqual(s["redirect_uris"], [{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}]) - def test_strict_redirect(self): - ing = copy.deepcopy(YOUTRACK) - ing["metadata"]["annotations"]["auth.renner.uno/oidc-redirect-uri"] = \ - "https://youtrack.renner.uno/hub/a, https://youtrack.renner.uno/hub/b" - self.assertEqual([u["url"] for u in c.oidc_spec(ing)["redirect_uris"]], - ["https://youtrack.renner.uno/hub/a", "https://youtrack.renner.uno/hub/b"]) + def test_slug_overrides_name(self): + cr = copy.deepcopy(YOUTRACK) + cr["spec"]["slug"] = "yt" + self.assertEqual(c.oidc_spec(cr)["slug"], "yt") - def test_rejects_foreign_redirect(self): - ing = copy.deepcopy(YOUTRACK) - ing["metadata"]["annotations"]["auth.renner.uno/oidc-redirect-uri"] = "https://evil.example/cb" - with self.assertRaises(c.GateError): - c.oidc_spec(ing) + def test_strict_is_default(self): + cr = copy.deepcopy(YOUTRACK) + cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}] + self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict") def test_rejects_bad_slug(self): - ing = copy.deepcopy(YOUTRACK) - ing["metadata"]["annotations"]["auth.renner.uno/oidc-client"] = "You Track" + cr = copy.deepcopy(YOUTRACK) + cr["spec"]["slug"] = "You Track" with self.assertRaises(c.GateError): - c.oidc_spec(ing) + c.oidc_spec(cr) - def test_rejects_two_hosts(self): - ing = copy.deepcopy(YOUTRACK) - ing["spec"]["rules"].append(dict(ing["spec"]["rules"][0], host="other.renner.uno")) + def test_rejects_missing_redirects(self): + cr = copy.deepcopy(YOUTRACK) + cr["spec"]["redirectURIs"] = [] with self.assertRaises(c.GateError): - c.oidc_spec(ing) + c.oidc_spec(cr) + + def test_rejects_bad_regex(self): + cr = copy.deepcopy(YOUTRACK) + cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}] + with self.assertRaises(c.GateError): + c.oidc_spec(cr) + + def test_status(self): + st = c.oidc_status(YOUTRACK, True, "ok") + self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/") + self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3)) + cr = dict(YOUTRACK, status=st) + self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"], + st["conditions"][0]["lastTransitionTime"]) + self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing")) class ReconcileOidc(unittest.TestCase): diff --git a/kubernetes/apps/authentik/gate-sync.yaml b/kubernetes/apps/authentik/gate-sync.yaml index fedff443..f903b8a4 100644 --- a/kubernetes/apps/authentik/gate-sync.yaml +++ b/kubernetes/apps/authentik/gate-sync.yaml @@ -62,6 +62,12 @@ rules: - apiGroups: ["cilium.io"] resources: ["ciliumnetworkpolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["auth.renner.uno"] + resources: ["oidcclients"] + verbs: ["get", "list", "watch"] + - apiGroups: ["auth.renner.uno"] + resources: ["oidcclients/status"] + verbs: ["get", "patch", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml new file mode 100644 index 00000000..700e95ee --- /dev/null +++ b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml @@ -0,0 +1,99 @@ +# OIDCClient: an Authentik OAuth2/OpenID client, reconciled by authentik-gate-sync (ci/images/authentik-gate-sync). +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: oidcclients.auth.renner.uno + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled +spec: + group: auth.renner.uno + names: + kind: OIDCClient + listKind: OIDCClientList + plural: oidcclients + singular: oidcclient + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - jsonPath: .status.issuer + name: Issuer + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: [launchURL, redirectURIs] + properties: + slug: + description: Application slug and client ID; issuer is /application/o//. Default metadata.name. + type: string + maxLength: 50 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + launchURL: + description: The app's URL, shown in the Authentik library. + type: string + pattern: ^https?:// + redirectURIs: + type: array + minItems: 1 + items: + type: object + required: [url] + properties: + url: + type: string + matchingMode: + type: string + enum: [strict, regex] + default: strict + group: + description: Authentik group whose members may sign in (managed in Authentik); none = any user. + type: string + authorizationFlow: + description: Authorization flow slug; default default-provider-authorization-implicit-consent. + type: string + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + issuer: + type: string + conditions: + type: array + items: + type: object + required: [type, status] + properties: + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + observedGeneration: + type: integer + format: int64 + lastTransitionTime: + type: string + format: date-time diff --git a/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml b/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml new file mode 100644 index 00000000..c5373994 --- /dev/null +++ b/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - crd-oidcclients.yaml diff --git a/kubernetes/infrastructure/kustomization.yaml b/kubernetes/infrastructure/kustomization.yaml index 4e0975d2..13fb0975 100644 --- a/kubernetes/infrastructure/kustomization.yaml +++ b/kubernetes/infrastructure/kustomization.yaml @@ -27,6 +27,7 @@ resources: - admission-policies/ - kuma-sync-crds/ - cloudflare-lb-sync-crds/ + - authentik-gate-sync-crds/ # The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and # an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md). # cnpg-barman-plugin/ lives in its own Flux Kustomization -- 2.54.0 From f76defb1e1688a0c757cad51ab7a4374aa1dc7fe Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Sun, 1 Nov 2026 09:49:33 +0000 Subject: [PATCH 3/3] fix(authentik-gate-sync): review of the OIDC clients - Desired redirect URIs carry redirect_uri_type: authorization, which the API returns on every entry (RedirectURISerializer default in 2026.8.4); without it every pass would re-PATCH the provider. - OIDC slugs and gate names are kept apart: a slug that is a gate name is refused, an existing application without this controller's OAuth2 provider is refused, and the sweep keeps proxy and OAuth2 applications by their own sets, so an OIDCClient can neither take over nor delete a gate. - Status is written only when it changed. - Adoption of an unassigned oidc- provider is documented; removal logs say OIDC client. - reconcile() takes k8s/ak for tests; new loop tests: create then quiet, duplicate slug, transient error keeps the client, sweep after the resource is gone, slug equal to a gate name. --- ci/images/authentik-gate-sync/controller.py | 40 +++++-- .../authentik-gate-sync/test_controller.py | 108 +++++++++++++++++- .../crd-oidcclients.yaml | 2 +- 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/ci/images/authentik-gate-sync/controller.py b/ci/images/authentik-gate-sync/controller.py index 4b598c94..d26d57aa 100644 --- a/ci/images/authentik-gate-sync/controller.py +++ b/ci/images/authentik-gate-sync/controller.py @@ -23,7 +23,10 @@ infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID provider oidc- (client ID = slug, secret generated by Authentik) and an application with that slug, so the issuer is /application/o//; with spec.group the same group policy as gates. Its status carries Ready and -the issuer. +the issuer. OIDC slugs and gate names are kept apart: a slug that is a gate's +name, or an existing application without this controller's OAuth2 provider, +is refused. An unassigned provider named oidc- is adopted (the remains +of an interrupted teardown, which deletes the application first). Fail closed: until the provider exists, or after it is removed (e.g. the named group is gone), the embedded outpost has no provider for the host and rejects @@ -262,7 +265,8 @@ def oidc_spec(cr): launch = spec.get("launchURL", "") if not launch.startswith(("https://", "http://")): raise GateError(f"launchURL {launch!r} is not an http(s) URL") - redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", "")} + redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""), + "redirect_uri_type": "authorization"} for r in spec.get("redirectURIs") or []] if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects): raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex") @@ -563,6 +567,9 @@ def ensure_oidc(ak, spec, flows, refs): want = oauth2_body(spec, flows, refs) provider = next((p for p in ak.list("providers/oauth2/") if p.get("name", "").lower() == name), None) + if app is not None and (provider is None or app.get("provider") != provider["pk"]): + # E.g. a gate's application: never re-point it at an OAuth2 provider. + raise GateError(f"application {slug} exists without OAuth2 provider {name}") if provider is None: # No client_secret: Authentik generates it (generate_client_secret). provider = ak.req("POST", "providers/oauth2/", want) @@ -612,7 +619,8 @@ def remove_gate(ak, app): policy = ak.one("policies/expression/", name=policy_name(app)) if policy is not None: ak.req("DELETE", f"policies/expression/{policy['pk']}/") - LOG.info("%s: removed gate from Authentik", name) + LOG.info("%s: removed %s from Authentik", name, + "OIDC client" if path == "providers/oauth2/" else "gate") def owned_applications(ak): @@ -630,10 +638,11 @@ def owned_applications(ak): # --- reconcile -------------------------------------------------------------- -def reconcile(): - k8s = K8s() - with open(AK_TOKEN_FILE) as f: - ak = Authentik(f.read().strip()) +def reconcile(k8s=None, ak=None): + k8s = k8s or K8s() + if ak is None: + with open(AK_TOKEN_FILE) as f: + ak = Authentik(f.read().strip()) ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) sources = [i for i in ingresses if is_gated(i)] @@ -681,6 +690,8 @@ def reconcile(): held.add(gate_name(ing)) failed += 1 + gate_names = {gate_name(i) for i in sources} + keep_clients = set() # OIDC slugs whose Authentik side should exist slugs = [oidc_slug(i) for i in clients] refs = None for cr in sorted(clients, key=oidc_slug): @@ -690,26 +701,30 @@ def reconcile(): spec = oidc_spec(cr) if slugs.count(slug) > 1: raise GateError(f"slug {slug} is claimed by more than one OIDCClient") + if slug in gate_names: + raise GateError(f"slug {slug} is the name of a gate") for flow in (spec["authorization_flow"], INVALIDATION_FLOW): if flow not in flows: raise GateError(f"flow {flow!r} not found in Authentik") - gates.add(slug) + keep_clients.add(slug) refs = refs or oidc_refs(ak) ensure_oidc(ak, spec, flows, refs) ok, message = True, f"provider {spec['name']}, application {slug}" except GateError as e: LOG.error("OIDCClient %s: %s", ref, e) failed += 1 - gates.discard(slug) + keep_clients.discard(slug) message = str(e) except Exception as e: LOG.error("OIDCClient %s: %s", ref, e) - gates.add(slug) + keep_clients.add(slug) failed += 1 message = f"transient: {e}" try: path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}' - k8s.patch_status(path, oidc_status(cr, ok, message)) + status = oidc_status(cr, ok, message) + if status != cr.get("status"): + k8s.patch_status(path, status) except ApiError as e: LOG.error("OIDCClient %s: status: %s", ref, e) @@ -723,7 +738,8 @@ def reconcile(): removed += 1 for app in list(owned_applications(ak)): - if app["slug"] not in gates: + keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates + if app["slug"] not in keep: remove_gate(ak, app) removed += 1 diff --git a/ci/images/authentik-gate-sync/test_controller.py b/ci/images/authentik-gate-sync/test_controller.py index 4b6d82f1..49866088 100644 --- a/ci/images/authentik-gate-sync/test_controller.py +++ b/ci/images/authentik-gate-sync/test_controller.py @@ -403,7 +403,8 @@ class OidcSpec(unittest.TestCase): self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]), ("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users")) self.assertEqual(s["redirect_uris"], - [{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}]) + [{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$", + "redirect_uri_type": "authorization"}]) def test_slug_overrides_name(self): cr = copy.deepcopy(YOUTRACK) @@ -490,7 +491,8 @@ class ReconcileOidc(unittest.TestCase): def test_redirect_change_patches_provider(self): self.run_once() - self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb"}]) + self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb", + "redirect_uri_type": "authorization"}]) self.run_once() p = next(iter(self.ak.db["providers/oauth2/"].values())) self.assertEqual(p["redirect_uris"], self.s["redirect_uris"]) @@ -512,5 +514,107 @@ class ReconcileOidc(unittest.TestCase): self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack")) + def test_refuses_application_without_its_provider(self): + gate_provider = self.ak.add("providers/proxy/", {"name": "g", "assigned_application_slug": "youtrack"}) + self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": c.MANAGER, + "provider": gate_provider["pk"], "pbm_uuid": "pbm-x"}) + with self.assertRaises(c.GateError): + self.run_once() + self.assertEqual(self.ak.list("providers/oauth2/"), []) + self.assertEqual(self.ak.get("core/applications/youtrack/")["provider"], gate_provider["pk"]) + + +class FakeK8s: + def __init__(self, ingresses=(), clients=(), services=None): + self.ingresses, self.clients = list(ingresses), list(clients) + self.services = services or {} + self.status, self.applied = {}, [] + + def get(self, path): + if path == "/apis/networking.k8s.io/v1/ingresses": + return {"items": copy.deepcopy(self.ingresses)} + if "labelSelector=" in path: + return {"items": []} + if path.startswith("/api/v1/namespaces/") and "/services/" in path: + return copy.deepcopy(self.services[path.rsplit("/", 1)[1]]) + raise AssertionError(path) + + def oidc_clients(self): + return copy.deepcopy(self.clients) + + def apply(self, obj): + self.applied.append(obj) + + def patch_status(self, path, status): + self.status[path.rsplit("/", 1)[1]] = status + for cr in self.clients: + if cr["metadata"]["name"] == path.rsplit("/", 1)[1]: + cr["status"] = status + + +class ReconcileLoop(unittest.TestCase): + def setUp(self): + self.ak = FakeAuthentik() + self.ak.db["flows/instances/"] = {} + for slug, pk in FLOWS.items(): + self.ak.add("flows/instances/", {"slug": slug, "pk": pk}) + self.ak.add("outposts/instances/", {"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST, + "providers": [], "config": {}}) + self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"}) + for i, scope in enumerate(c.OIDC_SCOPES): + self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope}) + self.ak.add("core/groups/", {"name": "YouTrack Users"}) + + def client(self, name="youtrack", **spec): + cr = copy.deepcopy(YOUTRACK) + cr["metadata"]["name"] = name + cr["spec"].update(spec) + return cr + + def test_creates_then_quiet(self): + k8s = FakeK8s(clients=[self.client()]) + c.reconcile(k8s, self.ak) + self.assertEqual(k8s.status["youtrack"]["conditions"][0]["status"], "True") + self.assertIsNotNone(self.ak.get("core/applications/youtrack/")) + before, k8s.status = len(self.ak.calls), {} + c.reconcile(k8s, self.ak) + self.assertEqual(self.ak.calls[before:], []) + self.assertEqual(k8s.status, {}) # unchanged status is not re-written + + def test_duplicate_slug_withholds_both(self): + k8s = FakeK8s(clients=[self.client(), self.client("other", slug="youtrack")]) + c.reconcile(k8s, self.ak) + self.assertIsNone(self.ak.get("core/applications/youtrack/")) + self.assertEqual({k: v["conditions"][0]["status"] for k, v in k8s.status.items()}, + {"youtrack": "False", "other": "False"}) + + def test_transient_error_keeps_client(self): + k8s = FakeK8s(clients=[self.client()]) + c.reconcile(k8s, self.ak) + self.ak.db["crypto/certificatekeypairs/"].clear() + c.reconcile(k8s, self.ak) + self.assertIsNotNone(self.ak.get("core/applications/youtrack/")) + self.assertIn("transient", k8s.status["youtrack"]["conditions"][0]["message"]) + + def test_sweeps_client_whose_resource_is_gone(self): + k8s = FakeK8s(clients=[self.client()]) + c.reconcile(k8s, self.ak) + k8s.clients = [] + c.reconcile(k8s, self.ak) + self.assertIsNone(self.ak.get("core/applications/youtrack/")) + self.assertEqual(self.ak.list("providers/oauth2/"), []) + + def test_slug_equal_to_gate_name_leaves_gate_alone(self): + k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}) + c.reconcile(k8s, self.ak) + gate = c.gate_name(LLAMA) + app = self.ak.get(f"core/applications/{gate}/") + k8s.clients = [self.client("evil", slug=gate)] + c.reconcile(k8s, self.ak) + self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"]) + self.assertEqual(self.ak.list("providers/oauth2/"), []) + self.assertEqual(k8s.status["evil"]["conditions"][0]["status"], "False") + + if __name__ == "__main__": unittest.main() diff --git a/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml index 700e95ee..000f58f7 100644 --- a/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml +++ b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml @@ -42,7 +42,7 @@ spec: required: [launchURL, redirectURIs] properties: slug: - description: Application slug and client ID; issuer is /application/o//. Default metadata.name. + description: Application slug and client ID; issuer is /application/o//. Default metadata.name (then checked by the controller only). type: string maxLength: 50 pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ -- 2.54.0