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")