Skip to content

Expired refresh token returns 403, but the CLI's halt branch keys on 401 — expiry fails open instead of stopping the command #2297

Open

Description

@dawsontoth

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);   // 401

bin/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 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:

} 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.

Activity

  1. added this to the v5.3 milestone on Aug 24, 2026
  2. added theissue type on Aug 24, 2026
  3. modified the milestones: v5.3, v5.4 on Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions