Skip to content

Token-only auth by default, with opt-in interactive_login() - #352

Draft
ggprior wants to merge 3 commits into
mainfrom
georg/token-only-auth
Draft

Token-only auth by default, with opt-in interactive_login()#352
ggprior wants to merge 3 commits into
mainfrom
georg/token-only-auth

Conversation

@ggprior

@ggprior ggprior commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

The CLI's interactive login collected an email and password and posted them to /auth/login/ as an OAuth2 password grant. Under OIDC the credential lives at the identity provider, so SSO users have no password to type and that path cannot succeed for them.

Two things made it worse:

  • Browser login was attempted first, but every failure fell through to the password menu.
  • The callback wait had no timeout and no fallback, so a dropped callback hung the client until Ctrl+C. The login URL was never printed, and webbrowser.open returns True on many headless boxes.

Default path: token only, never interactive

This is a library, so authentication is not allowed to block on input. init() resolves a token from, in order:

  1. a token set via set_access_token()
  2. TABPFN_TOKEN, read from os.environ at resolution time
  3. a token cached by an earlier interactive_login()

If none resolve it raises, pointing at https://ux.priorlabs.ai/account/api-keys and mentioning interactive_login(). It never prompts, even on a TTY. A token that was supplied but rejected gets a distinct message rather than "not found".

TABPFN_TOKEN is now read from os.environ at resolution time rather than snapshotted at import, so setting it after import tabpfn_client takes effect.

Incidental credential-leak fix

set_token() conflated "use this token" with "remember this token", and was called on every init(). So an env-supplied TABPFN_TOKEN was silently copied into site-packages/tabpfn_client/.tabpfn/config. It is now split:

  • set_token() — in-process only
  • persist_token() — writes the cache, called only by interactive_login()

A token the caller supplies stays in memory. The cache holds only the result of an explicit login.

Opt-in path: interactive_login()

from tabpfn_client import interactive_login
interactive_login()

Never reached from init(), fit(), or predict() — asserted by test_init_never_triggers_interactive_login. It offers:

  • Log in — opens the login page (SSO included) and waits for the API key. Adapted from the browser-auth flow in the TabPFN package: the callback server runs in a daemon thread while the main thread polls stdin, so the callback and a manual paste race each other. If an identity provider drops the callback, the user pastes a key and the flow still completes. Headless sessions get the URL plus an OSC 52 clipboard copy, bounded by a timeout.
  • Create an account — runs entirely in the terminal. This is what course providers like DLAI need: students creating accounts from a notebook, where a browser tab is not viable.

CLI password login stays removed, since that is what OIDC broke.

Signup flow, reshaped per OPC-26

Six steps to three (OPC-26):

Before After
Step 1 Terms & Conditions (interactive y/n) Notice under the email field
Step 2 Account Details Step 1/3 Account details
Step 3 Create Password ↳ merged
Step 4 Data Privacy (interactive y/n) Notice under the email field
Step 5 Your Information Step 2/3 Complete your profile
Step 6 Help Us Serve You Better ↳ merged
Step 3/3 Verify your email

Also: the role list is replaced with the agreed nine (Data Scientist, ML Engineer, AI Engineer, Software Engineer, Product Manager, Researcher, Student, Executive, Other), and "What do you want to use TabPFN for?" is dropped.

The notice prints before the email prompt rather than after, so it is on screen before anything is submitted. It also carries the "do not upload personal, confidential or sensitive data" clause, because the server still requires agreed_personally_identifiable_information — this way we are not asserting an agreement the user never saw (OPC-26 open question 3).

One deliberate deviation

The email opt-in is reworded as requested, but ships unchecked (y/N) rather than pre-checked.

The new wording is marketing ("product news, offers and resources"), not support. Consent for that must be a clear affirmative act under GDPR Art. 4(11) / Recital 32, and CJEU Planet49 (C-673/17) held that a pre-ticked box is not valid consent. Prior Labs GmbH is established in Germany, so this applies directly. Flipping the default is a one-line change if whoever owns consent policy signs off — tracked in OPC-26.

Verification

Against live api.priorlabs.ai / ux.priorlabs.ai:

  • Real TTY, no token: init() raises immediately with the api-keys message and does not wait for input. A non-TTY test cannot catch this regression, so it was checked through a pty.
  • Env token: TABPFN_TOKEN=<bogus> leaves no file on disk.
  • Browser path (pty, webbrowser.open stubbed): simulated GUI redirect delivered the token, callback won the race, paste prompt exited cleanly.
  • Callback server: success page redirects to /redirect-success; a callback without a token deliberately does not set the event, so the paste prompt keeps running.
  • Signup (pty, mocked server): full three-step flow completes; payload carries the new role, no use_case, both consent flags, and contact_via_email=False on a bare Enter.
  • Live /auth/password_policy/ returns ["Length(8)", "Uppercase(1)", "Numbers(1)", "Special(1)"] and the restored client parses it correctly.

171 unit tests pass; ruff and basedpyright clean.

Not verified: no real account was created against production, so the signup path is covered end-to-end against the mock server only. Say the word if you want a live run with a throwaway address. Separately, whether a real OIDC round-trip preserves the callback param is unconfirmed — if it is dropped, the paste fallback covers it, which is why the race matters.

🤖 Generated with Claude Code

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>
@ggprior

ggprior commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: interactive signup flow

Feedback 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.

ggprior and others added 2 commits August 12, 2026 16:01
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>
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 course providers like DLAI where students create accounts
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 is reshaped per OPC-26, 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 agreed nine
  - "What do you want to use TabPFN for?" is dropped

The marketing opt-in is reworded as requested, 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 it is a one-line change if Legal signs off;
tracked in OPC-26.

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>
@ggprior ggprior changed the title Token-only authentication, with opt-in interactive_login() Token-only auth by default, with opt-in interactive_login() Aug 12, 2026
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