From 205ff24e8c30ff4c3518a84d1d05e9d038835cc1 Mon Sep 17 00:00:00 2001 From: Max <84903084+second2falcon@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:59:32 +0200 Subject: [PATCH] Validate twitch_oauth.txt session token before use An expired or invalid session token in twitch_oauth.txt made streamlink fail with a generic "No playable streams found" error, which looks the same as the channel being offline. Add TwitchHelper.getSessionToken(), which reads the token and checks it against id.twitch.tv/oauth2/validate. On a 401 it logs an error, sends a system notification and returns undefined, so capture continues without auth instead of failing silently. Network errors during validation keep the token so a Twitch hiccup doesn't drop auth. - Live capture validates the token in a captureVideo() override before streamlink starts - VOD-at-end download uses the same helper - Scheduler checks the token every 6 hours so an expired one is reported before the next stream - Token censoring moved into the helper --- .../Core/Providers/Twitch/TwitchAutomator.ts | 27 +++++----- server/src/Core/Providers/Twitch/TwitchVOD.ts | 25 ++++----- server/src/Core/Scheduler.ts | 5 ++ server/src/Providers/Twitch.ts | 54 +++++++++++++++++++ server/src/Translations/en.json | 1 + 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/server/src/Core/Providers/Twitch/TwitchAutomator.ts b/server/src/Core/Providers/Twitch/TwitchAutomator.ts index e8277d84..536d7d2f 100644 --- a/server/src/Core/Providers/Twitch/TwitchAutomator.ts +++ b/server/src/Core/Providers/Twitch/TwitchAutomator.ts @@ -10,7 +10,7 @@ import { BaseConfigCacheFolder, BaseConfigDataFolder } from "../../BaseConfig"; import { ClientBroker } from "../../ClientBroker"; import { Config } from "../../Config"; import { KeyValue } from "../../KeyValue"; -import { LOGLEVEL, censoredLogWords, log } from "../../Log"; +import { LOGLEVEL, log } from "../../Log"; import { Webhook } from "../../Webhook"; import { BaseAutomator } from "../Base/BaseAutomator"; import { TwitchChannel } from "./TwitchChannel"; @@ -649,22 +649,21 @@ export class TwitchAutomator extends BaseAutomator { } } + private sessionToken?: string; + + public async captureVideo(): Promise { + // validate before streamlink runs, otherwise a bad token just shows up as "no playable streams" + this.sessionToken = await TwitchHelper.getSessionToken(); + return super.captureVideo(); + } + public providerArgs(): string[] { const cmd = []; - if ( - fs.existsSync( - path.join(BaseConfigDataFolder.config, "twitch_oauth.txt") - ) - ) { - const token = fs - .readFileSync( - path.join(BaseConfigDataFolder.config, "twitch_oauth.txt"), - "utf8" - ) - .trim(); - censoredLogWords.add(token.toString()); - cmd.push(`--twitch-api-header=Authorization=OAuth ${token}`); + if (this.sessionToken) { + cmd.push( + `--twitch-api-header=Authorization=OAuth ${this.sessionToken}` + ); } // enable low latency mode, probably not a good idea without testing diff --git a/server/src/Core/Providers/Twitch/TwitchVOD.ts b/server/src/Core/Providers/Twitch/TwitchVOD.ts index ab3565d8..d8ea6a82 100644 --- a/server/src/Core/Providers/Twitch/TwitchVOD.ts +++ b/server/src/Core/Providers/Twitch/TwitchVOD.ts @@ -54,7 +54,7 @@ import { FFmpegMetadata } from "../../FFmpegMetadata"; import { Helper } from "../../Helper"; import { Job } from "../../Job"; import { LiveStreamDVR } from "../../LiveStreamDVR"; -import { LOGLEVEL, censoredLogWords, log } from "../../Log"; +import { LOGLEVEL, log } from "../../Log"; import { Webhook } from "../../Webhook"; import { BaseVOD } from "../Base/BaseVOD"; import { TwitchChannel } from "./TwitchChannel"; @@ -426,31 +426,24 @@ export class TwitchVOD extends BaseVOD { */ if (Config.getInstance().cfg("twitch.voddownload.auth_enabled")) { if ( - fs.existsSync( + !fs.existsSync( path.join( BaseConfigDataFolder.config, "twitch_oauth.txt" ) ) ) { - const token = fs - .readFileSync( - path.join( - BaseConfigDataFolder.config, - "twitch_oauth.txt" - ), - "utf8" - ) - .trim(); - censoredLogWords.add(token.toString()); - cmd.push( - `--twitch-api-header=Authorization=OAuth ${token}` - ); - } else { throw new Error( "Twitch OAuth token not found but auth_enabled is true!" ); } + // undefined = expired/invalid, already logged + notified; download unauthenticated + const token = await TwitchHelper.getSessionToken(); + if (token) { + cmd.push( + `--twitch-api-header=Authorization=OAuth ${token}` + ); + } } // logging level diff --git a/server/src/Core/Scheduler.ts b/server/src/Core/Scheduler.ts index 8c0ad7bb..9499d4b1 100644 --- a/server/src/Core/Scheduler.ts +++ b/server/src/Core/Scheduler.ts @@ -111,6 +111,11 @@ export class Scheduler { void TwitchHelper.validateOAuth(); }); + // warn about an expired twitch_oauth.txt before the next stream starts (notifies on failure) + this.schedule("validate_session_token", "30 */6 * * *", () => { + void TwitchHelper.getSessionToken(); + }); + // refresh oauth token every 29 days // this.schedule("refresh_oauth", "0 0 */29 * *", () => { // // if (Config.getInstance().cfg("twitchapi.auth_type") == "app") return; diff --git a/server/src/Providers/Twitch.ts b/server/src/Providers/Twitch.ts index 9aa26166..5906f78e 100644 --- a/server/src/Providers/Twitch.ts +++ b/server/src/Providers/Twitch.ts @@ -1,4 +1,5 @@ import { BaseConfigCacheFolder, BaseConfigDataFolder } from "@/Core/BaseConfig"; +import { ClientBroker } from "@/Core/ClientBroker"; import { Config } from "@/Core/Config"; import { Helper } from "@/Core/Helper"; import { KeyValue } from "@/Core/KeyValue"; @@ -37,6 +38,7 @@ import type { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; import axios from "axios"; import chalk from "chalk"; import { format, parseJSON } from "date-fns"; +import { t } from "i18next"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -1505,6 +1507,58 @@ export class TwitchHelper { } } + /** + * Reads the streamlink session token from twitch_oauth.txt and validates it against Twitch. + * Separate from the app token (accessToken), don't mix them up. + * @returns the token if valid, undefined if missing or rejected by Twitch + */ + public static async getSessionToken(): Promise { + const file = path.join(BaseConfigDataFolder.config, "twitch_oauth.txt"); + if (!fs.existsSync(file)) return undefined; + + const token = fs.readFileSync(file, "utf8").trim(); + if (!token) return undefined; + censoredLogWords.add(token); + + try { + await axios.get( + "https://id.twitch.tv/oauth2/validate", + { headers: { Authorization: `OAuth ${token}` } } + ); + return token; + } catch (error) { + // ponytail: only a 401 means the token is bad; network hiccups keep using it + if ( + !axios.isAxiosError(error) || + error.response?.status !== 401 + ) { + log( + LOGLEVEL.WARNING, + "tw.helper.getSessionToken", + `Could not validate twitch_oauth.txt, using it anyway: ${ + (error as Error).message + }` + ); + return token; + } + } + + log( + LOGLEVEL.ERROR, + "tw.helper.getSessionToken", + "Twitch session token in twitch_oauth.txt is expired or invalid, capturing without it (no ad-skip / sub-only access). Replace it with a fresh auth-token cookie." + ); + ClientBroker.notify( + "Twitch token invalid", + t( + "notify.twitch-oauth-token-expired-or-invalid-check-config-twitch-oauth-txt" + ).toString(), + "", + "system" + ); + return undefined; + } + public static async checkTTVLolPlugin() { const bin = Helper.path_streamlink(); diff --git a/server/src/Translations/en.json b/server/src/Translations/en.json index 5d0a62f7..8db2f865 100644 --- a/server/src/Translations/en.json +++ b/server/src/Translations/en.json @@ -55,6 +55,7 @@ "this-broadcaster_user_login-has-gone-offline": "{{0}} has gone offline!", "was-streaming-for-formatdistancetonow-this-channel-latest_vod-started_at": "Was streaming for {{0}}.", "capturing-of-basename-failed-no-streams-available-nis-there-a-configuration-error": "Capturing of {{0}} failed, no streams available!\\nIs there a configuration error?", + "twitch-oauth-token-expired-or-invalid-check-config-twitch-oauth-txt": "Twitch session token in config/twitch_oauth.txt is expired or invalid.\\nCapturing without it until you replace it.", "live-non-capturing-channel-this-getlogin-changed-status": "Live non-capturing channel {{0}} changed status", "offline-channel-this-getlogin-changed-status": "Offline channel {{0}} changed status" },