Skip to content

crypto: add managed secrets API - #25415

Open
dominic-r wants to merge 37 commits into
mainfrom
dominic/rotation
Open

dominic-r wants to merge 37 commits into
mainfrom
dominic/rotation

Conversation

@dominic-r

@dominic-r dominic-r commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

Details

What does this PR change?

Adds managed secrets: a Secret model with its own API under System > Secrets. This is the first PR of a stack. The PRs above it move each object's credentials into secrets, then add the Admin pages and docs.

  • A secret is text (any text, which authentik can generate and rotate), JSON (a JSON or YAML object, validated when saved), or a file (uploaded bytes).
  • Each secret has its own permissions. Viewing a value and replacing or rotating it need separate permissions, and both are audited.
  • Rotation is manual. It generates a new value at least as long as the current one, and an optional length sets the size of a generated value.
  • Objects reference secrets through <old field>_ref fields. Attaching a secret needs permission to view its value, including through blueprints imported by users and the application wizard. Old credential field names fail with an error that names the replacement.
  • Consumers can reject a new value before it's saved, and are notified after a change is committed.
  • used_by reports protected relations, so the delete dialog no longer claims that objects using a secret will be deleted.

Values are stored unencrypted, as the credentials were before.

Secrets list Create a secret
Secrets list Create a secret
Secret value and length Rotate dialog for a proxy cookie secret
Secret picker in a provider Deleting a secret in use
Secret picker in an OAuth2 provider Deleting a secret that is in use

Stack:

  1. crypto: add managed secrets API #25415 Secret model and API (this PR)
  2. providers/oauth2: use managed secrets for OAuth and proxy providers #26095 OAuth2 and proxy providers, and the shared secret UI
  3. providers: use managed secrets for RADIUS and SCIM #26100 RADIUS and SCIM providers
  4. sources: use managed secrets for OAuth, Plex and Telegram #26096 OAuth, Plex and Telegram sources
  5. sources: use managed secrets for LDAP and Kerberos #26101 LDAP and Kerberos sources
  6. stages: use managed secrets for Duo and SMS #26102 Duo and SMS stages
  7. stages: use managed secrets for email and CAPTCHA #26103 Email and CAPTCHA stages
  8. outposts/events: use managed service credentials #26097 Notification transports and Kubernetes connections
  9. enterprise/providers: use managed cloud credentials #26104 Google Workspace and Microsoft Entra providers
  10. enterprise/endpoints: use managed connector credentials #26105 Fleet and Google Chrome connectors
  11. web/admin: add secret management pages #26107 Secrets page in the Admin interface
  12. website/docs: document managed credentials and manual rotation #26098 Documentation

Why is this change needed?

Credentials were columns on each object. They couldn't be shared, permissioned separately from the object, rotated, or audited.

How was this tested?

Added tests for each secret type, generation and rotation lengths, the view, replace and rotate permissions, and that values never reach API responses or the enterprise audit log. Also tests that blueprint imports and the application wizard refuse secrets the user can't view. Ran the full stack locally and rotated a proxy cookie secret from the Admin interface. The new value kept the minimum length, the rotation was recorded, and no event contains the value.

Linked issues


Checklist

  • The project has been linted, built, and tested (make all)
  • The documentation has been updated and formatted (make docs)
  • I have read the AI usage policy.

@dominic-r dominic-r self-assigned this Aug 23, 2026
@dominic-r
dominic-r requested review from a team as code owners August 23, 2026 21:13
@dominic-r dominic-r added area:frontend Features or issues related to the browser, TypeScript, Node.js, etc area:backend area:docs Features or issues related to Docusaurus labels Aug 23, 2026
@netlify

netlify Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit e232a2d
🔍 Latest deploy log https://app.netlify.com/projects/authentik-storybook/deploys/6acaaaea2043370008c2ed43
😎 Deploy Preview https://deploy-preview-25415--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-integrations ready!

Name Link
🔨 Latest commit e232a2d
🔍 Latest deploy log https://app.netlify.com/projects/authentik-integrations/deploys/6acaaaeacfa4cf00085ebd5f
😎 Deploy Preview https://deploy-preview-25415--authentik-integrations.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit e232a2d
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/6acaaaeaca4eb500083a4af8
😎 Deploy Preview https://deploy-preview-25415--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

❌ 15 Tests Failed:

Tests completed Failed Passed Skipped
4709 15 4694 14
View the top 3 failed test(s) by shortest run time
tests.e2e.test_flows_authenticators.TestFlowsAuthenticator::test_totp_validate
Stack Traces | 2.58s run time
self = <unittest.case._Outcome object at 0x7241005bd350>
test_case = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_validate>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_validate>
result = <TestCaseFunction test_totp_validate>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_validate>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_validate>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_validate>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators.TestFlowsAuthenticator::test_totp_setup
Stack Traces | 2.76s run time
self = <unittest.case._Outcome object at 0x7241005bcf50>
test_case = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_setup>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_setup>
result = <TestCaseFunction test_totp_setup>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_setup>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_setup>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_totp_setup>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn::test_passkey_login
Stack Traces | 2.89s run time
self = <unittest.case._Outcome object at 0x7241005f4230>
test_case = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_passkey_login>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_passkey_login>
result = <TestCaseFunction test_passkey_login>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_passkey_login>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_passkey_login>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_passkey_login>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators.TestFlowsAuthenticator::test_static_setup
Stack Traces | 2.91s run time
self = <unittest.case._Outcome object at 0x7241001438a0>
test_case = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_static_setup>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_static_setup>
result = <TestCaseFunction test_static_setup>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_static_setup>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_static_setup>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators.TestFlowsAuthenticator testMethod=test_static_setup>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn::test_webauthn_authenticate
Stack Traces | 3s run time
self = <unittest.case._Outcome object at 0x724102962430>
test_case = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate>
result = <TestCaseFunction test_webauthn_authenticate>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_enroll.TestFlowsEnroll::test_enroll_email_pretend_email_scanner
Stack Traces | 3.02s run time
self = <unittest.case._Outcome object at 0x724100135350>
test_case = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>
result = <TestCaseFunction test_enroll_email_pretend_email_scanner>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>

    def setUp(self):
>       super().setUp()

tests/e2e/test_flows_enroll.py:23: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email_pretend_email_scanner>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE::test_login_mfa_static_deny
Stack Traces | 3.04s run time
self = <unittest.case._Outcome object at 0x724100be3bd0>
test_case = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login_mfa_static_deny>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login_mfa_static_deny>
result = <TestCaseFunction test_login_mfa_static_deny>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login_mfa_static_deny>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login_mfa_static_deny>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login_mfa_static_deny>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn::test_webauthn_authenticate_sfe
Stack Traces | 3.06s run time
self = <unittest.case._Outcome object at 0x724102960750>
test_case = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate_sfe>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate_sfe>
result = <TestCaseFunction test_webauthn_authenticate_sfe>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate_sfe>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate_sfe>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_authenticate_sfe>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_enroll.TestFlowsEnroll::test_enroll_email
Stack Traces | 3.19s run time
self = <unittest.case._Outcome object at 0x724100498f50>
test_case = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>
result = <TestCaseFunction test_enroll_email>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>

    def setUp(self):
>       super().setUp()

tests/e2e/test_flows_enroll.py:23: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_email>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_recovery.TestFlowsRecovery::test_recover_email
Stack Traces | 3.33s run time
self = <unittest.case._Outcome object at 0x724100b1d3d0>
test_case = <tests.e2e.test_flows_recovery.TestFlowsRecovery testMethod=test_recover_email>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_recovery.TestFlowsRecovery testMethod=test_recover_email>
result = <TestCaseFunction test_recover_email>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_recovery.TestFlowsRecovery testMethod=test_recover_email>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_recovery.TestFlowsRecovery testMethod=test_recover_email>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_recovery.TestFlowsRecovery testMethod=test_recover_email>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_enroll.TestFlowsEnroll::test_enroll_2_step
Stack Traces | 3.37s run time
self = <unittest.case._Outcome object at 0x72410059e780>
test_case = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>
result = <TestCaseFunction test_enroll_2_step>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>

    def setUp(self):
>       super().setUp()

tests/e2e/test_flows_enroll.py:23: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_enroll.TestFlowsEnroll testMethod=test_enroll_2_step>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE::test_login
Stack Traces | 3.56s run time
self = <unittest.case._Outcome object at 0x7241010eb770>
test_case = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login>
result = <TestCaseFunction test_login>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login_sfe.TestFlowsLoginSFE testMethod=test_login>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_stage_setup.TestFlowsStageSetup::test_password_change
Stack Traces | 3.91s run time
self = <unittest.case._Outcome object at 0x7241002d6990>
test_case = <tests.e2e.test_flows_stage_setup.TestFlowsStageSetup testMethod=test_password_change>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_stage_setup.TestFlowsStageSetup testMethod=test_password_change>
result = <TestCaseFunction test_password_change>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_stage_setup.TestFlowsStageSetup testMethod=test_password_change>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_stage_setup.TestFlowsStageSetup testMethod=test_password_change>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_stage_setup.TestFlowsStageSetup testMethod=test_password_change>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn::test_webauthn_setup
Stack Traces | 4.18s run time
self = <unittest.case._Outcome object at 0x7241005f4c80>
test_case = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_setup>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_setup>
result = <TestCaseFunction test_webauthn_setup>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_setup>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_setup>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_authenticators_webauthn.TestFlowsAuthenticatorWebAuthn testMethod=test_webauthn_setup>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError
tests.e2e.test_flows_login.TestFlowsLogin::test_login
Stack Traces | 37s run time
self = <unittest.case._Outcome object at 0x724101e74b90>
test_case = <tests.e2e.test_flows_login.TestFlowsLogin testMethod=test_login>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login.TestFlowsLogin testMethod=test_login>
result = <TestCaseFunction test_login>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
>                   self._callSetUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:665: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login.TestFlowsLogin testMethod=test_login>

    def _callSetUp(self):
>       self.setUp()

.../hostedtoolcache/Python/3.14.7........./x64/lib/python3.14/unittest/case.py:612: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login.TestFlowsLogin testMethod=test_login>

    def setUp(self):
        super().setUp()
>       self.driver = self._get_driver()
                      ^^^^^^^^^^^^^^^^^^

tests/selenium.py:43: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <tests.e2e.test_flows_login.TestFlowsLogin testMethod=test_login>

    def _get_driver(self) -> WebDriver:
        count = 0
        opts = webdriver.ChromeOptions()
        opts.accept_insecure_certs = True
        opts.add_argument("--disable-search-engine-choice-screen")
        opts.add_extension(self._get_chrome_extension())
        # This breaks selenium when running remotely...?
        # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
        opts.add_experimental_option(
            "prefs",
            {
                "profile.password_manager_leak_detection": False,
            },
        )
        while count < RETRIES:
            try:
                driver = webdriver.Remote(
                    command_executor="http://localhost:4444/wd/hub",
                    options=opts,
                )
                driver.maximize_window()
                return driver
            except WebDriverException as exc:
                self.logger.warning("Failed to setup webdriver", exc=exc)
                count += 1
>       raise ValueError(f"Webdriver failed after {RETRIES}.")
E       ValueError: Webdriver failed after 3.

tests/selenium.py:72: ValueError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

authentik PR Installation instructions

Instructions for docker-compose

Add the following block to your .env file:

AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=gh-7b025acae2626f3a6ec70750ca8370f3e722a15f
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s

Afterwards, run the upgrade commands from the latest release notes.

Instructions for Kubernetes

Add the following block to your values.yml file:

authentik:
    outposts:
        container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
    image:
        repository: ghcr.io/goauthentik/dev-server
        tag: gh-7b025acae2626f3a6ec70750ca8370f3e722a15f

Afterwards, run the upgrade commands from the latest release notes.

@rissson
rissson self-requested a review August 23, 2026 23:15
@rissson

rissson commented Aug 23, 2026

Copy link
Copy Markdown
Member

we should have a longer discussion about this before actually implementing anything.

My thinking is as follows, following along the lines of #24961 :

  • have a new Secret model, which is referenced from all the places where we store a secret (like oauth2 client_secret, notification transport credentials, etc.)
  • we then have our usual permission system for that secret
  • we also can configure rotation policies on that secret (how often, if it should be rotated at all)
  • the admin interface should allow creating a secret "on the fly" from the modals where we need one

@dominic-r
dominic-r marked this pull request as draft August 25, 2026 00:10
@dominic-r

Copy link
Copy Markdown
Member Author

Marking as draft for now, will also discuss with G

@dominic-r dominic-r linked an issue Aug 30, 2026 that may be closed by this pull request
@dominic-r dominic-r changed the title core: rotate generated secrets core: secrets Aug 30, 2026
@dominic-r
dominic-r force-pushed the dominic/rotation branch 2 times, most recently from 5c0be0f to 420c3e3 Compare August 31, 2026 01:18
@dominic-r
dominic-r marked this pull request as ready for review September 4, 2026 22:32

@BeryJu BeryJu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Smaller PR pls
  • First PR that adds the system
  • Second PR that adds the usage
  • Have to remove the migrations that remove the old fields, those need to wait until 27.2
  • Move secret model next to crypto ....maybe

Comment thread authentik/secrets/models.py Outdated
Comment thread authentik/crypto/secrets/models.py
@dominic-r dominic-r changed the title core: secrets crypto: add managed secrets and manual rotation Sep 14, 2026
@dominic-r
dominic-r added this pull request to stack #26099 September 14, 2026 00:04
# Conflicts:
#	web/test/browser/700-applications.test.ts
#	web/test/browser/ak-mdx.test.ts
These test and outpost fixes do not depend on managed secrets and main
has since fixed the provider toolbar ambiguity the browser tests worked
around. They can land separately.
DRF ignores unknown fields, so a blueprint or API client that still
sends client_secret would apply cleanly and leave the provider with a
generated secret. Fail with a pointer to the reference field instead,
until the legacy columns are removed in 2027.2.
Multi-line text described an input widget, not the content, so every
JSON consumer had to parse and validate the value itself. A JSON secret
is now validated when it is saved, text values may span several lines,
and only text can be generated or rotated.

The enterprise audit log records model diffs and hides fields by name.
"value" matched none of the hidden names, so creating a secret wrote
its plaintext into the event. The field is now secret_value; the API
keeps value.

Rotation uses permission_required like other custom actions, and the
validation hook consumers use to veto a value now lives here with the
model that sends it.
Creating a text secret without a value can ask for a length, and
otherwise uses the default token length. Rotating keeps at least the
current length, so a secret a consumer generated with its own length,
such as a 128 character OAuth2 client secret, doesn't shrink to the
default.

Generated values use ASCII letters and digits, which every consumer
accepts, so there is no character set option.
Deleting an object that a PROTECT foreign key references fails, but
used_by reported those relations as cascading, so delete dialogs said
the referencing objects would be deleted too. Secrets are protected
by every object that uses them, so this would have shown for each one.
Serializers only check that a user may attach a secret when they have
a request, and the importer runs them without one. The application
wizard and blueprint uploads check permissions up front instead, so
they now also require "View secret's value" on every existing secret
the blueprint references. Without it, a user who may create a SCIM
provider could point it at their own server with any secret's UUID.
Each consumer repeated whether its secret is required, optional, or
generated when the object is created, in serializer arguments and
validators. The reference field now reads it from the model: a blank
reference is optional on create, and one that can't be null must stay
set on update. JSON consumers pass allowed_types like every other
consumer instead of using a subclass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Introduce a "Secret" object

4 participants