Repository: goauthentik/authentik
Pull request: core: add user settable token durations
## Details

This PR enables users to specify a non default lifetime for users created app application token. By the way, it enforces expires value on API side.

Closes #5881 

Current limitation : maximum duration is currently not enforce in the UI but only on API side. This is because I haven't find yet an easy way to provide that piece of information to the UI without add a new API endpoint which would require additionnal work. Nevertheless, if the chosen expiration date is invalid, an error message is still thrown up to the use explaining why it has failed.
In my opinion, this enforcement within the UI could be enforced in a future improvement.

---

## Checklist

-   [X] Local tests pass (`ak test authentik/`)
-   [X] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [X] The code has been formatted (`make web`)
-   [X] The translation files have been updated (`make i18n-extract`)

If applicable

-   [X] The documentation has been updated
-   [X] The documentation has been formatted (`make website`)


Problem statement:
Allow end users to create app passwords with longer (or no) expiry
**Is your feature request related to a problem? Please describe.**
So I have calibre-web authenticating to authentik via LDAP (for it's OPDS function). With MFA enabled, ideally that would use an app password. However, user created app passwords have a (fixed?) lifetime of 30 minutes which is not ideal for this use case.

**Describe the solution you'd like**
An option to allow users to create longer lived app passwords, or even potentially non-expiring one.

**Describe alternatives you've considered**
Some way to alter the default lifetime of those app passwords, but might not be ideal I suppose.

**Additional context**
Add any other context or screenshots about the feature request here.

Hints from issue discussion:
I second this, having no way to extend the expiration past 30 minutes makes App Passwords unusable for purposes other than evaluation.

Also if it's not too much trouble, I think it would be a good idea to have a configurable policy for this where administrators can set min/max/default expiration time. Maybe for Tokens too 😄 
I'd like to add a note here, the issue is specifically with app passwords created from the *end user* interface.

The creating them from the admin side faces no such limitations. But yes, this effectively renders app passwords effectively useless in scenarios where it's an *end user* trying to create the app password.
> The creating them from the admin side faces no such limitations.

I didn't even realize you could create them in the admin panel. Thanks for pointing that out!
Is there any plans for this functionality?

Commit message: lint-fix

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

Patch to review:
diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py
index 9c94992c5706..5fb185cacb64 100644
--- a/authentik/core/api/tokens.py
+++ b/authentik/core/api/tokens.py
@@ -20,9 +20,18 @@
 from authentik.core.api.used_by import UsedByMixin
 from authentik.core.api.users import UserSerializer
 from authentik.core.api.utils import PassiveSerializer
-from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
+from authentik.core.models import (
+    USER_ATTRIBUTE_TOKEN_EXPIRING,
+    USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
+    Token,
+    TokenIntents,
+    User,
+    default_token_duration,
+    token_expires_from_timedelta,
+)
 from authentik.events.models import Event, EventAction
 from authentik.events.utils import model_to_dict
+from authentik.lib.utils.time import timedelta_from_string
 from authentik.rbac.decorators import permission_required
 
 
@@ -49,6 +58,30 @@ def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
         attrs.setdefault("intent", TokenIntents.INTENT_API)
         if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]:
             raise ValidationError({"intent": f"Invalid intent {attrs.get('intent')}"})
+
+        if attrs.get("intent") == TokenIntents.INTENT_APP_PASSWORD:
+            # user IS in attrs
+            user: User = attrs.get("user")
+            max_token_lifetime = user.group_attributes(request).get(
+                USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
+            )
+            max_token_lifetime_dt = default_token_duration()
+            if max_token_lifetime is not None:
+                try:
+                    max_token_lifetime_dt = timedelta_from_string(max_token_lifetime)
+                except ValueError:
+                    max_token_lifetime_dt = default_token_duration()
+
+            if "expires" in attrs and attrs.get("expires") > token_expires_from_timedelta(
+                max_token_lifetime_dt
+            ):
+                raise ValidationError(
+                    {"expires": f"Token expires exceeds maximum lifetime ({max_token_lifetime})."}
+                )
+        elif attrs.get("intent") == TokenIntents.INTENT_API:
+            # For API tokens, expires cannot be overridden
+            attrs["expires"] = default_token_duration()
+
         return attrs
 
     class Meta:
diff --git a/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py b/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py
index 8d1d0dd97ff0..5b8cb38ab2e5 100644
--- a/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py
+++ b/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py
@@ -5,6 +5,7 @@
 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 
 import authentik.core.models
+from authentik.lib.generators import generate_id
 
 
 def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
@@ -16,6 +17,10 @@ def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
         token.save()
 
 
+def default_token_key():
+    return generate_id(60)
+
+
 class Migration(migrations.Migration):
     replaces = [
         ("authentik_core", "0012_auto_20201003_1737"),
@@ -62,7 +67,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="token",
             name="key",
-            field=models.TextField(default=authentik.core.models.default_token_key),
+            field=models.TextField(default=default_token_key),
         ),
         migrations.AlterUniqueTogether(
             name="token",
diff --git a/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py b/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py
new file mode 100644
index 000000000000..dd62bcf85221
--- /dev/null
+++ b/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+import authentik.core.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_core", "0033_alter_user_options"),
+        ("authentik_tenants", "0002_tenant_default_token_duration_and_more"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="authenticatedsession",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name="token",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name="token",
+            name="key",
+            field=models.TextField(default=authentik.core.models.default_token_key),
+        ),
+    ]
diff --git a/authentik/core/models.py b/authentik/core/models.py
index 0c3dd3fbf2cc..ada21495270c 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -1,6 +1,6 @@
 """authentik core models"""
 
-from datetime import timedelta
+from datetime import datetime, timedelta
 from hashlib import sha256
 from typing import Any, Optional, Self
 from uuid import uuid4
@@ -25,15 +25,17 @@
 from authentik.core.exceptions import PropertyMappingExpressionException
 from authentik.core.types import UILoginButton, UserSettingSerializer
 from authentik.lib.avatars import get_avatar
-from authentik.lib.config import CONFIG
 from authentik.lib.generators import generate_id
 from authentik.lib.models import (
     CreatedUpdatedModel,
     DomainlessFormattedURLValidator,
     SerializerModel,
 )
+from authentik.lib.utils.time import timedelta_from_string
 from authentik.policies.models import PolicyBindingModel
 from authentik.root.install_id import get_install_id
+from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
+from authentik.tenants.utils import get_current_tenant
 
 LOGGER = get_logger()
 USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
@@ -42,13 +44,13 @@
 USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
 USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
 USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec
+USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME = "goauthentik.io/user/token-maximum-lifetime"  # nosec
 USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
 USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
 USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
 USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
 USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
 
-
 options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
     # used_by API that allows models to specify if they shadow an object
     # for example the proxy provider which is built on top of an oauth provider
@@ -59,16 +61,33 @@
 )
 
 
-def default_token_duration():
+def default_token_duration() -> datetime:
     """Default duration a Token is valid"""
-    return now() + timedelta(minutes=30)
+    current_tenant = get_current_tenant()
+    token_duration = (
+        current_tenant.default_token_duration
+        if hasattr(current_tenant, "default_token_duration")
+        else DEFAULT_TOKEN_DURATION
+    )
+    return now() + timedelta_from_string(token_duration)
+
 
+def token_expires_from_timedelta(dt: timedelta) -> datetime:
+    """Return a `datetime.datetime` object with the duration of the Token"""
+    return now() + dt
 
-def default_token_key():
+
+def default_token_key() -> str:
     """Default token key"""
