Repository: streamlink/streamlink

Issue:
plugins.twitch: Show error message when the provided OAuth token is not valid
### Checklist

- [X] This is a feature request and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22feature+request%22)

### Description

I'd imagine the playlist server returning some kind of 403 error or similar when the authorization fails, and if this is the case, could there be an error message related to tokens instead of "No playable streams found"?
Would help with scripting a lot. Thanks.


Discussion:
Twitch authentication is not an officially supported feature, otherwise we'd add back the dedicated `--twitch-oauth-token` CLI parameter for that. This is because of how the GQL OAuth token is obtained by the user and how there's no proper integrated authentication flow, like there was on kraken or how there is on helix.

Twitch's GQL API returns status code 200 if the `Authorization` header contains random garbage data when requesting a `PlaybackAccessToken`. If the header value however is in the format `OAuth token` and `token` is invalid, only then the response will be 401 with an appropriate JSON payload.

The Twitch plugin would need to handle this separately and therefore implement partial authentication support, which I don't think is a good idea.

If you want to automate unofficial authentication, then interact with Twitch's GQL API yourself and find a query where you can validate the OAuth token.

```sh
$ curl \
  -D - \
  -H 'Client-ID: kimne78kx3ncx6brgo4mv6wki5h1ko' \
  -H 'Authorization: invalid-data' \
  -d '{"operationName": "PlaybackAccessToken", "extensions": {"persistedQuery": {"version": 1, "sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"}}, "variables": {"isLive": true, "login": "...", "isVod": false, "vodID": "", "playerType": "embed"}}' \
  'https://gql.twitch.tv/gql'
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 952
Content-Type: application/json
Access-Control-Allow-Origin: *
Date: Sat, 03 Dec 2022 13:07:38 GMT

{"data":{"streamPlaybackAccessToken":{"value":"...","signature":"...","__typename":"PlaybackAccessToken"}},"extensions":{"durationMilliseconds":57,"operationName":"PlaybackAccessToken","requestID":"..."}}
```

```sh
$ curl \
  -D - \
  -H 'Client-ID: kimne78kx3ncx6brgo4mv6wki5h1ko' \
  -H 'Authorization: OAuth invalid-token' \
  -d '{"operationName": "PlaybackAccessToken", "extensions": {"persistedQuery": {"version": 1, "sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"}}, "variables": {"isLive": true, "login": "...", "isVod": false, "vodID": "", "playerType": "embed"}}' \
  'https://gql.twitch.tv/gql'
HTTP/1.1 401 Unauthorized
Connection: keep-alive
Content-Length: 89
Content-Type: application/json
Access-Control-Allow-Origin: *
Date: Sat, 03 Dec 2022 13:07:53 GMT

{"error":"Unauthorized","status":401,"message":"The \"Authorization\" token is invalid."}
```
That is a good idea, thank you.
Btw, we can add generic access token failure handling. I'll submit a PR later. I have already implemented it with some added tests.

Resolving patch:
diff --git a/src/streamlink/options.py b/src/streamlink/options.py
--- a/src/streamlink/options.py
+++ b/src/streamlink/options.py
@@ -28,6 +28,9 @@ def __init__(self, defaults=None):
     def _normalise_dict(cls, src):
         return {_normalise_option_name(key): value for key, value in src.items()}
 
+    def clear(self):
+        self.options = self.defaults.copy()
+
     def set(self, key, value):
         self.options[_normalise_option_name(key)] = value
 
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -8,7 +8,6 @@
 """
 
 import argparse
-import json
 import logging
 import re
 import sys
@@ -251,20 +250,23 @@ def video(self, video_id, **extra_params):
 
 
 class TwitchAPI:
+    CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
+
     def __init__(self, session):
         self.session = session
         self.headers = {
-            "Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
+            "Client-ID": self.CLIENT_ID,
         }
         self.headers.update(**dict(session.get_plugin_option("twitch", "api-header") or []))
         self.access_token_params = dict(session.get_plugin_option("twitch", "access-token-param") or [])
         self.access_token_params.setdefault("playerType", "embed")
 
-    def call(self, data, schema=None):
+    def call(self, data, schema=None, **kwargs):
         res = self.session.http.post(
             "https://gql.twitch.tv/gql",
-            data=json.dumps(data),
-            headers=self.headers
+            json=data,
+            headers={**self.headers, **kwargs.pop("headers", {})},
+            **kwargs,
         )
 
         return self.session.http.json(res, schema=schema)
@@ -421,18 +423,30 @@ def access_token(self, is_live, channel_or_vod):
             validate.union_get("signature", "value"),
         )
 
-        return self.call(query, schema=validate.Schema(
-            {"data": validate.any(
+        return self.call(query, acceptable_status=(200, 400, 401, 403), schema=validate.Schema(
+            validate.any(
                 validate.all(
-                    {"streamPlaybackAccessToken": subschema},
-                    validate.get("streamPlaybackAccessToken")
+                    {"error": str, "message": str},
+                    validate.union_get("error", "message"),
+                    validate.transform(lambda data: ("error", *data)),
                 ),
                 validate.all(
-                    {"videoPlaybackAccessToken": subschema},
-                    validate.get("videoPlaybackAccessToken")
-                )
-            )},
-            validate.get("data")
+                    {
+                        "data": validate.any(
+                            validate.all(
+                                {"streamPlaybackAccessToken": subschema},
+                                validate.get("streamPlaybackAccessToken"),
+                            ),
+                            validate.all(
+                                {"videoPlaybackAccessToken": subschema},
+                                validate.get("videoPlaybackAccessToken"),
+                            ),
+                        ),
+                    },
+                    validate.get("data"),
+                    validate.transform(lambda data: ("token", *data)),
+                ),
+            ),
         ))
 
     def clips(self, clipname):
@@ -616,7 +630,12 @@ def _get_metadata(self):
 
     def _access_token(self, is_live, channel_or_vod):
         try:
-            sig, token = self.api.access_token(is_live, channel_or_vod)
+            response, *data = self.api.access_token(is_live, channel_or_vod)
+            if response != "token":
+                error, message = data
+                log.error(f"{error or 'Error'}: {message or 'Unknown error'}")
+                raise PluginError
+            sig, token = data
         except (PluginError, TypeError):
             raise NoStreamsError(self.url)
 


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -2,10 +2,12 @@
 from datetime import datetime, timedelta
 from unittest.mock import MagicMock, call, patch
 
+import pytest
 import requests_mock
 
 from streamlink import Streamlink
-from streamlink.plugins.twitch import Twitch, TwitchHLSStream, TwitchHLSStreamReader, TwitchHLSStreamWriter
+from streamlink.exceptions import NoStreamsError
+from streamlink.plugins.twitch import Twitch, TwitchAPI, TwitchHLSStream, TwitchHLSStreamReader, TwitchHLSStreamWriter
 from tests.mixins.stream_hls import EventedHLSStreamWriter, Playlist, Segment as _Segment, Tag, TestMixinStreamHLS
 from tests.plugins import PluginCanHandleUrl
 from tests.resources import text
@@ -389,6 +391,154 @@ def test_hls_low_latency_no_ads_reload_time(self):
         self.assertEqual(self.thread.reader.worker.playlist_reload_time, 23 / 3)
 
 
+class TestTwitchAPIAccessToken:
+    @pytest.fixture
+    def plugin(self, request):
+        session = Streamlink()
+        for param in getattr(request, "param", {}):
+            session.set_plugin_option("twitch", *param)
+        yield Twitch(session, "https://twitch.tv/channelname")
+        Twitch.options.clear()
+
+    @pytest.fixture
+    def mocker(self):
+        # The built-in requests_mock fixture is bad when trying to reference the following constants or classes
+        with requests_mock.Mocker() as mocker:
+            mocker.register_uri(requests_mock.ANY, requests_mock.ANY, exc=requests_mock.exceptions.InvalidRequest)
+            yield mocker
+
+    @pytest.fixture
+    def mock(self, request, mocker: requests_mock.Mocker):
+        mock = mocker.post("https://gql.twitch.tv/gql", **getattr(request, "param", {"json": {}}))
+        yield mock
+        assert mock.called_once
+        payload = mock.last_request.json()  # type: ignore[union-attr]
+        assert tuple(sorted(payload.keys())) == ("extensions", "operationName", "variables")
+        assert payload.get("operationName") == "PlaybackAccessToken"
+        assert payload.get("extensions") == {
+            "persistedQuery": {
+                "sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
+                "version": 1,
+            },
+        }
+
+    @pytest.fixture
+    def assert_live(self, mock):
+        yield
+        assert mock.last_request.json().get("variables") == {  # type: ignore[union-attr]
+            "isLive": True,
+            "isVod": False,
+            "login": "channelname",
+            "vodID": "",
+            "playerType": "embed",
+        }
+
+    @pytest.fixture
+    def assert_vod(self, mock):
+        yield
+        assert mock.last_request.json().get("variables") == {  # type: ignore[union-attr]
+            "isLive": False,
+            "isVod": True,
+            "login": "",
+            "vodID": "vodid",
+            "playerType": "embed",
+        }
+
+    @pytest.mark.parametrize("plugin,exp_headers,exp_variables", [
+        (
+            [],
+            {"Client-ID": TwitchAPI.CLIENT_ID},
+            {
+                "isLive": True,
+                "isVod": False,
+                "login": "channelname",
+                "vodID": "",
+                "playerType": "embed",
+            },
+        ),
+        (
+            [
+                ("api-header", [
+                    ("Authorization", "invalid data"),
+                    ("Authorization", "OAuth 0123456789abcdefghijklmnopqrst"),
+                ]),
+                ("access-token-param", [
+                    ("specialVariable", "specialValue"),
+                    ("playerType", "frontpage"),
+                ]),
+            ],
+            {
+                "Client-ID": TwitchAPI.CLIENT_ID,
+                "Authorization": "OAuth 0123456789abcdefghijklmnopqrst",
+            },
+            {
+                "isLive": True,
+                "isVod": False,
+                "login": "channelname",
+                "vodID": "",
+                "playerType": "frontpage",
+                "specialVariable": "specialValue",
+            },
+        ),
+    ], indirect=["plugin"])
+    def test_plugin_options(self, plugin: Twitch, mock: requests_mock.Mocker, exp_headers: dict, exp_variables: dict):
+        with pytest.raises(NoStreamsError):
+            plugin._access_token(True, "channelname")
+        requestheaders = dict(mock.last_request._request.headers)  # type: ignore[union-attr]
+        for header in plugin.session.http.headers.keys():
+            del requestheaders[header]
+        del requestheaders["Content-Type"]
+        del requestheaders["Content-Length"]
+        assert requestheaders == exp_headers
+        assert mock.last_request.json().get("variables") == exp_variables  # type: ignore[union-attr]
+
+    @pytest.mark.parametrize("mock", [{
+        "json": {"data": {"streamPlaybackAccessToken": {"value": '{"channel":"foo"}', "signature": "sig"}}},
+    }], indirect=True)
+    def test_live_success(self, plugin: Twitch, mock: requests_mock.Mocker, assert_live):
+        data = plugin._access_token(True, "channelname")
+        assert data == ("sig", '{"channel":"foo"}', [])
+
+    @pytest.mark.parametrize("mock", [{
+        "json": {"data": {"streamPlaybackAccessToken": None}},
+    }], indirect=True)
+    def test_live_failure(self, plugin: Twitch, mock: requests_mock.Mocker, assert_live):
+        with pytest.raises(NoStreamsError):
+            plugin._access_token(True, "channelname")
+
+    @pytest.mark.parametrize("mock", [{
+        "json": {"data": {"videoPlaybackAccessToken": {"value": '{"channel":"foo"}', "signature": "sig"}}},
+    }], indirect=True)
+    def test_vod_success(self, plugin: Twitch, mock: requests_mock.Mocker, assert_vod):
+        data = plugin._access_token(False, "vodid")
+        assert data == ("sig", '{"channel":"foo"}', [])
+
+    @pytest.mark.parametrize("mock", [{
+        "json": {"data": {"videoPlaybackAccessToken": None}},
+    }], indirect=True)
+    def test_vod_failure(self, plugin: Twitch, mock: requests_mock.Mocker, assert_vod):
+        with pytest.raises(NoStreamsError):
+            plugin._access_token(False, "vodid")
+
+    @pytest.mark.parametrize("plugin,mock", [
+        (
+            [("api-header", [("Authorization", "OAuth invalid-token")])],
+            {
+                "status_code": 401,
+                "json": {"error": "Unauthorized", "status": 401, "message": "The \"Authorization\" token is invalid."},
+            },
+        ),
+    ], indirect=True)
+    def test_auth_failure(self, caplog: pytest.LogCaptureFixture, plugin: Twitch, mock: requests_mock.Mocker, assert_live):
+        with pytest.raises(NoStreamsError) as cm:
+            plugin._access_token(True, "channelname")
+        assert str(cm.value) == "No streams found on this URL: https://twitch.tv/channelname"
+        assert mock.last_request._request.headers["Authorization"] == "OAuth invalid-token"  # type: ignore[union-attr]
+        assert [(record.levelname, record.module, record.message) for record in caplog.records] == [
+            ("error", "twitch", "Unauthorized: The \"Authorization\" token is invalid."),
+        ]
+
+
 class TestTwitchMetadata(unittest.TestCase):
     def setUp(self):
         self.mock = requests_mock.Mocker()