Skip to content

exchange_refresh_token surfaces server errors as JSON parse failures instead of useful message #342

Description

@elwynelwyn

When rbw sync runs with a stored refresh token that the server has rejected (expired, revoked, server-side invalidation, etc), the CLI shows this error:

rbw sync: failed to sync database from server: failed to parse JSON: missing field access_token at line 1 column 393

The actual problem is "your refresh token is no longer valid, please re-login", but it's surfaced as a JSON deserialization failure, which sent me down a debugging rabbit hole (checking server versions, API compatibility, etc).

This is possibly the root cause of #32 - same erorr message, no diagnosis on that thread.

LLM analysis

🤖 disclaimer: the following sections are LLM generated, I am not familiar with the rbw codebase 🤖

Click to expand: 🤖 root cause, repro, suggested fix

Root cause

Client::exchange_refresh_token (and exchange_refresh_token_async) don't check the HTTP status code before attempting to deserialize the response body:
https://github.com/doy/rbw/blob/main/src/api.rs (the exchange_refresh_token and exchange_refresh_token_async functions)

let res = client
    .post(self.identity_url("/connect/token"))
    .form(&connect_req)
    .send()
    .map_err(|source| Error::Reqwest { source })?;
let connect_res: ConnectRefreshTokenRes = res.json_with_path()?;
Ok(connect_res.access_token)

Compare to login() and sync(), which both check res.status() first and dispatch to a structured error path. exchange_refresh_token skips this and tries to deserialize whatever it gets as ConnectRefreshTokenRes { access_token: String }. When the server returns a non-200 with a JSON error body (which has no access_token field), serde fails with the parse error above.

Reproduction

Against Vaultwarden 1.35.2 (also confirmed on bitwarden.com — the response shape is identical):

curl -s -X POST https://your-server/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token&client_id=cli&refresh_token=not-a-real-token"

Returns HTTP 400 with body:

{"message":"Unable to refresh login credentials: Invalid refresh token","validationErrors":{"":["Unable to refresh login credentials: Invalid refresh token"]},"errorModel":{"message":"Unable to refresh login credentials: Invalid refresh token","object":"error"},"error":"","error_description":"","exceptionMessage":null,"exceptionStackTrace":null,"innerExceptionMessage":null,"object":"error"}

Body is exactly 393 bytes, matching the column 393 in the error message.

Note: the column N in the parse error will always equal the length of the server's error response body for the user's particular server/configuration. This explains why #32 shows column 25 (a much shorter error response, possibly from an older Bitwarden server version) while a modern Vaultwarden returns column 393. Same bug, different response sizes.

Suggested fix

Mirror the pattern from login(): check res.status() before deserializing, and on non-200 return a dedicated error like Error::RefreshTokenRejected (or reuse Error::RequestUnauthorized) so the agent can either prompt for re-login automatically or surface a clearer message telling the user to run rbw purge && rbw login.

Environment

  • rbw 1.15.0 (from nixpkgs)
  • Self-hosted Vaultwarden 1.35.2
  • Linux

Workaround for anyone else hitting this

rbw stop-agent
rbw purge
rbw login
rbw sync
rbw purge clears the local cache and tokens but preserves ~/.config/rbw/config.json.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions