diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index abfe19c8..e19639d8 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -148,18 +148,34 @@ 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` -(EAP-TTLS/PAP). +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---`) 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). 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 -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..19b20935 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 wlan.renner.uno. backends: - authentik.core.auth.InbuiltBackend + - authentik.core.auth.TokenBackend - id: dot1x-login model: authentik_stages_user_login.userloginstage @@ -1506,12 +1509,54 @@ 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) + 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 + 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 @@ -1624,3 +1669,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..61d2bd86 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,37 @@ 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; + # 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"; + proxy_set_header Content-Type $arg_ct; + proxy_set_header Accept "application/json"; } location = /healthz { @@ -187,8 +211,11 @@ 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). 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}$/; @@ -226,38 +253,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 = 29; - 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 + "-" + 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, + 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 +395,8 @@ data: @@ -382,25 +469,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