Skip to content

Add ActiveDirectory Interactive (browser) auth to mssql-odbc (T3) - #145

Open
Vahid-b wants to merge 9 commits into
mainfrom
dev/vahid/odbc-auth-t3-interactive
Open

Add ActiveDirectory Interactive (browser) auth to mssql-odbc (T3)#145
Vahid-b wants to merge 9 commits into
mainfrom
dev/vahid/odbc-auth-t3-interactive

Conversation

@Vahid-b

@Vahid-b Vahid-b commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Description

Implements T3 of the mssql-odbc authentication plan: Authentication=ActiveDirectoryInteractive (browser sign-in). The Azure SDK for Rust ships no interactive credential, so this hand-rolls the OAuth2 authorization-code + PKCE flow behind the same EntraIdTokenFactory seam introduced by T2 (#110). The parity reference is the classic C++ msodbcsql driver.

This PR also lands the separate login timeout (SQL_ATTR_LOGIN_TIMEOUT) for full msodbcsql parity — see Timeout model below.

Methods

  • ActiveDirectory InteractiveAuthentication=ActiveDirectoryInteractive, optional UIDlogin_hint. No secret is read from or written to the connection context.

How it works

A new mssql-odbc/src/auth/interactive.rs provides InteractiveTokenFactory (implements the mssql-tds EntraIdTokenFactory trait). configure_auth resolves the method, registers the factory in ClientContext.auth_method_map, and mssql-tds invokes it during the FedAuth handshake. The factory:

  1. Generates a PKCE verifier/S256 challenge and a 256-bit state.
  2. Binds a loopback listener on 127.0.0.1:0; advertises http://localhost:{port} as the redirect (matching MSAL.NET / the well-known public client's registered loopback).
  3. Opens the system browser at the /authorize endpoint (authority + tenant derived from the server's FEDAUTHINFO STS URL).
  4. Receives the redirect, validates state, and exchanges the code + verifier at /token as a public client (no secret).
  5. Returns the access token UTF-16LE-encoded (the FedAuth wire format). The token is not cached — each login (including session recovery) runs a fresh sign-in, matching the service-principal path; token caching/refresh is tracked in AB#46409.

Timeout model (msodbcsql parity)

Previously mssql-tds had a single connect_timeout that bounded both the overall login deadline and each TCP-connect attempt — so making room for a multi-minute browser/MFA sign-in also let an unreachable host hang for that long. This PR splits them, matching msodbcsql's separate connection vs. login timeouts:

  • connect_timeout (unchanged default 15s) — the per-TCP-connect cap; an unreachable server still fails fast.
  • login_timeout: Option<u32> (new) — the overall login deadline covering network connect + TDS handshake + auth token acquisition. None falls back to connect_timeout, so every existing caller behaves identically; Some(0) means wait indefinitely.

The ODBC SQL_ATTR_LOGIN_TIMEOUT attribute is now honored and maps to login_timeout. Interactive installs a generous default login timeout (330s) only when the app hasn't set its own SQL_ATTR_LOGIN_TIMEOUT, and leaves connect_timeout at its default. Follow-up (separate Connection Timeout connection-string keyword) can build on this seam.

Security

  • PKCE (S256) + single-use state bind the code to this process and guard against CSRF. The redirect handler requires a matching state before honoring any code or error, reads the request line incrementally under a per-connection timeout (with a size cap), and ignores unrelated local callbacks so a stray http://localhost request can neither inject a code nor abort the sign-in.
  • Empty access tokens are rejected so a bad/empty response is never used.
  • Server-provided STS authority is trusted as long as it is https — the same trust model as T2/SP (msodbcsql via azure-identity-cpp). Residual risk on a non-certificate-validated channel (TrustServerCertificate=yes): a rogue server could point sign-in at an attacker authority — use Encrypt=Strict or a validated certificate. Documented in the module; flagged below for a team-wide decision.
  • Windows browser launch uses explorer.exe <url> (no shell) — avoids cmd's %VAR% expansion (which corrupts the percent-encoded URL) and any injection from the server-provided authority. On macOS/Linux the spawned open/xdg-open child is reaped so it can't linger as a zombie.

Tests

Unit tests (CI-safe, no network): PKCE derivation (RFC 7636 vector) + verifier charset/length; authorize-URL construction (incl. login_hint present/absent); token-endpoint shape; redirect classification (code on state-match; state-mismatch-with-code → ignored; missing state → ignored; server error surfaced only on state-match; error without state-match → ignored); token-response parsing incl. empty-token rejection; configure_auth dispatch (registers factory, hides credentials, raises login_timeout while leaving connect_timeout at its default, and preserves an app-set login timeout). Timeout wiring: SQL_ATTR_LOGIN_TIMEOUT is stored (incl. 0 → infinite) and ClientContext defaults login_timeout to None (back-compat).

The full browser flow needs a human + a real tenant, so only pure logic is unit-tested (matches the wiki's manual-test plan).

Decisions flagged for review

  • Hardcoded well-known public-client id a94f9c62-… (the msodbcsql/SqlClient AD-Interactive app, http://localhost loopback). No per-connection override yet.
  • Interactive login timeout now uses the new login_timeout (330s default) rather than overloading connect_timeout, so an unreachable server still fails fast on the per-connect cap. An app-set SQL_ATTR_LOGIN_TIMEOUT takes precedence. Touches mssql-tds core (ClientContext) — additive and back-compatible (login_timeout: None falls back to connect_timeout).
  • Browser launches under SQL_DRIVER_NOPROMPT — msodbcsql parity: that flag governs the DSN dialog, not the AAD sign-in.
  • Authority-trust model = identical to merged T2; not adding an allowlist here would break sovereign clouds and diverge from parity. Deferred to a team decision (same thread as Add Entra service principal and managed identity auth to mssql-odbc (T2) #110).
  • No refresh token — tokens past their lifetime re-prompt; silent renewal ties to caching (AB#46409).
  • Cross-platform-first: browser + PKCE on all OSes now; Windows WAM/broker SSO deferred (T4-related).
  • Loopback is IPv4-only (127.0.0.1 bind, localhost redirect); hosts resolving localhost to ::1 only are a documented fast-follow.

Verification

cargo bfmt clean; cargo bclippy (workspace, -D warnings) clean. Ran cargo test -p mssql-odbc --lib346 passed; the changed mssql-tds modules (client_context, connection_provider) pass. Local cargo-nextest/llvm-cov unavailable, so full cargo btest + coverage runs in CI.

Related Issues

AB#46067

Checklist

  • cargo bfmt passes
  • cargo bclippy passes
  • cargo btest passes — ran cargo test -p mssql-odbc --lib (346 pass) locally; full nextest/coverage runs in CI
  • New/changed functionality has tests
  • Public API changes are documented

Implement the ActiveDirectory Interactive method behind the existing
EntraIdTokenFactory seam by hand-rolling the OAuth2 authorization-code
flow with PKCE (the Azure SDK ships no interactive credential). Bind a
loopback listener, open the system browser at the /authorize endpoint,
receive the redirect, and exchange the code at /token as a public client.

The redirect handler requires a matching state before honoring any code
or error, reads the request line incrementally under a per-connection
timeout, and ignores unrelated local callbacks. Empty access tokens are
rejected so they are not cached. The login-connect deadline is disabled
for interactive so the browser/MFA flow has time to complete.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds browser-based Active Directory Interactive authentication to the ODBC driver using OAuth2 authorization code flow with PKCE.

Changes:

  • Implements browser launch, loopback redirect handling, token exchange, and caching.
  • Registers interactive authentication with the TDS FedAuth pipeline.
  • Adds dependencies and unit tests for PKCE, redirects, URLs, and token parsing.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
mssql-odbc/src/auth/mod.rs Registers the interactive authentication module.
mssql-odbc/src/auth/interactive.rs Implements and tests the interactive OAuth2 flow.
mssql-odbc/src/auth/entra.rs Configures the interactive token factory and shared helpers.
mssql-odbc/src/api/driver_connect.rs Updates authentication support and unsupported-method testing.
mssql-odbc/Cargo.toml Adds networking, PKCE, randomness, and JSON dependencies.

Comment thread mssql-odbc/src/auth/entra.rs Outdated
// The browser sign-in (with MFA) can take minutes, so disable the
// login-connect deadline; the flow keeps its own inner bound
// (REDIRECT_TIMEOUT) and stays cancellable. See AB#46067.
context.connect_timeout = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +108 to +110
let token = self
.token
.get_or_try_init(|| async move {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

Comment thread mssql-odbc/src/auth/interactive.rs
Comment thread mssql-odbc/src/auth/entra.rs Outdated
Comment on lines +256 to +259
// The browser sign-in (with MFA) can take minutes, so disable the
// login-connect deadline; the flow keeps its own inner bound
// (REDIRECT_TIMEOUT) and stays cancellable. See AB#46067.
context.connect_timeout = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +480 to +484
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +488 to +492
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@-

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

81%

🎯 Overall Coverage

90.0%

📦 Project: mssql-tds + mssql-odbc + mssql-py-core
ℹ️ Note: diff coverage is reported, not enforced.


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql-odbc/src/api/driver_connect.rs (80.0%): Missing lines 259
  • mssql-odbc/src/api/exports.rs (0.0%): Missing lines 475-476,479,488-490
  • mssql-odbc/src/api/get_connect_attr.rs (96.0%): Missing lines 69-70,91-92
  • mssql-odbc/src/api/set_connect_attr.rs (100%)
  • mssql-odbc/src/auth/entra.rs (100%)
  • mssql-odbc/src/auth/interactive.rs (78.1%): Missing lines 150,167,258-267,269-275,278,280-289,291,296-299,302-306,308,310-320,359,370-371,373-378,380-388,390-396,401-403,406,410,419-424,446,452-453,455,457-459,502-503,583-585,594-596,620,626-630,633-636,639-642,648,654-662,777,808,933,948-949
  • mssql-odbc/src/handles/dbc.rs (50.0%): Missing lines 85
  • mssql-tds/src/connection/client_context.rs (100%)
  • mssql-tds/src/connection_provider/tds_connection_provider.rs (70.0%): Missing lines 151,199,203
  • mssql-tds/src/error/mod.rs (100%)

Summary

  • Total: 801 lines
  • Missing: 147 lines
  • Coverage: 81%

mssql-odbc/src/api/driver_connect.rs

  255     // Apply an app-set SQL_ATTR_LOGIN_TIMEOUT before configuring auth so an
  256     // explicit login timeout takes precedence over any method-specific default
  257     // (e.g. the larger default the interactive browser flow installs).
  258     if let Some(secs) = state.login_timeout {
! 259         context.login_timeout = Some(secs);
  260     }
  261 
  262     if let Err(method) = configure_auth(&mut context, resolved) {
  263         error!(

mssql-odbc/src/api/exports.rs

  471 /// - `attribute` must be a valid connection attribute identifier.
  472 /// - Output pointers must be valid and writable.
  473 #[unsafe(no_mangle)]
  474 pub unsafe extern "C" fn SQLGetConnectAttrW(
! 475     connection_handle: SqlHandle,
! 476     attribute: SqlInteger,
  477     value_ptr: SqlPointer,
  478     buffer_length: SqlInteger,
! 479     string_length_ptr: *mut SqlInteger,
  480 ) -> SqlReturn {
  481     crate::init_tracing();
  482     unsafe {
  483         super::get_connect_attr::sql_get_connect_attr_w(

  484             connection_handle,
  485             attribute,
  486             value_ptr,
  487             buffer_length,
! 488             string_length_ptr,
! 489         )
! 490     }
  491 }
  492 
  493 /// Sets a statement attribute.
  494 ///

mssql-odbc/src/api/get_connect_attr.rs

  65     buffer_length: SqlInteger,
  66     string_length_ptr: *mut SqlInteger,
  67 ) -> SqlReturn {
  68     if connection_handle.is_null() {
! 69         error!("SQLGetConnectAttrW: connection_handle is null");
! 70         return SQL_INVALID_HANDLE;
  71     }
  72 
  73     let dbc = unsafe { handle_from_raw::<DbcHandle>(connection_handle) };
  74     debug_assert_eq!(

  87     _buffer_length: SqlInteger,
  88     _string_length_ptr: *mut SqlInteger,
  89 ) -> SqlReturn {
  90     let Ok(mut state) = dbc.inner.lock() else {
! 91         error!("SQLGetConnectAttrW: dbc mutex poisoned");
! 92         return SQL_ERROR;
  93     };
  94     free_errors(&mut state);
  95 
  96     match attribute {

mssql-odbc/src/auth/interactive.rs

  146         &self,
  147         spn: String,
  148         sts_url: String,
  149         _auth_method: TdsAuthenticationMethod,
! 150     ) -> TdsResult<Vec<u8>> {
  151         // No token cache: like the service-principal path, each login runs the
  152         // flow fresh so session recovery cannot reuse an expired token. Token
  153         // caching/refresh is tracked in AB#46409.
  154         let (authority, tenant) = split_sts_url(&sts_url)?;

  163         )
  164         .await?;
  165 
  166         Ok(encode_utf16le(&token))
! 167     }
  168 }
  169 
  170 /// A PKCE verifier and its derived `S256` challenge (RFC 7636).
  171 struct Pkce {

  254     format!("http://localhost:{port}")
  255 }
  256 
  257 /// Runs the full interactive flow and returns the raw access token.
! 258 async fn acquire_interactive_token(
! 259     authority: &str,
! 260     tenant: &str,
! 261     client_id: &str,
! 262     scope: &str,
! 263     login_hint: Option<&str>,
! 264     login_timeout: Option<u32>,
! 265 ) -> TdsResult<String> {
! 266     let pkce = generate_pkce()?;
! 267     let state = random_base64url(RANDOM_BYTES)?;
  268 
! 269     let listener = TcpListener::bind("127.0.0.1:0")
! 270         .await
! 271         .map_err(|e| Error::ConnectionError(format!("failed to bind loopback listener: {e}")))?;
! 272     let port = listener
! 273         .local_addr()
! 274         .map_err(|e| Error::ConnectionError(format!("failed to read listener address: {e}")))?
! 275         .port();
  276     // Loopback redirect per RFC 8252; see `loopback_redirect_uri` for why the
  277     // advertised host differs from the bound address.
! 278     let redirect_uri = loopback_redirect_uri(port);
  279 
! 280     let authorize_url = build_authorize_url(&AuthorizeRequest {
! 281         authority,
! 282         tenant,
! 283         client_id,
! 284         scope,
! 285         redirect_uri: &redirect_uri,
! 286         state: &state,
! 287         challenge: &pkce.challenge,
! 288         login_hint,
! 289     })?;
  290 
! 291     info!("launching browser for interactive Entra sign-in");
  292     // Log only the endpoint components. The query string carries the CSRF
  293     // `state`, PKCE `challenge`, and the user's `login_hint`; keeping it out of
  294     // the logs avoids persisting account-identifying data and prevents anyone
  295     // with debug-log access from replaying `state` against the local callback.
! 296     debug!(
! 297         scheme = authorize_url.scheme(),
! 298         host = authorize_url.host_str().unwrap_or("?"),
! 299         path = authorize_url.path(),
  300         "built interactive authorize URL (query redacted)"
  301     );
! 302     open_browser(authorize_url.as_str()).map_err(|e| {
! 303         Error::ConnectionError(format!(
! 304             "failed to launch a browser for interactive sign-in: {e}"
! 305         ))
! 306     })?;
  307 
! 308     let code = wait_for_redirect_bounded(&listener, &state, login_timeout).await?;
  309 
! 310     exchange_code_for_token(
! 311         authority,
! 312         tenant,
! 313         client_id,
! 314         scope,
! 315         &redirect_uri,
! 316         &code,
! 317         &pkce.verifier,
! 318     )
! 319     .await
! 320 }
  321 
  322 /// Derives the browser-redirect wait cap from the effective login timeout.
  323 /// `Some(0)` → `None` (wait indefinitely, honoring an infinite
  324 /// `SQL_ATTR_LOGIN_TIMEOUT`); `Some(n)` → `n` seconds; `None` → [`REDIRECT_TIMEOUT`].

  355                      for the application)"
  356                         .into(),
  357                 )
  358             })?,
! 359         None => wait_for_redirect(listener, expected_state).await,
  360     }
  361 }
  362 
  363 /// Accepts loopback connections until the OAuth redirect with a matching `state`

  366 /// answered and ignored so they cannot abort or hijack the sign-in.
  367 async fn wait_for_redirect(listener: &TcpListener, expected_state: &str) -> TdsResult<String> {
  368     loop {
  369         let (mut stream, _) = listener.accept().await.map_err(|e| {
! 370             Error::ConnectionError(format!("failed to accept redirect connection: {e}"))
! 371         })?;
  372 
! 373         let outcome = read_request_query(&mut stream)
! 374             .await
! 375             .as_deref()
! 376             .map_or(RedirectOutcome::Unrelated, |query| {
! 377                 classify_redirect(query, expected_state)
! 378             });
  379 
! 380         match outcome {
! 381             RedirectOutcome::Code(code) => {
! 382                 write_http_response(
! 383                     &mut stream,
! 384                     "200 OK",
! 385                     "Sign-in complete. You can close this window and return to your application.",
! 386                 )
! 387                 .await;
! 388                 return Ok(code);
  389             }
! 390             RedirectOutcome::Failed(detail) => {
! 391                 write_http_response(
! 392                     &mut stream,
! 393                     "400 Bad Request",
! 394                     "Sign-in failed. You can close this window and return to your application.",
! 395                 )
! 396                 .await;
  397                 // Terminal auth decision (user cancelled, denied consent,
  398                 // `access_denied`, …), not a transient transport fault: use a
  399                 // non-transient security error so the connect-retry loop does not
  400                 // silently relaunch the browser.
! 401                 return Err(Error::Security(SecurityError::AuthenticationDenied(
! 402                     format!("interactive sign-in failed: {detail}"),
! 403                 )));
  404             }
  405             RedirectOutcome::Unrelated => {
! 406                 write_http_response(&mut stream, "404 Not Found", "").await;
  407             }
  408         }
  409     }
! 410 }
  411 
  412 /// Reads an incoming request up to the end of the request line (first CRLF) and
  413 /// returns its query string. The whole read is bounded by a single
  414 /// [`READ_TIMEOUT`] deadline — not a per-read timeout — so a client that dribbles

  415 /// bytes cannot keep resetting the timer and hold this sequential handler open,
  416 /// starving the real browser callback (worst under an infinite login timeout).
  417 /// Also bounded by [`MAX_REQUEST_BYTES`]. Returns `None` on timeout, EOF, error,
  418 /// or a request with no query.
! 419 async fn read_request_query<S>(stream: &mut S) -> Option<String>
! 420 where
! 421     S: AsyncReadExt + Unpin,
! 422 {
! 423     read_request_query_within(stream, READ_TIMEOUT).await
! 424 }
  425 
  426 /// [`read_request_query`] with an explicit overall budget so the deadline
  427 /// behavior can be unit-tested without waiting the full [`READ_TIMEOUT`].
  428 async fn read_request_query_within<S>(stream: &mut S, budget: Duration) -> Option<String>

  442             let line = String::from_utf8_lossy(&buf[..end]);
  443             return request_target_query(&line);
  444         }
  445         if buf.len() > MAX_REQUEST_BYTES {
! 446             break;
  447         }
  448     }
  449     None
  450 }

  448     }
  449     None
  450 }
  451 
