Skip to content

Headless login: replace manual redirect-paste with OAuth Device Authorization Grant (RFC 8628) #56

Description

@jdwit

Background

ytstudio login --headless today (src/ytstudio/api.py _authenticate_headless, ~L148-169) is a workaround, not a real headless flow:

  1. We build an auth URL with redirect_uri = http://127.0.0.1:9876/.
  2. 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).
  3. 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:

  1. POST https://oauth2.googleapis.com/device/code with client_id + scope → returns device_code, user_code, verification_url (google.com/device), interval, expires_in.
  2. 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.
  3. 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:

  1. 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.
  2. 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

  • Spike: confirm client type + scope grant for the device flow (above).
  • 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 (handle authorization_pending, slow_down, access_denied, expired_token; respect interval; overall timeout from expires_in).
  • Route authenticate(headless=True, ...) to _authenticate_device(); keep the old manual-paste as a hidden --paste escape hatch only if the spike shows scopes force it, else delete it (no dead fallback).
  • UX: on a no-display/SSH host, login (no flag) detects this ($DISPLAY/$SSH_CONNECTION/run_local_server bind failure) and suggests/auto-uses --headless.
  • Docs: update README/skill login section; drop the "page will fail to load" instructions.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions