Skip to content

Commit d9fec26

Browse files
committed
Fix OAuth credential lock recovery and deletion errors
1 parent 0b6c00d commit d9fec26

7 files changed

Lines changed: 313 additions & 17 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,25 @@ install(
425425
LIBRARY DESTINATION "${INSTALL_LIB_DIR}"
426426
ARCHIVE DESTINATION "${INSTALL_LIB_DIR}")
427427

428+
# Native Windows credential tests use real DPAPI files and separate processes.
429+
# Keep this independent of the POSIX-only launcher/unit-test target below.
430+
option(BUILD_VGI_OAUTH_STORE_TESTS "Build native Windows OAuth credential tests" OFF)
431+
if(WIN32 AND BUILD_VGI_OAUTH_STORE_TESTS)
432+
find_package(Python3 REQUIRED COMPONENTS Interpreter)
433+
add_executable(vgi_oauth_store_probe test/support/oauth_store_probe.cpp)
434+
target_include_directories(vgi_oauth_store_probe PRIVATE src/include duckdb/src/include)
435+
target_link_libraries(vgi_oauth_store_probe
436+
${EXTENSION_NAME}
437+
duckdb_static
438+
dummy_static_extension_loader)
439+
add_custom_target(test_vgi_oauth_store
440+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_oauth_store_windows.py
441+
--probe $<TARGET_FILE:vgi_oauth_store_probe>
442+
DEPENDS vgi_oauth_store_probe
443+
USES_TERMINAL
444+
VERBATIM)
445+
endif()
446+
428447
# Optional C++ unit-test binary for the launcher's pure helpers. Built when
429448
# ``BUILD_VGI_UNIT_TESTS=ON`` is passed to CMake (or as an env var). Pure
430449
# helpers are intentionally DuckDB-free (they throw std::invalid_argument and

‎src/include/vgi_oauth_store.hpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ bool LoadOAuthRefreshToken(const std::string &canonical_key, const OAuthRefreshC
2626
void StoreOAuthRefreshToken(const std::string &canonical_key, const OAuthRefreshContext &binding,
2727
const std::string &cache_mode, const std::string &refresh_token);
2828

29+
// Missing credentials are already deleted. Backend errors propagate in both
30+
// auto and persistent modes so logout cannot report success with a saved token.
2931
void DeleteOAuthRefreshToken(const std::string &canonical_key, const std::string &cache_mode);
3032

3133
} // namespace vgi

