@@ -2,7 +2,9 @@ package oauth
22
33import (
44 "context"
5+ "crypto/sha256"
56 "database/sql"
7+ "encoding/hex"
68 "errors"
79 "fmt"
810
@@ -34,7 +36,7 @@ func (r *Repository) Upsert(ctx context.Context, userID int64, token *Token) err
3436 model .AccessToken = encAccess
3537 model .RefreshToken = encRefresh
3638
37- if _ , err : = r .db .NewInsert ().Model (model ).On ("DUPLICATE KEY UPDATE" ).Exec (ctx ); err != nil {
39+ if _ , err = r .db .NewInsert ().Model (model ).On ("DUPLICATE KEY UPDATE" ).Exec (ctx ); err != nil {
3840 return fmt .Errorf ("failed to upsert token: %w" , err )
3941 }
4042
@@ -46,12 +48,11 @@ func (r *Repository) Upsert(ctx context.Context, userID int64, token *Token) err
4648// no row matched, which happens if the token was deleted (or replaced) while
4749// the refresh request was in flight.
4850func (r * Repository ) Update (ctx context.Context , userID int64 , currentRefreshToken string , token * Token ) (bool , error ) {
49- // The stored refresh token is ciphertext, so the optimistic-concurrency
50- // match must compare against the encrypted current value.
51- encCurrentRefresh , err := r .enc .Encrypt (currentRefreshToken )
52- if err != nil {
53- return false , fmt .Errorf ("failed to encrypt current refresh token: %w" , err )
54- }
51+ // The stored refresh token is ciphertext with a fresh random nonce per
52+ // value, so it is never stable across calls. Use a deterministic fingerprint
53+ // of the plaintext refresh token for optimistic concurrency instead.
54+ currentFingerprint := fingerprint (currentRefreshToken )
55+
5556 encAccess , err := r .enc .Encrypt (token .AccessToken )
5657 if err != nil {
5758 return false , fmt .Errorf ("failed to encrypt access token: %w" , err )
@@ -65,11 +66,12 @@ func (r *Repository) Update(ctx context.Context, userID int64, currentRefreshTok
6566 Model ((* tokenModel )(nil )).
6667 Set ("access_token = ?" , encAccess ).
6768 Set ("refresh_token = ?" , encRefresh ).
69+ Set ("token_fingerprint = ?" , fingerprint (token .RefreshToken )).
6870 Set ("scopes = ?" , token .Scopes ).
6971 Set ("expires_at = ?" , token .ExpiresAt ).
7072 Set ("updated_at = ?" , token .UpdatedAt ).
7173 Where ("user_id = ?" , userID ).
72- Where ("refresh_token = ?" , encCurrentRefresh ).
74+ Where ("token_fingerprint = ?" , currentFingerprint ).
7375 Exec (ctx )
7476 if err != nil {
7577 return false , fmt .Errorf ("failed to update token: %w" , err )
@@ -118,3 +120,11 @@ func (r *Repository) Delete(ctx context.Context, userID int64) error {
118120 }
119121 return nil
120122}
123+
124+ // fingerprint returns a stable SHA-256 hex digest of the plaintext refresh
125+ // token. Because AES-GCM seals with a random nonce, the stored ciphertext is
126+ // not suitable for optimistic-concurrency checks; the fingerprint is.
127+ func fingerprint (s string ) string {
128+ sum := sha256 .Sum256 ([]byte (s ))
129+ return hex .EncodeToString (sum [:])
130+ }
0 commit comments