From d005f202c61eb7e5f28f1ee58468f98e9d000ee9 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 19 Aug 2026 11:14:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20accept=20form-urlencoded?= =?UTF-8?q?=20on=20the=20user=20token=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept `application/x-www-form-urlencoded` requests on the user token endpoint, in addition to the existing JSON support. This aligns the endpoint with the OAuth 2.0 specification for token endpoint requests (RFC 6749, sections 3.2 and 4.4.2), so standard OAuth 2.0 client libraries can call it without any customization, while keeping backward compatibility with existing JSON clients. --- CHANGELOG.md | 4 + docs/openapi.yaml | 27 ++++ src/backend/core/external_api/viewsets.py | 4 + .../core/tests/test_external_api_token.py | 150 ++++++++++++++++++ 4 files changed, 185 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905f0872..a93b692c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Changed + +- ✨(backend) accept form-urlencoded on the user token endpoint + ### Fixed - 📝(docs) fix minor typos in comments and docstrings diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ab24487e..7706ce16 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -50,10 +50,24 @@ paths: The application must be authorized for the user's email domain. The returned token expires after a configured duration and must be refreshed by calling this endpoint again. + + Request parameters may be sent either as "application/x-www-form-urlencoded" + (as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json". operationId: generateToken requestBody: required: true content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRequest' + examples: + tokenRequest: + summary: Request token for user delegation + value: + client_id: "550e8400-e29b-41d4-a716-446655440000" + client_secret: "1234567890abcdefghijklmnopqrstuvwxyz" + grant_type: "client_credentials" + scope: "user@example.com" application/json: schema: $ref: '#/components/schemas/TokenRequest' @@ -117,6 +131,19 @@ paths: summary: Domain not authorized value: error: "This application is not authorized for this email domain." + '415': + description: | + Unsupported media type. The request body must be sent as + "application/x-www-form-urlencoded" or "application/json". + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + unsupportedMediaType: + summary: Unsupported request content type + value: + detail: 'Unsupported media type "text/plain" in request.' /rooms: get: diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 093961a6..733acd39 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets from rest_framework import ( exceptions as drf_exceptions, ) +from rest_framework import ( + parsers as drf_parsers, +) from rest_framework import ( response as drf_response, ) @@ -41,6 +44,7 @@ class ApplicationViewSet(viewsets.ViewSet): methods=["post"], url_path="token", url_name="token", + parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser], ) @FeatureFlag.require("application") def generate_jwt_access_token(self, request, *args, **kwargs): diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py index 6c13a884..d0505c53 100644 --- a/src/backend/core/tests/test_external_api_token.py +++ b/src/backend/core/tests/test_external_api_token.py @@ -5,6 +5,7 @@ Tests for external API /token endpoint # pylint: disable=W0621 from unittest import mock +from urllib.parse import urlencode import jwt import pytest @@ -88,6 +89,155 @@ def test_api_applications_generate_token_success(settings): } +def test_api_applications_generate_token_form_urlencoded(settings): + """The token endpoint should accept "application/x-www-form-urlencoded" + requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0 + token endpoints, so that standard OAuth 2.0 client libraries work + out of the box.""" + UserFactory(email="user@example.com") + application = ApplicationFactory( + is_active=True, + scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], + ) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + ( + f"client_id={application.client_id}" + f"&client_secret={plain_secret}" + "&grant_type=client_credentials" + "&scope=user%40example.com" + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert "access_token" in response.data + + response.data.pop("access_token") + + assert response.data == { + "token_type": "Bearer", + "expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS, + "scope": "rooms:list rooms:create", + } + + +def test_api_applications_generate_token_form_urlencoded_invalid_credentials(): + """Invalid credentials sent as form-urlencoded should be parsed and + rejected with 401, proving the request body is properly decoded.""" + user = UserFactory(email="user@example.com") + application = ApplicationFactory(is_active=True) + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + urlencode( + { + "client_id": application.client_id, + "client_secret": "wrong-secret", + "grant_type": "client_credentials", + "scope": user.email, + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 401 + assert "Invalid credentials" in str(response.data) + + +def test_api_applications_generate_token_form_urlencoded_missing_fields(): + """Missing required fields in a form-urlencoded request should return + a 400 validation error, like for JSON requests.""" + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + urlencode({"grant_type": "client_credentials"}), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + for field in ("client_id", "client_secret", "scope"): + assert field in response.data + + +def test_api_applications_generate_token_form_urlencoded_invalid_grant_type(): + """An unsupported grant_type sent as form-urlencoded should return 400.""" + user = UserFactory(email="user@example.com") + application = ApplicationFactory(is_active=True) + + plain_secret = "test-secret-123" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + urlencode( + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "authorization_code", + "scope": user.email, + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + assert "grant_type" in response.data + + +def test_api_applications_generate_token_form_urlencoded_special_characters(): + """Percent-encoded reserved characters ("&", "=", "+", "%") in the + client_secret should survive form-urlencoded decoding.""" + UserFactory(email="user@example.com") + application = ApplicationFactory( + is_active=True, + scopes=[ApplicationScope.ROOMS_LIST], + ) + + plain_secret = "s3cr3t&with=special+chars%42" + application.client_secret = plain_secret + application.save() + + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + urlencode( + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": "user@example.com", + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert "access_token" in response.data + + +def test_api_applications_generate_token_unsupported_media_type(): + """Content types other than JSON and form-urlencoded should still be + rejected with 415 Unsupported Media Type.""" + client = APIClient() + response = client.post( + "/external-api/v1.0/application/token/", + "client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co", + content_type="text/plain", + ) + + assert response.status_code == 415 + + def test_api_applications_generate_token_invalid_client_id(): """Invalid client_id should return 401.""" user = UserFactory(email="user@example.com")