HarperFast / HarperFast/harper
Expired refresh token returns 403, but the CLI's halt branch keys on 401 — expiry fails open instead of stopping the command
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
`refreshExpiredOperationToken` halts the command when the server rejects a refresh token, but it keys that halt on `401` alone. Harper answers an **expired** refresh token with `403`, so expiry — the one rejection that is guaranteed to happen to every refresh token — falls through to the non-fatal branch and the command continues.
## Mechanism
`security/tokenAuthentication.ts`, `validateRefreshToken`:
```ts
if (err?.name === 'TokenExpiredError') {
throw new ClientError(AUTHENTICATION_ERROR_MSGS.TOKEN_EXPIRED, HTTP_STATUS_CODES.FORBIDDEN); // 403
}
throw new ClientError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); // 401
```
`bin/cliOperations.ts`, `refreshExpiredOperationToken`:
```ts
} else if (refreshResponse.statusCode === 401) {
console.error('Refresh token expired or invalid. Please run harper login again.');
process.exit(1);
} else {
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`); // 403 lands here
}
```
So the message that names expiry is unreachable for an actually-expired token. A malformed or unrecognized token (401) halts correctly; an expired one does not.
## Why this is worth fixing rather than documenting
The trigger is structural, not exotic: every refresh token expires at `operationsApi.authentication.refreshTokenTimeout` (default `30d`), and `harper login --for-ci` exists to mint exactly these for pipelines. Day 31 is not an edge case, it is the scheduled end state.
**What the caller sees.** `refreshExpiredOperationToken` returns without setting `tokens.operation_token`, so:
- **Refresh token only** — the CI shape — no `Authorization` header is attached and the operation goes out unauthenticated. It fails, but with the operation's own 401 rather than the actionable "run harper login again", and the exit path is the operation's, not the deliberate `process.exit(1)`.
- **Payload `username=`/`password=` also present** — the legacy fallback at the end of `resolveRequestOptions` applies them, and the operation **succeeds under that identity instead of the configured one**. Not a privilege escalation (the caller supplied valid credentials), but it is a silent identity substitution where the intended behavior is a hard stop.
- **An expired operation token also present** — that token stays attached and is sent, so the server rejects it.
Only the first is common; the second is the one that concerns me, because the failure is a successful-looking run under the wrong principal.
## Suggested fix
Treat `403` as a rejection alongside `401` in the halt branch. Both mean "the server will not accept this refresh token", and neither is retryable:
```ts
} else if (refreshResponse.statusCode === 401 || refreshResponse.statusCode === 403) {
```
If the two need distinguishing in the message, `403` is specifically expiry and can say so — it is the more actionable of the pair.
Worth considering alongside it: a `200` response carrying no `operation_token` is currently not reported at all, so that path is silent as well.
## Versions
Present since the token env vars shipped in **v5.2.0** (#1876) and unchanged on `main` at v5.2.4 — verified by reading `origin/main`, not a stale checkout.
## Provenance
Found while documenting CLI token credentials for [HarperFast/documentation#630](https://github.com/HarperFast/documentation/pull/630) — the docs asserted the halt as a guarantee, which is what made the mismatch visible. That PR now carries a warning describing the actual behavior; if this is fixed, the warning should come back out, since it will otherwise be wrong on the live `/reference/v5` site.
Contributor guide
Research direction
Start in security/tokenAuthentication.ts and bin/cliOperations.ts, tracing validateRefreshToken through refreshExpiredOperationToken. Update the halt path so both rejected refresh-token statuses stop the command with the actionable login message, then verify that an expired token no longer reaches the non-fatal branch; also inspect the mentioned silent 200-without-token path separately.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nodejs, typescript
- Domain
- authentication, cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100