Skip to content

feat: token-based auth (replace deprecated email/password login)#1

Open
guyathomas wants to merge 2 commits into
masterfrom
feat/token-auth-poc
Open

feat: token-based auth (replace deprecated email/password login)#1
guyathomas wants to merge 2 commits into
masterfrom
feat/token-auth-poc

Conversation

@guyathomas

@guyathomas guyathomas commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Why

Qobuz disabled email/password login server-side in April 2026. qobuz-dl no longer authenticates.

What

Replace email/password with token-based login. User pastes user_id + user_auth_token from a logged-in browser session (one-liner in the DevTools console) and the CLI persists them at ~/.config/qobuz-dl/config.ini (mode 0600 on POSIX).

  • Client.from_token(...) classmethod is the new auth entry point; Client(email, pwd, ...) raises TypeError.
  • Atomic config writer; getpass for the token prompt.
  • --show-config redacts secrets by default; -S/--show-secrets opt-in.
  • Token-leak hardening on every request path (from None chains, sanitized HTTPError).
  • Downloads currently fail on some accounts due to a partial Qobuz signing rollout (MD5 → SHA-256 + HKDF). Login + metadata/search work; download subcommands exit cleanly with code 3 + README pointer rather than tracebacking.

Testing

  • 47 pytest tests covering auth, redaction, atomic writes, token-leak guards, dispatch precedence, corrupt-config recovery.
  • Downloads verified end-to-end on a Studio account (FLAC 16/44.1) on 2026-04-26.

Qobuz disabled email/password authentication server-side in April 2026.
This commit migrates qobuz-dl to a token-based flow where the user
captures `user_id` and `user_auth_token` from a logged-in browser
session at play.qobuz.com and pastes them into the CLI.

Implementation
--------------
* qopy.Client: constructor no longer authenticates; new
  `Client.from_token(user_id, user_auth_token, app_id, secrets)`
  classmethod hits the `user/login` token endpoint and configures the
  session. Old `Client(email, pwd, ...)` arity now raises TypeError.
* qopy: token-leak hardening across the request surface — all errors
  raised from `_authenticate_with_token` and `api_call` strip
  `request.url` (which embeds the token in the query string) via
  `from None` chains and a sanitized HTTPError replacement for
  `raise_for_status()`. `(connect, read)` timeouts on every request.
* qopy: `cfg_setup()` failures (the new April-2026 SHA-256/HKDF
  signing rollout breaks the MD5-signed `track/getFileUrl` probe) are
  downgraded to a logged warning so login + metadata/search still work.
* qopy: `_extract_label` falls back through credential.parameters
  shapes to "Unknown" rather than KeyError on response drift.
* core: `initialize_client_with_token` replaces `initialize_client`.
* cli: `_reset_config` rewritten — prompts for user_id (input) and
  user_auth_token (getpass, cross-platform: termios POSIX / msvcrt
  Windows) so the token never hits terminal scrollback. Persists via
  the new atomic writer.
* cli: `main()` dispatch reordered — `--reset`, `--show-config`, and
  `--purge` short-circuit BEFORE the strict config-load so a corrupt
  config can still be inspected and recovered. Auth errors from
  `from_token` are caught and exit cleanly with the (token-free)
  message.
* cli: corrupt-config error message surfaces the exception TYPE only;
  configparser.ParsingError can echo the offending source line which
  may include `user_auth_token = ...`.
* cli: download subcommands (`dl`, `fun`, `lucky`) exit cleanly with
  code 3 + README pointer when `client.sec is None` (the broken-signing
  case), instead of tracebacking from InvalidAppSecretError mid-download.
* cli: `--show-config` redacts `user_auth_token`, `secrets`, and the
  legacy `password` key by default; `-S/--show-secrets` opt-in reveals
  them. Round-trips through configparser so multi-line continuation
  values are fully redacted; falls back to a regex pass for malformed
  input. `app_id` stays visible (public constant in bundle.js).
* _config_io: new module providing `atomic_write_config(path, parser)`
  — tempfile-in-same-dir → fsync(fd) → close → os.replace → chmod 0600
  on POSIX → fsync(parent dir). Best-effort cleanup on failure leaves
  the previous credentials store intact.
* cli: `quality_fallback` boolean wiring fixed — was `(not A) or
  (not B)` which made `--no-fallback` only effective when both config
  and CLI agreed.
* commands: `-S/--show-secrets` flag added; `-sc/--show-config` help
  text notes default redaction.
* __init__: package docstring documents the migration; `__all__` set.
* setup.py: version 0.9.10.0; `python_requires>=3.9` (3.8 EOL Oct
  2024); `extras_require={"dev": [...]}`; tests excluded from package.
* docs: README rewritten — token-capture flow uses a console
  one-liner (`JSON.parse(localStorage.getItem('localuser'))`) with an
  Application → Local Storage fallback. Status banner notes downloads
  verified end-to-end; documents the InvalidAppSecretError clean-exit
  behaviour for accounts hit by the partial signing rollout.
* .gitignore: cover *.pyc, *.egg-info, .venv, .pytest_cache, build,
  dist.
Adds pytest configuration, fixtures, and tests covering every behavior
introduced in the token-auth migration. Targets ~security-critical and
regression-prone surfaces identified by the review pipeline.

Configuration
-------------
* pytest.ini: testpaths=tests, strict markers.
* tests/conftest.py: bundle_snippet + tmp_config_dir fixtures.
* tests/fixtures/: live bundle.js excerpt (canary for app_id regex
  and `localuser` storage marker), and four `user/login` response
  shape variants (studio, credential-label-only, no-short-label,
  free-account).

Coverage
--------
* test_qopy.py: Client.from_token happy path, query-param shape,
  label extraction tolerance (parametrized), 401/400 error mapping,
  free-account rejection. Token-leak guards: 5xx response, transport
  ConnectionError, and api_call HTTPError on token-bearing endpoints
  all checked against str/repr/log/__context__. cfg_setup downgrade
  paths for both InvalidAppSecretError and RequestException.
* test_cli_main.py: dispatch precedence (token > legacy > none),
  corrupt-config exit hygiene (raw error not echoed; UnicodeDecodeError
  handled), stale-token clean exit, --show-config / --purge work even
  on corrupt config, parametrized quality_fallback wiring across
  config-only / CLI-only / both-false (regression for the boolean
  bug), download-without-secret guard.
* test_cli_reset.py: token keys persisted, no email/password written,
  getpass used for token (regression guard), atomic_write_config is
  the writer.
* test_config_io.py: writes content correctly, sets 0600 on POSIX
  (skipif Windows), preserves original on os.replace failure, cleans
  temp on parser.write or fsync failure, chmod-failure-after-replace
  contract pinned.
* test_show_config.py: redacts user_auth_token / secrets / legacy
  password by default; --show-secrets reveals; multi-line continuation
  values fully redacted; benign text containing the word "password"
  is not over-matched; app_id stays visible.
* test_bundle_regex.py: live-bundle canary for the app_id regex and
  the `localuser` localStorage marker.
@guyathomas guyathomas force-pushed the feat/token-auth-poc branch from 8b96574 to 00f8ce2 Compare April 27, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant