Skip to content

Commit 4908eea

Browse files
rustyconoverclaude
andcommitted
feat(oauth): surface device-code prompt in Google Colab
In a Colab notebook, OAuth login appeared to hang: the device-code prompt (verification URL + user code) was only emitted via DUCKDB_LOG_WARNING, which the DuckDB Python client never surfaces, so the user never saw the code. Colab captures the kernel's C-level stderr into the cell, so: - Add IsColabEnvironment() (COLAB_RELEASE_TAG / COLAB_GPU / COLAB_JUPYTER_IP) and PrintPromptIfColab(): write the device-code prompt, "still waiting", and "success" messages (plus the PKCE visit-URL fallback) to stderr+fflush there. - PerformAuthFlow auto-mode also routes Colab to the device flow, so it never falls into the unusable server-side PKCE/localhost path. Also harden EnforceHttpsUrl: the loopback allowance used a prefix match with no host boundary, so http://127.0.0.1.evil.com / http://localhost.evil.com slipped past the HTTPS requirement (plaintext-downgrade on discovered token endpoints). Replaced with IsLoopbackHttpUrl(), which requires a ':'/'/'/end boundary after the loopback host and adds http://[::1]. The two pure helpers live in a dependency-free src/vgi_oauth_env.cpp so the Catch2 unit-test binary can exercise them without the OAuth/HTTP/Arrow link surface; test/cpp/test_oauth.cpp covers Colab detection and the loopback allow-list (incl. look-alike rejections). Scope is intentionally Colab-only; the broader Jupyter/JupyterLab non-blocking retry redesign is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f343236 commit 4908eea

5 files changed

Lines changed: 172 additions & 6 deletions

File tree

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ src/vgi_http_client.cpp
9393
src/vgi_http_function_connection.cpp
9494
src/vgi_cookie_jar.cpp
9595
src/vgi_oauth.cpp
96+
src/vgi_oauth_env.cpp
9697
src/vgi_catalogs.cpp
9798
src/vgi_table_function.cpp
9899
src/vgi_table_function_impl.cpp
@@ -163,6 +164,8 @@ if((BUILD_VGI_UNIT_TESTS OR DEFINED ENV{BUILD_VGI_UNIT_TESTS}) AND NOT WIN32)
163164
test/cpp/test_launcher_e2e.cpp
164165
test/cpp/test_launcher_flock_parity.cpp
165166
test/cpp/test_transport_detection.cpp
167+
test/cpp/test_oauth.cpp
168+
src/vgi_oauth_env.cpp
166169
src/vgi_launcher_internal.cpp
167170
src/vgi_unix_socket.cpp
168171
src/vgi_launcher.cpp

src/include/vgi_oauth.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ OAuthTokenSet PerformAuthFlow(const OAuthChallenge &challenge, ClientContext &co
203203
// Environment detection
204204
bool IsHeadlessEnvironment();
205205

206+
// True when running inside a Google Colab kernel. Colab captures the kernel's
207+
// C-level stderr into the notebook cell, so the device-code prompt (URL + user
208+
// code) is written to stderr there — otherwise it only reaches DUCKDB_LOG_WARNING,
209+
// which the Python client never surfaces, and login appears to hang.
210+
bool IsColabEnvironment();
211+
212+
// True iff url is a plain-http loopback URL (127.0.0.1 / localhost / [::1]) with a
213+
// proper host boundary after the host (':' port, '/' path, or end-of-string). Used
214+
// to allow http only for genuine loopback while rejecting look-alikes such as
215+
// http://127.0.0.1.evil.com that a prefix match would wrongly accept.
216+
bool IsLoopbackHttpUrl(const std::string &url);
217+
206218
// Utility functions
207219
std::optional<OAuthChallenge> ParseWWWAuthenticate(const std::string &header);
208220
std::string GenerateCodeVerifier();

src/vgi_oauth.cpp

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,7 @@ static void EnforceHttpsUrl(const std::string &url, const std::string &context_n
419419
if (url.empty()) {
420420
throw IOException("VGI OAuth: %s URL is empty (no OAuth challenge from server)", context_name);
421421
}
422-
if (url.substr(0, 8) != "https://" && url.substr(0, 16) != "http://127.0.0.1" &&
423-
url.substr(0, 16) != "http://localhost") {
422+
if (url.substr(0, 8) != "https://" && !IsLoopbackHttpUrl(url)) {
424423
throw IOException("VGI OAuth: %s URL must use HTTPS: %s", context_name, url);
425424
}
426425
}
@@ -846,6 +845,17 @@ bool IsHeadlessEnvironment() {
846845
return false;
847846
}
848847

848+
// Surface an auth-flow message on stderr when running in Colab. The flow already
849+
// logs via DUCKDB_LOG_WARNING, but the Python client never displays those; Colab
850+
// captures C-level stderr into the cell, so this is what the user actually sees.
851+
// fflush is required — Colab's fd capture won't reliably show buffered output.
852+
static void PrintPromptIfColab(const std::string &msg) {
853+
if (IsColabEnvironment()) {
854+
fprintf(stderr, "[VGI] %s\n", msg.c_str());
855+
fflush(stderr);
856+
}
857+
}
858+
849859
//===--------------------------------------------------------------------===//
850860
// Device Code Flow (RFC 8628)
851861
//===--------------------------------------------------------------------===//
@@ -959,6 +969,7 @@ static OAuthTokenSet PerformDeviceCodeFlowImpl(const OAuthChallenge &challenge,
959969
auth_msg += "\nOr visit: " + verification_uri_complete;
960970
}
961971
DUCKDB_LOG_WARNING(context, auth_msg);
972+
PrintPromptIfColab(auth_msg);
962973
// Note: in WASM, EM_ASM can't be used from side modules (extensions).
963974
// The auth message is logged via DUCKDB_LOG_WARNING. The terminal UI
964975
// should display DuckDB warning-level logs to show the user code.
@@ -1007,6 +1018,7 @@ static OAuthTokenSet PerformDeviceCodeFlowImpl(const OAuthChallenge &challenge,
10071018
auto since_last = std::chrono::duration_cast<std::chrono::seconds>(now - last_status_print).count();
10081019
if (since_last >= 30) {
10091020
DUCKDB_LOG_WARNING(context, "Still waiting for authentication...");
1021+
PrintPromptIfColab("Still waiting for authentication...");
10101022
last_status_print = now;
10111023
}
10121024

@@ -1030,6 +1042,7 @@ static OAuthTokenSet PerformDeviceCodeFlowImpl(const OAuthChallenge &challenge,
10301042
auto tokens = ParseTokenResponse(resp.body, "device code token response");
10311043
tokens.use_id_token = resource_meta.use_id_token_as_bearer;
10321044
DUCKDB_LOG_WARNING(context, "Authentication successful.");
1045+
PrintPromptIfColab("Authentication successful.");
10331046
return tokens;
10341047
}
10351048

@@ -1173,8 +1186,10 @@ OAuthTokenSet PerformAuthFlow(const OAuthChallenge &challenge,
11731186
return PerformDeviceCodeFlowImpl(challenge, resource_meta, server_meta, context);
11741187
}
11751188

1176-
// Headless environment
1177-
if (has_device_ep && IsHeadlessEnvironment()) {
1189+
// Headless / notebook environment. Colab is checked explicitly: it doesn't
1190+
// always trip the generic headless heuristics, and routing it to the
1191+
// server-side PKCE/localhost path would open an invisible browser and hang.
1192+
if (has_device_ep && (IsHeadlessEnvironment() || IsColabEnvironment())) {
11781193
VGI_STDERR_DEBUG("[VGI] oauth.auto_flow chose=device_code reason=headless_environment\n");
11791194
return PerformDeviceCodeFlowImpl(challenge, resource_meta, server_meta, context);
11801195
}
@@ -1596,8 +1611,10 @@ static OAuthTokenSet PerformPKCEFlowImpl(const OAuthChallenge &challenge,
15961611
}
15971612

15981613
// Always print the URL so user can manually navigate if browser fails
1599-
DUCKDB_LOG_WARNING(context, "Authentication required for " + GetResourceDisplayName(resource_meta) +
1600-
". Opening browser...\nIf the browser doesn't open, visit this URL:\n" + auth_url);
1614+
std::string pkce_msg = "Authentication required for " + GetResourceDisplayName(resource_meta) +
1615+
". Opening browser...\nIf the browser doesn't open, visit this URL:\n" + auth_url;
1616+
DUCKDB_LOG_WARNING(context, pkce_msg);
1617+
PrintPromptIfColab(pkce_msg);
16011618

16021619
OpenBrowser(auth_url);
16031620

src/vgi_oauth_env.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// © Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Dependency-free OAuth environment/URL helpers split out of vgi_oauth.cpp so the
5+
// C++ unit-test binary can exercise them without linking the full OAuth/HTTP/Arrow
6+
// surface. These touch only <cstdlib> / std::string.
7+
8+
#include "vgi_oauth.hpp"
9+
10+
#include <cstdlib>
11+
#include <string>
12+
13+
namespace duckdb {
14+
namespace vgi {
15+
16+
bool IsColabEnvironment() {
17+
// Colab runtimes export these; any one is a reliable Colab signal.
18+
return std::getenv("COLAB_RELEASE_TAG") || std::getenv("COLAB_GPU") ||
19+
std::getenv("COLAB_JUPYTER_IP");
20+
}
21+
22+
bool IsLoopbackHttpUrl(const std::string &url) {
23+
// Allow http only for genuine loopback hosts. A prefix match alone is unsafe:
24+
// "http://127.0.0.1.evil.com" starts with "http://127.0.0.1" but resolves to a
25+
// remote attacker host, which would let a malicious metadata document downgrade
26+
// the token exchange to plaintext. Require a host boundary (':' port, '/' path,
27+
// or end-of-string) immediately after the loopback host.
28+
static const char *kLoopbackHosts[] = {"http://127.0.0.1", "http://localhost", "http://[::1]"};
29+
for (const char *host : kLoopbackHosts) {
30+
const std::string prefix = host;
31+
if (url.compare(0, prefix.size(), prefix) == 0) {
32+
if (url.size() == prefix.size()) {
33+
return true; // exact host, no port/path
34+
}
35+
const char c = url[prefix.size()];
36+
if (c == ':' || c == '/') {
37+
return true; // port or path delimiter
38+
}
39+
}
40+
}
41+
return false;
42+
}
43+
44+
} // namespace vgi
45+
} // namespace duckdb

test/cpp/test_oauth.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Layer-1 unit tests for the dependency-free OAuth env/URL helpers in
5+
// vgi_oauth_env.cpp: Colab detection and the loopback-http allow-list used by
6+
// EnforceHttpsUrl. Pure functions — no network, no DuckDB context.
7+
8+
#include "catch.hpp"
9+
10+
#include "vgi_oauth.hpp"
11+
12+
#include <cstdlib>
13+
#include <string>
14+
15+
using duckdb::vgi::IsColabEnvironment;
16+
using duckdb::vgi::IsLoopbackHttpUrl;
17+
18+
namespace {
19+
20+
// RAII guard: save the three Colab env vars on construction, clear them, and
21+
// restore the originals on destruction so the test never leaks env state.
22+
struct ColabEnvGuard {
23+
const char *vars[3] = {"COLAB_RELEASE_TAG", "COLAB_GPU", "COLAB_JUPYTER_IP"};
24+
std::string saved[3];
25+
bool had[3] = {false, false, false};
26+
27+
ColabEnvGuard() {
28+
for (int i = 0; i < 3; i++) {
29+
if (const char *v = std::getenv(vars[i])) {
30+
saved[i] = v;
31+
had[i] = true;
32+
}
33+
unsetenv(vars[i]);
34+
}
35+
}
36+
~ColabEnvGuard() {
37+
for (int i = 0; i < 3; i++) {
38+
if (had[i]) {
39+
setenv(vars[i], saved[i].c_str(), 1);
40+
} else {
41+
unsetenv(vars[i]);
42+
}
43+
}
44+
}
45+
};
46+
47+
} // namespace
48+
49+
TEST_CASE("IsColabEnvironment detects Colab signals", "[oauth]") {
50+
ColabEnvGuard guard;
51+
52+
CHECK_FALSE(IsColabEnvironment()); // all cleared by the guard
53+
54+
setenv("COLAB_RELEASE_TAG", "release-2026", 1);
55+
CHECK(IsColabEnvironment());
56+
unsetenv("COLAB_RELEASE_TAG");
57+
CHECK_FALSE(IsColabEnvironment());
58+
59+
setenv("COLAB_GPU", "0", 1);
60+
CHECK(IsColabEnvironment());
61+
unsetenv("COLAB_GPU");
62+
63+
setenv("COLAB_JUPYTER_IP", "172.28.0.1", 1);
64+
CHECK(IsColabEnvironment());
65+
unsetenv("COLAB_JUPYTER_IP");
66+
CHECK_FALSE(IsColabEnvironment());
67+
}
68+
69+
TEST_CASE("IsLoopbackHttpUrl accepts genuine loopback hosts", "[oauth]") {
70+
CHECK(IsLoopbackHttpUrl("http://127.0.0.1"));
71+
CHECK(IsLoopbackHttpUrl("http://127.0.0.1:8080"));
72+
CHECK(IsLoopbackHttpUrl("http://127.0.0.1/callback"));
73+
CHECK(IsLoopbackHttpUrl("http://localhost"));
74+
CHECK(IsLoopbackHttpUrl("http://localhost:9000/cb"));
75+
CHECK(IsLoopbackHttpUrl("http://[::1]"));
76+
CHECK(IsLoopbackHttpUrl("http://[::1]:9000"));
77+
}
78+
79+
TEST_CASE("IsLoopbackHttpUrl rejects look-alike and remote hosts", "[oauth]") {
80+
// Host-boundary attacks that a naive prefix match would wrongly accept.
81+
CHECK_FALSE(IsLoopbackHttpUrl("http://127.0.0.1.evil.com"));
82+
CHECK_FALSE(IsLoopbackHttpUrl("http://127.0.0.1.evil.com/token"));
83+
CHECK_FALSE(IsLoopbackHttpUrl("http://localhost.evil.com"));
84+
CHECK_FALSE(IsLoopbackHttpUrl("http://localhostx"));
85+
// Plain remote / non-loopback.
86+
CHECK_FALSE(IsLoopbackHttpUrl("http://evil.com"));
87+
CHECK_FALSE(IsLoopbackHttpUrl("https://127.0.0.1")); // https handled separately
88+
CHECK_FALSE(IsLoopbackHttpUrl(""));
89+
}

0 commit comments

Comments
 (0)