! 452 async fn write_http_response(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
! 453     let response = format!(
  454         "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
! 455         body.len()
  456     );
! 457     let _ = stream.write_all(response.as_bytes()).await;
! 458     let _ = stream.flush().await;
! 459 }
  460 
  461 /// Extracts the query string from an HTTP request line such as
  462 /// `GET /?code=...&state=... HTTP/1.1`.
  463 fn request_target_query(request_line: &str) -> Option<String> {

  498     }
  499     if let Some(error) = get("error") {
  500         let description = get("error_description").unwrap_or_default();
  501         return RedirectOutcome::Failed(format!("{error}: {description}"));
! 502     }
! 503     RedirectOutcome::Unrelated
  504 }
  505 
  506 #[derive(Deserialize)]
  507 struct TokenResponse {

  579 async fn read_body_capped(mut response: reqwest::Response, cap: usize) -> TdsResult<Vec<u8>> {
  580     if let Some(len) = response.content_length()
  581         && len > cap as u64
  582     {
! 583         return Err(Error::ConnectionError(format!(
! 584             "token response too large: {len} bytes exceeds the {cap}-byte limit"
! 585         )));
  586     }
  587     let mut buf = Vec::new();
  588     while let Some(chunk) = response
  589         .chunk()

  590         .await
  591         .map_err(|e| Error::ConnectionError(format!("failed to read token response: {e}")))?
  592     {
  593         if buf.len() + chunk.len() > cap {
! 594             return Err(Error::ConnectionError(format!(
! 595                 "token response exceeded the {cap}-byte limit"
! 596             )));
  597         }
  598         buf.extend_from_slice(&chunk);
  599     }
  600     Ok(buf)

  616     Ok(token)
  617 }
  618 
  619 #[cfg(target_os = "windows")]
! 620 fn open_browser(url: &str) -> std::io::Result<()> {
  621     // Launch via explorer.exe (no shell): the percent-encoded URL is handed to
  622     // the default browser verbatim. Routing through `cmd /C start` would let cmd
  623     // expand `%..%` sequences (corrupting the URL, which contains `%XX`) and
  624     // parse `&`, and would risk command injection from the server-provided

  622     // the default browser verbatim. Routing through `cmd /C start` would let cmd
  623     // expand `%..%` sequences (corrupting the URL, which contains `%XX`) and
  624     // parse `&`, and would risk command injection from the server-provided
  625     // authority.
! 626     std::process::Command::new("explorer.exe")
! 627         .arg(url)
! 628         .spawn()
! 629         .map(|_| ())
! 630 }
  631 
  632 #[cfg(target_os = "macos")]
! 633 fn open_browser(url: &str) -> std::io::Result<()> {
! 634     reap(std::process::Command::new("open").arg(url).spawn()?);
! 635     Ok(())
! 636 }
  637 
  638 #[cfg(all(unix, not(target_os = "macos")))]
! 639 fn open_browser(url: &str) -> std::io::Result<()> {
! 640     reap(std::process::Command::new("xdg-open").arg(url).spawn()?);
! 641     Ok(())
! 642 }
  643 
  644 /// `open`/`xdg-open` exit as soon as they hand the URL to the browser, but a
  645 /// dropped `Child` is never waited on and would linger as a zombie. Reap it on a
  646 /// detached thread so the sign-in flow is not blocked.

  644 /// `open`/`xdg-open` exit as soon as they hand the URL to the browser, but a
  645 /// dropped `Child` is never waited on and would linger as a zombie. Reap it on a
  646 /// detached thread so the sign-in flow is not blocked.
  647 #[cfg(unix)]
! 648 fn reap(mut child: std::process::Child) {
  649     // `Builder::spawn` reports an OS thread-creation failure as an `Err` instead
  650     // of panicking the way `thread::spawn` does; a shared library must never
  651     // unwind across the FFI boundary. If the reaper can't start we drop the
  652     // child (detaching it) and move on — a rare zombie beats a panic, and the

  650     // of panicking the way `thread::spawn` does; a shared library must never
  651     // unwind across the FFI boundary. If the reaper can't start we drop the
  652     // child (detaching it) and move on — a rare zombie beats a panic, and the
  653     // browser helper is short-lived and exits on its own.
! 654     let spawned = std::thread::Builder::new()
! 655         .name("odbc-browser-reaper".into())
! 656         .spawn(move || {
! 657             let _ = child.wait();
! 658         });
! 659     if let Err(e) = spawned {
! 660         debug!("could not spawn browser-reaper thread ({e}); skipping wait");
! 661     }
! 662 }
  663 
  664 #[cfg(test)]
  665 mod tests {
  666     use super::*;

  773     #[test]
  774     fn classify_redirect_returns_code_on_state_match() {
  775         match classify_redirect("code=the-code&state=st", "st") {
  776             RedirectOutcome::Code(code) => assert_eq!(code, "the-code"),
! 777             _ => panic!("expected Code"),
  778         }
  779     }
  780 
  781     #[test]

  804             RedirectOutcome::Failed(detail) => {
  805                 assert!(detail.contains("access_denied"), "detail: {detail}");
  806                 assert!(detail.contains("user cancelled"), "detail: {detail}");
  807             }
! 808             _ => panic!("expected Failed"),
  809         }
  810     }
  811 
  812     #[test]

  929             let mut chunk = [0u8; 1024];
  930             loop {
  931                 let n = socket.read(&mut chunk).await.unwrap();
  932                 if n == 0 {
! 933                     break;
  934                 }
  935                 buf.extend_from_slice(&chunk[..n]);
  936                 let text = String::from_utf8_lossy(&buf).to_string();
  937                 if let Some(headers_end) = text.find("\r\n\r\n") {

  944                         })
  945                         .unwrap_or(0);
  946                     if buf.len() >= headers_end + 4 + len {
  947                         break;
! 948                     }
! 949                 }
  950             }
  951             let request = String::from_utf8_lossy(&buf).to_string();
  952             let response = format!(
  953                 "{status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",

mssql-odbc/src/handles/dbc.rs

  81             .field(
  82                 "access_token",
  83                 &self.access_token.as_ref().map(|_| "<REDACTED>"),
  84             )
! 85             .field("login_timeout", &self.login_timeout)
  86             .finish()
  87     }
  88 }

mssql-tds/src/connection_provider/tds_connection_provider.rs

  147             // (e.g. interactive sign-in).
  148             let login_timeout = context.login_timeout.unwrap_or(context.connect_timeout);
  149             let deadline = match login_timeout {
  150                 1.. => Some(Instant::now() + Duration::from_secs(login_timeout.into())),
! 151                 _ => None,
  152             };
  153 
  154             // Try Shared Memory before SSRP for local named instances (Windows only).
  155             // SM doesn't need instance resolution — it uses the name directly.

  195                         // or the login was rejected — would recur on every transport,
  196                         // and falling through would relaunch the interactive browser.
  197                         // Surface it immediately.
  198                         if !err.is_transient_connect_error() {
! 199                             debug!(
  200                                 "Shared Memory failed permanently ({}), not falling through",
  201                                 err
  202                             );
! 203                             return Err(err);
  204                         }
  205                         debug!("Shared Memory failed ({}), falling through to SSRP", err);
  206                     }
  207                 }


🔗 Quick Links

View Azure DevOps Build · Coverage Report

Vahid-b and others added 2 commits July 22, 2026 15:02
- connect_timeout=0 gave every TCP connect a 0ms budget (timeout(0ms, ...)
  fires immediately), failing the connection before the browser opened. Raise
  it to a bounded 330s (CONNECT_TIMEOUT_SECS) so the login stays bounded and
  the TCP connect keeps a real budget, mirroring SqlClient's enlarged Connect
  Timeout for interactive auth.
