Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-oauthlib for openSUSE:Factory 
checked in at 2026-11-10 14:38:31
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-oauthlib (Old)
 and      /work/SRC/openSUSE:Factory/.python-oauthlib.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-oauthlib"

Tue Nov 10 14:38:31 2026 rev:40 rq:1396215 version:4.0.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-oauthlib/python-oauthlib.changes  
2026-10-02 23:02:25.366979981 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-oauthlib.new.3759290/python-oauthlib.changes 
    2026-11-10 14:38:40.100165705 +0100
@@ -1,0 +2,8 @@
+Mon Nov  9 03:12:04 UTC 2026 - Steve Kowalik <[email protected]>
+
+- Update to 4.0.1:
+   * CVE-2026-49272: Breaking: The device code flow token endpoint did
+    not validate the device_code parameter, allowing a request with a known
+    client_id and a bogus device_code to obtain a bearer token. (bsc#1288518)
+
+-------------------------------------------------------------------
@@ -13 +21 @@
-    401 invalid_client.
+    401 invalid_client. (CVE-2026-118669, bsc#1288765)

Old:
----
  oauthlib-4.0.0.tar.gz

New:
----
  oauthlib-4.0.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-oauthlib.spec ++++++
--- /var/tmp/diff_new_pack.xyhrTU/_old  2026-11-10 14:38:40.906199440 +0100
+++ /var/tmp/diff_new_pack.xyhrTU/_new  2026-11-10 14:38:40.908199524 +0100
@@ -18,11 +18,10 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-oauthlib
-Version:        4.0.0
+Version:        4.0.1
 Release:        0
 Summary:        A Generic Implementation of the OAuth Request-Signing Logic
 License:        BSD-3-Clause
-Group:          Development/Languages/Python
 URL:            https://github.com/oauthlib/oauthlib
 Source:         
https://files.pythonhosted.org/packages/source/o/oauthlib/oauthlib-%{version}.tar.gz
 BuildRequires:  %{python_module PyJWT >= 2.0.0}

++++++ oauthlib-4.0.0.tar.gz -> oauthlib-4.0.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/CHANGELOG.rst 
new/oauthlib-4.0.1/CHANGELOG.rst
--- old/oauthlib-4.0.0/CHANGELOG.rst    2026-09-28 08:00:44.152200200 +0200
+++ new/oauthlib-4.0.1/CHANGELOG.rst    2026-10-31 10:52:29.649384000 +0100
@@ -1,6 +1,28 @@
 Changelog
 =========
 
+4.0.1 (2026-11-04):
+------------------
+OAuth2.0 Provider (Security):
+* **Security**: CVE-2026-49272 / GHSA-3jp2-3mrg-2vxc: The device code flow
+  token endpoint did not validate the ``device_code`` parameter. A request
+  with a known ``client_id`` and a bogus ``device_code`` could obtain a
+  bearer token. ``DeviceCodeGrant.validate_token_request`` now calls a new
+  ``DeviceCodeGrant.validate_device_code()`` which rejects missing or
+  unknown device codes (``400 invalid_request`` / ``400 invalid_grant``)
+  and delegates the code's authorization status to a new required
+  ``RequestValidator.validate_device_code()`` hook, per RFC 8628
+  Section 3.5. (cherry-picked from #954, merged as 1c91d64)
+
+  **Upgrade note for device flow integrators**: custom
+  ``RequestValidator`` implementations using the device flow must now
+  implement ``validate_device_code(client_id, code, request)``. Return one
+  of the ``DeviceCodeGrant.DEVICE_CODE_*`` statuses (``AUTHORIZED``,
+  ``PENDING``, ``SLOW_DOWN`` or ``EXPIRED``) for known codes, or ``None``
+  for an unknown code. When returning ``AUTHORIZED``, the validator must
+  also populate ``request.user`` and ``request.scopes`` from the stored
+  authorization.
+
 4.0.0 (2026-09-28):
 ------------------
 OAuth2.0 Provider:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/PKG-INFO new/oauthlib-4.0.1/PKG-INFO
--- old/oauthlib-4.0.0/PKG-INFO 2026-09-28 08:00:46.974020500 +0200
+++ new/oauthlib-4.0.1/PKG-INFO 2026-10-31 10:52:32.003015800 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: oauthlib
-Version: 4.0.0
+Version: 4.0.1
 Summary: A generic, spec-compliant, thorough implementation of the OAuth 
request-signing logic
 Home-page: https://github.com/oauthlib/oauthlib
 Author: The OAuthlib Community
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/oauthlib/__init__.py 
new/oauthlib-4.0.1/oauthlib/__init__.py
--- old/oauthlib-4.0.0/oauthlib/__init__.py     2026-09-28 08:00:44.157543000 
+0200
+++ new/oauthlib-4.0.1/oauthlib/__init__.py     2026-10-31 10:52:29.654582700 
+0100
@@ -12,7 +12,7 @@
 from logging import NullHandler
 
 __author__ = 'The OAuthlib Community'
-__version__ = '4.0.0'
+__version__ = '4.0.1'
 
 logging.getLogger('oauthlib').addHandler(NullHandler())
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/oauthlib/common.py 
new/oauthlib-4.0.1/oauthlib/common.py
--- old/oauthlib-4.0.0/oauthlib/common.py       2026-09-28 08:00:44.157665700 
+0200
+++ new/oauthlib-4.0.1/oauthlib/common.py       2026-10-31 10:52:29.654694600 
+0100
@@ -366,6 +366,7 @@
             "code_challenge": None,
             "code_challenge_method": None,
             "code_verifier": None,
+            "device_code": None,
             "extra_credentials": None,
             "grant_type": None,
             "redirect_uri": None,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/oauthlib/oauth2/rfc6749/grant_types/base.py 
new/oauthlib-4.0.1/oauthlib/oauth2/rfc6749/grant_types/base.py
--- old/oauthlib-4.0.0/oauthlib/oauth2/rfc6749/grant_types/base.py      
2026-09-28 08:00:44.159371600 +0200
+++ new/oauthlib-4.0.1/oauthlib/oauth2/rfc6749/grant_types/base.py      
2026-10-31 10:52:29.656203300 +0100
@@ -208,9 +208,9 @@
 
         if request.client is None or \
             not hasattr(request.client, 'client_id'):
-            raise NotImplementedError('Authenticate client must set the '
-                                      'request.client.client_id attribute '
-                                      'in authenticate_client.')
+            raise NotImplementedError('authenticate_client_id must set the '
+                                      'request.client attribute to a client '
+                                      'object with a client_id attribute.')
 
         if not request.client_id:
             request.client_id = request.client.client_id
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py 
new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py
--- 
old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py    
    2026-09-28 08:00:44.160139600 +0200
+++ 
new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py    
    2026-10-31 10:52:29.656896000 +0100
@@ -10,11 +10,12 @@
 from typing import Callable
 
 from oauthlib.common import Request, generate_token
-from oauthlib.oauth2.rfc6749 import errors
+from oauthlib.oauth2.rfc6749 import utils
 from oauthlib.oauth2.rfc6749.endpoints.base import (
     BaseEndpoint,
     catch_errors_and_unavailability,
 )
+from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant
 
 log = logging.getLogger(__name__)
 
@@ -52,6 +53,7 @@
         :param user_code_generator: a callable that returns a configurable 
user code
         """
         self.request_validator = request_validator
+        self.device_code_grant = DeviceCodeGrant(request_validator)
         self._expires_in = expires_in
         self._interval = interval
         self._verification_uri = verification_uri
@@ -101,44 +103,7 @@
         .. _`Section 3.2.1. of [RFC6749]`: 
https://www.rfc-editor.org/rfc/rfc6749#section-3.2.1
         .. _`Section 2.2 of [RFC6749]`: 
https://www.rfc-editor.org/rfc/rfc6749#section-2.2
         """
-
-        # First check duplicate parameters
-        for param in ("client_id", "scope"):
-            try:
-                duplicate_params = request.duplicate_params
-            except ValueError:
-                raise errors.InvalidRequestFatalError(
-                    description="Unable to parse query string", request=request
-                )
-            if param in duplicate_params:
-                raise errors.InvalidRequestFatalError(
-                    description="Duplicate %s parameter." % param, 
request=request
-                )
-
-        # the "application/x-www-form-urlencoded" format, per Appendix B of 
[RFC6749]
-        # https://www.rfc-editor.org/rfc/rfc6749#appendix-B
-        if request.headers["Content-Type"] != 
"application/x-www-form-urlencoded":
-            raise errors.InvalidRequestError(
-                "Content-Type must be application/x-www-form-urlencoded",
-                request=request,
-            )
-
-        # REQUIRED. The client identifier as described in Section 2.2.
-        # https://tools.ietf.org/html/rfc6749#section-2.2
-        # TODO: extract client_id an helper validation function.
-        if not request.client_id:
-            raise errors.MissingClientIdError(request=request)
-
-        if not self.request_validator.validate_client_id(request.client_id, 
request):
-            raise errors.InvalidClientIdError(request=request)
-
-        # The client authentication requirements of Section 3.2.1 of [RFC6749]
-        # apply to requests on this endpoint, which means that confidential
-        # clients (those that have established client credentials) authenticate
-        # in the same manner as when making requests to the token endpoint, and
-        # public clients provide the "client_id" parameter to identify
-        # themselves.
-        self._raise_on_invalid_client(request)
+        self.device_code_grant.validate_device_authorization_request(request)
 
     @catch_errors_and_unavailability
     def create_device_authorization_response(
@@ -189,6 +154,13 @@
               SHOULD wait between polling requests to the token endpoint. If no
               value is provided, clients MUST use 5 as the default.
 
+           scope
+              **OPTIONAL.** The space delimited scopes resolved for this 
request,
+              either the scopes the device asked for or the ones returned by
+              ``get_default_scopes``. Not defined by RFC 8628, it is included 
so
+              the caller can persist the authorized scopes with the 
device_code.
+              Omitted when no scopes were resolved.
+
            **For example:**
 
               .. code-block:: http
@@ -224,6 +196,12 @@
         if self.interval is not None:
             data["interval"] = self.interval
 
+        # ``validate_device_authorization_request`` resolves request.scopes,
+        # falling back to ``get_default_scopes`` when the device omitted the
+        # ``scope`` parameter. Echo the resolved value so callers can persist 
it
+        # alongside the device_code/user_code without re-running the validator.
+        if request.scopes:
+            data["scope"] = utils.list_to_scope(request.scopes)
 
         verification_uri_complete = self.verification_uri_complete(user_code)
         if verification_uri_complete:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/grant_types/device_code.py 
new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/grant_types/device_code.py
--- old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/grant_types/device_code.py       
2026-09-28 08:00:44.160276000 +0200
+++ new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/grant_types/device_code.py       
2026-10-31 10:52:29.657005800 +0100
@@ -1,5 +1,6 @@
 from __future__ import annotations
 import json
+import logging
 
 from typing import Callable
 
@@ -7,9 +8,33 @@
 
 from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors
 from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
+from oauthlib.oauth2.rfc8628 import errors as rfc8628_errors
+
+log = logging.getLogger(__name__)
 
 
 class DeviceCodeGrant(GrantTypeBase):
+    #: Maps a non-authorized device code status to the RFC 8628 Section 3.5
+    #: error it raises. The status strings are the errors' own ``error`` codes,
+    #: keeping a single source of truth.
+    _device_code_errors = {
+        cls.error: cls
+        for cls in (
+            rfc8628_errors.AuthorizationPendingError,
+            rfc8628_errors.SlowDownError,
+            rfc8628_errors.ExpiredTokenError,
+            rfc8628_errors.AccessDenied,
+        )
+    }
+
+    #: Authorization statuses returned by
+    #: :meth:`RequestValidator.validate_device_code`, per RFC 8628 Section 3.5.
+    DEVICE_CODE_AUTHORIZED = "authorized"
+    DEVICE_CODE_PENDING = rfc8628_errors.AuthorizationPendingError.error
+    DEVICE_CODE_SLOW_DOWN = rfc8628_errors.SlowDownError.error
+    DEVICE_CODE_EXPIRED = rfc8628_errors.ExpiredTokenError.error
+    DEVICE_CODE_DENIED = rfc8628_errors.AccessDenied.error
+
     def create_authorization_response(
         self, request: common.Request, token_handler: Callable
     ) -> tuple[dict, str, int]:
@@ -33,6 +58,61 @@
 
         return self.create_token_response(request, token_handler)
 
+    def validate_device_authorization_request(self, request: common.Request) 
-> None:
+        """Validate the device authorization request.
+
+        The client_id is required if the client is not authenticating with the
+        authorization server as described in `Section 3.2.1. of [RFC6749]`_.
+        The client identifier as described in `Section 2.2 of [RFC6749]`_.
+
+        :param request: OAuthlib request.
+        :type request: oauthlib.common.Request
+
+        .. _`Section 3.2.1. of [RFC6749]`: 
https://www.rfc-editor.org/rfc/rfc6749#section-3.2.1
+        .. _`Section 2.2 of [RFC6749]`: 
https://www.rfc-editor.org/rfc/rfc6749#section-2.2
+        """
+        # First check duplicate parameters
+        for param in ("client_id", "scope"):
+            try:
+                duplicate_params = request.duplicate_params
+            except ValueError:
+                raise rfc6749_errors.InvalidRequestFatalError(
+                    description="Unable to parse query string", request=request
+                )
+            if param in duplicate_params:
+                raise rfc6749_errors.InvalidRequestFatalError(
+                    description="Duplicate %s parameter." % param, 
request=request
+                )
+
+        # the "application/x-www-form-urlencoded" format, per Appendix B of 
[RFC6749]
+        # https://www.rfc-editor.org/rfc/rfc6749#appendix-B
+        content_type = request.headers.get("Content-Type", "")
+        content_type = content_type.split(";", 1)[0].strip().lower()
+        if content_type != "application/x-www-form-urlencoded":
+            raise rfc6749_errors.InvalidRequestError(
+                "Content-Type must be application/x-www-form-urlencoded", 
request=request
+            )
+
+        # REQUIRED. The client identifier as described in Section 2.2.
+        # https://tools.ietf.org/html/rfc6749#section-2.2
+        if not request.client_id:
+            raise rfc6749_errors.MissingClientIdError(request=request)
+
+        if not self.request_validator.validate_client_id(request.client_id, 
request):
+            raise rfc6749_errors.InvalidClientIdError(request=request)
+
+        # The client authentication requirements of Section 3.2.1 of [RFC6749]
+        # apply to requests on this endpoint, which means that confidential
+        # clients (those that have established client credentials) authenticate
+        # in the same manner as when making requests to the token endpoint, and
+        # public clients provide the "client_id" parameter to identify
+        # themselves.
+        self.validate_client_authentication(request)
+
+        # OPTIONAL. The scope of the access request as defined by Section 3.3
+        # of [RFC6749]. https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+        self.validate_scopes(request)
+
     def validate_token_request(self, request: common.Request) -> None:
         """
         Performs the necessary check against the request to ensure
@@ -60,11 +140,67 @@
         # Ensure client is authorized use of this grant type
         self.validate_grant_type(request)
 
+        # Validate the device_code first: per RFC 8628 Section 3.4 the token
+        # request carries no scope, so the validator must populate
+        # request.user and request.scopes from the stored authorization before
+        # validate_scopes runs (otherwise it would fall back to default 
scopes).
+        self.validate_device_code(request)
+
         self.validate_scopes(request)
 
         for validator in self.custom_validators.post_token:
             validator(request)
 
+    def validate_device_code(self, request: common.Request) -> None:
+        """Validate the ``device_code`` and its authorization status.
+
+        Delegates to :meth:`RequestValidator.validate_device_code`, which
+        returns one of the ``DEVICE_CODE_*`` statuses, and maps it to the
+        appropriate RFC 8628 Section 3.5 error.
+
+        :param request: OAuthlib request.
+        :type request: oauthlib.common.Request
+        """
+        if not request.device_code:
+            raise rfc6749_errors.InvalidRequestError(
+                "Missing device_code parameter.", request=request
+            )
+
+        status = self.request_validator.validate_device_code(
+            request.client_id, request.device_code, request
+        )
+        if status == self.DEVICE_CODE_AUTHORIZED:
+            # The validator is expected to populate these from the stored
+            # authorization; if it did not, validate_scopes will silently fall
+            # back to the client's default scopes.
+            if getattr(request, "user", None) is None:
+                log.debug("request.user was not set on device_code 
validation.")
+            if getattr(request, "scopes", None) is None and request.scope is 
None:
+                raise rfc6749_errors.ServerError(
+                    description="validate_device_code must set request.scopes 
for an authorized device_code.",
+                    request=request,
+                )
+            if getattr(request, "scopes", None) is None:
+                log.debug("request.scopes was not set on device_code 
validation.")
+            return
+
+        # A recognized non-authorized status maps to its RFC 8628 polling
+        # error; anything else means the device_code is unknown or invalid.
+        error_class = self._device_code_errors.get(status)
+        if error_class is not None:
+            raise error_class(request=request)
+
+        # None is the documented "unknown/invalid device_code" return. Any
+        # other unrecognized value most likely signals a validator that did
+        # not return a DEVICE_CODE_* status, so surface it for debugging.
+        if status is not None:
+            log.warning(
+                "validate_device_code returned unrecognized status %r; 
treating "
+                "the device_code as invalid. Return one of the "
+                "DeviceCodeGrant.DEVICE_CODE_* constants.", status
+            )
+        raise rfc6749_errors.InvalidGrantError(request=request)
+
     def create_token_response(
         self, request: common.Request, token_handler: Callable
     ) -> tuple[dict, str, int]:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/request_validator.py 
new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/request_validator.py
--- old/oauthlib-4.0.0/oauthlib/oauth2/rfc8628/request_validator.py     
2026-09-28 08:00:44.160276000 +0200
+++ new/oauthlib-4.0.1/oauthlib/oauth2/rfc8628/request_validator.py     
2026-10-31 10:52:29.657005800 +0100
@@ -23,3 +23,41 @@
         .. _`Section 3.1`: https://www.rfc-editor.org/rfc/rfc8628#section-3.1
         """
         return True
+
+    def validate_device_code(self, client_id, code, request, *args, **kwargs):
+        """Validate a ``device_code`` and return its authorization status.
+
+        Called when a device polls the token endpoint. The implementation
+        should look up the ``device_code``, confirm it was issued to the
+        authenticating client, and report where it is in the authorization
+        process described in `Section 3.5`_ by returning one of the
+        ``DeviceCodeGrant`` status constants:
+
+        - ``DEVICE_CODE_AUTHORIZED``: the end user approved the request.
+          The implementation should also set ``request.user`` (and any
+          scopes or claims to embed) so they end up on the issued token,
+          which removes the need for a separate token validator.
+        - ``DEVICE_CODE_PENDING``: the user has not yet completed the
+          interaction; raises ``AuthorizationPendingError``.
+        - ``DEVICE_CODE_SLOW_DOWN``: as pending, but the client is polling
+          too quickly; raises ``SlowDownError``.
+        - ``DEVICE_CODE_EXPIRED``: the ``device_code`` has expired; raises
+          ``ExpiredTokenError``.
+        - ``DEVICE_CODE_DENIED``: the user denied the request; raises
+          ``AccessDenied``.
+
+        Returning ``None`` (or any unrecognized value) is treated as an
+        unknown or invalid ``device_code`` and raises ``InvalidGrantError``.
+
+        :param client_id: Unicode client identifier.
+        :param code: Unicode device_code.
+        :param request: OAuthlib request.
+        :type request: oauthlib.common.Request
+        :rtype: str or None
+
+        Method is used by:
+            - Device Code Grant
+
+        .. _`Section 3.5`: https://www.rfc-editor.org/rfc/rfc8628#section-3.5
+        """
+        raise NotImplementedError("Subclasses must implement this method.")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/oauthlib.egg-info/PKG-INFO 
new/oauthlib-4.0.1/oauthlib.egg-info/PKG-INFO
--- old/oauthlib-4.0.0/oauthlib.egg-info/PKG-INFO       2026-09-28 
08:00:46.930867400 +0200
+++ new/oauthlib-4.0.1/oauthlib.egg-info/PKG-INFO       2026-10-31 
10:52:31.976083800 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: oauthlib
-Version: 4.0.0
+Version: 4.0.1
 Summary: A generic, spec-compliant, thorough implementation of the OAuth 
request-signing logic
 Home-page: https://github.com/oauthlib/oauthlib
 Author: The OAuthlib Community
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/tests/oauth2/rfc8628/endpoints/test_error_responses.py 
new/oauthlib-4.0.1/tests/oauth2/rfc8628/endpoints/test_error_responses.py
--- old/oauthlib-4.0.0/tests/oauth2/rfc8628/endpoints/test_error_responses.py   
2026-09-28 08:00:44.162932000 +0200
+++ new/oauthlib-4.0.1/tests/oauth2/rfc8628/endpoints/test_error_responses.py   
2026-10-31 10:52:29.659269300 +0100
@@ -8,15 +8,23 @@
 
 
 class ErrorResponseTest(TestCase):
-    def set_client(self, request):
+    def set_client(self, request, *args, **kwargs):
+        """Stand in for a validator that attaches the client, as documented."""
         request.client = mock.MagicMock()
-        request.client.client_id = "mocked"
+        request.client.client_id = request.client_id
         return True
 
-    def build_request(self, uri="https://example.com/device_authorize";, 
client_id="foo"):
-        body = ""
-        if client_id:
-            body = f"client_id={client_id}"
+    def set_client_id(self, client_id, request, *args, **kwargs):
+        return self.set_client(request)
+
+    def build_request(
+        self, uri="https://example.com/device_authorize";, client_id="foo", 
body=None
+    ):
+        # The body must be passed to the constructor: Request parses it once at
+        # initialization, so assigning to request.body afterwards would leave
+        # request.scope and request.duplicate_params stale.
+        if body is None:
+            body = f"client_id={client_id}" if client_id else ""
         return Request(
             uri,
             http_method="POST",
@@ -24,26 +32,29 @@
             headers={"Content-Type": "application/x-www-form-urlencoded"},
         )
 
-    def assert_request_raises(self, error, request):
+    def assert_request_raises(self, error, request, description=None):
         """Test that the request fails similarly on the validation and 
response endpoint."""
-        self.assertRaises(
-            error,
-            self.device.validate_device_authorization_request,
-            request,
-        )
-        self.assertRaises(
-            error,
-            self.device.create_device_authorization_response,
-            uri=request.uri,
-            http_method=request.http_method,
-            body=request.body,
-            headers=request.headers,
-        )
+        with self.assertRaises(error) as caught:
+            self.device.validate_device_authorization_request(request)
+        if description is not None:
+            self.assertEqual(description, caught.exception.description)
+
+        with self.assertRaises(error) as caught:
+            self.device.create_device_authorization_response(
+                uri=request.uri,
+                http_method=request.http_method,
+                body=request.body,
+                headers=request.headers,
+            )
+        if description is not None:
+            self.assertEqual(description, caught.exception.description)
 
     def setUp(self):
         self.validator = mock.MagicMock(spec=RequestValidator)
         self.validator.get_default_redirect_uri.return_value = None
         self.validator.get_code_challenge.return_value = None
+        self.validator.authenticate_client.side_effect = self.set_client
+        self.validator.authenticate_client_id.side_effect = self.set_client_id
         self.device = DeviceApplicationServer(self.validator, 
"https://example.com/verify";)
 
     def test_missing_client_id(self):
@@ -69,27 +80,40 @@
         self.assert_request_raises(errors.InvalidClientIdError, request)
 
     def test_duplicate_client_id(self):
-        request = self.build_request()
-        request.body = "client_id=foo&client_id=bar"
+        request = self.build_request(body="client_id=foo&client_id=bar")
         # Device code grant
-        self.validator.validate_client_id.return_value = False
-        self.assert_request_raises(errors.InvalidRequestFatalError, request)
+        self.assert_request_raises(
+            errors.InvalidRequestFatalError, request, "Duplicate client_id 
parameter."
+        )
 
     def test_unauthenticated_confidential_client(self):
         self.validator.client_authentication_required.return_value = True
+        self.validator.authenticate_client.side_effect = None
         self.validator.authenticate_client.return_value = False
         request = self.build_request()
         self.assert_request_raises(errors.InvalidClientError, request)
 
     def test_unauthenticated_public_client(self):
         self.validator.client_authentication_required.return_value = False
+        self.validator.authenticate_client_id.side_effect = None
         self.validator.authenticate_client_id.return_value = False
         request = self.build_request()
         self.assert_request_raises(errors.InvalidClientError, request)
 
     def test_duplicate_scope_parameter(self):
-        request = self.build_request()
-        request.body = "client_id=foo&scope=foo&scope=bar"
+        request = self.build_request(body="client_id=foo&scope=foo&scope=bar")
         # Device code grant
-        self.validator.validate_client_id.return_value = False
-        self.assert_request_raises(errors.InvalidRequestFatalError, request)
+        self.assert_request_raises(
+            errors.InvalidRequestFatalError, request, "Duplicate scope 
parameter."
+        )
+
+    def test_invalid_scope(self):
+        request = self.build_request(body="client_id=foo&scope=foo")
+
+        # Only reject the scope requested in the body, so that the test fails 
if
+        # the requested scope never reaches the validator.
+        def validate_scopes(client_id, scopes, client, request):
+            return scopes != ["foo"]
+
+        self.validator.validate_scopes.side_effect = validate_scopes
+        self.assert_request_raises(errors.InvalidScopeError, request)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/oauthlib-4.0.0/tests/oauth2/rfc8628/grant_types/test_device_code.py 
new/oauthlib-4.0.1/tests/oauth2/rfc8628/grant_types/test_device_code.py
--- old/oauthlib-4.0.0/tests/oauth2/rfc8628/grant_types/test_device_code.py     
2026-09-28 08:00:44.163003000 +0200
+++ new/oauthlib-4.0.1/tests/oauth2/rfc8628/grant_types/test_device_code.py     
2026-10-31 10:52:29.659338200 +0100
@@ -5,18 +5,24 @@
 from oauthlib import common
 
 from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant
+from oauthlib.oauth2.rfc8628.request_validator import RequestValidator
 from oauthlib.oauth2.rfc6749.tokens import BearerToken
 
 def create_request(body: str = "") -> common.Request:
     request = common.Request("http://a.b/path";, body=body or None)
     request.scopes = ("hello", "world")
     request.expires_in = 1800
-    request.client = "batman"
     request.client_id = "abcdef"
+    # Client authentication must leave request.client set to an object whose
+    # client_id matches request.client_id, otherwise 
validate_client_confidential
+    # /validate_client_public raise before the grant's own checks run.
+    request.client = mock.Mock()
+    request.client.client_id = request.client_id
     request.code = "1234"
     request.response_type = "code"
     request.grant_type = "urn:ietf:params:oauth:grant-type:device_code"
     request.redirect_uri = "https://a.b/";
+    request.device_code = "device_code_1234"
     return request
 
 
@@ -54,6 +60,7 @@
     request: common.Request = create_request()
     request.client = client
     client.client_id = request.client_id
+    validator.validate_device_code.return_value = 
DeviceCodeGrant.DEVICE_CODE_AUTHORIZED
 
     auth = DeviceCodeGrant(validator)
 
@@ -70,8 +77,7 @@
 def test_create_token_response():
     validator = mock.MagicMock()
     request: common.Request = create_request()
-    request.client = mock.Mock()
-    request.client.client_id = request.client_id
+    validator.validate_device_code.return_value = 
DeviceCodeGrant.DEVICE_CODE_AUTHORIZED
 
     auth = DeviceCodeGrant(validator)
 
@@ -104,7 +110,6 @@
 def test_invalid_client_authentication_error_confidential_client():
     validator = mock.MagicMock()
     request: common.Request = create_request()
-    request.client = mock.Mock()
 
     auth = DeviceCodeGrant(validator)
     bearer = BearerToken(validator)
@@ -131,7 +136,6 @@
 def test_invalid_client_authentication_error_public_client():
     validator = mock.MagicMock()
     request: common.Request = create_request()
-    request.client = mock.Mock()
 
     auth = DeviceCodeGrant(validator)
     bearer = BearerToken(validator)
@@ -158,7 +162,6 @@
 def test_invalid_grant_type_error():
     validator = mock.MagicMock()
     request: common.Request = create_request()
-    request.client = mock.Mock()
 
     request.grant_type = "not_device_code"
 
@@ -184,7 +187,6 @@
     request: common.Request = create_request(
         "client_id=123&scope=openid&scope=openid"
     )
-    request.client = mock.Mock()
 
     auth = DeviceCodeGrant(validator)
     bearer = BearerToken(validator)
@@ -201,3 +203,162 @@
     assert status_code == 400
 
     validator.save_token.assert_not_called()
+
+
[email protected](
+    ("status", "expected_error"),
+    [
+        (DeviceCodeGrant.DEVICE_CODE_PENDING, "authorization_pending"),
+        (DeviceCodeGrant.DEVICE_CODE_SLOW_DOWN, "slow_down"),
+        (DeviceCodeGrant.DEVICE_CODE_EXPIRED, "expired_token"),
+        (DeviceCodeGrant.DEVICE_CODE_DENIED, "access_denied"),
+    ],
+)
+def test_device_code_status_errors(status, expected_error):
+    validator = mock.MagicMock()
+    request: common.Request = create_request()
+    validator.validate_device_code.return_value = status
+
+    auth = DeviceCodeGrant(validator)
+    bearer = BearerToken(validator)
+
+    _headers, body, status_code = auth.create_token_response(request, bearer)
+    body = json.loads(body)
+
+    assert body == {"error": expected_error}
+    assert status_code == 400
+
+    validator.validate_device_code.assert_called_once_with(
+        request.client_id, request.device_code, request
+    )
+    validator.save_token.assert_not_called()
+
+
[email protected](
+    "status",
+    [
+        None,  # documented "unknown / invalid device_code" return
+        "not_a_real_status",  # validator returned an unrecognized value
+        True,  # validator wrongly returned a bool instead of a status
+    ],
+)
+def test_invalid_device_code(status):
+    validator = mock.MagicMock()
+    request: common.Request = create_request()
+    validator.validate_device_code.return_value = status
+
+    auth = DeviceCodeGrant(validator)
+    bearer = BearerToken(validator)
+
+    _headers, body, status_code = auth.create_token_response(request, bearer)
+    body = json.loads(body)
+
+    assert body == {"error": "invalid_grant"}
+    assert status_code == 400
+
+    validator.save_token.assert_not_called()
+
+
+def test_device_code_scopes_populated_before_scope_validation():
+    """Authorized scopes from the stored grant must be validated, not defaults.
+
+    Per RFC 8628 Section 3.4 the device token request carries no scope, so
+    validate_device_code must populate request.scopes from the stored
+    authorization before validate_scopes runs; otherwise validate_scopes falls
+    back to get_default_scopes and the token silently receives default scopes
+    (issue #944).
+    """
+    validator = mock.MagicMock()
+    request: common.Request = create_request()
+    # The device token request has no scope of its own.
+    request.scope = None
+    request.scopes = None
+
+    def authorize(client_id, code, req):
+        req.scopes = ["read", "write"]
+        return DeviceCodeGrant.DEVICE_CODE_AUTHORIZED
+
+    validator.validate_device_code.side_effect = authorize
+
+    auth = DeviceCodeGrant(validator)
+    bearer = BearerToken(validator)
+    auth.create_token_response(request, bearer)
+
+    validator.get_default_scopes.assert_not_called()
+    validator.validate_scopes.assert_called_once_with(
+        "abcdef", ["read", "write"], request.client, request
+    )
+
+
+def test_device_code_authorized_without_scopes_fails_loudly():
+    """An authorized device_code must not silently fall back to default scopes.
+
+    If ``validate_device_code`` returns ``DEVICE_CODE_AUTHORIZED`` without
+    populating ``request.scopes`` (and the token request itself carries no
+    ``scope``), failing loudly with a ``server_error`` is the only safe
+    option: continuing would let ``validate_scopes`` fall back to
+    ``get_default_scopes`` and reintroduce the consent-integrity bug from
+    issue #944.
+    """
+    validator = mock.MagicMock()
+    request: common.Request = create_request()
+    # The device token request has no scope of its own, and the validator
+    # forgets to set request.scopes on the authorized device_code.
+    request.scope = None
+    request.scopes = None
+    validator.validate_device_code.return_value = 
DeviceCodeGrant.DEVICE_CODE_AUTHORIZED
+
+    auth = DeviceCodeGrant(validator)
+    bearer = BearerToken(validator)
+
+    _headers, body, status_code = auth.create_token_response(request, bearer)
+    body = json.loads(body)
+
+    assert body == {
+        "error": "server_error",
+        "error_description": (
+            "validate_device_code must set request.scopes for an "
+            "authorized device_code."
+        ),
+    }
+    assert status_code == 400
+
+    validator.get_default_scopes.assert_not_called()
+    validator.validate_scopes.assert_not_called()
+    validator.save_token.assert_not_called()
+
+
+def test_validate_device_code_is_required_on_real_validator():
+    """The hook lives on the rfc8628 RequestValidator and must be implemented.
+
+    Guards against the gap mocks hide: a MagicMock auto-creates
+    ``validate_device_code``, so the behavior tests above pass even if the
+    method did not exist. A real validator subclass that has not implemented
+    it must fail loudly rather than silently.
+    """
+    request: common.Request = create_request()
+    with pytest.raises(NotImplementedError):
+        RequestValidator().validate_device_code(
+            request.client_id, request.device_code, request
+        )
+
+
+def test_missing_device_code():
+    validator = mock.MagicMock()
+    request: common.Request = create_request()
+    request.device_code = None
+
+    auth = DeviceCodeGrant(validator)
+    bearer = BearerToken(validator)
+
+    _headers, body, status_code = auth.create_token_response(request, bearer)
+    body = json.loads(body)
+
+    assert body == {
+        "error": "invalid_request",
+        "error_description": "Missing device_code parameter.",
+    }
+    assert status_code == 400
+
+    validator.validate_device_code.assert_not_called()
+    validator.save_token.assert_not_called()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/oauthlib-4.0.0/tests/oauth2/rfc8628/test_server.py 
new/oauthlib-4.0.1/tests/oauth2/rfc8628/test_server.py
--- old/oauthlib-4.0.0/tests/oauth2/rfc8628/test_server.py      2026-09-28 
08:00:44.163003000 +0200
+++ new/oauthlib-4.0.1/tests/oauth2/rfc8628/test_server.py      2026-10-31 
10:52:29.659338200 +0100
@@ -8,11 +8,30 @@
 
 
 class DeviceAuthorizationEndpointTest(TestCase):
+    def _set_client(self, request, *args, **kwargs):
+        """Stand in for a validator that attaches the client, as documented."""
+        request.client = mock.MagicMock()
+        request.client.client_id = request.client_id
+        return True
+
+    def _build_validator(self, scopes=None):
+        validator = mock.MagicMock(spec=RequestValidator)
+        validator.authenticate_client.side_effect = self._set_client
+        validator.authenticate_client_id.side_effect = (
+            lambda client_id, request, *a, **kw: self._set_client(request)
+        )
+        validator.get_default_scopes.return_value = scopes
+        return validator
+
     def _configure_endpoint(
-        self, interval=None, verification_uri_complete=None, 
user_code_generator=None
+        self,
+        interval=None,
+        verification_uri_complete=None,
+        user_code_generator=None,
+        scopes=None,
     ):
         self.endpoint = DeviceAuthorizationEndpoint(
-            request_validator=mock.MagicMock(spec=RequestValidator),
+            request_validator=self._build_validator(scopes),
             verification_uri=self.verification_uri,
             interval=interval,
             verification_uri_complete=verification_uri_complete,
@@ -20,7 +39,6 @@
         )
 
     def setUp(self):
-        self.request_validator = mock.MagicMock(spec=RequestValidator)
         self.verification_uri = "http://i.b/l/verify";
         self.uri = "http://i.b/l";
         self.http_method = "POST"
@@ -48,6 +66,25 @@
 
     @mock.patch(
         
"oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token",
+        lambda: "abc",
+    )
+    def test_device_authorization_grant_echoes_requested_scope(self):
+        self.body = "client_id=abc&scope=read+write"
+        _, body, _ = 
self.endpoint.create_device_authorization_response(*self.response_payload())
+        self.assertEqual("read write", body["scope"])
+
+    @mock.patch(
+        
"oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token",
+        lambda: "abc",
+    )
+    def test_device_authorization_grant_echoes_default_scope(self):
+        """A device that omits scope gets the resolved defaults back (issue 
#949)."""
+        self._configure_endpoint(scopes=["read", "write"])
+        _, body, _ = 
self.endpoint.create_device_authorization_response(*self.response_payload())
+        self.assertEqual("read write", body["scope"])
+
+    @mock.patch(
+        
"oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token",
         lambda: "abc",
     )
     def test_device_authorization_grant_interval(self):

Reply via email to