Skip to content

Commit 6265149

Browse files
authored
Block recovery on invalid OAuth credentials (#16)
## Summary - classify refresh-token `invalid_grant`, HTTP 401, and unusable token files as explicit OAuth authorization conditions - keep OAuth client/config failures fatal even when returned with HTTP 401 - serialize token refresh across encoder, helper, authorization, and optional telemetry processes - refresh and replay an exact buffered YouTube API request once after HTTP 401 - preserve refresh-token rotation returned by the OAuth token endpoint - pause API recovery without ambiguous backoff until the private token file changes - preserve an already-public FFmpeg stream under its local watchdog and revalidate exact lifecycle state after reauthorization - limit child-specific signal handling to visible test-pattern runs so device authorization terminates normally - document the blocked lifecycle, shared local lock, recovery command, and automatic resume behavior ## Root Cause The API helper correctly reported a rejected refresh token as a permanent failure, but the supervisor accepted only retryable classes and converted `fatal` to `ambiguous`. That produced an endless retry cycle instead of an actionable authorization block. A cached access token that remained locally unexpired could also be rejected by YouTube with HTTP 401; that path bypassed refresh-token `invalid_grant` handling. The API CLI additionally installed test-pattern signal handlers for every subcommand, causing `authorize` to swallow termination signals when no child existed. ## Safety - no OAuth, stream, camera, host, or deployment values are included - OAuth-blocked waits perform no YouTube API request or lifecycle-state mutation - HTTP 401 receives at most one token refresh and one exact replay; 403, 5xx, timeouts, and other failures are never replayed - `invalid_client`, `invalid_scope`, and `unauthorized_client` remain fatal and never trigger token refresh or token-file wait - token refresh is single-flight through one mode-`0600` local-filesystem advisory lock - a rotated refresh token returned by Google is retained; an omitted or empty replacement preserves the existing token - a failed post-refresh replay waits on the post-refresh token fingerprint and cannot busy-loop on its own token write - existing mutation locks, exact-ID reconciliation, and write-ahead create intent remain unchanged - token fingerprints are bounded, local-only, and never logged or persisted - transient API and quota retry behavior is unchanged ## Rollback Revert PR #16 after merge and redeploy the two prior runtime scripts. No state migration or credential change is required. ## Validation - `.venv/bin/ruff check bin tests` - `.venv/bin/python -m py_compile bin/youtube-autoencoder bin/youtube-autoencoder-api` - `.venv/bin/pytest -q` -> 244 passed - `git diff --check` - real concurrent expired-token callers -> one refresh request, shared result - rotated refresh-token response -> replacement retained on disk - HTTP 400/401 client/config failures -> fatal with no token refresh - distinct-family adversarial plan review -> Antigravity APPROVE and Claude APPROVE ## Public-Safety Checklist - [x] No OAuth client files, refresh tokens, stream keys, camera credentials, or generated runtime state were committed. - [x] Documentation was updated for user-facing behavior changes. - [x] Local tests or relevant manual validation were run.
1 parent abf5d2d commit 6265149

9 files changed

Lines changed: 944 additions & 59 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
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 a mode-`0600` cross-process refresh lock and a single exact request replay when YouTube rejects a cached access token with HTTP 401.
9+
- Prevented a second post-refresh 401 from creating a supervisor busy loop by waiting on the post-refresh token fingerprint.
10+
- Added bounded local credential fingerprinting and signal-interruptible waiting that makes no API calls until token content changes, then resumes automatically.
11+
- Preserved already-public FFmpeg ingest under its local watchdog during an OAuth block and revalidated the exact cached lifecycle state after reauthorization.
12+
- Limited custom child-stopping signal handlers to visible test-pattern runs so device authorization terminates normally.
13+
- Added regression coverage and operator documentation for revoked, missing, malformed, unreadable, and oversized token states.
14+
515
## 2026-07-11 - Description-Neutral Lifecycle Identity
616

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

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Important files:
6161
- `youtube-autoencoder.env`: private service configuration.
6262
- `google-oauth-client.json`: Google OAuth client configuration.
6363
- `youtube-token.json`: OAuth access and refresh token cache.
64+
- `youtube-token.json.lock`: mode-`0600` advisory lock serializing access-token refreshes across the encoder, API helper, authorization flow, and optional telemetry.
6465
- `youtube-live-state.json`: versioned, non-secret lifecycle cache containing exact resource IDs, local create intent, privacy, lifecycle, and retry metadata.
6566
- `youtube-live-state.lock`: serialized mutation lock for create, bind, transition, privacy, and explicit completion operations.
6667
- `supervisor.lock`: process-lifetime lock preventing two encoder supervisors from running concurrently.
@@ -131,7 +132,8 @@ See the [recovery state machine](docs/architecture-and-flows.md#recovery-state-m
131132
| Insert response is lost or ambiguous | The durable `verify_create` intent polls under ambiguous backoff and never issues another insert automatically. |
132133
| 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. |
133134
| Ambiguous or unknown remote state | Reconciliation fails closed and creates nothing until the ambiguity is resolved. |
134-
| OAuth access token expires | API helper refreshes from the stored refresh token. |
135+
| OAuth access token expires locally or YouTube rejects the cached token with HTTP 401 | The API helper performs one serialized refresh and replays the exact buffered JSON request once. Concurrent helpers reuse the first successful refresh. |
136+
| 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. |
135137
| Previous broadcast is `complete`, `revoked`, or confirmed missing | One new unlisted generation may be staged after the source probe passes. |
136138

137139
Recovery deadlines survive service and host restarts. Exponential backoff uses these class floors and caps:
@@ -143,6 +145,10 @@ Recovery deadlines survive service and host restarts. Exponential backoff uses t
143145
| Quota | 15 minutes | 6 hours | `userRequestsExceedRateLimit`, quota errors, HTTP 429. |
144146
| Ambiguous | 5 minutes | 1 hour | Multiple managed candidates, unknown lifecycle, conflicting state. |
145147

148+
OAuth authorization failures are blocked conditions, not retry classes. They do not consume API quota while the token file is unchanged.
149+
150+
Token refresh serialization uses an advisory `fcntl` lock and therefore requires a local filesystem. Every process that can refresh or replace the token must use the same lock path; override `YTA_YOUTUBE_TOKEN_REFRESH_LOCK_FILE` only when the encoder, helper, authorization command, and telemetry share that exact local path.
151+
146152
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.
147153

148154
### Test Pattern Flow
@@ -399,6 +405,7 @@ systemctl --user enable --now youtube-autoencoder.service
399405
| `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`. |
400406
| `authorization_pending` | The browser approval has not completed yet. | Finish the device-code flow; the CLI will keep polling until the code expires. |
401407
| `slow_down` | Polling is too frequent. | The helper backs off automatically. |
408+
| `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. |
402409
| 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. |
403410
| 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. |
404411

@@ -521,10 +528,12 @@ Each eligible collection performs one [`videos.list`](https://developers.google.
521528

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

524-
### 2026-07-11 - Description-Neutral Lifecycle Identity
531+
### 2026-07-14 - OAuth-Blocked Recovery
525532

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.
533+
- Refresh-token rejection and unusable token files now enter an explicit OAuth-blocked state instead of ambiguous retry backoff.
534+
- A cached token rejected with HTTP 401 now receives one cross-process serialized refresh and one exact request replay; a second rejection blocks on the post-refresh credential state without looping.
535+
- 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.
536+
- Device authorization keeps normal termination semantics; child-specific signal handling is limited to the visible test-pattern command.
528537

529538
## Repository Layout
530539

bin/youtube-autoencoder

Lines changed: 108 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
@@ -83,6 +89,7 @@ class ApiCommandError(RuntimeError):
8389
self.http_status = self.payload.get("http_status")
8490
self.reasons = tuple(str(reason) for reason in self.payload.get("reasons") or [])
8591
self.retry_class = str(self.payload.get("retry_class") or "fatal")
92+
self.token_updated = self.payload.get("token_updated") is True
8693
self.public_fallback_allowed = self.payload.get("public_fallback_allowed") is not False
8794
retry_after = self.payload.get("retry_after")
8895
try:
@@ -199,9 +206,65 @@ def lifecycle_action(lifecycle: str) -> str:
199206

200207

201208
def classify_api_error(error: ApiCommandError) -> str:
209+
if error.retry_class == "oauth":
210+
return "oauth"
202211
return error.retry_class if error.retry_class in RETRY_LIMITS else "ambiguous"
203212

204213

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

907970

908971
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)
972+
while True:
973+
token_fingerprint = oauth_token_fingerprint()
919974
try:
920-
api_command_while_streaming(
921-
["set-retry", retry_class, str(attempt), not_before],
922-
runtime,
923-
timeout=30,
975+
return manage_youtube_lifecycle(runtime, prepared_state=cached_state)
976+
except ApiCommandError as error:
977+
if not verified_public_state(cached_state) or not error.public_fallback_allowed:
978+
raise
979+
ensure_encoder_running(runtime)
980+
retry_class = classify_api_error(error)
981+
if retry_class == "oauth":
982+
log(
983+
"YouTube OAuth authorization required; preserving verified public stream and waiting for "
984+
"credential change. Run: youtube-autoencoder-api authorize. "
985+
f"error={redact_text(str(error))}"
986+
)
987+
wait_baseline = oauth_wait_baseline(error, token_fingerprint)
988+
if not wait_for_oauth_token_change(wait_baseline, runtime=runtime):
989+
return dict(cached_state)
990+
continue
991+
attempt = retry_attempt(cached_state, retry_class)
992+
delay = retry_delay(retry_class, attempt=attempt, retry_after=error.retry_after)
993+
not_before = retry_not_before(delay)
994+
try:
995+
api_command_while_streaming(
996+
["set-retry", retry_class, str(attempt), not_before],
997+
runtime,
998+
timeout=30,
999+
)
1000+
except EncoderStopped:
1001+
raise
1002+
except Exception as persist_error: # noqa: BLE001
1003+
log(f"could not persist public-stream API cooldown: {redact_text(str(persist_error))}")
1004+
ensure_encoder_running(runtime)
1005+
log(
1006+
"YouTube API unavailable; preserving verified public stream without mutation "
1007+
f"class={retry_class} attempt={attempt} retry_not_before={not_before}"
9241008
)
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)
1009+
return dict(cached_state)
9351010

9361011

9371012
@contextlib.contextmanager
@@ -1082,13 +1157,22 @@ def supervisor_loop() -> int:
10821157
sleep_interruptibly(cooldown)
10831158
continue
10841159
try:
1160+
token_fingerprint = oauth_token_fingerprint()
10851161
rc = run_once(cached_state=state)
10861162
if stopping:
10871163
break
10881164
raise EncoderStopped(f"ffmpeg stream attempt exited rc={rc}")
10891165
except Exception as exc: # noqa: BLE001
10901166
if stopping:
10911167
break
1168+
if isinstance(exc, ApiCommandError) and classify_api_error(exc) == "oauth":
1169+
log(
1170+
"YouTube OAuth authorization required; pausing API recovery until the credential changes. "
1171+
"Run: youtube-autoencoder-api authorize. "
1172+
f"error={redact_text(str(exc))}"
1173+
)
1174+
wait_for_oauth_token_change(oauth_wait_baseline(exc, token_fingerprint))
1175+
continue
10921176
retry_class = failure_retry_class(exc)
10931177
try:
10941178
state = read_recovery_state()

0 commit comments

Comments
 (0)