- Stop caching the access token in a OnceCell: like the service-principal path,
  acquire a fresh token each login so session recovery cannot reuse an expired
  token. Caching/refresh is tracked in AB#46409.
- Drop the misleading "open this URL manually" hint from the browser-launch
  error; the loopback listener is gone once the error returns.
- Reap the macOS open / Linux xdg-open child on a detached thread so it does
  not linger as a zombie.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Split the mssql-tds single connect_timeout into a network-connect cap
(connect_timeout) and an overall login deadline (login_timeout), matching
msodbcsql's separate connection vs. login timeouts. login_timeout is an
Option<u32> that falls back to connect_timeout when unset, so every existing
caller keeps identical behavior.

Wire the ODBC SQL_ATTR_LOGIN_TIMEOUT attribute to login_timeout and have the
interactive (T3) browser flow raise login_timeout instead of connect_timeout.
An unreachable server now still fails fast on the per-TCP-connect cap while the
browser/MFA round-trip has time to complete. An app-set SQL_ATTR_LOGIN_TIMEOUT
takes precedence over the interactive default.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Vahid-b

Vahid-b commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 816589d0: implemented the separate SQL_ATTR_LOGIN_TIMEOUT login timeout (full msodbcsql parity), replacing the earlier "enlarge connect_timeout to 330s" workaround.

