Skip to content

Commit ffe5f65

Browse files
committed
fix: block on invalid OAuth credentials
1 parent abf5d2d commit ffe5f65

9 files changed

Lines changed: 406 additions & 34 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable project changes are recorded here. The README shows only the most recent entry.
44

5+
## 2026-07-14 - OAuth-Blocked Recovery
6+
7+
- Classified refresh-token `invalid_grant` responses and unusable token files as explicit OAuth authorization blocks instead of ambiguous retry failures.
8+
- Added bounded local credential fingerprinting and signal-interruptible waiting that makes no API calls until token content changes, then resumes automatically.
9+
- Preserved already-public FFmpeg ingest under its local watchdog during an OAuth block and revalidated the exact cached lifecycle state after reauthorization.
10+
- Limited custom child-stopping signal handlers to visible test-pattern runs so device authorization terminates normally.
11+
- Added regression coverage and operator documentation for revoked, missing, malformed, unreadable, and oversized token states.
12+
513
## 2026-07-11 - Description-Neutral Lifecycle Identity
614

715
- Stopped setting `liveBroadcast` and `liveStream` descriptions; new insert payloads omit the description field entirely and privacy updates remain status-only.

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ See the [recovery state machine](docs/architecture-and-flows.md#recovery-state-m
132132
| Lifecycle state is missing or corrupt | Legacy markers may be read once for migration; otherwise conflicting same-stream or same-title resources block creation until an operator reconciles ownership. |
133133
| Ambiguous or unknown remote state | Reconciliation fails closed and creates nothing until the ambiguity is resolved. |
134134
| OAuth access token expires | API helper refreshes from the stored refresh token. |
135+
| OAuth refresh token is missing, invalid, expired, revoked, unreadable, or malformed | API mutation pauses without entering ambiguous backoff. The service waits for the token file to change and resumes automatically after `youtube-autoencoder-api authorize` saves a replacement. If a verified public stream is already running, FFmpeg remains under its local progress watchdog while the control plane is blocked. |
135136
| Previous broadcast is `complete`, `revoked`, or confirmed missing | One new unlisted generation may be staged after the source probe passes. |
136137

137138
Recovery deadlines survive service and host restarts. Exponential backoff uses these class floors and caps:
@@ -143,6 +144,8 @@ Recovery deadlines survive service and host restarts. Exponential backoff uses t
143144
| Quota | 15 minutes | 6 hours | `userRequestsExceedRateLimit`, quota errors, HTTP 429. |
144145
| Ambiguous | 5 minutes | 1 hour | Multiple managed candidates, unknown lifecycle, conflicting state. |
145146

147+
OAuth authorization failures are blocked conditions, not retry classes. They do not consume API quota while the token file is unchanged.
148+
146149
The project never sets or updates YouTube `liveBroadcast` or `liveStream` descriptions. Normal recovery validates the exact broadcast ID in the private schema-v3 state file. Before a new insert, the helper persists the required title, scheduled start, privacy, creation window, and stream relationship; if the insert outcome is ambiguous, those fields may identify exactly one remote candidate, but they can never authorize another automatic insert. Legacy description markers are read only during one-way migration from schema v2.
147150

148151
### Test Pattern Flow
@@ -399,6 +402,7 @@ systemctl --user enable --now youtube-autoencoder.service
399402
| `invalid_client` | OAuth client type does not support the device-code flow. | Create a client for TVs and Limited Input devices, then replace `google-oauth-client.json`. |
400403
| `authorization_pending` | The browser approval has not completed yet. | Finish the device-code flow; the CLI will keep polling until the code expires. |
401404
| `slow_down` | Polling is too frequent. | The helper backs off automatically. |
405+
| `invalid_grant` | The refresh token expired, was revoked, or no longer belongs to the OAuth client. Testing-mode authorizations commonly expire after seven days. | Move the OAuth app to In production for unattended use, rerun `youtube-autoencoder-api authorize`, and select the account that owns or manages the intended channel. The running service detects the saved token and resumes automatically. |
402406
| Token works briefly then expires | App is still in Testing mode. | Add the correct test user for setup, then move the app to In production for unattended use and complete required verification. |
403407
| API calls fail despite valid OAuth | The account does not own/manage the YouTube channel, live streaming is not enabled, or quota/policy blocks the operation. | Reauthorize with the right channel account, enable live streaming, and check project quota and YouTube Studio restrictions. |
404408

@@ -521,10 +525,11 @@ Each eligible collection performs one [`videos.list`](https://developers.google.
521525

522526
Only the most recent changelog entry is shown here. See `CHANGELOG.md` for full history.
523527

524-
### 2026-07-11 - Description-Neutral Lifecycle Identity
528+
### 2026-07-14 - OAuth-Blocked Recovery
525529

526-
- New YouTube stream and broadcast inserts omit the description field, and normal lifecycle recovery never updates descriptions.
527-
- Schema-v3 exact-ID state and a durable create fingerprint retain duplicate-safe recovery; legacy markers are read only for one-way migration.
530+
- Refresh-token rejection and unusable token files now enter an explicit OAuth-blocked state instead of ambiguous retry backoff.
531+
- The supervisor waits without API calls until token content changes, preserves an already-public FFmpeg stream under its watchdog, and revalidates exact lifecycle state after reauthorization.
532+
- Device authorization keeps normal termination semantics; child-specific signal handling is limited to the visible test-pattern command.
528533

529534
## Repository Layout
530535

bin/youtube-autoencoder

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import contextlib
88
import dataclasses
99
import datetime as dt
1010
import fcntl
11+
import hashlib
1112
import json
1213
import os
1314
import pathlib
@@ -33,6 +34,11 @@ OBS_SERVICE_FILE = pathlib.Path(os.environ.get("YTA_OBS_SERVICE_FILE", OBS_PROFI
3334
OBS_SCENE_FILE = pathlib.Path(
3435
os.environ.get("YTA_OBS_SCENE_FILE", HOME / ".config/obs-studio/basic/scenes/Untitled.json")
3536
).expanduser()
37+
CONFIG_DIR = pathlib.Path(os.environ.get("YTA_CONFIG_DIR", HOME / ".config/youtube-autoencoder")).expanduser()
38+
OAUTH_TOKEN_FILE = pathlib.Path(
39+
os.environ.get("YTA_YOUTUBE_TOKEN_FILE", CONFIG_DIR / "youtube-token.json")
40+
).expanduser()
41+
OAUTH_TOKEN_MAX_BYTES = 1024 * 1024
3642

3743
child: subprocess.Popen[Any] | None = None
3844
stopping = False
@@ -199,9 +205,61 @@ def lifecycle_action(lifecycle: str) -> str:
199205

200206

201207
def classify_api_error(error: ApiCommandError) -> str:
208+
if error.retry_class == "oauth":
209+
return "oauth"
202210
return error.retry_class if error.retry_class in RETRY_LIMITS else "ambiguous"
203211

204212

213+
def oauth_token_fingerprint() -> tuple[Any, ...]:
214+
try:
215+
token_stat = OAUTH_TOKEN_FILE.stat()
216+
except FileNotFoundError:
217+
return ("missing",)
218+
except OSError as exc:
219+
return ("unreadable", exc.errno)
220+
221+
metadata = (
222+
token_stat.st_dev,
223+
token_stat.st_ino,
224+
token_stat.st_mode & 0o7777,
225+
token_stat.st_uid,
226+
token_stat.st_gid,
227+
token_stat.st_size,
228+
token_stat.st_mtime_ns,
229+
)
230+
if token_stat.st_size > OAUTH_TOKEN_MAX_BYTES:
231+
return ("oversized", *metadata)
232+
try:
233+
with OAUTH_TOKEN_FILE.open("rb") as handle:
234+
raw = handle.read(OAUTH_TOKEN_MAX_BYTES + 1)
235+
except OSError as exc:
236+
return ("unreadable", *metadata, exc.errno)
237+
if len(raw) > OAUTH_TOKEN_MAX_BYTES:
238+
return ("oversized", *metadata)
239+
return ("readable", *metadata, hashlib.sha256(raw).hexdigest())
240+
241+
242+
def oauth_blocked_poll_interval() -> float:
243+
return max(0.1, float(env("YTA_OAUTH_BLOCKED_POLL_SEC", "5")))
244+
245+
246+
def wait_for_oauth_token_change(
247+
baseline: tuple[Any, ...],
248+
*,
249+
runtime: StreamRuntime | None = None,
250+
) -> bool:
251+
while not stopping:
252+
if oauth_token_fingerprint() != baseline:
253+
log("OAuth credential change detected; resuming YouTube API recovery")
254+
return True
255+
duration = oauth_blocked_poll_interval()
256+
if runtime is None:
257+
sleep_interruptibly(duration)
258+
else:
259+
wait_with_runtime(runtime, duration)
260+
return False
261+
262+
205263
def retry_delay(
206264
retry_class: str,
207265
*,
@@ -906,32 +964,43 @@ def revalidate_public_state(runtime: StreamRuntime, state: dict[str, Any]) -> di
906964

907965

908966
def manage_with_public_fallback(runtime: StreamRuntime, cached_state: dict[str, Any]) -> dict[str, Any]:
909-
try:
910-
return manage_youtube_lifecycle(runtime, prepared_state=cached_state)
911-
except ApiCommandError as error:
912-
if not verified_public_state(cached_state) or not error.public_fallback_allowed:
913-
raise
914-
ensure_encoder_running(runtime)
915-
retry_class = classify_api_error(error)
916-
attempt = retry_attempt(cached_state, retry_class)
917-
delay = retry_delay(retry_class, attempt=attempt, retry_after=error.retry_after)
918-
not_before = retry_not_before(delay)
967+
while True:
968+
token_fingerprint = oauth_token_fingerprint()
919969
try:
920-
api_command_while_streaming(
921-
["set-retry", retry_class, str(attempt), not_before],
922-
runtime,
923-
timeout=30,
970+
return manage_youtube_lifecycle(runtime, prepared_state=cached_state)
971+
except ApiCommandError as error:
972+
if not verified_public_state(cached_state) or not error.public_fallback_allowed:
973+
raise
974+
ensure_encoder_running(runtime)
975+
retry_class = classify_api_error(error)
976+
if retry_class == "oauth":
977+
log(
978+
"YouTube OAuth authorization required; preserving verified public stream and waiting for "
979+
"credential change. Run: youtube-autoencoder-api authorize. "
980+
f"error={redact_text(str(error))}"
981+
)
982+
if not wait_for_oauth_token_change(token_fingerprint, runtime=runtime):
983+
return dict(cached_state)
984+
continue
985+
attempt = retry_attempt(cached_state, retry_class)
986+
delay = retry_delay(retry_class, attempt=attempt, retry_after=error.retry_after)
987+
not_before = retry_not_before(delay)
988+
try:
989+
api_command_while_streaming(
990+
["set-retry", retry_class, str(attempt), not_before],
991+
runtime,
992+
timeout=30,
993+
)
994+
except EncoderStopped:
995+
raise
996+
except Exception as persist_error: # noqa: BLE001
997+
log(f"could not persist public-stream API cooldown: {redact_text(str(persist_error))}")
998+
ensure_encoder_running(runtime)
999+
log(
1000+
"YouTube API unavailable; preserving verified public stream without mutation "
1001+
f"class={retry_class} attempt={attempt} retry_not_before={not_before}"
9241002
)
925-
except EncoderStopped:
926-
raise
927-
except Exception as persist_error: # noqa: BLE001
928-
log(f"could not persist public-stream API cooldown: {redact_text(str(persist_error))}")
929-
ensure_encoder_running(runtime)
930-
log(
931-
"YouTube API unavailable; preserving verified public stream without mutation "
932-
f"class={retry_class} attempt={attempt} retry_not_before={not_before}"
933-
)
934-
return dict(cached_state)
1003+
return dict(cached_state)
9351004

9361005

9371006
@contextlib.contextmanager
@@ -1082,13 +1151,22 @@ def supervisor_loop() -> int:
10821151
sleep_interruptibly(cooldown)
10831152
continue
10841153
try:
1154+
token_fingerprint = oauth_token_fingerprint()
10851155
rc = run_once(cached_state=state)
10861156
if stopping:
10871157
break
10881158
raise EncoderStopped(f"ffmpeg stream attempt exited rc={rc}")
10891159
except Exception as exc: # noqa: BLE001
10901160
if stopping:
10911161
break
1162+
if isinstance(exc, ApiCommandError) and classify_api_error(exc) == "oauth":
1163+
log(
1164+
"YouTube OAuth authorization required; pausing API recovery until the credential changes. "
1165+
"Run: youtube-autoencoder-api authorize. "
1166+
f"error={redact_text(str(exc))}"
1167+
)
1168+
wait_for_oauth_token_change(token_fingerprint)
1169+
continue
10921170
retry_class = failure_retry_class(exc)
10931171
try:
10941172
state = read_recovery_state()

bin/youtube-autoencoder-api

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ RATE_LIMIT_REASONS = {
5555
"userRateLimitExceeded",
5656
"userRequestsExceedRateLimit",
5757
}
58+
OAUTH_REQUIRED_REASONS = {"invalid_grant"}
59+
TOKEN_FILE_MAX_BYTES = 1024 * 1024
5860
STATE_SCHEMA_VERSION = 3
5961
LEGACY_STATE_SCHEMA_VERSION = 2
6062
RECOVERABLE_STATES = {"created", "ready", "testStarting", "testing", "liveStarting", "live"}
@@ -81,13 +83,19 @@ class YouTubeApiError(RuntimeError):
8183

8284
@property
8385
def retry_class(self) -> str:
86+
if any(reason in OAUTH_REQUIRED_REASONS for reason in self.reasons):
87+
return "oauth"
8488
if self.status == 429 or any(reason in RATE_LIMIT_REASONS for reason in self.reasons):
8589
return "quota"
8690
if self.status is None or self.status >= 500:
8791
return "api"
8892
return "fatal"
8993

9094

95+
class OAuthAuthorizationRequired(RuntimeError):
96+
retry_class = "oauth"
97+
98+
9199
class ReconciliationError(RuntimeError):
92100
retry_class = "ambiguous"
93101

@@ -335,17 +343,36 @@ def authorize(_args: argparse.Namespace) -> int:
335343
raise TimeoutError("OAuth device code expired before authorization completed")
336344

337345

346+
def read_token_data() -> dict[str, Any]:
347+
try:
348+
with TOKEN_FILE.open("rb") as handle:
349+
raw = handle.read(TOKEN_FILE_MAX_BYTES + 1)
350+
except FileNotFoundError as exc:
351+
raise OAuthAuthorizationRequired("missing token file; run: youtube-autoencoder-api authorize") from exc
352+
except OSError as exc:
353+
raise OAuthAuthorizationRequired(
354+
f"could not read token file {TOKEN_FILE}; rerun authorization or fix its permissions"
355+
) from exc
356+
if len(raw) > TOKEN_FILE_MAX_BYTES:
357+
raise OAuthAuthorizationRequired("token file is too large; rerun authorization")
358+
try:
359+
data = json.loads(raw)
360+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
361+
raise OAuthAuthorizationRequired("invalid token file; rerun authorization") from exc
362+
if not isinstance(data, dict):
363+
raise OAuthAuthorizationRequired("invalid token file; rerun authorization")
364+
return data
365+
366+
338367
def token() -> str:
339-
if not TOKEN_FILE.exists():
340-
raise FileNotFoundError("missing token file; run: youtube-autoencoder-api authorize")
341-
data = read_json(TOKEN_FILE)
368+
data = read_token_data()
342369
expires_at = int(data.get("created_at") or 0) + int(data.get("expires_in") or 0) - 120
343370
if data.get("access_token") and time.time() < expires_at:
344371
return str(data["access_token"])
345372

346373
refresh_token = str(data.get("refresh_token") or "")
347374
if not refresh_token:
348-
raise ValueError("token file has no refresh_token; rerun authorization")
375+
raise OAuthAuthorizationRequired("token file has no refresh_token; rerun authorization")
349376
cfg = client_config()
350377
refreshed = http_json(
351378
"POST",
@@ -1364,8 +1391,6 @@ def transition_command(args: argparse.Namespace) -> int:
13641391

13651392

13661393
def main() -> int:
1367-
signal.signal(signal.SIGTERM, stop_child)
1368-
signal.signal(signal.SIGINT, stop_child)
13691394
parser = argparse.ArgumentParser(description="Control YouTube Live for YouTube AutoEncoder")
13701395
sub = parser.add_subparsers(dest="command", required=True)
13711396

@@ -1476,6 +1501,9 @@ def main() -> int:
14761501
clear_retry_p.set_defaults(func=clear_retry_command)
14771502

14781503
args = parser.parse_args()
1504+
if args.command == "run-visible-test":
1505+
signal.signal(signal.SIGTERM, stop_child)
1506+
signal.signal(signal.SIGINT, stop_child)
14791507
return int(args.func(args))
14801508

14811509

config/youtube-autoencoder.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ YTA_YOUTUBE_STAGING_PRIVACY=unlisted
3636
YTA_YOUTUBE_LIVE_PRIVACY=public
3737
YTA_YOUTUBE_TITLE_PREFIX=AutoEncoder Live
3838
YTA_YOUTUBE_POLL_INTERVAL_SEC=5
39+
# Polls only the local token file while OAuth authorization is blocked; no API request is made.
40+
YTA_OAUTH_BLOCKED_POLL_SEC=5
3941
YTA_YOUTUBE_TRANSITION_TIMEOUT_SEC=180
4042
YTA_YOUTUBE_LOCK_TIMEOUT_SEC=10
4143
YTA_YOUTUBE_COMPLETE_ON_EXIT=false

docs/architecture-and-flows.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ stateDiagram-v2
8181
state "Testing, live, and publication gates" as RecoveryGates
8282
state "Verified public stream" as RecoveryStable
8383
state "Public stream with API cooldown" as RecoveryPublicFallback
84+
state "Public stream waiting for OAuth replacement" as RecoveryPublicOAuthBlocked
8485
8586
[*] --> RecoveryProbe
8687
RecoveryProbe --> RecoveryReconcile : source available
@@ -92,18 +93,23 @@ stateDiagram-v2
9293
RecoveryGates --> RecoveryStable : two healthy live observations and privacy readback
9394
RecoveryStable --> RecoveryPublicFallback : API unavailable, media healthy
9495
RecoveryPublicFallback --> RecoveryStable : API recovers
96+
RecoveryStable --> RecoveryPublicOAuthBlocked : OAuth rejected, media healthy
97+
RecoveryPublicOAuthBlocked --> RecoveryStable : token changes and exact IDs revalidate
9598
}
9699
state "Persist classified cooldown" as RecoveryBackoff
100+
state "Wait for OAuth token replacement" as RecoveryOAuthBlocked
97101
98102
[*] --> RecoveryStartup
99103
RecoveryStartup --> RecoveryBackoff : retry deadline active
100104
RecoveryBackoff --> RecoveryStartup : deadline expires or host restarts
101105
RecoveryStartup --> RecoveryGeneration : no active deadline
102106
RecoveryGeneration --> RecoveryBackoff : recoverable failure, preserve ownership
107+
RecoveryGeneration --> RecoveryOAuthBlocked : OAuth rejected before verified public ingest
108+
RecoveryOAuthBlocked --> RecoveryStartup : token content changes
103109
RecoveryCreate --> RecoveryBackoff : insert outcome uncertain, never reinsert
104110
```
105111

106-
The helper never sets or updates YouTube descriptions. Schema-v3 state stores the exact broadcast ID and a write-ahead create fingerprint. A lost insert response enters `verify_create`; recovery may adopt exactly one normalized remote match, but zero or multiple matches stay blocked under durable ambiguous backoff. Legacy description markers are read only for one-way schema-v2 migration.
112+
The helper never sets or updates YouTube descriptions. Schema-v3 state stores the exact broadcast ID and a write-ahead create fingerprint. A lost insert response enters `verify_create`; recovery may adopt exactly one normalized remote match, but zero or multiple matches stay blocked under durable ambiguous backoff. OAuth rejection does not enter that backoff: the service makes no further API calls until the private token file changes. Legacy description markers are read only for one-way schema-v2 migration.
107113

108114
Return to [Recovery Behavior](../README.md#recovery-behavior).
109115

docs/raspberry-pi.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ YTA_INSTANCE_ID=encoder-hostname
4646
YTA_YOUTUBE_STAGING_PRIVACY=unlisted
4747
YTA_YOUTUBE_LIVE_PRIVACY=public
4848
YTA_YOUTUBE_POLL_INTERVAL_SEC=5
49+
YTA_OAUTH_BLOCKED_POLL_SEC=5
4950
YTA_FFMPEG_PROGRESS_TIMEOUT_SEC=45
5051
YTA_YOUTUBE_COMPLETE_ON_EXIT=false
5152
```
@@ -66,6 +67,8 @@ sudo -u encoder -H youtube-autoencoder-api authorize
6667

6768
Approve the device code in a browser for the Google account that owns the YouTube channel.
6869

70+
If a refresh token later expires or is revoked, the service enters an OAuth-blocked state without creating or mutating broadcasts. Run the same authorization command again. The service detects the atomically saved replacement token and resumes automatically; no service restart is required.
71+
6972
For a fresh deployment without an existing reusable stream, provision and validate it before enabling the production service:
7073

7174
```bash

0 commit comments

Comments
 (0)