Skip to content

Automated SciToken acquisition in the fdp CLI - #11

Merged
sammuli merged 25 commits into
mainfrom
feat/fdp-scitoken-automation
Jun 23, 2026
Merged

Automated SciToken acquisition in the fdp CLI#11
sammuli merged 25 commits into
mainfrom
feat/fdp-scitoken-automation

Conversation

@sammuli

@sammuli sammuli commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds automated SciToken (bearer token) acquisition to the fdp CLI, so users no longer hand-manage ~/.fdp/token. A new fdp/auth.py module owns the credential lifecycle; fdp mints/refreshes tokens via the pelican GitHub-OAuth flow (pelican credentials token get) and feeds them into the existing BEARER_TOKEN path.

New behavior

  • fdp login [--write] — mint/refresh a token, cached at ~/.fdp/cache/<device>.token (bare JWT, 0600, atomic write).
  • fdp logout — delete the cached token.
  • fdp run … — auto-acquires a token when none is valid and the session is interactive (stdin+stderr TTYs); FDP_NO_AUTO_LOGIN=1 disables it for batch jobs.
  • fdp env — never launches the OAuth flow; now emits the device's declared bearer env-var name instead of a hardcoded BEARER_TOKEN.
  • Token resolution order: -t/--bearer-token$BEARER_TOKEN → managed cache → legacy ~/.fdp/token (file sources expiry-checked; an unusable legacy file warns with a migration hint).

Design notes

  • Shells out to pelican (already a runtime dep) rather than reimplementing OAuth. Verified against a live client: the command emits a bare JWT on stdout, sends the device-code prompt/warnings to stderr, and requires a TTY — so the subprocess pipes stdout only and inherits stdin+stderr.
  • Auth namespace URL comes from Tokamak.pelican_root; the target env var from the locator's auth.env. No catalog schema changes.

Design + plan: docs/superpowers/specs/2026-06-22-fdp-scitoken-automation-design.md, docs/superpowers/plans/2026-06-22-fdp-scitoken-automation.md.

Note on scope: main was 3 commits behind this branch's fork point, so this PR also brings main up to the already-released 0.2.4 PTData index_pattern work (db5c4a4, 732a979, 5025784) that was cut via release-0.2.4 tag but never merged. Those commits are unchanged here.

Test Plan

Automated

  • Full suite green: pixi run test130 passed, 6 skipped (skips require toksearch_d3d installed). 65 dedicated tests across test_auth.py / test_environment.py / test_cli.py; all pelican/OAuth calls mocked.
  • CI build_and_test green.

Manual

Requires a real terminal (pelican's OAuth flow needs a TTY) in an env that has both this branch's fdp and a device contributor (toksearch_d3d provides the d3d catalog entry). The bare fdp dev env has no device and fdp login errors with "No tokamak contributors are installed."

Setup — none needed. The repos/toksearch_d3d pixi env already has fdp editable-installed from repos/fdp, so it runs whatever branch that repo is checked out to:

cd repos/fdp && git checkout feat/fdp-scitoken-automation   # this branch
cd ../toksearch_d3d
pixi run fdp --help          # sanity: login/logout appear; catalog list shows d3d

Run the steps below from repos/toksearch_d3d with pixi run …. To switch back afterward, just git checkout main in repos/fdp — nothing to uninstall. (That env is Python 3.10, so a pip install -e of fdp is refused by the >=3.11 metadata floor — but the existing editable install runs fine under 3.10; the floor is about ml-metadata wheels, not fdp syntax.)

  • A — fdp login mints a token. pixi run fdp login → device-code prompt on stderr; after approval prints Logged in to d3d (read); token valid until <UTC>. Verify: stat -c '%a %n' ~/.fdp/cache/d3d.token600, and the file starts with eyJ… (a JWT).
  • B — resolution prefers the fresh cache. unset BEARER_TOKEN; pixi run fdp env | grep BEARER_TOKENexport BEARER_TOKEN='<cached JWT>'.
  • C — auto-acquire on fdp run (interactive). Force the no-valid-token state, then run:
    unset BEARER_TOKEN
    mv ~/.fdp/token ~/.fdp/token.bak 2>/dev/null
    mv ~/.fdp/cache/d3d.token /tmp/d3d.token.bak 2>/dev/null
    pixi run fdp run python -c "import os; print('BEARER_TOKEN' in os.environ)"
    → OAuth prompt appears, then prints True. (Restore later: mv ~/.fdp/token.bak ~/.fdp/token.)
  • D — opt-out / non-TTY does not prompt. With sources still invalid:
    FDP_NO_AUTO_LOGIN=1 pixi run fdp run python -c "import os; print('BEARER_TOKEN' in os.environ)"
    echo | pixi run fdp run python -c "import os; print('BEARER_TOKEN' in os.environ)"
    → both print False with no OAuth prompt. Opt-out is silent; the non-TTY case warns to stderr.
  • E — fdp env never launches the flow. With sources invalid + interactive TTY: pixi run fdp env >/dev/null → exports + warning, but no OAuth prompt.
  • F — fdp logout. pixi run fdp logout → "Removed cached token."; a second call → "No cached token to remove."; ls ~/.fdp/cache/d3d.token → No such file.

Cleanup: mv ~/.fdp/token.bak ~/.fdp/token 2>/dev/null; then git checkout main in repos/fdp if you want the env back on the released fdp. The mv dance forces the no-valid-token state without deleting dated token files — just restore the symlink. If fdp login picks a device other than d3d, adjust the cache path (pixi run fdp catalog list shows names).

🤖 Generated with Claude Code

sammuli and others added 25 commits June 6, 2026 14:36
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onment and fdp env; pin generic config keys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backend/preset is a deployment-level choice (toksearch.llm resolves it
from --backend / $FDP_LLM_BACKEND / ~/.fdp/config.toml / built-in default),
not per-device. Remove the per-device --backend auto-injection from
_build_llm_cmd; update the --backend help text and tests. The fdp_schema
default_llm_preset field is left in place (now unused) to avoid an
AttributeError coupling with older fdp releases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a ptdata_indexed locator declares index_pattern, _tokamak_env now
emits PTDATA_JSON_INDEX_PATTERN so libfdpio can resolve the latest
matching subdir at read time. Backward compat: unset pattern → key
absent (proven by test_omits_pattern_when_unset). Parity fixture updated
to drop the pinned timestamp suffix from index_dir and add the glob pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scaffold fdp/auth.py with decode_exp(), which extracts the 'exp' claim
from a JWT without signature verification. Five unit tests cover the
happy path, expired tokens, missing exp, malformed base64, and non-JWT
inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AttributeError (e.g. int/None input calling .split) was not caught,
contradicting the docstring guarantee. Add AttributeError to the except
tuple and add a test to document the behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds get_valid_token(handle, explicit=None) with four-level precedence:
explicit arg (verbatim) > $BEARER_TOKEN (verbatim) > device cache
(~/.fdp/cache/<name>.token, unexpired JWT only) > legacy (~/.fdp/token,
unexpired JWT only). Devices without a bearer_token locator return None.
Also adds os/time/Path imports and private helpers (_bearer_env,
_is_unexpired, _cache_path, _read_token_file). 7 new tests in
TestGetValidToken cover each branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An empty or None explicit is treated as 'no override' — document this
in get_valid_token docstring and add a one-line docstring to
_is_unexpired. In tests: hoist all mid-file and per-method imports to
the top-level block, add addCleanup for BEARER_TOKEN in env-var test,
and strengthen test_fresh_cache_used to assert the exact cached token
is returned (not just non-None).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AuthError, CachedToken, _extract_token, _pelican_get_token,
_write_cache, login, and logout to fdp/auth.py.  login() shells out to
pelican credentials token get, writes the bare JWT to
~/.fdp/cache/<device>.token with mode 0o600 via atomic os.replace,
and returns a CachedToken summary.  logout() removes the cache file and
returns whether one existed.  _extract_token handles bare-JWT, JSON dict
(access_token/token keys), and bare JSON string outputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tests

Remove stray `import subprocess` and redundant `import tempfile` from
test_auth.py. Add TestExtractToken.test_access_token_beats_token to lock
in access_token > token key priority. Strengthen write-scope test with
positional argv[4] assertion. In _write_cache, add explicit os.chmod on
the cache dir so 0700 holds regardless of umask or pre-existing dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ensure_token() that resolves a usable bearer token and optionally
triggers the interactive login() flow when none exists. Auto-login is
gated on: device declares bearer auth, FDP_NO_AUTO_LOGIN is unset, and
the session is interactive (TTY auto-detect or caller-forced bool).
Warns and returns None when it cannot acquire. Add _auto_login_allowed()
helper and import sys/warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When FDP_NO_AUTO_LOGIN is set the user opted out deliberately; emitting a
"run fdp login" warning is noise they did not ask for. The warning is still
emitted in the non-interactive case (e.g. fdp env), where the caller may
genuinely be unaware that no token is available.

Tests tightened to assert: no UserWarning in the opt-out path, and
assertWarns(UserWarning) in the non-interactive path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the inline token-resolution logic in resolve_bearer_token and
setup_environment with delegation to fdp.auth.get_valid_token /
ensure_token, so expiry-checking, cache lookup, and interactive login
are all owned by the auth module. Adds auto_login kwarg to
setup_environment (used by `fdp run`). Updates test_bearer_token_from_file
to write a real unexpired JWT since the legacy path now requires a valid JWT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ency

- Rename _bearer_env → bearer_env (public API; used by environment.py).
- get_valid_token now warns when the legacy ~/.fdp/token exists but is
  expired or not a valid JWT, so upgrading users get a migration hint.
- setup_environment's no-token warning respects FDP_NO_AUTO_LOGIN
  (consistent with ensure_token's existing opt-out guard).
- Remove dead _has_bearer_auth from environment.py (superseded by
  auth.bearer_env when resolve_bearer_token was delegated to auth).
- Tests: wrap test_expired_legacy_skipped in assertWarns; add
  test_unusable_legacy_file_warns; add test_warns_when_bearer_device_has_no_token
  and test_no_warn_when_opted_out to TestSetupEnvironment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire auth.login/logout into new `fdp login [--write]` and `fdp logout`
subcommands; thread auto_login=True into setup_environment for `fdp run`;
fix do_env to emit the device-declared bearer env-var name instead of
hardcoded BEARER_TOKEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap _resolve_device_handle() in do_login and do_logout with a
(ValueError, KeyError) guard so unknown or missing devices produce a
clean error message + sys.exit(1) instead of an uncaught traceback.

Refactor all four TestCliLoginLogout tests to use the existing
_patch_catalog(stack) helper, which patches entry_points AND registers
a teardown callback that clears _cat._cache, eliminating the catalog
state leak that the previous inline pattern left behind.

Also strengthen test_login_dispatches_to_auth_login to assert that the
real device handle (schema.name == "d3d") is passed to auth.login.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ests

Use os.open() with O_CREAT|O_TRUNC|0o600 in _write_cache so the
temp token file is never briefly world-readable under a permissive
umask. Add two tests for custom bearer-env-var derivation and
read-through (test_auth.py), plus an end-to-end auto_login test that
runs setup_environment(auto_login=True) through to ensure_token
(test_environment.py). 130 passed / 6 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sammuli
sammuli merged commit 585021f into main Jun 23, 2026
1 check passed
@sammuli
sammuli deleted the feat/fdp-scitoken-automation branch July 1, 2026 01:21
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