Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a83c869
docs(cli): slot token credentials into the canonical auth precedence
dawsontoth Aug 17, 2026
50292cb
docs(release-notes): cover CI token credentials in 5.2
dawsontoth Aug 17, 2026
613c2e8
docs(cli): a blank token namespace is skipped, not a hard failure
dawsontoth Aug 21, 2026
5bd6ec0
docs(cli): narrow two token-handling claims to what 5.2.4 actually does
dawsontoth Aug 24, 2026
f8e5064
docs(cli): split refresh-failure behavior by credential shape
dawsontoth Aug 24, 2026
82412b6
docs(release-notes): scope the refresh-failure caveat to the CI crede…
dawsontoth Aug 24, 2026
4c1da6c
docs(cli): expiry answers 403 and does not halt the command
dawsontoth Aug 24, 2026
a8b252d
docs(cli): a lost target falls back to the saved login, not straight …
dawsontoth Aug 24, 2026
8b2c9ea
docs(cli): state the one-credential-style rule once, up front
dawsontoth Aug 24, 2026
9fa2436
docs(cli): point the 403 warning at its tracking issue
dawsontoth Aug 24, 2026
f0d42ca
docs(cli): loopback targets make a token failure succeed, not fail
dawsontoth Aug 24, 2026
f34693f
docs(cli): resolve the contradiction the loopback bullet created
dawsontoth Aug 24, 2026
8f5c882
docs(cli): the loopback case needs configuration, not a result check
dawsontoth Aug 24, 2026
366b09c
docs(cli): only authorizeLocal:false closes the loopback exposure, an…
dawsontoth Aug 24, 2026
6e8e01b
docs(cli): the socket rule is also the limit of the loopback remediation
dawsontoth Aug 24, 2026
3cd66b4
docs(cli): scope the loopback remediation to a pointer, keep the hazard
dawsontoth Aug 24, 2026
59691d4
docs(security): give authorizeLocal the facts the CLI page defers to it
dawsontoth Aug 24, 2026
1a8dde1
docs(cli): local authorization applies only when no credential is att…
dawsontoth Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 96 additions & 3 deletions reference/cli/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ harper get_components
harper set_configuration logging_level=info
```

When no `target` parameter is specified, the CLI defaults to using the local domain socket connection, providing secure, authenticated access to the local Harper instance.
When no `target` parameter is specified, the CLI falls back to the target saved by a previous `harper login` if there is one, and only otherwise to the local domain socket connection, which gives authenticated access to the local Harper instance. See [Authentication Precedence](#authentication-precedence) for what that means on a shared machine.

## Remote Operations

Expand All @@ -42,11 +42,28 @@ For remote Operations API commands, the CLI uses the first complete authenticati
2. Credentials embedded in the `target` URL
3. `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` environment variables
4. Legacy `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` environment variables
5. A token saved by `harper login`
6. `username=` and `password=` operation parameters (legacy fallback)
5. `HARPER_CLI_OPERATION_TOKEN` and `HARPER_CLI_REFRESH_TOKEN` environment variables, or their legacy `CLI_TARGET_` equivalents — see [Token credentials for CI/CD](#token-credentials-for-cicd)
6. A token saved by `harper login`
7. `username=` and `password=` operation parameters (legacy fallback)

:::tip
**Configure one credential style per context, not two.** Precedence exists to resolve a conflict, but it resolves it silently, and the ways this page describes for authentication to go wrong all need two styles live at once: a payload `username=`/`password=` pair takes over when a token stops resolving, a blank token variable hands the run to whatever saved login the machine has. Pick one and leave the others unset:

| Context | Use |
| ----------------------- | ----------------------------------- |
| CI/CD pipeline | `HARPER_CLI_REFRESH_TOKEN` |
| A one-off admin command | `auth_username=` / `auth_password=` |
| Local development | `harper login` |

Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. One caveat: this rule bounds _which_ credential is used, not what happens when none resolves. A loopback node authorizes a request that arrives with no credential at all as superuser, so the style matters less there than whether a credential is attached — see the refresh-behavior note below.
:::

Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used.

Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below.

Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius.

Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`.

