Background
ytstudio login --headless today (src/ytstudio/api.py _authenticate_headless, ~L148-169) is a workaround, not a real headless flow:
- We build an auth URL with
redirect_uri = http://127.0.0.1:9876/.
- The user opens it on some machine, approves, and the browser then tries to load
127.0.0.1:9876 — which fails (nothing is listening there on the remote machine).
- The user must copy the failed redirect URL out of the address bar and paste the whole thing back into the CLI, from which we scrape
?code=...&state=....
This is fragile and confusing: it relies on the user recognizing and copying an error page's URL, only works if they happen to have a browser somewhere, and the "the page will fail to load, that's expected" instruction reads like a bug. It exists because Google removed the OOB (urn:ietf:wg:oauth:2.0:oob) copy/paste flow in 2022, so we faked it with a dead loopback.
Goal
Replace the manual-paste headless path with a proper browserless flow: the user gets a short code + URL, approves on any device, and the CLI finishes on its own. No copy-pasting URLs, no local browser required on the CLI host.
Proposed approach: OAuth 2.0 Device Authorization Grant (RFC 8628)
Google's "TV and Limited Input devices" flow:
POST https://oauth2.googleapis.com/device/code with client_id + scope → returns device_code, user_code, verification_url (google.com/device), interval, expires_in.
- CLI prints: "Go to google.com/device and enter code ABCD-EFGH", then polls
POST https://oauth2.googleapis.com/token with
grant_type=urn:ietf:params:oauth:grant-type:device_code + device_code every interval s.
- On approval the token endpoint returns
access_token + refresh_token; build google.oauth2.credentials.Credentials(...) from them and hand off to the existing _save_credentials() / _show_login_success().
google-auth-oauthlib has no device-flow helper (confirmed against the current google-auth docs), so this is implemented directly with requests against the two endpoints. Everything downstream (credential storage, refresh in get_credentials, profiles) stays unchanged — we only swap how the initial Credentials object is minted.
Validation spike first (before building)
Two things must be confirmed, because they can block the whole approach:
- Client type. The device endpoint requires an OAuth client of type "TVs and Limited Input devices". Our current client is a Desktop client. Confirm whether we add a second (device) client to
client_secrets.json handling, or whether the Desktop client is accepted. This likely means ytstudio init must accept/store a device client id+secret.
- Scope support. Verify the device flow grants our full scope set, especially
yt-analytics-monetary.readonly and youtube.force-ssl. Limited-input flows historically restrict some sensitive scopes. If a scope is rejected, decide: drop it for headless, or keep manual-paste as the fallback for full-scope headless.
Do this as a throwaway script against Jelmer's own project before writing production code.
Implementation plan
Tests
- Unit-test the poll loop with a mocked token endpoint: pending → slow_down → success, plus
access_denied and expired_token → clean SystemExit with a clear message.
- Reuse existing
mock_auth patterns in tests/conftest.py; assert credentials are saved via the same path as the browser flow.
Notes / related
- Same OAuth project caveat we just hit elsewhere: while the consent screen is External + Testing, refresh tokens expire after 7 days regardless of flow. Publishing to Production removes that. Worth a one-line note in the login docs so headless users don't think the device flow is broken when the token dies after a week.
Background
ytstudio login --headlesstoday (src/ytstudio/api.py_authenticate_headless, ~L148-169) is a workaround, not a real headless flow:redirect_uri = http://127.0.0.1:9876/.127.0.0.1:9876— which fails (nothing is listening there on the remote machine).?code=...&state=....This is fragile and confusing: it relies on the user recognizing and copying an error page's URL, only works if they happen to have a browser somewhere, and the "the page will fail to load, that's expected" instruction reads like a bug. It exists because Google removed the OOB (
urn:ietf:wg:oauth:2.0:oob) copy/paste flow in 2022, so we faked it with a dead loopback.Goal
Replace the manual-paste headless path with a proper browserless flow: the user gets a short code + URL, approves on any device, and the CLI finishes on its own. No copy-pasting URLs, no local browser required on the CLI host.
Proposed approach: OAuth 2.0 Device Authorization Grant (RFC 8628)
Google's "TV and Limited Input devices" flow:
POST https://oauth2.googleapis.com/device/codewithclient_id+scope→ returnsdevice_code,user_code,verification_url(google.com/device),interval,expires_in.POST https://oauth2.googleapis.com/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code+device_codeeveryintervals.access_token+refresh_token; buildgoogle.oauth2.credentials.Credentials(...)from them and hand off to the existing_save_credentials()/_show_login_success().google-auth-oauthlibhas no device-flow helper (confirmed against the current google-auth docs), so this is implemented directly withrequestsagainst the two endpoints. Everything downstream (credential storage, refresh inget_credentials, profiles) stays unchanged — we only swap how the initialCredentialsobject is minted.Validation spike first (before building)
Two things must be confirmed, because they can block the whole approach:
client_secrets.jsonhandling, or whether the Desktop client is accepted. This likely meansytstudio initmust accept/store a device client id+secret.yt-analytics-monetary.readonlyandyoutube.force-ssl. Limited-input flows historically restrict some sensitive scopes. If a scope is rejected, decide: drop it for headless, or keep manual-paste as the fallback for full-scope headless.Do this as a throwaway script against Jelmer's own project before writing production code.
Implementation plan
init: support storing a device/limited-input client if the spike shows we need a separate client.api.py: add_authenticate_device()implementing the RFC 8628 request/poll loop (handleauthorization_pending,slow_down,access_denied,expired_token; respectinterval; overall timeout fromexpires_in).authenticate(headless=True, ...)to_authenticate_device(); keep the old manual-paste as a hidden--pasteescape hatch only if the spike shows scopes force it, else delete it (no dead fallback).login(no flag) detects this ($DISPLAY/$SSH_CONNECTION/run_local_serverbind failure) and suggests/auto-uses--headless.Tests
access_deniedandexpired_token→ cleanSystemExitwith a clear message.mock_authpatterns intests/conftest.py; assert credentials are saved via the same path as the browser flow.Notes / related