‎src/vgi_oauth.cpp‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,19 +2022,20 @@ std::string OAuthCatalogAuth::HandleUnauthorized(const OAuthChallenge &challenge
20222022
// registered OAuth client — falling through would mask the
20232023
// real error behind a misleading "token exchange failed" message.
20242024
std::string refresh_err = e.what();
2025-
if (refresh_err.find("invalid_grant") != std::string::npos) {
2026-
VGI_STDERR_DEBUG("[VGI] oauth.clearing_stale_refresh_token\n");
2027-
if (!canonical_key.empty()) {
2028-
DeleteOAuthRefreshToken(canonical_key, cache_mode_);
2029-
}
2030-
}
2025+
const bool invalid_grant = refresh_err.find("invalid_grant") != std::string::npos;
20312026
if (!lock.owns_lock()) lock.lock();
20322027
if (WasCleared()) throw;
20332028
// Clear stale refresh token on invalid_grant
2034-
if (refresh_err.find("invalid_grant") != std::string::npos) {
2029+
if (invalid_grant) {
20352030
state->token.refresh_token.clear();
20362031
}
2032+
// Finish the state transition before deletion, which can itself
2033+
// fail. Otherwise other connections remain stuck IN_PROGRESS.
20372034
StoreFailed(refresh_err);
2035+
if (invalid_grant && !canonical_key.empty()) {
2036+
VGI_STDERR_DEBUG("[VGI] oauth.clearing_stale_refresh_token\n");
2037+
DeleteOAuthRefreshToken(canonical_key, cache_mode_);
2038+
}
20382039
throw;
20392040
} catch (...) {
20402041
// Non-std::exception throw (rare, but possible from
@@ -2148,14 +2149,16 @@ std::string OAuthCatalogAuth::HandleUnauthorized(const OAuthChallenge &challenge
21482149
void OAuthCatalogAuth::ClearTokens() {
21492150
std::lock_guard<std::mutex> binding_lock(binding_mutex_);
21502151
std::string key;
2152+
uint64_t logout_epoch;
21512153
{
21522154
std::lock_guard<std::mutex> lock(state_->mutex);
2153-
state_->epoch++;
2155+
logout_epoch = ++state_->epoch;
21542156
if (!state_->refresh_ctx.issuer.empty()) {
21552157
key = OAuthSessionKey(profile_, state_->refresh_ctx);
21562158
}
21572159
state_->token = OAuthTokenSet();
2158-
state_->refresh_ctx = OAuthRefreshContext();
2160+
// Retain the binding until deletion succeeds so a failed logout can be
2161+
// retried against the same stored credential.
21592162
state_->status = AuthState::Status::IDLE;
21602163
state_->owner = std::thread::id();
21612164
state_->error_message.clear();
@@ -2165,6 +2168,12 @@ void OAuthCatalogAuth::ClearTokens() {
21652168
auto lease = AcquireOAuthCredentialLease(key, cache_mode_);
21662169
DeleteOAuthRefreshToken(key, cache_mode_);
21672170
}
2171+
{
2172+
std::lock_guard<std::mutex> lock(state_->mutex);
2173+
if (state_->epoch == logout_epoch && state_->status == AuthState::Status::IDLE) {
2174+
state_->refresh_ctx = OAuthRefreshContext();
2175+
}
2176+
}
21682177
}
21692178

21702179
bool OAuthCatalogAuth::GetTokenInfo(TokenInfo &info) {

‎src/vgi_oauth_store.cpp‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,11 @@ class WindowsLease final : public OAuthCredentialLease {
231231
explicit WindowsLease(const std::string &key) {
232232
std::string name = "Local\\QueryFarm.VGI.OAuth." + VgiSha256Hex(key);
233233
handle_ = CreateMutexA(nullptr, FALSE, name.c_str());
234-
if (!handle_ || WaitForSingleObject(handle_, INFINITE) != WAIT_OBJECT_0) {
234+
const auto result = handle_ ? WaitForSingleObject(handle_, INFINITE) : WAIT_FAILED;
235+
// WAIT_ABANDONED grants ownership after the previous owner exits. Keep
236+
// the lease; the caller reloads and validates the protected record before
237+
// using it, and the destructor must release this ownership normally.
238+
if (result != WAIT_OBJECT_0 && result != WAIT_ABANDONED) {
235239
if (handle_)
236240
CloseHandle(handle_);
237241
throw IOException("VGI OAuth: could not acquire credential cache lease");
@@ -307,6 +311,9 @@ void PlatformStore(const std::string &key, const std::string &data) {
307311
void PlatformDelete(const std::string &key) {
308312
std::error_code ec;
309313
std::filesystem::remove(StorePath(key), ec);
314+
if (ec) {
315+
throw IOException("VGI OAuth: DPAPI cache delete failed (Windows error %d)", ec.value());
316+
}
310317
}
311318

312319
void PlatformPrune() {
@@ -389,12 +396,9 @@ void DeleteOAuthRefreshToken(const std::string &key, const std::string &mode) {
389396
if (!WantsPersistence(mode))
390397
return;
391398
#if defined(__APPLE__) || defined(_WIN32)
392-
try {
393-
PlatformDelete(key);
394-
} catch (...) {
395-
if (mode != "auto")
396-
throw;
397-
}
399+
// Forgetting an existing credential must report failure even in auto mode:
400+
// falling back to memory would leave the saved token available to reconnect.
401+
PlatformDelete(key);
398402
#endif
399403
}
400404

‎test/README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,25 @@ make test
88
or
99
```bash
1010
make test_debug
11-
```
11+
```
12+
13+
## Windows OAuth credential storage
14+
15+
Configure the extension build with `-DBUILD_VGI_OAUTH_STORE_TESTS=ON`, then run:
16+
17+
```powershell
18+
cmake --build build/release --config Release --target test_vgi_oauth_store
19+
```
20+
21+
This target needs Python 3 and runs the production credential store in separate
22+
Windows processes. It checks DPAPI persistence and replacement, concurrent
23+
updates, recovery after a lock owner crashes, and deletion errors in both
24+
`auto` and `persistent` modes. An isolated `LOCALAPPDATA` directory contains only
25+
synthetic credentials and is removed after each test. The suite does not contact
26+
an OAuth provider or access existing user credentials.
27+
28+
To rerun an already-built probe directly:
29+
30+
```powershell
31+
python test/test_oauth_store_windows.py --probe build/release/extension/vgi/vgi_oauth_store_probe.exe
32+
```

‎test/support/oauth_store_probe.cpp‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// © Copyright 2026 Query Farm LLC - https://query.farm
2+
// Child process for the native Windows OAuth store tests.
3+
4+
#include "vgi_oauth.hpp"
5+
#include "vgi_oauth_store.hpp"
6+
7+
#ifndef WIN32_LEAN_AND_MEAN
8+
#define WIN32_LEAN_AND_MEAN
9+
#endif
10+
#include <windows.h>
11+
12+
#include <chrono>
13+
#include <iostream>
14+
#include <stdexcept>
15+
#include <string>
16+
#include <thread>
17+
18+
using namespace duckdb::vgi;
19+
20+
static void Check(bool condition, const char *message) {
21+
if (!condition) {
22+
throw std::runtime_error(message);
23+
}
24+
}
25+
26+
static OAuthRefreshContext TestBinding() {
27+
OAuthRefreshContext ctx;
28+
ctx.token_endpoint = "https://issuer.example.invalid/token";
29+
ctx.issuer = "https://issuer.example.invalid";
30+
ctx.resource = "https://resource.example.invalid/vgi";
31+
ctx.client_id = "test-client";
32+
ctx.scope = "openid offline_access";
33+
return ctx;
34+
}
35+
36+
int main(int argc, char **argv) {
37+
try {
38+
Check(argc >= 4, "Usage: oauth_store_probe operation key mode [value]");
39+
const std::string operation = argv[1], key = argv[2], mode = argv[3];
40+
const auto binding = TestBinding();
41+
auto lease = AcquireOAuthCredentialLease(key, mode);
42+
// All persistent test cases have an available backend. In particular,
43+
// auto must not silently fall back after encountering an abandoned mutex.
44+
Check(bool(lease) == (mode == "persistent" || mode == "auto"), "Unexpected credential lease availability");
45+
if (operation == "store") {
46+
Check(argc == 5, "Missing synthetic token");
47+
StoreOAuthRefreshToken(key, binding, mode, argv[4]);
48+
} else if (operation == "read") {
49+
Check(argc == 5, "Missing expected token");
50+
std::string token;
51+
Check(LoadOAuthRefreshToken(key, binding, mode, token), "Credential missing");
52+
Check(token == argv[4], "Unexpected stored token");
53+
} else if (operation == "absent") {
54+
std::string token;
55+
Check(!LoadOAuthRefreshToken(key, binding, mode, token), "Credential unexpectedly present");
56+
} else if (operation == "delete") {
57+
DeleteOAuthRefreshToken(key, mode);
58+
} else if (operation == "increment") {
59+
std::string token;
60+
Check(LoadOAuthRefreshToken(key, binding, mode, token), "Counter missing");
61+
std::this_thread::sleep_for(std::chrono::milliseconds(75));
62+
StoreOAuthRefreshToken(key, binding, mode, std::to_string(std::stoi(token) + 1));
63+
} else if (operation == "hold") {
64+
std::cout << "LOCKED" << std::endl;
65+
Sleep(INFINITE);
66+
} else {
67+
throw std::runtime_error("Unknown operation");
68+
}
69+
std::cout << "OK " << operation << '\n';
70+
return 0;
71+
} catch (const std::exception &e) {
72+
std::cerr << e.what() << '\n';
73+
return 1;
74+
}
75+
}

0 commit comments

Comments
 (0)