### Authentication Methods
Expand Down Expand Up @@ -94,9 +111,17 @@ Starting in v5.2.0, a complete environment-variable credential pair takes preced
- `HARPER_CLI_TARGET` - Sets the default `target` for CLI commands. `CLI_TARGET` is the legacy equivalent.
- `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` - Preferred credential pair for the target.
- `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` - Lower-priority legacy credential pair.
- `HARPER_CLI_REFRESH_TOKEN` - Long-lived token the CLI exchanges for a fresh operation token on each run. `CLI_TARGET_REFRESH_TOKEN` is the legacy equivalent.
- `HARPER_CLI_OPERATION_TOKEN` - A short-lived operation token supplied directly, for callers that mint their own. `CLI_TARGET_OPERATION_TOKEN` is the legacy equivalent.

Each credential namespace is independent. For example, the CLI never combines `HARPER_CLI_USERNAME` with `CLI_TARGET_PASSWORD`. If either namespace supplies only a username or only a password, that incomplete pair is skipped with a warning.

The same rule holds for tokens: whichever namespace supplies a token owns both halves of it, so an operation token from one namespace is never paired with a refresh token from the other.

The namespace is chosen by which one is **set**, not by which one has a usable value — so `HARPER_CLI_REFRESH_TOKEN=` (present but empty) claims the choice and shadows a perfectly good `CLI_TARGET_REFRESH_TOKEN`, which is never consulted. The run then falls through to the saved login token. Unset the preferred variable rather than blanking it.

