Skip to content

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

Merged
ggprior merged 9 commits into
mainfrom
georg/token-only-auth
Aug 24, 2026
Merged

Token-only auth by default, with opt-in interactive_login()#352
ggprior merged 9 commits into
mainfrom
georg/token-only-auth

Conversation

@ggprior

@ggprior ggprior commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

Screenshot 2026-08-20 at 15 30 18

Interactive Login is behind tabpfn_client.interactive_login() only --

Screenshot 2026-08-20 at 15 32 05

@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 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
@ggprior
ggprior force-pushed the georg/token-only-auth branch from 77cfa63 to 7c477a0 Compare August 19, 2026 10:29
ggprior and others added 2 commits August 19, 2026 12:34
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>
@ggprior
ggprior force-pushed the georg/token-only-auth branch from 7c477a0 to ba67321 Compare August 19, 2026 10:37
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>
@ggprior
ggprior force-pushed the georg/token-only-auth branch from ba67321 to 9a1607a Compare August 19, 2026 11:34
ggprior and others added 3 commits August 19, 2026 13:38
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>
@ggprior
ggprior marked this pull request as ready for review August 20, 2026 13:32
@ggprior
ggprior requested a review from a team as a code owner August 20, 2026 13:32
@ggprior
ggprior requested a review from simo-prior August 20, 2026 13:32
@ggprior
ggprior requested review from safaricd and removed request for simo-prior August 20, 2026 13:33

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

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

Comment thread src/tabpfn_client/interactive_auth.py
Comment thread src/tabpfn_client/interactive_auth.py
Comment thread src/tabpfn_client/interactive_auth.py
Comment thread src/tabpfn_client/constants.py
Comment thread src/tabpfn_client/prompt_agent.py
Comment thread src/tabpfn_client/service_wrapper.py Outdated
Comment thread src/tabpfn_client/interactive_auth.py Outdated
Comment thread src/tabpfn_client/interactive_auth.py Outdated
Comment thread src/tabpfn_client/interactive_auth.py Outdated
ggprior and others added 2 commits August 24, 2026 09:53
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 safaricd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for applying the changes

@ggprior
ggprior added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 52b4dc2 Aug 24, 2026
10 checks passed
@ggprior
ggprior deleted the georg/token-only-auth branch August 24, 2026 12:58
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.

2 participants