mssql-tds core (additive, back-compatible):

  • ClientContext gains login_timeout: Option<u32>. The outer login deadline now uses login_timeout.unwrap_or(connect_timeout), so callers that only set connect_timeout are byte-for-byte unchanged. connect_timeout remains the per-TCP-connect cap.

mssql-odbc:

  • SQL_ATTR_LOGIN_TIMEOUT is now honored (stored on the DBC, applied to login_timeout at connect; 0 → wait indefinitely) instead of being a silent no-op.
  • Interactive installs the 330s login-timeout default only when the app hasn't set SQL_ATTR_LOGIN_TIMEOUT, and leaves connect_timeout at its default — so an unreachable server fails fast on the per-connect cap while the browser/MFA round-trip still fits.

Net effect: the black-hole-server concern from the earlier review is resolved properly rather than papered over.

Validation: cargo bfmt + workspace cargo bclippy -D warnings clean; cargo test -p mssql-odbc --lib → 346 pass; changed mssql-tds modules (client_context, connection_provider) pass. Full nextest/coverage in CI.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

mssql-odbc/src/auth/interactive.rs:315

  • READ_TIMEOUT is restarted for every read, so a local client that sends one byte every few seconds can monopolize this serial handler until the full redirect timeout and prevent the real browser callback from being processed. Apply one timeout to the entire request-line read (or track an absolute deadline), rather than timing each individual read.
        let n = match tokio::time::timeout(READ_TIMEOUT, stream.read(&mut chunk)).await {
            Ok(Ok(n)) if n > 0 => n,
            _ => break, // EOF, read error, or timeout
        };

mssql-odbc/src/auth/interactive.rs:435

  • The token response is aggregated without any size limit. Because this endpoint is derived from server-provided FEDAUTHINFO, a malicious or malfunctioning HTTPS authority can stream an arbitrarily large body and exhaust the host application's memory before the outer login timeout fires. Read chunks with a small explicit cap appropriate for an OAuth JSON response and reject responses that exceed it.
    let status = response.status();
    let bytes = response
        .bytes()
        .await
        .map_err(|e| Error::ConnectionError(format!("failed to read token response: {e}")))?;

mssql-odbc/src/auth/interactive.rs:422

  • The client uses reqwest's default redirect policy, which follows cross-origin and HTTPS-to-HTTP redirects. A 307/308 response from the token endpoint would therefore resend the authorization code and PKCE verifier to the redirect target, bypassing the module's HTTPS-only endpoint check. OAuth token endpoints do not need redirects here; disable them (or validate every redirect target before following it).
    let client = reqwest::Client::builder()
        .build()
        .map_err(|e| Error::ConnectionError(format!("failed to build HTTP client: {e}")))?;

Comment on lines 279 to 283
let login_timeout = context.login_timeout.unwrap_or(context.connect_timeout);
let deadline = match login_timeout {
1.. => Some(Instant::now() + Duration::from_secs(login_timeout.into())),
_ => None,
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b55b3c. The login deadline is now computed at the top of execute_action_chain, before the shared-memory shortcut and SSRP/LocalDB resolution, so it bounds those phases too. The SM attempt uses the remaining login budget (deadline - now) instead of connect_timeout, so interactive sign-in against a local named instance is no longer cancelled after the default 15s. connect_timeout still bounds each individual TCP-connect attempt, and the change is back-compatible for callers that only set connect_timeout (login_timeout falls back to it).

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
})?;

info!("launching browser for interactive Entra sign-in");
debug!(%authorize_url, "interactive authorize URL");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b55b3c. The debug log now emits only scheme/host/path; the query string (CSRF state, PKCE challenge, and login_hint) is no longer logged.

One note: the additional locations flagged (lines 312/420/431) don't actually log anything — they're the request read-timeout, the reqwest client builder, and a response.status() read respectively. Line 235 was the only statement logging the URL, so that's the only change needed.

Comment on lines +125 to +127
let secs = value_ptr as usize as u32;
state.login_timeout = Some(secs);
debug!(secs, "SQLSetConnectAttrW: login timeout stored");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b55b3c. Implemented SQLGetConnectAttrW (new get_connect_attr.rs) backed by DbcState.login_timeout: for SQL_ATTR_LOGIN_TIMEOUT it writes the stored value into the caller's SQLUINTEGER buffer, returning the driver default of 15 when unset and 0 when the app set "wait indefinitely". Added set/get round-trip, default-when-unset, and null-pointer rejection tests.

…deadline ordering

- Implement SQLGetConnectAttrW for SQL_ATTR_LOGIN_TIMEOUT so a set/get
  round-trip returns the stored value (driver default 15 when unset); add
  round-trip, default, and null-pointer tests.
- Redact the interactive authorize URL debug log: drop the query string
  (CSRF state, PKCE challenge, login_hint) and log only scheme/host/path.
- Compute the login deadline before the Windows shared-memory shortcut so it
  bounds the SM/SSRP/LocalDB phases too; the SM attempt now uses the remaining
  login budget instead of connect_timeout, so interactive sign-in against a
  local named instance isn't cancelled after the default 15s. Back-compatible
  for callers that only set connect_timeout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

mssql-tds/src/connection_provider/tds_connection_provider.rs:150

  • This deadline still does not bound every phase as claimed. Both SSRP calls at lines 223/227 await their independent ssrp_timeout_ms, and LocalDB resolution at line 266 performs a blocking LocalDBStartInstance call without consulting deadline; for example, login_timeout = 1 with ssrp_timeout_ms = 10_000 can run for roughly ten seconds. Apply the remaining login budget to these resolution phases too, and add a regression test with distinct login and SSRP timeouts.
            let login_timeout = context.login_timeout.unwrap_or(context.connect_timeout);
            let deadline = match login_timeout {
                1.. => Some(Instant::now() + Duration::from_secs(login_timeout.into())),
                _ => None,
            };

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +251 to +255
let code = tokio::time::timeout(REDIRECT_TIMEOUT, wait_for_redirect(&listener, &state))
.await
.map_err(|_| {
Error::ConnectionError("timed out waiting for interactive sign-in".into())
})??;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2382c55c. The browser-redirect wait is no longer a fixed 300s.