For a pipeline, a token is the right style: it is scoped to authentication, it can be revoked without changing the account password, and it cannot be used to log in interactively. Use it _instead of_ a password or payload credentials, not alongside them — see [Token credentials for CI/CD](#token-credentials-for-cicd).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reference/cli/overview.md:160-168 still tells CI users to use HARPER_CLI_USERNAME/HARPER_CLI_PASSWORD, omits both token variables, and says the displayed sources are resolved "in the order shown." That leaves the overview inconsistent with this recommendation and the canonical precedence list. Please add token credentials (ideally including the --for-ci path) there, or reword the overview as an intentionally abbreviated list without the ordering claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a8dde1 — added token credentials with the --for-ci pointer, moved the CI/CD recommendation onto them, and replaced the ordering claim with an explicit note that the list is abbreviated and not in precedence order (pointing at the canonical list for the real order).

Thank you for catching this — it is a symmetry miss on my part. I updated authentication.md and commands.md and never grepped for other pages making precedence claims, which is exactly the check I should have run when the same pattern spans several surfaces. Worth noting the overview also still recommended username/password for CI, which now contradicts the one-style guidance this PR adds, so it was two inconsistencies rather than one.

🤖 Addressed by Claude Code


**Example `.env` file**:

```bash
Expand Down Expand Up @@ -162,6 +187,74 @@ harper add_user \
target=https://prod-server.com:9925
```

##### Token credentials for CI/CD

<VersionBadge version="v5.2.0" />

Rather than storing an admin password in your CI provider, log in once locally and hand CI a **refresh token**. The CLI mints a fresh, short-lived operation token from it on every run, so the only durable secret the pipeline holds is a revocable token.

`harper login --for-ci` writes the variables CI needs to **stdout** in `.env` format — and nothing else, so the output pipes cleanly. Everything a human reads (banner, prompts, status, warnings) goes to stderr:

```bash
# Set both GitHub Actions secrets in one command — the token is never displayed
harper login --for-ci | gh secret set --env-file -
```

The block it emits:

```bash
HARPER_CLI_TARGET=https://example.com:9925/
HARPER_CLI_REFRESH_TOKEN=eyJhbGciOi...
```

Because stdout carries only these two lines, piping it into a secret store keeps the token off your screen and out of your shell history. Piping it to the clipboard does not: a clipboard-history tool will keep a copy on disk, and since a user holds exactly one refresh token at a time, that copy is the pipeline's live credential. Pipe it to the thing that will store it — which is not true of copying it out of terminal output by hand. **Pipe it.** There is no guard on a terminal stdout, so running `harper login --for-ci` bare prints the refresh token into your scrollback, where the terminal may persist it. (Interactively the command does first ask you to confirm minting for that user, since doing so revokes any token the user already holds; that prompt is skipped when stdin is not a TTY.) If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing.

Expose the two values to the deploy step and no other credentials are needed:

```yaml
- name: Deploy
run: harper deploy project=my-app restart=true replicated=true
env:
HARPER_CLI_TARGET: ${{ secrets.HARPER_CLI_TARGET }}
HARPER_CLI_REFRESH_TOKEN: ${{ secrets.HARPER_CLI_REFRESH_TOKEN }}
```

**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as **malformed or unrecognized** answers `401`, and the CLI stops with a non-zero exit and a "run harper login again" message.

:::warning
**An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401`, and the CLI's halt branch keys on `401` alone. This is a defect rather than intended behavior, tracked as [harper#2297](https://github.com/HarperFast/harper/issues/2297); this note should come out when it is fixed. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting.

Do not build a runbook around a non-zero exit at day 31 — watch the operation's own result. That is sufficient for a **remote** target, where removing payload credentials makes expiry fail visibly.

It is not sufficient for a **loopback** target. `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`, so an expired token there produces a genuinely successful, fully privileged run — indistinguishable from a correctly authenticated one. No check on the exit code or the result can catch it, because there is nothing failing to observe.

**The fix is configuration, not monitoring**, and it belongs to the node rather than the CLI: local authorization has to be turned off on any Harper instance a CI runner can reach. `authentication.authorizeLocal: false` closes the loopback-TCP path, but it is not the whole story — the operations API domain socket is trusted by a separate rule the flag does not gate, and a same-host reverse proxy makes ordinary remote traffic arrive as loopback. Get the full picture, and the restart requirement, from [`authorizeLocal`](../security/configuration.md#authorizelocal) before relying on any of it.

The point for a pipeline author is narrower: **an expired token against a node with local authorization enabled produces a successful, fully privileged run.** No exit code and no result check can detect it. If you cannot confirm how local authorization is configured on the node you deploy to, do not rely on token expiry surfacing as a failure.
:::

A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied:

- **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. Against a remote target the command fails as unauthenticated — unless it also carries `username=` and `password=` operation parameters, in which case it authenticates as that pair instead, a different identity than the one you configured.
- **Refresh token only, against a loopback target**: it does not fail at all. `authentication.authorizeLocal` defaults to `true`, so a request from `127.0.0.1` or `::1` is authenticated as **superuser** with no credential checked. On a self-hosted runner pointed at its own node, an expired token therefore produces a green, fully privileged run rather than an error. This is the failure mode least likely to be noticed.
- **An expired operation token as well**: that token is still attached, so the request carries an `Authorization` header and the server validates it — rejecting it with `403`, since expiry answers `403` on the operations path too. You get a rejection rather than a silent identity switch, but do not alarm on `401` for it.

The difference between those two outcomes is whether an `Authorization` header is sent at all, and it is the hinge for the loopback case above: local authorization is only consulted when a request arrives with **no** credential. A request carrying a token — even an expired one — is validated and rejected on its merits, loopback or not. A request carrying nothing is what a loopback node authorizes as superuser. So the dangerous shape is precisely the refresh-token-only one, where a failed refresh leaves no header to validate.

A `200` response that contains no `operation_token` is not reported at all. A refresh failure therefore does not reliably halt a pipeline, and a zero exit is not proof that the identity you configured is the one that ran.

**A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. Treat that warning as a CI failure signal: a blank secret does not stop the run, it changes which identity performs it.

**Lifetimes.** Operation tokens expire after `authentication.operationTokenTimeout` (default `1d`) and refresh tokens after `authentication.refreshTokenTimeout` (default `30d`). The pipeline needs a new refresh token when that window closes.

:::warning
**Each user holds only one valid refresh token at a time.** Harper stores a single refresh-token hash per user, so minting a new one revokes that user's previous token. A routine local `harper login` as the same account will break a pipeline holding the older token, and the failure only surfaces on the pipeline's next refresh.

Create a **dedicated CI user** and run `harper login --for-ci` as that user. That scopes the pipeline's permissions to what it actually needs, and confines the blast radius of a leak to that account.

There is no operation that revokes a refresh token directly. To invalidate one, either run `harper login --for-ci` again as that user — minting overwrites the stored hash, so the previous token stops working — or deactivate the user with `alter_user`. `harper logout` is not a revocation: it deletes your local copy and leaves the server-side hash valid.
:::

#### Method 3: Dedicated Authentication Parameters

<VersionBadge version="v5.2.0" />
Expand Down
14 changes: 14 additions & 0 deletions reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ harper login <URL>
**Optional Parameters**:

- `<URL>` - The URL of the Harper instance to log in to.
- `--for-ci` - After logging in, print CI/CD credentials to stdout. Available since v5.2.0.

**Prompts**:

Expand All @@ -164,6 +165,19 @@ You'll be asked to type in the following information:
- `<username>` - Harper admin username.
- `<password>` - Harper admin password.

#### `--for-ci`

<VersionBadge version="v5.2.0" />

Prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to **stdout** in `.env` format — and nothing else, so the output pipes directly into a secret store without the token being displayed. Everything else (banner, prompts, status, warnings) goes to stderr:

```bash
# Set both GitHub Actions secrets in one command
harper login --for-ci | gh secret set --env-file -
```

Run this as a **dedicated CI user**, not your own account: a user holds only one valid refresh token at a time, so issuing one for CI revokes any other token that user already had. See [Token credentials for CI/CD](authentication.md#token-credentials-for-cicd) for how the CLI consumes these variables and where they sit in authentication precedence.

### `harper logout`

Available since: v5.0.0
Expand Down
5 changes: 3 additions & 2 deletions reference/cli/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,12 @@ Provide credentials via:

- **Dedicated authentication parameters**: Use `auth_username=<user> auth_password=<pass>` for one-off commands
- **Target URL credentials**: Embed a complete username and password in the URL (supported, but not recommended because URLs are easily exposed)
- **Environment variables and `.env` files**: Use `HARPER_CLI_TARGET`, `HARPER_CLI_USERNAME`, and `HARPER_CLI_PASSWORD` (recommended for CI/CD and project-specific configuration)
- **Environment variables and `.env` files**: Use `HARPER_CLI_TARGET` with `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` (project-specific configuration)
- **Token credentials**: `HARPER_CLI_REFRESH_TOKEN`, provisioned with [`harper login --for-ci`](./authentication.md#token-credentials-for-cicd) (recommended for CI/CD)
- **Persistent Login**: `harper login` to store tokens (recommended for local development)
- **Legacy parameters**: `username=<user> password=<pass>` remain a fallback when no higher-priority authentication source is available

Starting in v5.2.0, these sources are resolved in the order shown. See [CLI Authentication](./authentication.md#authentication-precedence) for the complete order, including the preferred and legacy environment-variable namespaces.
This list is abbreviated and is **not** in precedence order. See [Authentication Precedence](./authentication.md#authentication-precedence) for the resolution order, which changed in v5.2.0, and for the preferred and legacy environment-variable namespaces. Configure one style per context rather than combining them.

**Example: Persistent Login and `.env`**:

Expand Down
2 changes: 1 addition & 1 deletion reference/configuration/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ authentication:
- `cacheTTL` — Session cache duration (ms); _Default_: `30000`
- `enableSessions` — Cookie-based sessions; _Default_: `true`
- `operationTokenTimeout` — Access token lifetime; _Default_: `1d`
- `refreshTokenTimeout` — Refresh token lifetime; _Default_: `1d`
- `refreshTokenTimeout` — Refresh token lifetime; _Default_: `30d`
- `logging` — Authentication event logging (Added in: v4.6.0); sub-options: `path`, `level`, `tag`, `stdStreams`. See [Logging Configuration](../logging/configuration.md)

---
Expand Down
10 changes: 9 additions & 1 deletion reference/security/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ authentication:

_Type: boolean — Default: `true`_

Automatically authorizes requests from the loopback IP address (`127.0.0.1`) as the superuser, without requiring credentials. Disable this for any Harper server that may be accessed by untrusted users from the same instance — for example, when using a local proxy or for general server hardening.
Automatically authorizes requests from the loopback IP address (`127.0.0.1` or `::1`) as the superuser, without requiring credentials. Disable this for any Harper server that may be accessed by untrusted users from the same instance — for example, when using a local proxy or for general server hardening.

Three things to know before relying on either setting:

- **It is read once at startup.** Changing it does not affect a running instance; restart Harper for the new value to take effect.
- **A same-host reverse proxy makes remote traffic look local.** If a proxy on the Harper node forwards to Harper, requests arrive from `127.0.0.1` however remote the original client was, so leaving this enabled authorizes those clients as superuser. Choosing a non-loopback target URL does not avoid this.
- **It does not gate the operations API domain socket.** A connection arriving on that socket is authorized as superuser regardless of this setting, which is what lets local `harper` CLI commands work without credentials. Disabling this option therefore does not protect a proxy whose upstream is the socket — do not expose the socket through a proxy, and treat filesystem permissions on it as the access control.

For why this matters to a CI pipeline whose token has expired, see [Token credentials for CI/CD](../cli/authentication.md#token-credentials-for-cicd).

### `cacheTTL`

Expand Down
Loading
Loading