Two refresh requests using the same token, 22ms apart:
12:19:20.239 INFO Refresh token grant requested prefix=zmqGetaqkfNzh2J1
12:19:20.261 INFO Refresh token grant requested prefix=zmqGetaqkfNzh2J1
ERROR tranquil_pds::oauth: Database error in OAuth flow: Constraint violation:
duplicate key value violates unique constraint "oauth_used_refresh_token_pkey"
WARN Refresh token not found
INFO Refresh token reuse within grace period, returning existing tokens
Refresh token rotation currently has a race between validating the token and rotating it.
handle_refresh_token_grant resolves the presented token at
grants.rs L320,
but rotation only happens later at
L437-446.
rotate_token then starts its own transaction and reads current_refresh_token again, without locking the row
(postgres/oauth.rs L221-266).
This means two requests using the same refresh token can both resolve it as Valid and both continue into rotation.
Depending on timing, I can see two ways this goes wrong:
- Both transactions read the same
current_refresh_tokenand try to insert it intooauth_used_refresh_token. Since the token is the primary key, the second insert fails with a unique constraint violation. The transaction rolls back and an otherwise valid refresh request gets a 500. - The second transaction starts after the first one commits. It then reads the new refresh token created by the first request, marks that one as used, and rotates again. The first client now has a refresh token which was already invalidated before it ever had a chance to use it.
The second case is particularly hard to notice because nothing obviously fails at the time.
Recovery currently depends on the 60 second refresh-token grace period (postgres/oauth.rs L48).
Within that window, a retry resolves to InGracePeriod and gets usable tokens back. That seems to be why this normally recovers by itself.
Outside the grace period, the old token only exists in oauth_used_refresh_token, lookup returns Used, and the handler revokes the entire token family
(grants.rs L368-380).
Deleting the oauth_token row also cascades the used-token rows, which seems to explain why later retries log Refresh token not found instead of refresh-token reuse.
So two overlapping refresh requests from the same client can eventually sign the user out.
There is also a logging issue around this. The ERROR above comes from db_err_to_oauth
(oauth/mod.rs L7-10),
which logs every DbError as an ERROR and maps it to ServerError.
That means a unique constraint violation caused by this race looks exactly like an actual database failure, both in the logs and to the client.
P.S. This isn't particularly hard to trigger either. Tangled's client appears to issue overlapping refresh requests during normal use.