From cc11984d1aba079da28f222f53d5c51703b28256 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:45:51 -0700 Subject: [PATCH 01/11] Remove/hide client_secret from application update and detail views --- .../templates/oauth2_provider/application_detail.html | 5 ----- oauth2_provider/views/application.py | 1 - 2 files changed, 6 deletions(-) diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 74b71ee74..aa782013d 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -11,11 +11,6 @@

{{ application.name }}

-
  • -

    {% trans "Client secret" %}

    - -
  • -
  • {% trans "Hash client secret" %}

    {{ application.hash_client_secret|yesno:_("yes,no") }}

    diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index b896c45e3..0d862fe48 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -94,7 +94,6 @@ def get_form_class(self): fields=( "name", "client_id", - "client_secret", "hash_client_secret", "client_type", "authorization_grant_type", From 782dc4473b3ac63ee5088806ca86a505dea247ab Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:46:22 -0700 Subject: [PATCH 02/11] Remove hash_client_secret from the application update view --- oauth2_provider/views/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 0d862fe48..90914a535 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -94,7 +94,6 @@ def get_form_class(self): fields=( "name", "client_id", - "hash_client_secret", "client_type", "authorization_grant_type", "redirect_uris", From 0ecf0b8740f87dbd186c6a0220fafa3f79ba754e Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:49:49 -0700 Subject: [PATCH 03/11] Improve help_text for Application.hash_client_secret --- ...application_hash_client_secret_help_text.py | 18 ++++++++++++++++++ oauth2_provider/models.py | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py diff --git a/oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py b/oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py new file mode 100644 index 000000000..5e593d36b --- /dev/null +++ b/oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2024-08-04 11:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0010_application_allowed_origins'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='hash_client_secret', + field=models.BooleanField(default=True, help_text='Uncheck if you need to support OIDC with JWT and HS256.'), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 621ce5b34..c903d57e2 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -135,7 +135,9 @@ class AbstractApplication(models.Model): db_index=True, help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) - hash_client_secret = models.BooleanField(default=True) + hash_client_secret = models.BooleanField( + default=True, help_text=_("Uncheck if you need to support OIDC with JWT and HS256.") + ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) From edc611b3b454dc27017ca778721414f2ad35381a Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:50:18 -0700 Subject: [PATCH 04/11] Remove client ID from application form --- .../oauth2_provider/application_form.html | 48 +++++++++++-------- oauth2_provider/views/application.py | 1 - 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html index 7d8c07989..2a2b20b2d 100644 --- a/oauth2_provider/templates/oauth2_provider/application_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -3,12 +3,22 @@ {% load i18n %} {% block content %}
    -
    -

    - {% block app-form-title %} - {% trans "Edit application" %} {{ application.name }} - {% endblock app-form-title %} -

    +

    + {% block app-form-title %} + {% trans "Edit application" %} {{ application.name }} + {% endblock app-form-title %} +

    + + {% if application.client_id %} +
    + +
    + {{ application.client_id }} +
    +
    + {% endif %} + + {% csrf_token %} {% for field in form %} @@ -22,21 +32,21 @@

    {% endfor %} + -
    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -
    +
    + {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
    -
    -
    - - {% trans "Go Back" %} - - -
    +
    +
    + + {% trans "Go Back" %} + +
    - +
    {% endblock %} diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 90914a535..345ec1c9d 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -93,7 +93,6 @@ def get_form_class(self): get_application_model(), fields=( "name", - "client_id", "client_type", "authorization_grant_type", "redirect_uris", From cdf0f159c293e45d131c6014f550c4eb238cfca1 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:50:45 -0700 Subject: [PATCH 05/11] Display client ID as uneditable --- .../templates/oauth2_provider/application_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index aa782013d..1696aa76b 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -8,7 +8,7 @@

    {{ application.name }}

    • {% trans "Client id" %}

      - +

      {{ application.client_id }}

    • From fd64f0ee0162c9cb3a789b92a558ad4e8a3c54f0 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:51:33 -0700 Subject: [PATCH 06/11] Don't allow users to set the client ID or client secret, and only display the client secret once --- .../oauth2_provider/application_detail.html | 12 ++++++++- oauth2_provider/views/application.py | 27 ++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 1696aa76b..68acaf025 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -7,10 +7,20 @@

      {{ application.name }}

      • -

        {% trans "Client id" %}

        +

        {% trans "Client ID" %}

        {{ application.client_id }}

      • + {% if client_secret %} +
      • +

        {% trans "Client secret" %}

        +

        {{ client_secret }}

        + {% if show_client_secret_once %} +

        {% translate "This will only be displayed once - copy it now!" %}

        + {% endif %} +
      • + {% endif %} +
      • {% trans "Hash client secret" %}

        {{ application.hash_client_secret|yesno:_("yes,no") }}

        diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 345ec1c9d..f683fda0b 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -22,7 +22,9 @@ class ApplicationRegistration(LoginRequiredMixin, CreateView): View used to register a new Application for the request.user """ + context_object_name = "application" template_name = "oauth2_provider/application_registration_form.html" + success_template_name = "oauth2_provider/application_detail.html" def get_form_class(self): """ @@ -32,8 +34,6 @@ def get_form_class(self): get_application_model(), fields=( "name", - "client_id", - "client_secret", "hash_client_secret", "client_type", "authorization_grant_type", @@ -46,7 +46,22 @@ def get_form_class(self): def form_valid(self, form): form.instance.user = self.request.user - return super().form_valid(form) + if not form.cleaned_data["hash_client_secret"]: + return super().form_valid(form) + + client_secret = form.instance.client_secret + self.object = form.save() + return self.response_class( + request=self.request, + template=self.success_template_name, + context=self.get_context_data( + client_secret=client_secret, + show_client_secret_once=self.object.hash_client_secret, + **{self.context_object_name: self.object}, + ), + using=self.template_engine, + content_type=self.content_type, + ) class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): @@ -57,6 +72,12 @@ class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): context_object_name = "application" template_name = "oauth2_provider/application_detail.html" + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if not ctx["application"].hash_client_secret: + ctx["client_secret"] = ctx["application"].client_secret + return ctx + class ApplicationList(ApplicationOwnerIsUserMixin, ListView): """ From f65b59a741c0949bedb74fdb2a97a50da3c9525c Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 04:27:05 -0700 Subject: [PATCH 07/11] Use Django's messages framework to only show the client secret once --- docs/getting_started.rst | 4 +- docs/install.rst | 2 +- .../oauth2_provider/application_detail.html | 14 +++++-- oauth2_provider/views/application.py | 38 ++++++++++--------- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index e95618723..a5e4a68f8 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -245,9 +245,9 @@ Start the development server:: Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. -Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret``, we will use it in a minute. +Fill the form as show in the screenshot below and after saving take note of the ``client secret`` (possibly shown in the flash message) and the ``client ID``, we will use them both in a minute. -If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect `), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's with ``HS256``. +If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect `), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. Unchecking that means your client secret will be stored on the server in cleartext but is the only way to successfully use signed JWT's with ``HS256``. .. note:: ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must. diff --git a/docs/install.rst b/docs/install.rst index 3d46c507d..d985fc41d 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -5,7 +5,7 @@ Install with pip:: pip install django-oauth-toolkit -Add ``oauth2_provider`` to your ``INSTALLED_APPS`` +Enable and configure Django's messages framework, and add ``oauth2_provider`` to your ``INSTALLED_APPS`` .. code-block:: python diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 68acaf025..c34133067 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -5,6 +5,17 @@

        {{ application.name }}

        + {% if messages %} +
          + {% for message in messages %} + + {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %} + {{ message }} + + {% endfor %} +
        + {% endif %} +
        • {% trans "Client ID" %}

          @@ -15,9 +26,6 @@

          {{ application.name }}

        • {% trans "Client secret" %}

          {{ client_secret }}

          - {% if show_client_secret_once %} -

          {% translate "This will only be displayed once - copy it now!" %}

          - {% endif %}
        • {% endif %} diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index f683fda0b..3db88ebfd 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,6 +1,9 @@ +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.models import modelform_factory from django.urls import reverse_lazy +from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from ..models import get_application_model @@ -22,9 +25,7 @@ class ApplicationRegistration(LoginRequiredMixin, CreateView): View used to register a new Application for the request.user """ - context_object_name = "application" template_name = "oauth2_provider/application_registration_form.html" - success_template_name = "oauth2_provider/application_detail.html" def get_form_class(self): """ @@ -46,22 +47,23 @@ def get_form_class(self): def form_valid(self, form): form.instance.user = self.request.user - if not form.cleaned_data["hash_client_secret"]: - return super().form_valid(form) - - client_secret = form.instance.client_secret - self.object = form.save() - return self.response_class( - request=self.request, - template=self.success_template_name, - context=self.get_context_data( - client_secret=client_secret, - show_client_secret_once=self.object.hash_client_secret, - **{self.context_object_name: self.object}, - ), - using=self.template_engine, - content_type=self.content_type, - ) + # If we are hashing the client secret, display the cleartext value in a flash message with + # Django's messages framework + if form.cleaned_data["hash_client_secret"]: + messages.add_message( + self.request, + messages.SUCCESS, + # Since the client_secret is not user-supplied, we can manually mark this entire + # string as safe so Django doesn't re-encode the HTML markup + mark_safe( + _( + "The application client secret is:
          %s
          " + "This will only be shown once, so copy it now!" + ) + % form.instance.client_secret + ), + ) + return super().form_valid(form) class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): From 1b96825db4e2a070c3022863d1369ce762ed5224 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 05:32:41 -0700 Subject: [PATCH 08/11] Use a template to render the flash message containing client_secret --- .../application_client_secret_message.html | 9 +++++++++ oauth2_provider/views/application.py | 14 ++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 oauth2_provider/templates/oauth2_provider/application_client_secret_message.html diff --git a/oauth2_provider/templates/oauth2_provider/application_client_secret_message.html b/oauth2_provider/templates/oauth2_provider/application_client_secret_message.html new file mode 100644 index 000000000..eaada0f05 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_client_secret_message.html @@ -0,0 +1,9 @@ +{% load i18n %} + +{% block message_content %} +{% blocktranslate %} +The application client secret is: +
          {{ client_secret }}
          +This will only be shown once, so copy it now! +{% endblocktranslate %} +{% endblock %} diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 3db88ebfd..202412003 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,9 +1,8 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.models import modelform_factory +from django.template.loader import render_to_string from django.urls import reverse_lazy -from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from ..models import get_application_model @@ -53,14 +52,9 @@ def form_valid(self, form): messages.add_message( self.request, messages.SUCCESS, - # Since the client_secret is not user-supplied, we can manually mark this entire - # string as safe so Django doesn't re-encode the HTML markup - mark_safe( - _( - "The application client secret is:
          %s
          " - "This will only be shown once, so copy it now!" - ) - % form.instance.client_secret + render_to_string( + "oauth2_provider/application_client_secret_message.html", + {"client_secret": form.instance.client_secret}, ), ) return super().form_valid(form) From 48be189c4ddd2256d89523224082d234dc03a62f Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 4 Aug 2024 05:53:43 -0700 Subject: [PATCH 09/11] Update tests to remove client_id from forms --- tests/test_application_views.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 88617807d..a665f13ce 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -39,8 +39,6 @@ def test_application_registration_user(self): form_data = { "name": "Foo app", - "client_id": "client_id", - "client_secret": "client_secret", "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "post_logout_redirect_uris": "http://other_example.com", @@ -55,7 +53,8 @@ def test_application_registration_user(self): self.assertEqual(app.user.username, "foo_user") app = Application.objects.get() self.assertEqual(app.name, form_data["name"]) - self.assertEqual(app.client_id, form_data["client_id"]) + self.assertIsNotNone(app.client_id) + self.assertIsNotNone(app.client_secret) self.assertEqual(app.redirect_uris, form_data["redirect_uris"]) self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) self.assertEqual(app.client_type, form_data["client_type"]) @@ -113,7 +112,6 @@ def test_application_update(self): self.client.login(username="foo_user", password="123456") form_data = { - "client_id": "new_client_id", "redirect_uris": "http://new_example.com", "post_logout_redirect_uris": "http://new_other_example.com", "client_type": Application.CLIENT_PUBLIC, @@ -126,7 +124,6 @@ def test_application_update(self): self.assertRedirects(response, reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.app_foo_1.refresh_from_db() - self.assertEqual(self.app_foo_1.client_id, form_data["client_id"]) self.assertEqual(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) self.assertEqual(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) self.assertEqual(self.app_foo_1.client_type, form_data["client_type"]) From f78e51f1531ccc88dbe649d7320a2becc0cf4cc1 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 22 Sep 2024 00:57:51 -0700 Subject: [PATCH 10/11] Rebase database migration --- ...=> 0013_improve_application_hash_client_secret_help_text.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename oauth2_provider/migrations/{0011_improve_application_hash_client_secret_help_text.py => 0013_improve_application_hash_client_secret_help_text.py} (86%) diff --git a/oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py b/oauth2_provider/migrations/0013_improve_application_hash_client_secret_help_text.py similarity index 86% rename from oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py rename to oauth2_provider/migrations/0013_improve_application_hash_client_secret_help_text.py index 5e593d36b..1c590654b 100644 --- a/oauth2_provider/migrations/0011_improve_application_hash_client_secret_help_text.py +++ b/oauth2_provider/migrations/0013_improve_application_hash_client_secret_help_text.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0010_application_allowed_origins'), + ('oauth2_provider', '0012_add_token_checksum'), ] operations = [ From 571b1a8f43212ea985b2ef19bcdf9f157455d331 Mon Sep 17 00:00:00 2001 From: blag Date: Sun, 22 Sep 2024 03:48:11 -0700 Subject: [PATCH 11/11] Update tests --- tests/test_application_views.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index a665f13ce..d02c43633 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -37,6 +37,12 @@ def test_get_form_class(self): def test_application_registration_user(self): self.client.login(username="foo_user", password="123456") + get_response = self.client.get(reverse("oauth2_provider:register")) + self.assertEqual(get_response.status_code, 200) + + self.assertNotIn("client_id", get_response.context["form"].fields) + self.assertNotIn("client_secret", get_response.context["form"].fields) + form_data = { "name": "Foo app", "client_type": Application.CLIENT_CONFIDENTIAL, @@ -46,6 +52,10 @@ def test_application_registration_user(self): "algorithm": "", } + # Check that all fields in form_data are form fields + for field in form_data.keys(): + self.assertIn(field, get_response.context["form"].fields.keys()) + response = self.client.post(reverse("oauth2_provider:register"), form_data) self.assertEqual(response.status_code, 302) @@ -96,12 +106,21 @@ def test_application_detail_owner(self): response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.assertEqual(response.status_code, 200) + self.assertNotIn("client_secret", response.context) self.assertContains(response, self.app_foo_1.name) self.assertContains(response, self.app_foo_1.redirect_uris) self.assertContains(response, self.app_foo_1.post_logout_redirect_uris) self.assertContains(response, self.app_foo_1.client_type) self.assertContains(response, self.app_foo_1.authorization_grant_type) + # We don't allow users to update this, setting it False to test context + self.app_foo_1.hash_client_secret = False + self.app_foo_1.save() + + response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) + self.assertEqual(response.status_code, 200) + self.assertIn("client_secret", response.context) + def test_application_detail_not_owner(self): self.client.login(username="foo_user", password="123456") @@ -111,12 +130,28 @@ def test_application_detail_not_owner(self): def test_application_update(self): self.client.login(username="foo_user", password="123456") + get_response = self.client.get(reverse("oauth2_provider:update", args=(self.app_foo_1.pk,))) + self.assertEqual(get_response.status_code, 200) + + self.assertNotIn("client_id", get_response.context["form"].fields) + self.assertNotIn("client_secret", get_response.context) + self.assertNotIn("client_secret", get_response.context["form"].fields) + self.assertNotIn("hash_client_secret", get_response.context["form"].fields) + + new_app_name = self.app_foo_1.name + " - Updated" + form_data = { + "name": new_app_name, "redirect_uris": "http://new_example.com", "post_logout_redirect_uris": "http://new_other_example.com", "client_type": Application.CLIENT_PUBLIC, "authorization_grant_type": Application.GRANT_OPENID_HYBRID, } + + # Check that all fields in form_data are form fields + for field in form_data.keys(): + self.assertIn(field, get_response.context["form"].fields.keys()) + response = self.client.post( reverse("oauth2_provider:update", args=(self.app_foo_1.pk,)), data=form_data, @@ -124,6 +159,7 @@ def test_application_update(self): self.assertRedirects(response, reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.app_foo_1.refresh_from_db() + self.assertEqual(self.app_foo_1.name, new_app_name) self.assertEqual(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) self.assertEqual(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) self.assertEqual(self.app_foo_1.client_type, form_data["client_type"])