Token-only auth by default, with opt-in interactive_login() - #352
Conversation
Follow-up: interactive signup flowFeedback on the signup steps (role list, redundant steps, email opt-in wording) is tracked separately in OPC-26 — Rework interactive signup flow in tabpfn-client. It is deliberately out of scope for this PR, which is about where authentication happens, not what the signup form asks. OPC-26 carries three open questions that need answers before anyone implements — notably whether a CLI-native signup should exist at all now that registration happens in the browser. |
77cfa63 to
7c477a0
Compare
The CLI's interactive login prompted for an email and password and posted them to /auth/login/ as an OAuth2 password grant. Since OIDC was introduced the credential lives at the identity provider, so that endpoint can only return 401 for any OIDC-provisioned user. Browser login was tried first but every failure fell through to that dead end, and its callback wait had no timeout, so a dropped callback hung the client until Ctrl+C. Authentication is now token-only. init() resolves a token from, in order: a token set via set_access_token(), the TABPFN_TOKEN environment variable, or a token cached by an earlier run. TABPFN_TOKEN is read from os.environ at resolution time rather than snapshotted at import, so setting it after `import tabpfn_client` takes effect. With no token, an interactive session prompts for a paste and a non-interactive one raises with instructions pointing at https://ux.priorlabs.ai/account/api-keys. A token that was supplied but rejected reports that, rather than "not found". Browser-based login and registration remain available, but only when asked for by name: from tabpfn_client import interactive_login interactive_login() It is never reached from init(), fit(), or predict(). The flow is adapted from the browser-auth implementation in the TabPFN package: the localhost callback server runs in a daemon thread while the main thread polls stdin, so a callback dropped by an identity provider no longer traps the user -- they can paste an API key instead. Headless sessions get the URL plus an OSC 52 clipboard copy, and the wait is bounded by a timeout. Removes browser_auth.py and the password, registration, and email verification code paths, along with the server endpoints they called and the password-strength dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
init() still fell back to an interactive paste prompt when no token was found and stdin was a TTY. That is the wrong shape for a library: authentication must not block on input. Auth is now either fully explicit -- a token the caller supplies -- or fully interactive via interactive_login(), which the caller has to invoke by name. init() resolves a token from set_access_token(), then TABPFN_TOKEN, then the cache, and raises with instructions if none applies. It never reads stdin. Splits the token setter, which conflated "use this token" with "remember this token": set_token() -> in-process only persist_token() -> writes the cache, called only by interactive_login() This fixes an incidental credential leak: every init() with a TABPFN_TOKEN in the environment used to copy that token into site-packages/tabpfn_client/.tabpfn/config. A token supplied by the caller now stays in memory, and the cache holds only the result of an explicit login. Verified in a real pty that init() raises immediately without waiting on input, and that an env-supplied token leaves no file on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7c477a0 to
ba67321
Compare
The previous commits removed the client's email/password registration flow
along with password login. That went too far: CLI-native signup is still
needed, notably for teaching and workshop settings where users create an
account from inside a notebook and cannot be sent to a browser tab.
Signup returns, but only through interactive_login(), which now offers:
[1] Log in -> existing browser / paste-key path
[2] Create an account -> terminal-only signup
[q] Quit
CLI password *login* stays removed, since that is what OIDC broke. Returning
users log in via the browser, paste an API key, or set TABPFN_TOKEN.
The signup flow drops from six steps to three:
- the standalone Terms and Data Privacy steps are replaced by a notice under
the email field, shown before the prompt so it is on screen before anything
is submitted
- "Your Information" and "Help Us Serve You Better" merge into
"Complete your profile"
- the role list is replaced with the nine current roles
- "What do you want to use TabPFN for?" is dropped
The marketing opt-in is reworded, but ships *unchecked* (y/N) rather than
pre-checked. The new wording is marketing rather than support, and a
pre-ticked box is not valid consent under GDPR Art. 4(11) -- see CJEU
Planet49 (C-673/17). Flipping the default is a one-line change should it be
revisited.
Restores the endpoints signup needs (register, validate_email,
password_policy, verify_email, send_verification_email) and the
password-strength dependency. login and send_reset_password_email stay out.
Verified end-to-end in a pty against a mocked server: the full three-step flow
completes, and the registration payload carries the new role, no use_case,
both consent flags, and contact_via_email=False on a bare Enter. The live
password policy endpoint was checked against production and parses correctly.
No real account was created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ba67321 to
9a1607a
Compare
The token errors already named the api-keys page and both ways to supply a
token, but the surrounding failures did not, leaving the user with a
statement and nowhere to go.
- connection failures name the network and proxy as things to check, and
the issue tracker if it persists
- the unverified-email errors point at the GUI, taken from the server
config so on-prem deployments get their own host, and say to check the
inbox first
- registration failures carry the issue tracker alongside the server's
message
Cancellation stays bare: the user chose it. A login timeout also stays bare,
since it falls through to "Login was not completed", which carries the full
instructions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
interactive_login() ran the menu unconditionally, so calling it twice made the user log in twice even though the first call had cached a working token. The success line promises the key is cached for future runs, which was true for init() but not for interactive_login() itself. It now returns an already-working token and says so. force_relogin=True restores the old behaviour, which is what switching accounts needs. The check runs before the terminal check, so a script that calls interactive_login() with a cached token succeeds without a TTY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Status text that is not part of a prompt went to stdout unconditionally, so every init() in a batch job printed "Found existing access token" plus any server greeting, and interactive_login() added its own line even when no one was watching. Adds ui.notify(), which prints when stdout is a terminal and logs at INFO otherwise, and routes the three non-prompt messages through it. Prompts and the flows behind interactive_login() still write to stdout directly: those only run when a human is driving them. Drops PromptAgent._print, whose last caller was one of the three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a65e2d7. Configure here.
Addresses the review of #352. Notebooks can reach the signup flow. The TTY guard rejected any environment without a TTY, which includes every IPython kernel, so the terminal signup was unreachable from Jupyter or Colab -- the environments it exists for. Interactivity is now TTY *or* kernel. A kernel's `sys.stdin.readline()` returns an empty string immediately, which reads as EOF, so prompts go through `input()` instead. The localhost callback is skipped under a kernel: the browser that opens the login page belongs to the reader, and its localhost is not necessarily this process's, so pasting the key back is the route that always works. Browser login no longer crashes on Windows. `select()` accepts only sockets there, so racing stdin against the callback raised OSError and the paste fallback was never reached. Windows now watches stdin from its own thread; POSIX keeps select. The callback server binds 127.0.0.1 rather than every interface. It accepted a token from any host that could reach the machine, so anyone on the same network could inject one during the login window. A rejected token now discards only the source it came from. A bad TABPFN_TOKEN deleted the cached token from an earlier login, so unsetting the variable left nothing behind and forced another login. A bad cached token is still cleared. The already-logged-in shortcut tolerates an unreachable server instead of surfacing a transport traceback, and a browser that fails to open is reported rather than silently waited on. Verified in a live Jupyter kernel (full signup completes, correct payload), in a pty (terminal flow unchanged), and by reproducing the LAN token injection against a non-loopback address before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
safaricd
left a comment
There was a problem hiding this comment.
LGTM, thanks for applying the changes

The default path no longer contains "magic" to prompt for tokens. Instead we explicitly require a user to pass a token.
Error message clearly describes what to do---
Interactive Login is behind
tabpfn_client.interactive_login() only--