Skip to content

feature(Auth): Add aperiodic login, logout and whoami with a saved per-machine key - #30

Merged
szemyd merged 4 commits into
mainfrom
claude/aperiodic-login
Nov 10, 2026
Merged

szemyd merged 4 commits into
mainfrom
claude/aperiodic-login

Conversation

@szemyd

@szemyd szemyd commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Needs the server side first: dream-faster/unravel-router#1174 (the /api/v1/cli/* routes and the /device page) is merged; release this after it's live in production. Until then aperiodic login gets a 404 from /cli/login, and every other command works as before.

What changes

  • aperiodic login:
    • If this machine already has a saved key that still works, it says Already signed in as "<name>" on <plan>. Run 'aperiodic login --force' to replace it. and stops. Logging in twice doesn't pile up keys.
    • Otherwise it starts a login and opens aperiodic.io/device?code=… in the browser. It only opens an https link on the API's own host; anything else is printed. Over SSH (SSH_CONNECTION / SSH_TTY), on Linux without a display, or with --no-browser, it prints the link to open on any device instead.
    • It shows the code to check and polls until the person allows it, backing off on slow_down and stopping on access_denied / expired_token. Then it saves the machine's own named key, and when it replaced one, revokes the old one (best effort).
    • --name overrides the key's name, which defaults to <hostname> (aperiodic CLI).
  • Credentials file: ~/.config/aperiodic/credentials, JSON, mode 0600 in a 0700 directory, written through a temp file and renamed into place. It lives in $XDG_CONFIG_HOME/aperiodic, or %APPDATA%\aperiodic on Windows; $APERIODIC_CONFIG_DIR overrides both. Only a missing api_key counts as no credentials. The Python client reads the same file (aperiodic-io/client#43).
  • Key lookup for every command (metric, symbols, raw, stream): APERIODIC_API_KEY first, then DEMO-KEY with --preview, then the saved key. A saved key never answers a preview request, so a revoked one can't break the free sample. The order is written down in the README, and the Python client uses the same one.
  • A rejected saved key says The key saved in <path> was rejected (revoked?). Run 'aperiodic login' again. instead of a bare 401.
  • aperiodic logout: revokes this machine's key on the server (DELETE /cli/key) and deletes the file. It passes on the server's reason when revoking doesn't happen: for an account's default key it says to rotate it at /home/api, and for a key that's already revoked it says so. If the server can't be reached, it still removes the file and says where to revoke the key by hand.
  • aperiodic whoami: shows the plan, which key is in use and where it came from, datasets in full, markets (for a plan held to some, as Free is: "BTC, ETH, SOL and 10 RWA perps"), history and live.
  • Usage text and README updated.

Tests

  • cli_auth_test.go runs against an httptest stand-in of the server and covers:
    • login: it opens only safe links, shows the code, polls until the key arrives, and saves it with mode 0600 (checked where the OS has file modes, so not on Windows);
    • re-login: a working key is kept, --force replaces and revokes, and a revoked key is replaced;
    • over SSH it prints the link and opens nothing;
    • it backs off on slow_down, and a denial saves nothing;
    • APERIODIC_API_KEY wins over the saved key, and --preview uses DEMO-KEY even with a saved key;
    • a revoked saved key gets the "log in again" message;
    • the credentials file tolerates any created_at, and saving leaves no temp file behind;
    • logout revokes and removes the key, and reports the server's reason;
    • whoami output, including the markets line and the demo key;
    • login -h exits 0;
    • with no key at all, the error points to aperiodic login.
  • gofmt and go vet (also with GOOS=windows) are clean. Every test that needs no live key passes.
  • The 8 live tests (requireAPIKey) fail locally without APERIODIC_API_KEY, as they do on main. CI sets the key.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

…r-machine key

- `aperiodic login` signs in through aperiodic.io/device: it opens the
  browser, or prints the link over SSH and without a display, polls until
  the person allows it, and saves the machine's own named key.
- The key is saved in ~/.config/aperiodic/credentials (0600; %APPDATA% on
  Windows, or $APERIODIC_CONFIG_DIR), which the Python client reads too.
- Every command finds its key in APERIODIC_API_KEY first, then the saved
  key, then (with --preview) the demo key.
- `aperiodic logout` revokes this machine's key and removes it;
  `aperiodic whoami` shows the key and what the plan covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur
Windows has no permission bits: Go reports a writable file as 0666, and
the file is kept private by the ACL of the user's profile instead. The
0600 check now runs on Linux and macOS only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

szemyd commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Critical review: please address before merge

I checked this against the server routes in dream-faster/unravel-router#1174 and ran go vet plus the non-live tests (they pass). I also tested the shared credentials file across languages: a file written by Python's _save_credentials is read correctly here. The device flow itself is solid. The problems are around it.

1. Logging in again leaks a key each time (affects the Python client too)

runLogin (cli_auth.go:130) mints a new named key, and finishLogin (:207) overwrites credentials without looking at what was there. The old key stays live on the server, and nothing on this machine refers to it any more. It happens on every repeat aperiodic login, and whenever the CLI and aperiodic.login() from Python take turns on the same machine, since each overwrites the other's key. The server caps named keys at 25 (MAX_NAMED_KEYS). After that, login fails with 409 key_limit, and the user has to clean up a page full of keys all called " (aperiodic CLI)" by hand.

Fix (same in both clients): if a saved key exists, check it with /cli/whoami first. If it's valid, print "Already signed in as X on . Use aperiodic login --force to replace it." and exit 0. On --force, or when the old key is dead, make a best-effort DELETE /cli/key with the old key after the new one is saved. Add a test for the re-login path.

2. --preview now depends on the saved key (regression)

resolveAPIKey (cli.go:119-120) now returns the saved key before the demo key. Before this PR, --preview with no env var always used DEMO-KEY. Now, if the saved key has been revoked on /home/api (which named keys exist to allow), aperiodic … --preview fails with 401. That breaks the one path that's meant to work with no account. It also differs from aperiodic-io/client#43, where preview=True still uses the demo key.

Fix: use env, then demo key for preview, then the saved key. In other words, keep APERIODIC_API_KEY first for backwards compatibility, but don't let the saved key take over a preview request. Add a test: a saved key plus --preview should send DEMO-KEY.

3. A revoked saved key fails with 401: Unauthorized and no way forward

client.go:83 turns 401 into the bare message "Unauthorized". With saved, revocable keys this becomes a common state. When the key came from the file (findAPIKey already returns the source), say: "The key saved in <path> was rejected (revoked?). Run aperiodic login again." whoami should do the same.

4. Check the URL before handing it to the OS opener

cli_auth.go:157 passes the server's verification_uri_complete straight to open / xdg-open / rundll32 url.dll,FileProtocolHandler. The last one runs local paths and other protocol handlers. The risk is low, since the value comes over TLS from our own server or from APERIODIC_API_URL, but the fix is cheap: only open it when it parses as https:// (or http://localhost for tests) on the same host as baseURL(), and otherwise just print it.

5. A metadata field can disable the key

credentials.go:21: CreatedAt time.Time is parsed strictly, so "created_at": "" (or any other writer's format) makes LoadCredentials fail. I confirmed it: you get Warning: … parsing time "" followed by "no API key", even though api_key is fine. Make it a string (or *time.Time that tolerates errors), so that only a missing api_key counts as no credentials. Python reads the file loosely, and the Go side should too.

Nits

  • SaveCredentials uses a fixed credentials.tmp. Concurrent logins race on it, and if a stale .tmp already exists, os.WriteFile(…, 0o600) keeps its old, looser mode. Use os.CreateTemp(dir, "credentials-*") (0600) and then os.Rename.
  • logout throws away the server's reason. 400 not_a_named_key means "rotate the default key on /home/api" rather than "revoke", and a 401 means it's already revoked. Print error_description when you have one.
  • whoami labels a kind: "demo" key as "your account's default key" (cli_auth.go:280).
  • aperiodic login -h exits 2 (fs.Parse returns flag.ErrHelp), while logout help / whoami help exit 0. Treat flag.ErrHelp as 0.
  • HTTPClient, Sleep and Now are new exported fields on the public CLI struct, added only for tests. That's acceptable, but unexported fields set from an in-package test would keep the API smaller.

Fine as is

The RFC 8628 handling (sleep before polling, slow_down +5s, stopping on access_denied / expired_token, and polling again after a dropped connection), the SSH and headless detection, the 0600 file in a 0700 directory, the note when APERIODIC_API_KEY is set, and logout removing the file even when the server can't be reached.


Generated by Claude Code

…DEMO-KEY, clearer revoked-key errors

- login keeps a saved key that still works (checked with /cli/whoami) and
  says so; --force, or a revoked saved key, signs in again and then revokes
  the old key, best effort, so keys no longer pile up to the 25-key limit.
- Key order is APERIODIC_API_KEY, then DEMO-KEY for --preview, then the
  saved key: a revoked saved key can no longer break the free sample.
- A 401 on the saved key says which file it came from and to log in again,
  for data commands, raw, stream and whoami.
- The device link goes to the OS opener only when it is https (or http on
  loopback) on the API's own host; otherwise it is printed.
- created_at is kept as text, so no other writer's format can make the key
  unreadable.
- Credentials are written through a fresh 0600 temp file; logout passes on
  the server's reason; whoami names the demo key; login -h exits 0; the
  test seams on CLI are unexported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

szemyd commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Every point is addressed in 486bcfa:

  1. Re-login leak: aperiodic login first checks the saved key with /cli/whoami. If it works, it prints Already signed in as "<name>" on <plan>. Run 'aperiodic login --force' to replace it. and exits 0. With --force, or when the saved key is rejected (or the server can't be reached), it runs the device flow and saves the new key. It then makes a best-effort DELETE /cli/key with the old key and ignores any failure. Tests: TestLoginKeepsASavedKeyThatStillWorks, TestForcedLoginRevokesTheKeyItReplaces, TestLoginReplacesASavedKeyThatWasRevoked.
  2. --preview: the order is now APERIODIC_API_KEY, then DEMO-KEY for --preview, then the saved key, and the README states it. Test: TestPreviewUsesTheDemoKeyEvenWithASavedKey (a revoked saved key plus --preview sends DEMO-KEY; an env key still wins).
  3. Revoked saved key: a 401 on the saved key now says The key saved in <path> was rejected (revoked?). Run 'aperiodic login' again. This applies to metrics, symbols, raw, stream and whoami; env keys keep the old message. Test: TestARevokedSavedKeySaysToSignInAgain.
  4. Opener: the device link is opened only when it is https on the API's host, or http on loopback; anything else is printed instead. Tests: TestLoginOpensOnlyAWebLinkOnTheAPIHost (file:///etc/passwd and a foreign host are printed, not opened) and TestIsOpenableLink.
  5. created_at: it is now plain text with omitempty, so only a missing api_key counts as no credentials. Test: TestCredentialsLoadWhateverTheNotesSay (empty, odd and Python-style dates all load).

Nits:

  • Credentials file: os.CreateTemp + 0600 + rename. Test: TestSavingLeavesNoTemporaryFileAndKeepsTheModePrivate (a stale 0644 credentials.tmp no longer leaks its mode).
  • Logout: it passes on the server's reason. A default key quotes "rotate it at /home/api", and a 401 or revoked: false reads "already revoked". The file is removed in every case. Test: TestLogoutSaysWhatTheServerSaid.
  • whoami on the demo key: it shows "the shared demo key". Test: TestWhoamiNamesTheDemoKey.
  • Help exit code: aperiodic login -h exits 0, and the usage lists --force. Test: TestLoginHelpExitsZero.
  • Test hooks: HTTPClient, Sleep and Now are now unexported. Only the in-package test set them.

gofmt, go vet (also with GOOS=windows) and every test that needs no live key pass. The Python client follows the same key order and re-login rule in aperiodic-io/client#43.


Generated by Claude Code

@szemyd
szemyd merged commit c802788 into main Nov 10, 2026
9 checks passed
@szemyd
szemyd deleted the claude/aperiodic-login branch November 10, 2026 14:59
szemyd added a commit to aperiodic-io/client that referenced this pull request Nov 10, 2026
… own (#43)

**Needs the server side first:**
[dream-faster/unravel-router#1174](dream-faster/unravel-router#1174)
(the `/api/v1/cli/*` routes and `/device`) is merged; release this after
it's live in production. It's the same flow as
[aperiodic-io/cli#30](aperiodic-io/cli#30), and
both write the same credentials file. Until the server is deployed,
`login()` gets a 404 when starting, and everything else works as before.

## What changes

- **`aperiodic.login()`**, or `python -m aperiodic login` on a machine
without the CLI:
- If this machine already has a saved key that still works, it says
`Already signed in as "<name>" on <plan>. Call
aperiodic.login(force=True) to replace it.` and returns that login. On
the command line the flag is `--force`. Logging in twice doesn't pile up
keys.
- Otherwise it starts a login, prints the `aperiodic.io/device?code=…`
link and the code, and polls until the person allows it.
- It backs off on `slow_down` and stops on `access_denied` /
`expired_token`.
    - It keeps polling through 5xx, 429 and dropped connections.
- It saves the machine's own named key to
`~/.config/aperiodic/credentials`. The file is written through a temp
file at mode 0600, with the same path rules as the CLI
(`$APERIODIC_CONFIG_DIR`, `$XDG_CONFIG_HOME`, `%APPDATA%`). When it
replaced a key, it revokes the old one (best effort).
- It opens a browser only on a desktop: not over SSH, in a notebook (the
kernel may be remote) or on Linux without a display.
- It returns `LoginResult(key_name, plan_name, path)`. The key itself is
never printed or returned, so it can't end up in a notebook's output.
- **Key lookup:** a passed `api_key=` first, then `APERIODIC_API_KEY`,
then the demo key for `preview=True`, then the saved key. A saved key
never answers a preview request, so a revoked one can't break the free
sample. This applies to every endpoint, including `stream()`,
`get_symbols()` and `get_symbols_async()`, which now take
`api_key=None`. The README spells out the order, and the CLI uses the
same one.
- **A rejected saved key** raises `The key saved in <path> was rejected
(revoked?). Run aperiodic.login() again.` instead of a bare 401.
- **`aperiodic.logout()`** revokes the key (`DELETE /cli/key`), removes
the file and returns `LogoutResult(removed, revoked, message)`. The
message says what happened: revoked, already revoked, or the account's
default key (rotate it at /home/api).
- **`aperiodic.whoami()`** returns the key and what its plan covers.
`python -m aperiodic whoami` prints it, with a Markets line for a plan
held to some (Free: "BTC, ETH, SOL and 10 RWA perps").
- **Errors:**
- With no key, the error says to run `aperiodic.login()` or set
`APERIODIC_API_KEY`.
- Offline, the command line prints `Error: Could not reach …` instead of
a traceback.
- **Standard library only** (`urllib`): the core install has no HTTP
client (httpx is an extra). In Pyodide, `login()` explains it needs
regular Python.

## Tests

- `tests/test_auth.py` runs against a local `ThreadingHTTPServer`
stand-in of the server and covers:
- login saves the key with mode 0600 and never prints or returns it; a
stale temp file can't loosen the mode;
- re-login: a working key is kept, `force=True` replaces and revokes,
and a revoked key is replaced;
- a denial saves nothing, and polling backs off on `slow_down` and
survives 5xx / 429 / non-JSON pages;
  - offline `login` / `whoami` fail with a message, not a traceback;
- lookup order: passed key, then env, then the saved key, with no key
leaking in from the machine running the tests;
  - preview stays on the demo key;
  - a rejected saved key names the file and says to log in again;
- logout revokes and removes the key and says what happened in one line;
  - `get_symbols` (sync and async) finds the saved key;
- `whoami` and the `python -m aperiodic` entry point, including the
Markets line and `--force`;
  - the new functions are exported.
- `tests/test_stream.py`: `stream()` uses the saved key when none is
passed, and a rejected saved key says to log in again.
- `test_auth`, `test_readme` and `test_stream` pass (89). ruff 0.14.1
(CI's version) is clean.
- The full suite fails the same 34 integration tests here as on `main`
(they need a real key and network).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

---------

Co-authored-by: Claude <noreply@anthropic.com>
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