CLI profiles & login
gantrycli authenticates through profiles, modelled on the AWS CLI: a
non-secret config file plus a separate secrets file, an active-profile selector,
and a login verb. login has two modes — an SSO browser flow for interactive
users, and a token path for non-browser clients (CI, SSH, containers) that
authenticate with a PAT.
Files
Two files under ~/.gantrycd/ (override the directory with
GANTRYCD_CONFIG_DIR):
config.yaml — non-secret, safe to share
active_profile: dev
profiles:
dev:
api_url: https://gantrycd.example.com
auth: sso # sso | service_account | pat
sso:
provider: google # global provider key …
# org: acme # …or an org-scoped identity source:
# source: corp-okta
ci:
api_url: https://gantrycd.example.com
auth: service_account
service_account_id: sa_abc # informational only
credentials.yaml — secrets, written 0600
profiles:
dev: # SSO: short-lived, refreshed by `login`
token: gantrycd_session_…
expires_at: 2026-05-30T18:04:00Z
ci: # service account / PAT: static, no expiry
token: gantrycd_sa_…
The split mirrors ~/.aws/{config,credentials}: config.yaml can be committed
or shared; credentials.yaml holds the bearer tokens and is kept out of it.
Credential resolution
gantrycli picks a profile in this order (highest first):
--profile <name>(global flag)GANTRYCD_PROFILEactive_profileinconfig.yaml- the profile named
default
CI / runner escape hatch: if GANTRYCD_API_URL is set together with
GANTRYCD_PAT or GANTRYCD_SA_TOKEN, those are used directly and profile files
are ignored entirely — the equivalent of AWS_ACCESS_KEY_ID. Supported path for
runners and pipelines; keeps pre-profile setups working.
For a resolved SSO profile, an expired expires_at is reported as
“run gantrycli login” rather than silently failing or auto-launching a
browser inside an unrelated command — matching aws sso.
Implementation: cmd/cli/internal/cliconfig (config.go resolution, files.go
on-disk shapes). Every verb dials through cliconfig.Dial(); the root command’s
PersistentPreRun records --profile once via SetProfileOverride.
Commands
| Command | Purpose |
|---|---|
gantrycli configure | Create/update a profile. SSO needs --sso-provider (or --sso-org+--sso-source); service_account/pat read the token from a hidden prompt or --token-stdin. Targets --profile/GANTRYCD_PROFILE/default (never the active profile). |
gantrycli login | Authenticate the active/--profile profile. SSO profiles run the browser flow; pat/service_account profiles (or any profile with --token-stdin) read a token and cache it — no browser. |
gantrycli logout | Best-effort server-side session revoke, then drop the cached credential. |
gantrycli profile list | List profiles (active marked *) with auth, URL, credential status. |
gantrycli profile show [name] | Show a profile’s settings (never the token). |
gantrycli profile use <name> | Set active_profile. |
Non-browser clients (PAT)
Headless environments — CI runners, SSH sessions, containers — can’t complete the SSO browser flow. They authenticate with a personal access token instead. Three equivalent ways:
# (a) one-shot env escape hatch — no files, like AWS_ACCESS_KEY_ID
export GANTRYCD_API_URL=https://gantrycd.example.com
export GANTRYCD_PAT=gantrycd_pat_…
gantrycli whoami
# (b) a persistent pat profile
echo "$PAT" | gantrycli configure --profile ci --api-url https://gantrycd.example.com --auth pat --token-stdin
# (c) `login` with a token — also works to add a PAT to an SSO profile for CI
echo "$PAT" | gantrycli login --profile dev --token-stdin
gantrycli login on a pat/service_account profile reads the token (hidden
prompt or --token-stdin), caches it, and verifies it with GET /auth/me.
--token-stdin forces this token path even on an sso profile, so a CI job
can authenticate with a PAT against a deployment whose humans use SSO.
PATs are minted in the web UI under /profile/tokens (the backend locks PAT
creation to interactive sessions, so the CLI can’t mint them).
Service accounts
A service-account profile is just a static gantrycd_sa_ token — no login
round-trip needed:
echo "$SA_TOKEN" | gantrycli configure --profile ci \
--api-url https://gantrycd.example.com \
--auth service_account --service-account-id sa_abc --token-stdin
The token is sent as Authorization: Bearer and resolves to the SA principal
(internal/backend/middleware/auth.go, gantrycd_sa_ prefix).
SSO login flow (loopback + code exchange)
gantrycli login on an SSO profile is a loopback flow modelled on RFC 8252:
the CLI never sees the IdP secret, and — crucially — the session token never
travels through the browser. What rides the loopback redirect is a single-use,
~1-minute exchange code; the CLI swaps it for the token over a direct
back-channel call.
gantrycli login --profile dev
1. bind a listener on 127.0.0.1:<ephemeral>, generate a high-entropy state nonce
2. open the browser to
<api>/api/v1/auth/sso/<provider>/start
?cli_redirect_uri=http://127.0.0.1:<port>/callback?state=<nonce>
(or …/sso/orgs/<org>/sources/<source>/start for an org source)
3. backend SSO callback completes the IdP dance, then — because the loopback
cookie is set — issues a single-use login *code* (no session yet) and
303-redirects ?code=<code> to the loopback URL instead of setting a cookie
4. the local listener verifies state == nonce, captures the code, shows a
"you can close this tab" page
5. the CLI POSTs the code to /api/v1/auth/sso/cli/exchange; the backend
atomically spends the code and mints the session, returning the token + expiry
6. login verifies the token with GET /auth/me, then writes it to credentials.yaml
Why a session token (not a PAT)
SSO is interactive and ephemeral, so it yields the credential that already has
those semantics: a session with a TTL (honoring per-org session_ttl_hours)
and the existing session-cleanup job. login re-run = refresh, like
aws sso login. The session is created only at code exchange — an abandoned
login leaves no session behind.
Verify-then-store
Both login paths (SSO and PAT) verify the credential with GET /auth/me
before writing it to credentials.yaml. A typo’d, expired, or rejected token
fails the command and leaves any previously working credential untouched —
authenticating first means a bad login can’t clobber a good credential.
Backend pieces
internal/backend/middleware/auth.go— the Bearer dispatcher gained agantrycd_session_prefix case (authenticateSessionToken) that validates the token against the session store and resolves the user principal. CSRF checks are skipped: they defend cookie auth, and anAuthorizationheader is never auto-attached, so a Bearer request isn’t CSRF-exposed.internal/backend/handlers/sso_handlers.go—StartFlow/StartOrgSourceFlowacceptcli_redirect_uri, validate it is an http loopback URL (127.0.0.0/8,::1,localhost), and stash it (base64) in a short-lived HttpOnlysso_cli_redirectcookie.Callbackreads and clears that cookie and, when present, issues a single-use code and redirects it to the loopback.ExchangeCLILoginCode(unauthenticated, IP-rate-limited) swaps the code for the token.internal/backend/services/auth_service.go—IssueCLILoginCodestores a hashed, single-use, ~1-minute code bound to the user + intended session TTL;ExchangeCLILoginCodeconsumes it and mints the session in one transaction (the code is spent iff a session is issued). Codes live insso_cli_login_codes;Consumeis a singleUPDATE … WHERE used_at IS NULLso a leaked or replayed code can mint at most one session.internal/backend/handlers/auth_handlers.go—Logoutreads the session token from the cookie or agantrycd_session_Bearer header, sogantrycli logoutrevokes the session server-side even though it presents the token as a Bearer credential (no cookie).
Security notes
- The session token never enters the browser. Only the single-use code does, so a token can’t leak into browser history or the backend’s redirect/access logs. The code is short-lived, single-use, and useless after one exchange.
- The code (and any redirect) is only ever delivered to a loopback target.
A non-loopback
cli_redirect_uriis rejected with 400 at start; the value is re-validated at callback so a tampered cookie can’t become an open redirect. - The high-entropy
statenonce binds the loopback callback to the exactlogininvocation; a stray request to the loopback port is ignored, and a refreshed success tab can’t wedge the listener (the result send is non-blocking). - The exchange endpoint is unauthenticated by necessity (the CLI has no credential yet) but IP-rate-limited like the SSO callback; the unguessable single-use code is the bearer of trust and is accepted at most once.
- Headless hosts use the PAT path above instead of the loopback flow. A future OAuth device authorization grant could layer onto the same session-token machinery for SSO-without-a-local-browser.