+    current_tenant = get_current_tenant()
+    token_length = (
+        current_tenant.default_token_length
+        if hasattr(current_tenant, "default_token_length")
+        else DEFAULT_TOKEN_LENGTH
+    )
     # We use generate_id since the chars in the key should be easy
     # to use in Emails (for verification) and URLs (for recovery)
-    return generate_id(CONFIG.get_int("default_token_length"))
+    return generate_id(token_length)
 
 
 class UserTypes(models.TextChoices):
@@ -624,12 +643,17 @@ class Meta:
 class ExpiringModel(models.Model):
     """Base Model which can expire, and is automatically cleaned up."""
 
-    expires = models.DateTimeField(default=default_token_duration)
+    expires = models.DateTimeField(default=None, null=True)
     expiring = models.BooleanField(default=True)
 
     class Meta:
         abstract = True
 
+    def save(self, *args, **kwargs):
+        if self.expiring and self.expires is None:
+            self.expires = default_token_duration()
+        super().save(*args, **kwargs)
+
     def expire_action(self, *args, **kwargs):
         """Handler which is called when this object is expired. By
         default the object is deleted. This is less efficient compared
diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py
index 26e67406046e..c70d1b98d310 100644
--- a/authentik/core/tests/test_token_api.py
+++ b/authentik/core/tests/test_token_api.py
@@ -1,5 +1,6 @@
 """Test token API"""
 
+from datetime import datetime, timedelta
 from json import loads
 
 from django.urls.base import reverse
@@ -7,7 +8,13 @@
 from rest_framework.test import APITestCase
 
 from authentik.core.api.tokens import TokenSerializer
-from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
+from authentik.core.models import (
+    USER_ATTRIBUTE_TOKEN_EXPIRING,
+    USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
+    Token,
+    TokenIntents,
+    User,
+)
 from authentik.core.tests.utils import create_test_admin_user
 from authentik.lib.generators import generate_id
 
@@ -76,6 +83,77 @@ def test_token_create_non_expiring(self):
         self.assertEqual(token.intent, TokenIntents.INTENT_API)
         self.assertEqual(token.expiring, False)
 
+    def test_token_create_expiring(self):
+        """Test token creation endpoint"""
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
+        self.user.save()
+        response = self.client.post(
+            reverse("authentik_api:token-list"), {"identifier": "test-token"}
+        )
+        self.assertEqual(response.status_code, 201)
+        token = Token.objects.get(identifier="test-token")
+        self.assertEqual(token.user, self.user)
+        self.assertEqual(token.intent, TokenIntents.INTENT_API)
+        self.assertEqual(token.expiring, True)
+
+    def test_token_create_expiring_custom_ok(self):
+        """Test token creation endpoint"""
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
+        self.user.save()
+        expires = datetime.now() + timedelta(hours=1)
+        response = self.client.post(
+            reverse("authentik_api:token-list"),
+            {
+                "identifier": "test-token",
+                "expires": expires,
+                "intent": TokenIntents.INTENT_APP_PASSWORD,
+            },
+        )
+        self.assertEqual(response.status_code, 201)
+        token = Token.objects.get(identifier="test-token")
+        self.assertEqual(token.user, self.user)
+        self.assertEqual(token.intent, TokenIntents.INTENT_APP_PASSWORD)
+        self.assertEqual(token.expiring, True)
+        self.assertEqual(token.expires.timestamp(), expires.timestamp())
+
+    def test_token_create_expiring_custom_nok(self):
+        """Test token creation endpoint"""
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
+        self.user.save()
+        expires = datetime.now() + timedelta(hours=3)
+        response = self.client.post(
+            reverse("authentik_api:token-list"),
+            {
+                "identifier": "test-token",
+                "expires": expires,
+                "intent": TokenIntents.INTENT_APP_PASSWORD,
+            },
+        )
+        self.assertEqual(response.status_code, 400)
+
+    def test_token_create_expiring_custom_api(self):
+        """Test token creation endpoint"""
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True
+        self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2"
+        self.user.save()
+        expires = datetime.now() + timedelta(seconds=3)
+        response = self.client.post(
+            reverse("authentik_api:token-list"),
+            {
+                "identifier": "test-token",
+                "expires": expires,
+                "intent": TokenIntents.INTENT_API,
+            },
+        )
+        self.assertEqual(response.status_code, 201)
+        token = Token.objects.get(identifier="test-token")
+        self.assertEqual(token.user, self.user)
+        self.assertEqual(token.intent, TokenIntents.INTENT_API)
+        self.assertEqual(token.expiring, True)
+        self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
+
     def test_list(self):
         """Test Token List (Test normal authentication)"""
         Token.objects.all().delete()
diff --git a/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py b/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py
new file mode 100644
index 000000000000..60d1f7a253f0
--- /dev/null
+++ b/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_providers_rac", "0001_squashed_0003_alter_connectiontoken_options_and_more"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="connectiontoken",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+    ]
diff --git a/authentik/events/migrations/0006_alter_systemtask_expires.py b/authentik/events/migrations/0006_alter_systemtask_expires.py
new file mode 100644
index 000000000000..9b5c2738ca52
--- /dev/null
+++ b/authentik/events/migrations/0006_alter_systemtask_expires.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "authentik_events",
+            "0004_systemtask_squashed_0005_remove_systemtask_finish_timestamp_and_more",
+        ),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="systemtask",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+    ]
diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml
index 22eda58ae872..f4a05d8e1529 100644
--- a/authentik/lib/default.yml
+++ b/authentik/lib/default.yml
@@ -110,7 +110,6 @@ events:
     asn: "/geoip/GeoLite2-ASN.mmdb"
 
 cert_discovery_dir: /certs
-default_token_length: 60
 
 tenants:
   enabled: false
diff --git a/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py b/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py
new file mode 100644
index 000000000000..249f2f377880
--- /dev/null
+++ b/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "authentik_providers_oauth2",
+            "0017_accesstoken_session_id_authorizationcode_session_id_and_more",
+        ),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="accesstoken",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name="authorizationcode",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name="devicetoken",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name="refreshtoken",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+    ]
diff --git a/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py b/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py
new file mode 100644
index 000000000000..7f186e796528
--- /dev/null
+++ b/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_stages_consent", "0005_alter_consentstage_mode"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="userconsent",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+    ]
diff --git a/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py b/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py
new file mode 100644
index 000000000000..45a04df43285
--- /dev/null
+++ b/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.2 on 2024-02-29 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_stages_invitation", "0007_invitation_flow"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="invitation",
+            name="expires",
+            field=models.DateTimeField(default=None, null=True),
+        ),
+    ]
diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py
index 1c0a63670ca1..60a37225b342 100644
--- a/authentik/tenants/api/settings.py
+++ b/authentik/tenants/api/settings.py
@@ -23,6 +23,8 @@ class Meta:
             "footer_links",
             "gdpr_compliance",
             "impersonation",
+            "default_token_duration",
+            "default_token_length",
         ]
 
 
diff --git a/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py
new file mode 100644
index 000000000000..0aa7accfc886
--- /dev/null
+++ b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.0.2 on 2024-02-20 08:26
+
+import django.core.validators
+from django.db import migrations, models
+
+import authentik.lib.utils.time
+from authentik.lib.config import CONFIG
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_tenants", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="tenant",
+            name="default_token_duration",
+            field=models.TextField(
+                default=CONFIG.get("default_token_duration", "minutes=30"),
+                help_text="Default token duration",
+                validators=[authentik.lib.utils.time.timedelta_string_validator],
+            ),
+        ),
+        migrations.AddField(
+            model_name="tenant",
+            name="default_token_length",
+            field=models.PositiveIntegerField(
+                default=CONFIG.get_int("default_token_length", 60),
+                help_text="Default token length",
+                validators=[django.core.validators.MinValueValidator(1)],
+            ),
+        ),
+    ]
diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py
index c7a7876ea65f..9a52dfe38330 100644
--- a/authentik/tenants/models.py
+++ b/authentik/tenants/models.py
@@ -5,6 +5,7 @@
 
 from django.apps import apps
 from django.core.exceptions import ValidationError
+from django.core.validators import MinValueValidator
 from django.db import models
 from django.db.utils import IntegrityError
 from django.dispatch import receiver
@@ -22,6 +23,9 @@
 
 VALID_SCHEMA_NAME = re.compile(r"^t_[a-z0-9]{1,61}$")
 
+DEFAULT_TOKEN_DURATION = "minutes=30"  # nosec
+DEFAULT_TOKEN_LENGTH = 60
+
 
 def _validate_schema_name(name):
     if not VALID_SCHEMA_NAME.match(name):
@@ -81,6 +85,16 @@ class Tenant(TenantMixin, SerializerModel):
     impersonation = models.BooleanField(
         help_text=_("Globally enable/disable impersonation."), default=True
     )
+    default_token_duration = models.TextField(
+        help_text=_("Default token duration"),
+        default=DEFAULT_TOKEN_DURATION,
+        validators=[timedelta_string_validator],
+    )
+    default_token_length = models.PositiveIntegerField(
+        help_text=_("Default token length"),
+        default=DEFAULT_TOKEN_LENGTH,
+        validators=[MinValueValidator(1)],
+    )
 
     def save(self, *args, **kwargs):
         if self.schema_name == "template":
diff --git a/schema.yml b/schema.yml
index 38c0d042839c..1c9fa1e18bdb 100644
--- a/schema.yml
+++ b/schema.yml
@@ -40242,6 +40242,15 @@ components:
         impersonation:
           type: boolean
           description: Globally enable/disable impersonation.
+        default_token_duration:
+          type: string
+          minLength: 1
+          description: Default token duration
+        default_token_length:
+          type: integer
+          maximum: 2147483647
+          minimum: 0
+          description: Default token length
     PatchedStaticDeviceRequest:
       type: object
       description: Serializer for static authenticator devices
@@ -43376,6 +43385,14 @@ components:
         impersonation:
           type: boolean
           description: Globally enable/disable impersonation.
+        default_token_duration:
+          type: string
+          description: Default token duration
+        default_token_length:
+          type: integer
+          maximum: 2147483647
+          minimum: 0
+          description: Default token length
     SettingsRequest:
       type: object
       description: Settings Serializer
@@ -43407,6 +43424,15 @@ components:
         impersonation:
           type: boolean
           description: Globally enable/disable impersonation.
+        default_token_duration:
+          type: string
+          minLength: 1
+          description: Default token duration
+        default_token_length:
+          type: integer
+          maximum: 2147483647
+          minimum: 0
+          description: Default token length
     SeverityEnum:
       enum:
       - notice
diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts
index c1af0fb0687a..87b835438acb 100644
--- a/web/src/admin/admin-settings/AdminSettingsForm.ts
+++ b/web/src/admin/admin-settings/AdminSettingsForm.ts
@@ -1,5 +1,6 @@
 import { first } from "@goauthentik/app/common/utils";
 import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import "@goauthentik/components/ak-number-input";
 import "@goauthentik/components/ak-switch-input";
 import "@goauthentik/components/ak-text-input";
 import "@goauthentik/elements/CodeMirror";
@@ -192,6 +193,24 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
                 help=${msg("Globally enable/disable impersonation.")}
             >
             </ak-switch-input>
+            <ak-text-input
+                name="defaultTokenDuration"
+                label=${msg("Default token duration")}
+                required
+                value="${ifDefined(this._settings?.defaultTokenDuration)}"
+                .bighelp=${html`<p class="pf-c-form__helper-text">
+                        ${msg("Default duration for generated tokens")}
+                    </p>
+                    <ak-utils-time-delta-help></ak-utils-time-delta-help>`}
+            >
+            </ak-text-input>
+            <ak-number-input
+                label=${msg("Default token length")}
+                required
+                name="defaultTokenLength"
+                value="${first(this._settings?.defaultTokenLength, 60)}"
+                help=${msg("Default length of generated tokens")}
+            ></ak-number-input>
         `;
     }
 }
diff --git a/web/src/user/user-settings/tokens/UserTokenForm.ts b/web/src/user/user-settings/tokens/UserTokenForm.ts
index 78ce3413dfbc..b3d2088b6f66 100644
--- a/web/src/user/user-settings/tokens/UserTokenForm.ts
+++ b/web/src/user/user-settings/tokens/UserTokenForm.ts
@@ -28,6 +28,7 @@ export class UserTokenForm extends ModelForm<Token, string> {
 
     async send(data: Token): Promise<Token> {
         if (this.instance) {
+            data.intent = this.instance.intent;
             return new CoreApi(DEFAULT_CONFIG).coreTokensUpdate({
                 identifier: this.instance.identifier,
                 tokenRequest: data,
@@ -41,6 +42,14 @@ export class UserTokenForm extends ModelForm<Token, string> {
     }
 
     renderForm(): TemplateResult {
+        const now = new Date();
+        const expiringDate = this.instance?.expires
+            ? new Date(
+                  this.instance.expires.getTime() -
+                      this.instance.expires.getTimezoneOffset() * 60000,
+              )
+            : new Date(now.getTime() + 30 * 60000 - now.getTimezoneOffset() * 60000);
+
         return html` <ak-form-element-horizontal
                 label=${msg("Identifier")}
                 ?required=${true}
@@ -59,6 +68,16 @@ export class UserTokenForm extends ModelForm<Token, string> {
                     value="${ifDefined(this.instance?.description)}"
                     class="pf-c-form-control"
                 />
-            </ak-form-element-horizontal>`;
+            </ak-form-element-horizontal>
+            ${this.intent == IntentEnum.AppPassword
+                ? html`<ak-form-element-horizontal label=${msg("Expiring")} name="expires">
+                      <input
+                          type="datetime-local"
+                          value="${expiringDate.toISOString().slice(0, -8)}"
+                          min="${now.toISOString().slice(0, -8)}"
+                          class="pf-c-form-control"
+                      />
+                  </ak-form-element-horizontal>`
+                : html``}`;
     }
 }
diff --git a/web/src/user/user-settings/tokens/UserTokenList.ts b/web/src/user/user-settings/tokens/UserTokenList.ts
index 2b5015e15f6b..da61c9631a5c 100644
--- a/web/src/user/user-settings/tokens/UserTokenList.ts
+++ b/web/src/user/user-settings/tokens/UserTokenList.ts
@@ -160,7 +160,11 @@ export class UserTokenList extends Table<Token> {
                 <ak-forms-modal>
                     <span slot="submit"> ${msg("Update")} </span>
                     <span slot="header"> ${msg("Update Token")} </span>
-                    <ak-user-token-form slot="form" .instancePk=${item.identifier}>
+                    <ak-user-token-form
+                        intent=${item.intent ?? IntentEnum.Api}
+                        slot="form"
+                        .instancePk=${item.identifier}
+                    >
                     </ak-user-token-form>
                     <button slot="trigger" class="pf-c-button pf-m-plain">
                         <pf-tooltip position="top" content=${msg("Edit")}>
diff --git a/website/docs/installation/configuration.mdx b/website/docs/installation/configuration.mdx
index e994837c089d..9467d9557095 100644
--- a/website/docs/installation/configuration.mdx
+++ b/website/docs/installation/configuration.mdx
@@ -322,14 +322,6 @@ Requires authentik 2021.12.1
 
 When enabled, all the events caused by a user will be deleted upon the user's deletion. Defaults to `true`.
 
-### `AUTHENTIK_DEFAULT_TOKEN_LENGTH`
-
-:::info
-Requires authentik 2022.4.1
-:::
-
-Configure the length of generated tokens. Defaults to 60.
-
 ### `AUTHENTIK_IMPERSONATION`
 
 :::info
diff --git a/website/docs/releases/2024/next.md b/website/docs/releases/2024/next.md
new file mode 100644
index 000000000000..214e60930bce
--- /dev/null
+++ b/website/docs/releases/2024/next.md
@@ -0,0 +1,60 @@
+---
+title: Release next
+slug: /releases/next
+---
+
+<!-- ## Breaking changes -->
+
+## Breaking changes
+
+### Manual action is required
+
+### Manual action may be required
+
+-   **Configuration options migrated to the Admin interface**
+
+    The following config options have been moved from the config file and can now be set using the Admin interface (under **System** -> **Settings**) or the API:
+
+    -   `AUTHENTIK_DEFAULT_TOKEN_LENGTH`
+
+    When upgrading to 2024.next, the currently configured options will be automatically migrated to the database, and can be removed from the `.env` or helm values file afterwards.
+
+## New features
+
+-   Configurable app password token expiring
+
+    Thanks @jmdilly for contributing this feature!
+
+    Admins can now configure the default token duration (which defaults to `minutes=30`) in the admin interface as specified above. This value can also be overridden per-user with the `goauthentik.io/user/token-maximum-lifetime` attribute.
+
+## Upgrading
+
+This release does not introduce any new requirements.
+
+### docker-compose
+
+To upgrade, download the new docker-compose file and update the Docker stack with the new version, using these commands:
+
+```
+wget -O docker-compose.yml https://goauthentik.io/version/xxxx.x/docker-compose.yml
+docker compose up -d
+```
+
+The `-O` flag retains the downloaded file's name, overwriting any existing local file with the same name.
+
+### Kubernetes
+
+Upgrade the Helm Chart to the new version, using the following commands:
+
+```shell
+helm repo update
+helm upgrade authentik authentik/authentik -f values.yaml --version ^xxxx.x
+```
+
+## Minor changes/fixes
+
+<!-- _Insert the output of `make gen-changelog` here_ -->
+
+## API Changes
+
+<!-- _Insert output of `make gen-diff` here_ -->
diff --git a/website/docs/user-group-role/user/user_ref.md b/website/docs/user-group-role/user/user_ref.md
index 88d8a3af5c04..2288b072f2e7 100644
--- a/website/docs/user-group-role/user/user_ref.md
+++ b/website/docs/user-group-role/user/user_ref.md
@@ -70,6 +70,14 @@ Optional flag, when set to false, Tokens created by the user will not expire.
 
 Only applies when the token creation is triggered by the user with this attribute set. Additionally, the flag does not apply to superusers.
 
+### `goauthentik.io/user/token-maximum-lifetime`:
+
+Optional flag, when set, defines the maximum lifetime of user-created tokens. Defaults to the system setting if not set.
+
+Only applies when `goauthentik.io/user/token-expires` set to true.
+
+Format is string of format `days=10;hours=1;minute=3;seconds=5`.
+
 ### `goauthentik.io/user/debug`:
 
 See [Troubleshooting access problems](../../troubleshooting/access), when set, the user gets a more detailed explanation of access decisions.


Code review comments:
diff_hunk: @@ -624,12 +643,17 @@ class Meta:
 class ExpiringModel(models.Model):
     """Base Model which can expire, and is automatically cleaned up."""
 
-    expires = models.DateTimeField(default=default_token_duration)
+    expires = models.DateTimeField(default=None, null=True)
     expiring = models.BooleanField(default=True)
 
     class Meta:
         abstract = True
 
+    def save(self, *args, **kwargs):
path: authentik/core/models.py
text: @user1:
should probably use `pre_save` signal instead of this