InteractiveTokenFactory now carries the effective login_timeout (set on the context by the interactive arm — the app's SQL_ATTR_LOGIN_TIMEOUT, or the 330s default when unset), and redirect_wait_cap derives the wait from it:

  • Some(n)n seconds (honors app values above or below the old fixed cap),
  • Some(0) → no cap (honors an infinite login timeout),
  • None → the REDIRECT_TIMEOUT fallback.

The provider already wraps create_token in timeout(deadline) where deadline is derived from the same login_timeout, so the inner (browser-wait) and outer (login-deadline) bounds now agree instead of the inner 300s silently winning. Added 3 unit tests for the cap derivation.

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +441 to +444
let bytes = response
.bytes()
.await
.map_err(|e| Error::ConnectionError(format!("failed to read token response: {e}")))?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2382c55c. read_body_capped replaces response.bytes(): it rejects an advertised Content-Length over the cap, then streams the body via chunk() enforcing a 1 MiB MAX_TOKEN_RESPONSE_BYTES limit (real token responses are a few KB). Since the STS authority is taken from the server's FEDAUTHINFO, this closes the unbounded-read / memory-exhaustion exposure you flagged.

Comment thread mssql-odbc/src/api/get_connect_attr.rs Outdated
Comment on lines +57 to +61
unsafe fn sql_get_connect_attr_w_impl(
connection_handle: SqlHandle,
attribute: SqlInteger,
value_ptr: SqlPointer,
_buffer_length: SqlInteger,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored in 2382c55c. get_connect_attr.rs now follows the same panic-boundary → unsafe-shim → safe-core split as get_data.rs:

  • sql_get_connect_attr_w — public unsafe fn, all-argument debug! + ffi_entry! panic boundary.
  • sql_get_connect_attr_w_implunsafe fn, null-handle → SQL_INVALID_HANDLE, handle_from_raw::<DbcHandle>, debug_assert_eq! on the object type.
  • sql_get_connect_attr_w_safe(&DbcHandle, …) — safe core: lock, free_errors, attribute match.

Good catch — the file had mirrored the older set_connect_attr.rs, which predates this convention.

Comment thread mssql-odbc/src/api/get_connect_attr.rs Outdated
// buffer. `Some(0)` reflects an app-set "wait indefinitely"; an
// unset attribute reports the driver default.
let secs = state.login_timeout.unwrap_or(DEFAULT_LOGIN_TIMEOUT_SECS);
unsafe { (value_ptr as *mut u32).write_unaligned(secs) };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2382c55c. The value write now goes through write_if_some(value_ptr as *mut u32, secs) (util.rs) instead of a raw write_unaligned. The explicit null-pointer check stays ahead of it (returns SQL_ERROR with a diagnostic), so write_if_some only ever runs on a validated non-null pointer.

Comment on lines +39 to +44
debug!(
?connection_handle,
attribute,
?value_ptr,
"SQLGetConnectAttrW called",
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2382c55c. The entry debug! now logs buffer_length and string_length_ptr alongside the handle, attribute, and value pointer, matching get_data.rs's all-argument trace.

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
/// detached thread so the sign-in flow is not blocked.
#[cfg(unix)]
fn reap(mut child: std::process::Child) {
std::thread::spawn(move || {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2382c55c. reap now uses std::thread::Builder::new().name(…).spawn(…) and handles the Err: on an OS thread-creation failure it logs and skips the wait (the child detaches — a rare zombie) instead of panicking. That keeps the shared library from unwinding across the FFI boundary, per the no-panic rule.

Comment thread mssql-odbc/src/api/get_connect_attr.rs Outdated
Comment on lines +86 to +90
post_sql_error(
&mut state,
SQLSTATE_HY009,
0,
"SQL_ATTR_LOGIN_TIMEOUT value pointer is null",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2382c55c. The null-value_ptr case now calls post_diag(&mut state, ERR_INVALID_NULL_POINTER) (sqlstate.rs) instead of an inline post_sql_error(SQLSTATE_HY009, …) literal, so the SQLSTATE and message come from the shared diagnostic constant.

Getter (SQLGetConnectAttrW): refactor get_connect_attr.rs to the crate's
canonical panic-boundary -> unsafe shim -> safe-core layering (mirroring
get_data.rs); write the value via write_if_some; report a null value pointer
with post_diag(ERR_INVALID_NULL_POINTER); log buffer_length and
string_length_ptr in the entry trace.

Interactive auth (interactive.rs):
- Bound the browser-redirect wait by the effective login_timeout instead of a
  fixed 300s, so an app SQL_ATTR_LOGIN_TIMEOUT above/below the default (or 0 =
  infinite) is honored. The factory now carries the effective login timeout and
  derives the cap via redirect_wait_cap; REDIRECT_TIMEOUT stays as the fallback.
- Cap the /token response body at 1 MiB (read_body_capped) so a hostile
  FEDAUTHINFO-selected authority cannot stream an unbounded body into memory.
- Reap the browser-launcher child via thread::Builder::spawn and handle the
  error instead of thread::spawn, which panics on OS thread-creation failure
  (a shared library must not unwind across the FFI boundary).

Tests: +3 redirect_wait_cap unit tests. fmt + clippy (-D warnings) clean;
352 mssql-odbc lib tests and 8 connection_provider tests pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

mssql-tds/src/connection_provider/tds_connection_provider.rs:150

  • This only calculates a deadline; it does not bound the SSRP and LocalDB awaits below (lines 223/227 and 266). For example, an app-set 1-second login timeout can still wait for a longer SSRP timeout before the retry loop ever checks deadline, so the new overall-login contract is not enforced. Wrap those resolution phases in the remaining budget, or apply the timeout around the whole action-chain future.
            let login_timeout = context.login_timeout.unwrap_or(context.connect_timeout);
            let deadline = match login_timeout {
                1.. => Some(Instant::now() + Duration::from_secs(login_timeout.into())),
                _ => None,
            };

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +365 to +369
async fn read_request_query(stream: &mut tokio::net::TcpStream) -> Option<String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
let n = match tokio::time::timeout(READ_TIMEOUT, stream.read(&mut chunk)).await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. READ_TIMEOUT was applied per read() call, so a client that dribbles bytes (or just holds the socket open) reset the 5s timer on every chunk and could hold this sequential loopback handler for the entire login budget — and indefinitely once the login timeout is infinite (the Some(0) path added in the previous round). Because wait_for_redirect accepts one connection at a time, that starves the real browser callback.

Fixed in 2a1415b: read_request_query now bounds the whole request-line read with a single tokio::time::timeout_at(now + READ_TIMEOUT, …) deadline instead of a per-read timeout, so total read time is capped regardless of how the bytes arrive. I split out read_request_query_within(stream, budget) and added two unit tests — a complete request line parses, and a one-byte-then-stall client is cut off well within the budget.

Comment thread mssql-odbc/src/auth/interactive.rs Outdated
Comment on lines +351 to +353
return Err(Error::ConnectionError(format!(
"interactive sign-in failed: {detail}"
)));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this was an active bug rather than theoretical. RedirectOutcome::Failed (user clicks Cancel / access_denied, consent_required, …) returned Error::ConnectionError, which is_transient_connect_error (mssql-tds/src/error/mod.rs:402-407) classifies as transient. The ODBC connect path uses ClientContext's default connect_retry_count = 1 (ODBC parses ConnectRetryCount but doesn't wire it into the context), so the provider loops 0..=1 = two attempts and re-runs create_token on the retry — the browser pops a second time before the connection finally fails.

Fixed in 2a1415b: a state-matched authorization error now maps to Error::Security(SecurityError::AuthenticationDenied(..)), which is non-transient (it falls through the _ => false arm of is_transient_connect_error; pinned by the new authentication_denied_is_not_transient test). Genuinely transient transport faults around the sign-in stay ConnectionError. I reused the existing SecurityError variant instead of adding one, so there's no change to the public error surface.

Comment on lines +492 to +498
if !status.is_success() {
let detail = serde_json::from_str::<TokenErrorResponse>(body.as_ref())
.map(|e| format!("{}: {}", e.error, e.error_description))
.unwrap_or_else(|_| body.into_owned());
return Err(Error::ConnectionError(format!(
"interactive token exchange failed ({status}): {detail}"
)));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the redirect-failure comment. A non-2xx from the /token endpoint returned Error::ConnectionError (transient), so the connect-retry loop relaunched the whole browser flow — even for permanent RFC 6749 §5.2 failures such as invalid_grant.

Fixed in 2a1415b: the !status.is_success() path now returns Error::Security(SecurityError::AuthenticationDenied(..)) (non-transient), so a token-exchange failure is surfaced to the caller instead of silently re-prompting. I classify the entire non-2xx case as non-transient deliberately: create_token is atomic (a fresh browser + consent on every call, no token cache), so a provider-level retry is never a valid recovery for a token-exchange failure — msodbcsql likewise surfaces the error rather than reopening the browser.

Address Copilot review on PR #145:
- Map interactive auth denials (user cancel / access_denied, and permanent
  token-endpoint failures) to Error::Security(SecurityError::AuthenticationDenied),
  which is non-transient, so the connect-retry loop no longer relaunches the
  browser on a terminal auth outcome.
- Bound the loopback request-line read by a single overall deadline instead of a
  per-read timeout, so a slow local client cannot hold the sequential callback
  handler open and starve the real browser redirect.

Add tests: bounded request-line read behavior (mssql-odbc) and a regression
guard that AuthenticationDenied is not a transient connect error (mssql-tds).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

mssql-tds/src/connection_provider/tds_connection_provider.rs:142

  • This deadline does not actually bound every phase named here. The SSRP calls at lines 223/227 use only ssrp_timeout_ms, and LocalDB resolution at line 266 is also awaited without the remaining login budget. For example, a caller can set login_timeout = 1 and ssrp_timeout_ms = 30, yet named-instance resolution can run for 30 seconds before the deadline is checked. Wrap these resolution phases in the remaining deadline (and move blocking LocalDB work off the runtime thread) so login_timeout is truly an overall cap.
            // Compute the overall login deadline up front so it bounds every
            // phase of login — the shared-memory shortcut below, SSRP/LocalDB
            // resolution, and the connect/retry loop — not just the final
            // transport attempts. `login_timeout` falls back to `connect_timeout`
            // for callers that only set the historical single knob; `0` means

Comment on lines +232 to +237
/// Overall login deadline in seconds, covering the network connect, the TDS
/// handshake, and any auth token acquisition (e.g. the interactive browser
/// flow). `None` falls back to [`Self::connect_timeout`] (preserving the
/// historical single-knob behavior); `Some(0)` disables the deadline (wait
/// indefinitely). Maps to the ODBC `SQL_ATTR_LOGIN_TIMEOUT` attribute.
pub login_timeout: Option<u32>,
The deadline bounds the shared-memory shortcut and the connect/retry loop, but
not name resolution: SSRP uses its own ssrp_timeout_ms and LocalDB resolution is
a local pipe lookup. Describe that accurately instead of claiming it bounds every
phase of login. Addresses a low-confidence Copilot note on PR #145.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

mssql-odbc/src/auth/interactive.rs:565

  • A successful HTTP status with malformed JSON or an empty token is converted to ConnectionError, which is_transient_connect_error retries. That retry restarts the entire connection and opens a second browser even though the authority already returned a definitive invalid response. Classify response-validation failures as a non-transient protocol error.
    let token = serde_json::from_str::<TokenResponse>(body)
        .map(|token| token.access_token)
        .map_err(|e| Error::ConnectionError(format!("failed to parse token response: {e}")))?;
    if token.is_empty() {
        return Err(Error::ConnectionError(
            "token response contained an empty access token".into(),
        ));

Comment on lines +162 to +165
// Bound the shared-memory attempt (which wraps the full TDS/auth
// handshake) by the remaining login budget rather than
// `connect_timeout`, so interactive sign-in against a local named
// instance isn't cancelled after the default 15s.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a direct consequence of the round-3 change that made interactive-auth denials non-transient. The shared-memory shortcut runs the full TDS/auth handshake, so a cancelled interactive sign-in against a local named instance now surfaces as AuthenticationDenied, and the old Err arm fell through to SSRP/TCP for every error — relaunching the browser.

Fixed in 95bc1c9: the SM Err arm now only falls through when the failure is transient (err.is_transient_connect_error() — a transport-level problem such as SM not being available). A non-transient failure (the cancelled/denied login, or any permanent login error) returns immediately. That matches SNI semantics — a definitive login failure would recur on every transport — and stops the second browser.

Comment thread mssql-odbc/src/api/get_connect_attr.rs Outdated
Comment on lines +110 to +112
// Attributes not backed by stored state report success without writing,
// matching the historical stub behavior.
_ => SQL_SUCCESS,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — returning SQL_SUCCESS without writing value_ptr leaves the caller's buffer undefined while claiming success, and it was inconsistent with the set-side, which already rejects unknown attributes with HYC00.

Fixed in 95bc1c9: the _ arm now posts HYC00 ("Connection attribute not supported") and returns SQL_ERROR, mirroring sql_set_connect_attr_w. Added an unsupported_attribute_returns_error test to cover it.

Address Copilot round-4 review on PR #145:
- tds_connection_provider: the shared-memory shortcut runs the full
  handshake, so it can now return a non-transient AuthenticationDenied
  (interactive sign-in cancelled). Only fall through to SSRP/TCP on
  transient failures; return non-transient errors immediately so a
  cancelled login does not relaunch the browser.
- get_connect_attr: unsupported attributes now return SQL_ERROR/HYC00
  instead of SQL_SUCCESS without writing, matching the set-side.
- interactive: a 200 OK with a malformed body or empty access_token is
  a protocol failure, not a transient blip; map both to ProtocolError
  so they stay out of the connect retry loop.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

mssql-tds/src/connection_provider/tds_connection_provider.rs:142

  • The new “overall” deadline does not actually bound the name-resolution phase. It is created here, but resolve_ssrp(...).await and resolve_localdb_instance(...).await run without the remaining-budget timeout; the latter synchronously calls LocalDBStartInstance (connection/transport/localdb.rs:441-449), so SQL_ATTR_LOGIN_TIMEOUT can expire while SQLDriverConnect remains blocked. Wrap these phases (or the whole action-chain future) in the overall budget; LocalDB’s blocking call also needs to run off the async worker for a Tokio timeout to take effect.
            // Compute the overall login deadline up front. It bounds the
            // shared-memory shortcut below and the connect/retry loop — the phases
            // that run the TDS/auth handshake. Name resolution is bounded
            // separately and is not covered by this deadline: SSRP uses its own
            // `ssrp_timeout_ms` (default 1s) and LocalDB resolution is a local pipe

@Vahid-b
Vahid-b marked this pull request as ready for review July 26, 2026 07:50
@Vahid-b
Vahid-b requested a review from a team as a code owner July 26, 2026 07:50
Comment thread mssql-odbc/src/auth/interactive.rs Outdated
/// ActiveDirectory Interactive. It is registered with an `http://localhost`
/// loopback redirect for native clients, so reusing it matches the classic
/// driver and needs no per-app registration.
const PUBLIC_CLIENT_ID: &str = "a94f9c62-97fe-4d19-b06d-472bed8d2bcb";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didnt find this client id in msodbcsql repo. Is this a different client id that's intentionally used here? Just curious, as the comment says it's the client id used by msodbcsql.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to msodbcsql's actual ODBC client id, 2c1229aa-16c5-4ff5-b46b-4f7fe2a2a9c8 (tds/Parse.cpp:3608 , aadauth/AzureADAuth.cpp:839 ), so both drivers present one identity to Entra.

One gap remains, now documented on PUBLIC_CLIENT_ID : msodbcsql only signs in through the Windows WAM broker, so that app is registered for broker redirects only — http://localhost isn't registered yet. Until it is, the flow fails late, after the user has already completed MFA (AADSTS50011). The redirect timeout now points at the browser and names that error instead of expiring silently.

T3 is split into three work items so this PR isn't blocked on the registration turnaround:

Work item Scope
AB#46067 — Part 1 (this PR) OAuth2 auth-code + PKCE loopback flow on all platforms; separate SQL_ATTR_LOGIN_TIMEOUT and new SQLGetConnectAttr for msodbcsql parity
AB#46684 — Part 2 Windows interactive via mssql-auth (OneAuth/WAM), matching msodbcsql. Its broker redirect is already registered, so it isn't blocked by Part 3. This PR's flow stays as the Windows fallback for contexts WAM can't serve, e.g. Session 0.
AB#46683 — Part 3 Add http://localhost to app 2c1229aa. Unblocks Linux/macOS, where loopback is the only option — msodbcsql compiles its WAM path out off Windows, and this is what SqlClient uses there.

On Windows this PR ships loopback as an interim, superseded by Part 2. A caller-supplied client_id override would sidestep the registration dependency, but msodbcsql has no such keyword and it would let a connection string redirect sign-in to an arbitrary app — out of scope in AB#46067.

I plan to merge Part 1 (current PR) now, documented as non-functional until AB#46683 lands, or hold it until the registration is in place.

The previous id a94f9c62-97fe-4d19-b06d-472bed8d2bcb does not exist in
Entra (AADSTS700016), so interactive sign-in could never have succeeded.
Present msodbcsql's ODBC client id instead so both drivers are a single
identity in Entra.

That app is registered for Windows broker redirects only, so the loopback
flow stays blocked until http://localhost is added (AB#46683). Document
the pending registration and the late AADSTS50011 failure, and make the
redirect timeout name the browser-side cause instead of expiring silently.

Also document the per-platform mechanism: loopback is the only option on
Linux/macOS and matches SqlClient, while Windows moves to mssql-auth
(OneAuth/WAM) under AB#46684 with this flow as the fallback.

Add mock token-endpoint tests covering the request form, OAuth error
mapping, malformed and empty responses, and an unreachable endpoint, plus
coverage for the redirect-wait timeout arm.

AB#46067

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants