From b1be3181efb3fa8a084774653eddbbe911384f1c Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 12:52:09 +0100 Subject: [PATCH 1/5] feat(radius): Android WLAN profile with a per-device app password wlan.renner.uno signs the user in through the new authentik OAuth2 application wlan-setup (confidential, scope goauthentik.io/api). The callback exchanges the code (client_secret_basic), creates an app password with the user's own access token (10 years) and returns the Passpoint profile carrying it in the response to the user's GET. The page asks for no password and stores nothing; deleting the app password in authentik removes the device. - dot1x-password stage also accepts app passwords (TokenBackend). - Client secret: ESO Password generator wlan-setup-oauth, read by the authentik worker (blueprint) and by wlan-setup. - authentik calls go through an internal nginx location; headers come from $arg_* because subrequests share the parent's variable cache. - Reloader annotations on the wlan-setup Deployment metadata. Co-Authored-By: Claude Opus 5.5 --- docs/radius/dot1x.md | 28 ++- .../apps/authentik/blueprints-configmap.yaml | 43 ++++ kubernetes/apps/authentik/helmrelease.yaml | 15 +- kubernetes/apps/authentik/kustomization.yaml | 1 + .../apps/authentik/wlan-setup-config.yaml | 205 ++++++++++++------ .../apps/authentik/wlan-setup-oauth.yaml | 32 +++ kubernetes/apps/authentik/wlan-setup.yaml | 21 +- 7 files changed, 256 insertions(+), 89 deletions(-) create mode 100644 kubernetes/apps/authentik/wlan-setup-oauth.yaml diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index abfe19c8..28c73bfe 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -148,18 +148,30 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). It serves the Apple profile (caller's username filled in), a Windows file that imports the -WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password. Android -gets a Passpoint profile (`application/x-wifi-config`): the page POSTs the -password the user types there, nginx (njs) builds the profile and keeps it in -memory for at most 120 s under a one-time token bound to the user, and a tap on -the resulting link GETs it, as the Android installer requires. It matches WLANs -that advertise Hotspot 2.0 with domain and NAI realm `renner.uno` +WLAN profile with `netsh`, and the Linux `nmcli` command. None of these carries a password. + +Android gets a Passpoint profile (`application/x-wifi-config`) with its own +app password. "Auf Android installieren" (`/android/install`) signs the user in +through the authentik OAuth2 application `wlan-setup` (scope +`goauthentik.io/api`). On `/android/callback`, nginx (njs) exchanges the code, +creates an app password (`wlan-android--`, 10 years) with the +user's own access token, and returns the profile in the response to that GET, +as the Android installer requires. The page stores nothing. Deleting the app +password in authentik removes that device from the WLAN. The profile matches +WLANs that advertise Hotspot 2.0 with domain and NAI realm `renner.uno` (EAP-TTLS/PAP). +App password lifetimes are capped by the group attribute +`goauthentik.io/user/token-maximum-lifetime` (default: the token default of +30 minutes). WLAN users need a group with +`goauthentik.io/user/token-maximum-lifetime: days=3650`, or the creation fails +(502 on the callback, reason in the wlan-setup log). + All clients use EAP-TTLS with inner PAP. The server certificate is a public Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be -installed. Username and password are the authentik credentials. PAP runs -inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air. +installed. The 802.1X password stage (`dot1x-password`) accepts the authentik +password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and +is never sent in clear over the air. ### Android diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 4f4c6a1e..06b44509 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1483,8 +1483,11 @@ data: name: dot1x-password state: present attrs: + # App passwords: the WLAN credential that devices store (Android + # Passpoint profiles carry it), created by the user in authentik. backends: - authentik.core.auth.InbuiltBackend + - authentik.core.auth.TokenBackend - id: dot1x-login model: authentik_stages_user_login.userloginstage @@ -1624,3 +1627,43 @@ data: kubernetes_httproute_annotations: {} kubernetes_httproute_parent_refs: [] kubernetes_json_patches: null + + # wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the + # user's own access token (scope goauthentik.io/api), creates the WLAN + # app password for the Android Passpoint profile. Confidential client; + # the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No + # policy binding: every authentik user may set up the WLAN. + - id: wlan-setup-provider + model: authentik_providers_oauth2.oauth2provider + identifiers: + name: WLAN setup + state: present + attrs: + authentication_flow: null + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + client_type: confidential + client_id: wlan-setup + client_secret: !Env WLAN_SETUP_CLIENT_SECRET + grant_types: + - authorization_code + access_token_validity: minutes=5 + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]] + redirect_uris: + - url: https://wlan.renner.uno/android/callback + matching_mode: strict + + - model: authentik_core.application + identifiers: + slug: wlan-setup + state: present + attrs: + name: WLAN setup + slug: wlan-setup + provider: !KeyOf wlan-setup-provider + meta_launch_url: https://wlan.renner.uno + meta_description: "WLAN setup page; creates WLAN app passwords" diff --git a/kubernetes/apps/authentik/helmrelease.yaml b/kubernetes/apps/authentik/helmrelease.yaml index 49c36276..5bc30466 100644 --- a/kubernetes/apps/authentik/helmrelease.yaml +++ b/kubernetes/apps/authentik/helmrelease.yaml @@ -171,10 +171,11 @@ spec: worker: # Overrides the global key; keep authentik-secrets in the list. podAnnotations: - secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets - # Shared secret of the 802.1X Radius provider, set via `!Env` in - # dot1x-radius.yaml. Worker only (blueprint applier); optional so a - # missing Secret fails only the dot1x blueprint entry. + secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth + # Shared secret of the 802.1X Radius provider and client secret of the + # WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only + # (blueprint applier); optional so a missing Secret fails only the + # blueprint entries that use it. env: - name: DOT1X_OUTPOST_SECRET valueFrom: @@ -182,6 +183,12 @@ spec: name: radius-secrets key: DOT1X_OUTPOST_SECRET optional: true + - name: WLAN_SETUP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: wlan-setup-oauth + key: client_secret + optional: true resources: requests: # Same reasoning as server above, and the worker is burstier still: diff --git a/kubernetes/apps/authentik/kustomization.yaml b/kubernetes/apps/authentik/kustomization.yaml index a662721c..93d498e9 100644 --- a/kubernetes/apps/authentik/kustomization.yaml +++ b/kubernetes/apps/authentik/kustomization.yaml @@ -16,6 +16,7 @@ resources: - freeradius-network-attachment.yaml - freeradius.yaml - freeradius-cnp.yaml + - wlan-setup-oauth.yaml - wlan-setup-config.yaml - wlan-setup.yaml # ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS; diff --git a/kubernetes/apps/authentik/wlan-setup-config.yaml b/kubernetes/apps/authentik/wlan-setup-config.yaml index ac888e55..f611a227 100644 --- a/kubernetes/apps/authentik/wlan-setup-config.yaml +++ b/kubernetes/apps/authentik/wlan-setup-config.yaml @@ -2,19 +2,21 @@ # against freeradius, outer identity anonymous, server name radius.renner.uno). # Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the # username comes from X-authentik-username. Only the Android Passpoint profile -# carries the password (the format requires it); it exists in nginx memory for -# at most 120 s and is handed out once. +# carries a credential (the format requires it): a per-device authentik app +# password that the page creates with the user's own OAuth access token. apiVersion: v1 kind: ConfigMap metadata: name: wlan-setup namespace: authentik data: - # The image's nginx.conf plus the njs module and a single worker, so the - # one-time tokens of passpoint.js live in one process. + # The image's nginx.conf plus the njs module and the OAuth client secret + # passpoint.js reads from the environment. nginx.conf: | load_module modules/ngx_http_js_module.so; + env WLAN_SETUP_CLIENT_SECRET; + worker_processes 1; error_log /var/log/nginx/error.log notice; pid /tmp/nginx.pid; @@ -52,8 +54,12 @@ data: js_path /etc/nginx/njs/; js_import passpoint from passpoint.js; - # token -> {user, profile}; entries expire after 120 s. - js_shared_dict_zone zone=passpoint:1m timeout=120s evict; + + # authentik-server, resolved once at start; reached only by passpoint.js + # subrequests (/_ak/...). + upstream authentik { + server authentik-server.authentik.svc.cluster.local:80; + } server { listen 8080; @@ -147,19 +153,33 @@ data: try_files /renner-unifi-wlan.cmd =404; } - # Android: the page POSTs the password and gets a one-time token; a tap - # on /android/profile/ then GETs the Passpoint profile, as the - # Android installer requires (application/x-wifi-config, base64, - # no Content-Disposition). - location = /android/prepare { - limit_except POST { deny all; } - client_max_body_size 1k; - client_body_buffer_size 1k; - js_content passpoint.prepare; + # Android: a tap on /android/install starts an authorization-code + # sign-in at authentik (the user is already signed in there); the + # callback creates the app password as the user and answers the same + # navigation with the Passpoint profile (application/x-wifi-config, + # base64, no Content-Disposition), as the Android installer requires. + location = /android/install { + js_content passpoint.install; } - location ~ "^/android/profile/[A-Za-z0-9_-]{32}$" { - js_content passpoint.deliver; + location = /android/callback { + # The query carries the one-time authorization code. + access_log off; + js_content passpoint.callback; + } + + # Subrequests from passpoint.js only. Credential and content type + # arrive as arguments and leave as headers; proxy_pass with a variable + # URI does not forward the arguments. $arg_* only: subrequests share + # the parent's variable cache, so a map would keep its first value. + location ~ ^/_ak(/.*)$ { + internal; + proxy_pass http://authentik$1; + proxy_set_header Host auth.renner.uno; + proxy_set_header Cookie ""; + proxy_set_header Authorization "$arg_scheme $arg_cred"; + proxy_set_header Content-Type $arg_ct; + proxy_set_header Accept "application/json"; } location = /healthz { @@ -187,8 +207,10 @@ data: # the Passpoint WLAN announces; EAP-TTLS (21) with inner PAP. Trust anchor # ISRG Root X1 (the radius-tls chain ends there), AAA server name # radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's - # PpsMoParser expects it). A token is bound to the user who created it and - # can be fetched once. + # PpsMoParser expects it). The credential is a new app password per install, + # created as the user with the access token of the "WLAN setup" OAuth client + # (authorization code, scope goauthentik.io/api); its lifetime needs the + # group attribute goauthentik.io/user/token-maximum-lifetime >= days=3650. passpoint.js: | const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc="; const USER = /^[A-Za-z0-9._@+-]{1,64}$/; @@ -226,38 +248,104 @@ data: return b64(mime); } - function caller(r) { - const user = r.headersIn["X-authentik-username"] || ""; - return USER.test(user) ? user : ""; - } + const CLIENT_ID = "wlan-setup"; + const REDIRECT_URI = "https://wlan.renner.uno/android/callback"; + const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/"; + const STATE_COOKIE = "wlan_oauth_state"; + const LIFETIME_DAYS = 3650; - function prepare(r) { - const user = caller(r); - const password = r.requestText || ""; - if (!user) { r.return(403); return; } - if (!password || password.length > 256) { r.return(400); return; } + function random() { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); - const token = Buffer.from(bytes).toString("base64url"); - ngx.shared.passpoint.set(token, JSON.stringify({ user: user, body: profile(user, password) })); - r.headersOut["Content-Type"] = "text/plain"; - r.return(200, token); + return Buffer.from(bytes).toString("base64url"); } - function deliver(r) { - const token = r.uri.substring(r.uri.lastIndexOf("/") + 1); - const dict = ngx.shared.passpoint; - const entry = dict.get(token); - if (!entry) { r.return(404); return; } - const data = JSON.parse(entry); - if (data.user !== caller(r)) { r.return(404); return; } - dict.delete(token); - r.headersOut["Content-Type"] = "application/x-wifi-config"; - r.headersOut["Content-Transfer-Encoding"] = "base64"; - r.return(200, data.body); + function cookie(r, name) { + const all = r.headersIn["Cookie"] || ""; + const parts = all.split(";"); + for (let n = 0; n < parts.length; n++) { + const part = parts[n]; + const i = part.indexOf("="); + if (i > 0 && part.substring(0, i).trim() === name) return part.substring(i + 1).trim(); + } + return ""; } - export default { prepare, deliver }; + function form(fields) { + return Object.keys(fields).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(fields[k])).join("&"); + } + + const FORM = "application/x-www-form-urlencoded"; + const JSON_CT = "application/json"; + const CRED = /^[A-Za-z0-9._~+\/=-]+$/; + + // authentik call through the internal /_ak location; the arguments + // become the Authorization and Content-Type headers there (raw, nginx + // does not decode $arg_*). + async function ak(r, method, path, scheme, cred, ct, body) { + if (!CRED.test(cred)) throw new Error(method + " " + path + ": no credential"); + const args = "scheme=" + scheme + "&cred=" + cred + "&ct=" + ct; + const opts = { method: method, args: args }; + if (body !== undefined) opts.body = body; + const reply = await r.subrequest("/_ak" + path, opts); + if (reply.status < 200 || reply.status > 299) { + throw new Error(method + " " + path + " -> " + reply.status + " " + reply.responseText.substring(0, 300)); + } + return JSON.parse(reply.responseText); + } + + function install(r) { + const state = random(); + r.headersOut["Set-Cookie"] = STATE_COOKIE + "=" + state + + "; Path=/android/; Max-Age=300; HttpOnly; Secure; SameSite=Lax"; + r.return(302, AUTHORIZE + "?" + form({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: "openid profile goauthentik.io/api", + state: state, + })); + } + + async function callback(r) { + try { + const state = cookie(r, STATE_COOKIE); + if (!state || r.args.state !== state || !r.args.code) { r.return(400, "Bitte die Einrichtung neu starten.\n"); return; } + const client = Buffer.from(CLIENT_ID + ":" + process.env.WLAN_SETUP_CLIENT_SECRET).toString("base64"); + const grant = await ak(r, "POST", "/application/o/token/", "Basic", client, FORM, form({ + grant_type: "authorization_code", + code: r.args.code, + redirect_uri: REDIRECT_URI, + })); + const access = grant.access_token; + const me = await ak(r, "GET", "/api/v3/core/users/me/", "Bearer", access, ""); + const user = me.user.username; + if (!USER.test(user) || user !== r.headersIn["X-authentik-username"]) { r.return(403); return; } + + const now = new Date(); + const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, ""); + const identifier = "wlan-android-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp; + const expires = new Date(now.getTime() + LIFETIME_DAYS * 86400000).toISOString(); + await ak(r, "POST", "/api/v3/core/tokens/", "Bearer", access, JSON_CT, JSON.stringify({ + identifier: identifier, + intent: "app_password", + description: "WLAN Android, " + now.toISOString().substring(0, 10) + " (wlan.renner.uno)", + expiring: true, + expires: expires, + })); + const key = (await ak(r, "GET", "/api/v3/core/tokens/" + identifier + "/view_key/", "Bearer", access, "")).key; + + r.headersOut["Set-Cookie"] = STATE_COOKIE + "=; Path=/android/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"; + r.headersOut["Content-Type"] = "application/x-wifi-config"; + r.headersOut["Content-Transfer-Encoding"] = "base64"; + r.return(200, profile(user, key)); + } catch (e) { + r.error("wlan-setup android: " + e.message); + r.return(502, "Die Einrichtung ist fehlgeschlagen.\n"); + } + } + + export default { install, callback }; index.html: | @@ -302,14 +390,8 @@ data: @@ -382,25 +464,6 @@ data: .then(function (t) { render(t.trim()); }) .catch(function () {}); - // Android: the server builds the Passpoint profile from the password and - // keeps it under a one-time token; the installer only starts on a GET - // that the user triggers by tapping, so the token becomes a link. - document.getElementById("android-form").addEventListener("submit", function (ev) { - ev.preventDefault(); - var field = document.getElementById("android-password"); - var install = document.getElementById("android-install"); - var status = document.getElementById("android-status"); - if (!field.value) return; - fetch("/android/prepare", { method: "POST", credentials: "same-origin", body: field.value }) - .then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); }) - .then(function (token) { - field.value = ""; - install.href = "/android/profile/" + token.trim(); - install.hidden = false; - status.textContent = "Bereit. Gültig für 2 Minuten."; - }) - .catch(function () { status.textContent = "Das hat nicht geklappt. Bitte neu laden und erneut versuchen."; }); - }); document.getElementById("copy-nmcli").addEventListener("click", function () { var text = document.getElementById("nmcli").textContent; diff --git a/kubernetes/apps/authentik/wlan-setup-oauth.yaml b/kubernetes/apps/authentik/wlan-setup-oauth.yaml new file mode 100644 index 00000000..93226569 --- /dev/null +++ b/kubernetes/apps/authentik/wlan-setup-oauth.yaml @@ -0,0 +1,32 @@ +# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint). +# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET +# for the blueprint, wlan-setup reads it for the code exchange. +apiVersion: generators.external-secrets.io/v1alpha1 +kind: Password +metadata: + name: wlan-setup-oauth + namespace: authentik +spec: + length: 64 + digits: 16 + symbols: 0 + allowRepeat: true + noUpper: false + secretKeys: + - client_secret +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: wlan-setup-oauth + namespace: authentik +spec: + refreshPolicy: CreatedOnce + target: + name: wlan-setup-oauth + dataFrom: + - sourceRef: + generatorRef: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Password + name: wlan-setup-oauth diff --git a/kubernetes/apps/authentik/wlan-setup.yaml b/kubernetes/apps/authentik/wlan-setup.yaml index 065fe3cb..fb89627b 100644 --- a/kubernetes/apps/authentik/wlan-setup.yaml +++ b/kubernetes/apps/authentik/wlan-setup.yaml @@ -1,8 +1,9 @@ # wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi" (Apple -# profile, Windows import file, Linux nmcli command; config and files in -# wlan-setup-config.yaml). Public, so a device can reach it before it is in the -# WLAN (mobile data, Renner Gast). Any Authentik user passes the gate (no -# group): the page holds no secrets, only the caller's own username. +# profile, Windows import file, Linux nmcli command, Android Passpoint profile +# with its own app password; config and files in wlan-setup-config.yaml). +# Public, so a device can reach it before it is in the WLAN (mobile data, +# Renner Gast). Any Authentik user passes the gate (no group); the page stores +# no user credentials. apiVersion: apps/v1 kind: Deployment metadata: @@ -10,6 +11,9 @@ metadata: namespace: authentik labels: app.kubernetes.io/name: wlan-setup + annotations: + configmap.reloader.stakater.com/reload: wlan-setup + secret.reloader.stakater.com/reload: wlan-setup-oauth spec: replicas: 1 selector: @@ -19,8 +23,6 @@ spec: metadata: labels: app.kubernetes.io/name: wlan-setup - annotations: - configmap.reloader.stakater.com/reload: wlan-setup spec: automountServiceAccountToken: false securityContext: @@ -32,6 +34,13 @@ spec: containers: - name: nginx image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e + env: + # Client secret of the "WLAN setup" OAuth2 provider (passpoint.js). + - name: WLAN_SETUP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: wlan-setup-oauth + key: client_secret ports: - name: http containerPort: 8080 -- 2.54.0 From c781e263fc81036384b96a6b1a8bca23fe4c8d9c Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 13:02:10 +0100 Subject: [PATCH 2/5] fix(radius): wlan-setup authentik subrequests take large replies - subrequest_output_buffer_size 256k: users/me with groups, avatar and permissions exceeds the one-page default and was cut off. - proxy_pass_request_headers off: browser headers (Accept-Encoding, User-Agent, X-authentik-*) no longer reach authentik. - Random suffix on the app password identifier, so two installs in the same second do not collide. Co-Authored-By: Claude Opus 5.5 --- kubernetes/apps/authentik/wlan-setup-config.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kubernetes/apps/authentik/wlan-setup-config.yaml b/kubernetes/apps/authentik/wlan-setup-config.yaml index f611a227..8d8b52c4 100644 --- a/kubernetes/apps/authentik/wlan-setup-config.yaml +++ b/kubernetes/apps/authentik/wlan-setup-config.yaml @@ -175,6 +175,10 @@ data: location ~ ^/_ak(/.*)$ { internal; proxy_pass http://authentik$1; + # njs reads the whole reply from this buffer (default one page). + subrequest_output_buffer_size 256k; + # Only the headers set here reach authentik. + proxy_pass_request_headers off; proxy_set_header Host auth.renner.uno; proxy_set_header Cookie ""; proxy_set_header Authorization "$arg_scheme $arg_cred"; @@ -324,7 +328,7 @@ data: const now = new Date(); const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, ""); - const identifier = "wlan-android-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp; + const identifier = "wlan-android-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp + "-" + random().substring(0, 6).replace(/[^A-Za-z0-9]/g, "x"); const expires = new Date(now.getTime() + LIFETIME_DAYS * 86400000).toISOString(); await ak(r, "POST", "/api/v3/core/tokens/", "Bearer", access, JSON_CT, JSON.stringify({ identifier: identifier, -- 2.54.0 From 29da21c28f3ead2f8ee17dc37e82c067ed9c3b20 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 13:05:27 +0100 Subject: [PATCH 3/5] docs(radius): app password lifetime cap is 30 days without the attribute Co-Authored-By: Claude Opus 5.5 --- docs/radius/dot1x.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index 28c73bfe..4fa828a6 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -162,10 +162,11 @@ WLANs that advertise Hotspot 2.0 with domain and NAI realm `renner.uno` (EAP-TTLS/PAP). App password lifetimes are capped by the group attribute -`goauthentik.io/user/token-maximum-lifetime` (default: the token default of -30 minutes). WLAN users need a group with -`goauthentik.io/user/token-maximum-lifetime: days=3650`, or the creation fails -(502 on the callback, reason in the wlan-setup log). +`goauthentik.io/user/token-maximum-lifetime` (without it: 30 days). WLAN users +need a group with `goauthentik.io/user/token-maximum-lifetime: days=3650`, or +the creation fails with `Token expires exceeds maximum lifetime` (502 on the +callback, reason in the wlan-setup log). Creating the app password needs no +admin rights. All clients use EAP-TTLS with inner PAP. The server certificate is a public Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be -- 2.54.0 From 21625685b4b12079ceb9d14d60b1d4d033bad5e7 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 13:11:12 +0100 Subject: [PATCH 4/5] feat(radius): WLAN app passwords expire 60 days after last use New app passwords start with 29 days (below the 30-day API cap, so no group attribute is needed). The expression policy dot1x-app-password-renew, re-evaluated on the dot1x-login binding, renews the app password that TokenBackend matched to 60 days on each 802.1X login (at most one write per day; always passes, failure_result true). authentik rejects expired app passwords and deletes them in clean_expired_models (every 5 min). Co-Authored-By: Claude Opus 5.5 --- docs/radius/dot1x.md | 19 ++++---- .../apps/authentik/blueprints-configmap.yaml | 45 ++++++++++++++++++- .../apps/authentik/wlan-setup-config.yaml | 7 +-- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index 4fa828a6..e19639d8 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -154,19 +154,22 @@ Android gets a Passpoint profile (`application/x-wifi-config`) with its own app password. "Auf Android installieren" (`/android/install`) signs the user in through the authentik OAuth2 application `wlan-setup` (scope `goauthentik.io/api`). On `/android/callback`, nginx (njs) exchanges the code, -creates an app password (`wlan-android--`, 10 years) with the +creates an app password (`wlan-android---`) with the user's own access token, and returns the profile in the response to that GET, as the Android installer requires. The page stores nothing. Deleting the app password in authentik removes that device from the WLAN. The profile matches WLANs that advertise Hotspot 2.0 with domain and NAI realm `renner.uno` -(EAP-TTLS/PAP). +(EAP-TTLS/PAP). Creating the app password needs no admin rights. -App password lifetimes are capped by the group attribute -`goauthentik.io/user/token-maximum-lifetime` (without it: 30 days). WLAN users -need a group with `goauthentik.io/user/token-maximum-lifetime: days=3650`, or -the creation fails with `Token expires exceeds maximum lifetime` (502 on the -callback, reason in the wlan-setup log). Creating the app password needs no -admin rights. +App passwords expire when unused. The page creates them with 29 days (the API +caps new tokens at 30 days unless the group attribute +`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login +with an app password renews it to 60 days: the policy +`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per +day). authentik rejects expired app passwords and deletes them in its +`clean_expired_models` task. A device that has not connected for 60 days, or a +profile that was never installed, loses its app password and needs a new +profile from the page. All clients use EAP-TTLS with inner PAP. The server certificate is a public Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 06b44509..279c67df 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1484,7 +1484,7 @@ data: state: present attrs: # App passwords: the WLAN credential that devices store (Android - # Passpoint profiles carry it), created by the user in authentik. + # Passpoint profiles carry it), created by wlan.renner.uno. backends: - authentik.core.auth.InbuiltBackend - authentik.core.auth.TokenBackend @@ -1509,12 +1509,53 @@ data: order: 20 state: present - - model: authentik_flows.flowstagebinding + - id: binding-dot1x-login + model: authentik_flows.flowstagebinding identifiers: target: !KeyOf dot1x-flow stage: !KeyOf dot1x-login order: 100 state: present + attrs: + evaluate_on_plan: false + re_evaluate_policies: true + + # Sliding expiry: an app password used for 802.1X expires 60 days + # after its last use; authentik rejects and deletes expired app + # passwords. auth_method_args.token is set by TokenBackend. Always + # passes; failure_result true keeps an error from blocking the login. + - id: dot1x-app-password-renew + model: authentik_policies_expression.expressionpolicy + identifiers: + name: dot1x-app-password-renew + state: present + attrs: + expression: | + from datetime import timedelta + from django.utils.timezone import now + from authentik.core.models import Token, TokenIntents + ref = (request.context.get("auth_method_args") or {}).get("token") + if request.context.get("auth_method") == "token" and isinstance(ref, dict): + renewed = now() + timedelta(days=60) + Token.objects.filter( + pk=ref.get("pk"), + user=request.user, + intent=TokenIntents.INTENT_APP_PASSWORD, + expiring=True, + expires__lt=renewed - timedelta(days=1), + ).update(expires=renewed) + return True + + - model: authentik_policies.policybinding + identifiers: + policy: !KeyOf dot1x-app-password-renew + target: !KeyOf binding-dot1x-login + order: 0 + state: present + attrs: + enabled: true + negate: false + failure_result: true - id: dot1x-has-vlan model: authentik_policies_expression.expressionpolicy diff --git a/kubernetes/apps/authentik/wlan-setup-config.yaml b/kubernetes/apps/authentik/wlan-setup-config.yaml index 8d8b52c4..61d2bd86 100644 --- a/kubernetes/apps/authentik/wlan-setup-config.yaml +++ b/kubernetes/apps/authentik/wlan-setup-config.yaml @@ -213,8 +213,9 @@ data: # radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's # PpsMoParser expects it). The credential is a new app password per install, # created as the user with the access token of the "WLAN setup" OAuth client - # (authorization code, scope goauthentik.io/api); its lifetime needs the - # group attribute goauthentik.io/user/token-maximum-lifetime >= days=3650. + # (authorization code, scope goauthentik.io/api). It starts with 29 days + # (below the 30-day token cap); each 802.1X login renews it to 60 days + # (policy dot1x-app-password-renew). passpoint.js: | const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc="; const USER = /^[A-Za-z0-9._@+-]{1,64}$/; @@ -256,7 +257,7 @@ data: const REDIRECT_URI = "https://wlan.renner.uno/android/callback"; const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/"; const STATE_COOKIE = "wlan_oauth_state"; - const LIFETIME_DAYS = 3650; + const LIFETIME_DAYS = 29; function random() { const bytes = new Uint8Array(24); -- 2.54.0 From 6c2c278f653c419f36b2e3aa24f5cffe70665ad0 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 13:16:27 +0100 Subject: [PATCH 5/5] feat(radius): log WLAN app password renewals Co-Authored-By: Claude Opus 5.5 --- kubernetes/apps/authentik/blueprints-configmap.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 279c67df..19b20935 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1537,13 +1537,14 @@ data: ref = (request.context.get("auth_method_args") or {}).get("token") if request.context.get("auth_method") == "token" and isinstance(ref, dict): renewed = now() + timedelta(days=60) - Token.objects.filter( + count = Token.objects.filter( pk=ref.get("pk"), user=request.user, intent=TokenIntents.INTENT_APP_PASSWORD, expiring=True, expires__lt=renewed - timedelta(days=1), ).update(expires=renewed) + ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count) return True - model: authentik_policies.policybinding -- 2.54.0