Skip to content
GantryCD

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

  1. --profile <name> (global flag)
  2. GANTRYCD_PROFILE
  3. active_profile in config.yaml
  4. 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

CommandPurpose
gantrycli configureCreate/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 loginAuthenticate 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 logoutBest-effort server-side session revoke, then drop the cached credential.
gantrycli profile listList 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 a gantrycd_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 an Authorization header is never auto-attached, so a Bearer request isn’t CSRF-exposed.
  • internal/backend/handlers/sso_handlers.goStartFlow / StartOrgSourceFlow accept cli_redirect_uri, validate it is an http loopback URL (127.0.0.0/8, ::1, localhost), and stash it (base64) in a short-lived HttpOnly sso_cli_redirect cookie. Callback reads 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.goIssueCLILoginCode stores a hashed, single-use, ~1-minute code bound to the user + intended session TTL; ExchangeCLILoginCode consumes it and mints the session in one transaction (the code is spent iff a session is issued). Codes live in sso_cli_login_codes; Consume is a single UPDATE … WHERE used_at IS NULL so a leaked or replayed code can mint at most one session.
  • internal/backend/handlers/auth_handlers.goLogout reads the session token from the cookie or a gantrycd_session_ Bearer header, so gantrycli logout revokes 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_uri is 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 state nonce binds the loopback callback to the exact login invocation; 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.