From 6bd058ac46c771ff7dc133b72f42c6c341dba6f4 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 27 Jul 2026 20:32:42 +0200 Subject: [PATCH] fix: make oidc device code redemption atomic --- backend/internal/oidc/store.go | 77 +++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/backend/internal/oidc/store.go b/backend/internal/oidc/store.go index c155eed7..0f9118e5 100644 --- a/backend/internal/oidc/store.go +++ b/backend/internal/oidc/store.go @@ -432,34 +432,63 @@ func (s *Store) GetDeviceCodeSessionByUserCodeSignature(ctx context.Context, sig } func (s *Store) AcceptDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string, request fosite.DeviceRequester) (string, error) { - userCodeSession, err := s.getSession(ctx, sessionKindUserCode, signature) - if err != nil { - return "", err - } - request.SetUserCodeState(fosite.UserCodeAccepted) + var deviceCodeSignature string + err := withTx(ctx, s.db, func(ctx context.Context) error { + // Verify the currently persisted user-code state because the caller may hold a stale unused request + userCodeSession, err := s.getSession(ctx, sessionKindUserCode, signature) + if err != nil { + return err + } + storedRequest, err := s.decodeDeviceRequester(ctx, userCodeSession.RequestData) + if err != nil { + return err + } + if !userCodeSession.Active || storedRequest.GetUserCodeState() != fosite.UserCodeUnused { + return fosite.ErrNotFound + } - requestData, err := s.encodeDeviceRequester(request) - if err != nil { - return "", err - } + // Prepare the accepted request before claiming the persisted unused state + request.SetUserCodeState(fosite.UserCodeAccepted) + requestData, err := s.encodeDeviceRequester(request) + if err != nil { + return err + } + deviceCodeSession, err := s.getSessionByRequestID(ctx, sessionKindDeviceCode, userCodeSession.RequestID) + if err != nil { + return err + } - deviceCodeSession, err := s.getSessionByRequestID(ctx, sessionKindDeviceCode, userCodeSession.RequestID) - if err != nil { - return "", err - } + // Claim the user code by deactivating it only if no competing acceptance already did so + result := s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND key = ? AND request_id = ? AND active = ?", sessionKindUserCode, signature, userCodeSession.RequestID, true). + Updates(map[string]any{ + "active": false, + "request_data": requestData, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fosite.ErrNotFound + } - err = s.dbFor(ctx). - Model(&OAuth2Session{}). - Where("kind IN ? AND request_id = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, userCodeSession.RequestID). - Updates(map[string]any{ - "request_data": requestData, - }). - Error - if err != nil { - return "", err - } + // Update the paired device-code session only after the user-code claim succeeds + result = s.dbFor(ctx). + Model(&OAuth2Session{}). + Where("kind = ? AND request_id = ? AND active = ?", sessionKindDeviceCode, userCodeSession.RequestID, true). + Update("request_data", requestData) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fosite.ErrNotFound + } - return deviceCodeSession.Key, nil + deviceCodeSignature = deviceCodeSession.Key + return nil + }) + return deviceCodeSignature, err } // Satisfies fositestorage.Transactional