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