Repository navigation
Expired refresh token returns 403, but the CLI's halt branch keys on 401 — expiry fails open instead of stopping the command #2297
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:
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); // 401bin/cliOperations.ts, refreshExpiredOperationToken:
} 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
Authorizationheader 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 deliberateprocess.exit(1). - Payload
username=/password=also present — the legacy fallback at the end ofresolveRequestOptionsapplies 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:
} 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 — 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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity