Skip to content

feat: token auth and credential store - #12

Open
RogerCecilpot wants to merge 1 commit into
spike/261-credential-storagefrom
feat/245-token-auth
Open

RogerCecilpot wants to merge 1 commit into
spike/261-credential-storagefrom
feat/245-token-auth

Conversation

@RogerCecilpot

Copy link
Copy Markdown

Closes devlyft/devlyft#245.

Next in the stacked chain after #6 (spike/261-credential-storage). Merge order: #2 → #5 → #3 → #4 → #8 → #9 → #10 → #7 → #6 → this.

What

  • Commands (internal/cli/auth):
    • auth login --with-token [--insecure-storage]: reads the token from stdin; refuses when stdin is a terminal or when DEVLYFT_API_TOKEN is set (exit 2). The token is checked against the API before it is stored.
    • auth status: checks the credential in use and shows host, account, source (env|keyring|file) and organizations. Exit 3 when not logged in or rejected. Never prints the token.
    • auth token: prints the token (help says it is sensitive).
    • auth logout [--yes]: deletes the stored credential; confirms at a terminal, needs --yes otherwise.
    • auth list: read-only; stored credentials plus DEVLYFT_API_TOKEN if set. Touches neither the keyring nor the API.
    • All support --format json|yaml, --jq and --quiet. There is no --token flag (a golden test pins that).
  • Precedence (internal/auth): DEVLYFT_API_TOKEN > stored credential for the API host. With the env var set, the credential store is never opened. auth.TokenSource implements the facade's api.TokenSource.
  • Credential store (internal/auth/credstore), as adjusted by the #261 spike:
    • go-keyring behind a timeout: 3s off a TTY, 60s at one.
    • keyring.ErrNotFound maps to not found; any other error falls back to credentials.json with one warning naming the reason (no D-Bus session, no Secret Service provider, keyring is locked, keyring timed out, running in CI, running in a container).
    • The keyring is skipped entirely in CI, in containers (/.dockerenv, /run/.containerenv, $container), under --insecure-storage, and on Linux with no session bus. That last rule stops godbus autolaunching dbus-launch. WSL is treated as headless Linux.
    • credentials.json (mode 0600, directory 0700, atomic rename) is also the index: it records every credential's credential_store, so reads go straight to the right store. Keyring-held entries carry no token. Replacing a keyring credential with a file one deletes the old keyring secret.
    • gomock mock in credstore/mock, generated by make generate.
  • API URL: --api-url / DEVLYFT_API_URL / https://api.devlyft.io, in internal/config.ResolveAPIURL, a small function for #244 to replace.
  • Hints (spec §7): a 401 from the API gets "DEVLYFT_API_TOKEN is invalid or expired." when the env var is set, otherwise "Run devlyft auth login --with-token to log in again.". This is set in cli.execute, the single error path.
  • redact now also masks bare dvl_… API tokens.
  • The debug logger moved from internal/cmdutil to internal/logging, so cmdutil.Factory can carry the API client factory without an import cycle in internal/api's tests.

Decisions worth a look

  • No identity endpoint. The public API has no /me, and an API token's user-or-org scope isn't exposed. --with-token validates with ListOrganizations and records the organizations the token can see. auth status shows them, which covers "org-scoped tokens show the org", but it can't show a user's email. A GET /v1/users/me (or a token-introspection endpoint) on the platform would fix that.
  • Account name. Credentials are stored under account default per host until contexts (#244) name accounts.
  • Browser login. Plain auth login is a usage error pointing at --with-token until #263.
  • Logout doesn't revoke API tokens. It only deletes the local copy, and the help says so. Server-side revocation of CLI sessions comes with #263.
  • Keyring read failures. If the keyring holds a credential but can't be read (for example, locked), commands fail with exit 3 and a hint to unlock it or log in with --insecure-storage. There's no silent fallback, because the file doesn't hold that token.

Deferred

  • #244: contexts, the account on the context, and config.yaml. The credential index moves under it if wanted.
  • #263, #264: browser and device login, refresh tokens (store only the refresh token and metadata, per the spike), and server-side revocation on logout.

Tests

  • credstore (89.5%): a fake keyring and injected environment. Covers each skip path with zero keyring calls, every error class falling back with one warning, timeout, stale and locked reads, delete with an unavailable keyring, list never touching the keyring, file and directory modes (skipped on Windows), bad and newer files, and the warning text per platform.
  • auth (100%): precedence via the gomock store, error mapping and hints, and the token source resolving once.
  • CLI: 33 golden files for every subcommand, the error paths and JSON output (internal/cli/auth at 91.5%).
  • TestAuthNeverLeaksToken: runs the real HTTP client against httptest with --verbosity debug and checks the token never reaches stderr or debug logs, and reaches stdout only through auth token.

🤖 Generated with Claude Code

`devlyft auth login --with-token` reads an API token from stdin, checks
it against the API (ListOrganizations, since the public API has no
identity endpoint) and stores it with the organizations it can see.
`auth status` checks the credential in use and shows its source
(env|keyring|file); `auth token` prints it; `auth logout` deletes it
(confirmation or --yes); `auth list` shows stored credentials without
touching the keyring or the API. There is no --token flag.

DEVLYFT_API_TOKEN takes precedence over stored credentials and is never
written; with it set the credential store is not opened at all.

internal/auth/credstore follows the #261 spike: go-keyring behind a
timeout (3s off a TTY, 60s at one), any non-not-found keyring error falls
back to credentials.json (0600 in a 0700 directory, atomic rename) with
one warning naming the reason, and the keyring is skipped entirely in CI,
in containers, under --insecure-storage, and on Linux without a session
bus (so godbus never autolaunches dbus-launch). credentials.json also
indexes every credential with its credential_store, so reads go straight
to the right store.

Also:
- --api-url / DEVLYFT_API_URL, resolved in internal/config until
  contexts arrive (#244).
- 401s from the API get the spec's hint: DEVLYFT_API_TOKEN is invalid or
  expired, or run `devlyft auth login`.
- redact masks bare dvl_ API tokens.
- The debug logger moves from cmdutil to internal/logging, so cmdutil can
  depend on the API facade without an import cycle in api's tests.

Refs devlyft/devlyft#245

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RogerCecilpot
RogerCecilpot force-pushed the spike/261-credential-storage branch from 82ef0d4 to d0d5548 Compare October 25, 2026 13:32
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