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.
When
rbw syncruns with a stored refresh token that the server has rejected (expired, revoked, server-side invalidation, etc), the CLI shows this error: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(andexchange_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_tokenandexchange_refresh_token_asyncfunctions)Compare to
login()andsync(), which both checkres.status()first and dispatch to a structured error path.exchange_refresh_tokenskips this and tries to deserialize whatever it gets asConnectRefreshTokenRes { access_token: String }. When the server returns a non-200 with a JSON error body (which has noaccess_tokenfield), serde fails with the parse error above.Reproduction
Against Vaultwarden 1.35.2 (also confirmed on bitwarden.com — the response shape is identical):
Returns HTTP 400 with body:
Body is exactly 393 bytes, matching the
column 393in the error message.Note: the
column Nin 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 showscolumn 25(a much shorter error response, possibly from an older Bitwarden server version) while a modern Vaultwarden returnscolumn 393. Same bug, different response sizes.Suggested fix
Mirror the pattern from
login(): checkres.status()before deserializing, and on non-200 return a dedicated error likeError::RefreshTokenRejected(or reuseError::RequestUnauthorized) so the agent can either prompt for re-login automatically or surface a clearer message telling the user to runrbw purge && rbw login.Environment
Workaround for anyone else hitting this
rbw stop-agent
rbw purge
rbw login
rbw sync
rbw purgeclears the local cache and tokens but preserves~/.config/rbw/config.json.