GitHub Advisoryadvisory · PyPI

GHSA-7984-qgvg-3j9c OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client

HIGH Published 2026-10-30 Modified 2026-10-30 Official page ↗
Aliases
CVE-2026-118669
Ecosystem
PyPI
Packages
oauthlib
Severity
High
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:H/SI:H/SA:N
Weaknesses
CWE-863
Reviewed
2026-10-30

Affected packages

• PyPI oauthlib: < 4.0.0 → fixed in 4.0.0

Description

Summary OAuthlib's token grant handlers can authenticate one client while authorizing the grant type and requested scopes using a different, attacker-supplied `client_id` from the request body. In affected token grant flows, `authenticate_client(request)` sets `request.client` to the authenticated client object, but OAuthlib does not require `request.client_id` to match `request.client.client_id`. If the request body already contains `client_id=...`, OAuthlib keeps that value and passes it to `validate_grant_type()` and `validate_scopes()`. As a result, an authenticated low-privilege confidential client can authenticate as itself, submit another client's `client_id` in the token request body, and cause grant and scope validation to run under that other client identity. Affected Surface The affected public surface is the OAuth2 token endpoint handling for: • `client_credentials` • `password` / Resource Owner Password Credentials The issue is relevant to OAuthlib provider deployments where: • the attacker has credentials for one valid lower-privilege confidential client; • another client has broader grants or scopes; • the application validator uses the `client_id` argument passed by OAuthlib in `validate_grant_type()` or `validate_scopes()`, as the `RequestValidator` API is designed to support. Deployments whose validators ignore the `client_id` argument and authorize only from `request.client` may not be affected. Details `Request` parses query and body parameters into request attributes, including `client_id`: • https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/common.py#L360-L397 The `RequestValidator.authenticate_client()` contract requires the integrator to authenticate the client and set `request.client`: • https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/request_validator.py#L41-L66 For the `client_credentials` grant, OAuthlib authenticates the client, verifies that `request.client` has a `client_id`, then performs grant and scope validation before normalizing `request.client_id` from `request.client.client_id`: • https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py#L106-L119 The problematic order is: ```python if not self.request_validator.authenticate_client(request): ... elif not hasattr(request.client, 'client_id'): ... self.validate_grant_type(request) request.client_id = request.client_id or request.client.client_id self.validate_scopes(request) ``` Because OAuthlib only fills `request.client_id` when it is missing, an attacker-supplied body value such as `client_id=high` survives even when `request.client.client_id == "low"`. The shared grant helper then authorizes using `request.client_id`, while passing the authenticated client object separately: • `validate_grant_type(request.client_id, request.grant_type, request.client, request)`: https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/base.py#L152-L162 • `validate_scopes(request.client_id, request.scopes, request.client, request)`: https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/base.py#L164-L176 The same identity split exists in the Resource Owner Password Credentials flow. After client authentication and user validation, OAuthlib validates the grant and scopes before replacing a missing `request.client_id` from `request.client.client_id`: • https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L87-L97 • https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L178-L195 This creates an inconsistent identity state: ```text request.client.client_id == "low" # authenticated client request.client_id == "high" # attacker-supplied request parameter ``` OAuthlib then calls application authorization hooks with both values, but uses the attacker-controlled `request.client_id` as the first-class `client_id` argument. Impact An authenticated lower-privilege confidential client can obtain tokens authorized under another client's grant and scope policy if the application's validator follows the `RequestValidator` API and authorizes by the `client_id` argument. Concrete impact can include: • issuing access tokens with scopes not assigned to the authenticated client; • allowing a low-privilege client to use a grant type reserved for a higher-privilege client; • persisting tokens under a spoofed `client_id` while the authenticated client object is different. This is a token authorization bypass, not an unauthenticated attack. The attacker needs valid credentials for one client. PoC Create the following files in an empty directory. `requirements.txt`: ```text oauthlib==3.3.1 ``` `repro.py`: ```python import base64 import json from oauthlib.oauth2 import ( BearerToken, ClientCredentialsGrant, RequestValidator, ResourceOwnerPasswordCredentialsGrant, TokenEndpoint, ) class Client: def __init__(self, client_id): self.client_id = client_id CLIENTS = { "low": { "secret": "low-secret", "grants": {"client_credentials", "password"}, "scopes": {"read"}, }, "high": { "secret": "high-secret", "grants": {"client_credentials", "password"}, "scopes": {"read", "admin"}, }, } class DemoValidator(RequestValidator): def __init__(self): self.saved_tokens = [] def authenticate_client(self, request, *args, **kwargs): authorization = request.headers.get("Authorization", "") prefix = "Basic " if not authorization.startswith(prefix): return False decoded = base64.b64decode(authorization[len(prefix):]).decode("utf-8") client_id, secret = decoded.split(":", 1) record = CLIENTS.get(client_id) if record is None or record["secret"] != secret: return False # This is the authenticated client identity. request.client = Client(client_id) return True def client_authentication_required(self, request, *args, **kwargs): return True def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): record = CLIENTS.get(client_id) return record is not None and grant_type in record["grants"] def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): record = CLIENTS.get(client_id) return record is not None and set(scopes or []) <= record["scopes"] def get_default_scopes(self, client_id, request, *args, **kwargs): return [] def validate_user(self, username, password, client, request, *args, **kwargs): if username == "alice" and password == "wonderland": request.user = username return True return False def save_bearer_token(self, token, request, *args, **kwargs): self.saved_tokens.append({ "token": dict(token), "authenticated_client_id": request.client.client_id, "request_client_id": request.client_id, "scopes": list(request.scopes or []), }) validator = DemoValidator() token = BearerToken(validator, expires_in=3600) endpoint = TokenEndpoint( default_grant_type="client_credentials", default_token_type=token, grant_types={ "client_credentials": ClientCredentialsGrant(validator), "password": ResourceOwnerPasswordCredentialsGrant(validator), }, ) def low_client_headers(): value = base64.b64encode(b"low:low-secret").decode("ascii") return {"Authorization": f"Basic {value}"} def toke…

Related

Alias · CVE (1)

  • CVE-2026-118669 OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. vulnerability

Weakness · CWE (1)

  • CWE-863 Incorrect Authorization weakness

References