feat(radius): WLAN app passwords expire 60 days after last use
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 13:11:12 +01:00
co-authored by Claude Opus 5.5
parent 29da21c28f
commit 21625685b4
3 changed files with 58 additions and 13 deletions
+11 -8
View File
@@ -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-<user>-<timestamp>`, 10 years) with the
creates an app password (`wlan-android-<user>-<timestamp>-<random>`) 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
@@ -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
@@ -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);