Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion snowflake-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ repository = "https://github.com/mycelial/snowflake-rs"
version = "0.14.0"

[features]
all = ["cert-auth", "polars"]
all = ["cert-auth", "polars", "browser-auth"]
cert-auth = ["dep:snowflake-jwt"]
browser-auth = ["dep:getrandom", "dep:urlencoding", "dep:open"]
default = ["cert-auth"]
# support for conversion of arrow and json payloads to dataframes
polars = ["dep:polars-core", "dep:polars-io"]
Expand Down Expand Up @@ -49,6 +50,11 @@ polars-io = { version = ">=0.41", features = [
"ipc_streaming",
], optional = true }

# browser-auth support
getrandom = { version = "0.3", optional = true }
urlencoding = { version = "2", optional = true }
open = { version = "5", optional = true }

# put request support
glob = { version = "0.3" }
object_store = { version = "0.13", features = ["aws"] }
Expand Down
189 changes: 189 additions & 0 deletions snowflake-api/src/browser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! External browser SSO authentication support.
//!
//! This module provides the helpers needed to authenticate via Snowflake's
//! external browser flow, where the user is redirected to their `IdP` in a browser
//! and the token is received via a local callback.

use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;

use thiserror::Error;

#[derive(Error, Debug)]
pub enum BrowserAuthError {
#[error("Failed to bind local listener: {0}")]
BindFailed(std::io::Error),

#[error("Failed to get local address: {0}")]
LocalAddrFailed(std::io::Error),

#[error("Failed to accept connection: {0}")]
AcceptFailed(std::io::Error),

#[error("Failed to read request: {0}")]
ReadFailed(std::io::Error),

#[error("Invalid request format")]
InvalidRequest,

#[error("Missing token in callback URL: {0}")]
MissingToken(String),

#[error("Failed to URL decode token: {0}")]
DecodeFailed(String),

#[error("Failed to open browser: {0}")]
BrowserOpenFailed(std::io::Error),
}

/// Generate a cryptographically secure proof key (32 bytes, base64 encoded).
///
/// This is used as part of the SSO challenge to verify the token came from
/// the expected authentication flow.
pub fn generate_proof_key() -> String {
use base64::Engine;

let mut randomness = [0u8; 32];
getrandom::fill(&mut randomness).expect("failed to generate random bytes");
base64::engine::general_purpose::STANDARD.encode(randomness)
}

/// Create a local TCP listener on localhost with a random available port.
///
/// Returns the listener and the port it's bound to.
pub fn create_local_listener() -> Result<(TcpListener, u16), BrowserAuthError> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(BrowserAuthError::BindFailed)?;

let port = listener
.local_addr()
.map_err(BrowserAuthError::LocalAddrFailed)?
.port();

Ok((listener, port))
}

/// Wait for the browser callback and extract the token.
///
/// The callback comes as: `GET /?token=<url_encoded_token> HTTP/1.1`
///
/// This function blocks until a connection is received and the token is extracted.
pub fn wait_for_token(listener: &TcpListener) -> Result<String, BrowserAuthError> {
let (mut stream, _addr) = listener.accept().map_err(BrowserAuthError::AcceptFailed)?;

let mut reader = BufReader::new(&stream);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.map_err(BrowserAuthError::ReadFailed)?;

let token = extract_token_from_request(&request_line)?;

let response = "HTTP/1.1 200 OK\r\n\
Content-Type: text/html\r\n\
Connection: close\r\n\
\r\n\
<!doctype html>\
<html>\
<head>\
<meta charset=\"utf-8\">\
<title>Authentication Complete</title>\
<style>\
* { margin: 0; padding: 0; box-sizing: border-box; }\
body {\
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;\
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\
min-height: 100vh;\
display: flex;\
align-items: center;\
justify-content: center;\
}\
.card {\
background: white;\
border-radius: 16px;\
padding: 3em 4em;\
text-align: center;\
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);\
}\
.checkmark { font-size: 4em; margin-bottom: 0.25em; color: #22c55e; }\
h1 { color: #1a1a2e; font-weight: 600; margin-bottom: 0.5em; }\
p { color: #6b7280; font-size: 1.1em; }\
</style>\
<script>\
window.onload = function() { window.open('', '_self', ''); window.close(); };\
</script>\
</head>\
<body>\
<div class=\"card\">\
<div class=\"checkmark\">&#10003;</div>\
<h1>Authentication Successful</h1>\
<p>You can close this tab.</p>\
</div>\
<script>setTimeout(function() { window.close(); }, 5000);</script>\
</body>\
</html>";

let _ = stream.write_all(response.as_bytes());

Ok(token)
}

/// Extract the token from the HTTP request line.
fn extract_token_from_request(request_line: &str) -> Result<String, BrowserAuthError> {
// request_line looks like: GET /?token=<token> HTTP/1.1
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
return Err(BrowserAuthError::InvalidRequest);
}

let path = parts[1]; // /?token=<token>

if !path.starts_with("/?token=") {
return Err(BrowserAuthError::MissingToken(path.to_string()));
}

let encoded_token = &path[8..]; // skip "/?token="

let token = urlencoding::decode(encoded_token)
.map_err(|e| BrowserAuthError::DecodeFailed(e.to_string()))?
.into_owned();

Ok(token)
}

/// Open the SSO URL in the default browser.
pub fn open_browser(url: &str) -> Result<(), BrowserAuthError> {
open::that(url).map_err(BrowserAuthError::BrowserOpenFailed)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_extract_token() {
let request = "GET /?token=abc123 HTTP/1.1";
let token = extract_token_from_request(request).unwrap();
assert_eq!(token, "abc123");
}

#[test]
fn test_extract_token_url_encoded() {
let request = "GET /?token=abc%20123 HTTP/1.1";
let token = extract_token_from_request(request).unwrap();
assert_eq!(token, "abc 123");
}

#[test]
fn test_generate_proof_key() {
let key = generate_proof_key();
// base64 of 32 bytes is 44 characters
assert_eq!(key.len(), 44);
}

#[test]
fn test_missing_token_error() {
let request = "GET /callback HTTP/1.1";
let result = extract_token_from_request(request);
assert!(matches!(result, Err(BrowserAuthError::MissingToken(_))));
}
}
7 changes: 6 additions & 1 deletion snowflake-api/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct QueryContext {

pub enum QueryType {
LoginRequest,
AuthenticatorRequest,
TokenRequest,
CloseSession,
JsonQuery,
Expand All @@ -48,6 +49,10 @@ impl QueryType {
path: "session/v1/login-request",
accept_mime: "application/json",
},
Self::AuthenticatorRequest => QueryContext {
path: "session/authenticator-request",
accept_mime: "application/json",
},
Self::TokenRequest => QueryContext {
path: "/session/token-request",
accept_mime: "application/snowflake",
Expand Down Expand Up @@ -171,7 +176,7 @@ impl Connection {
.send()
.await?;

Ok(resp.json::<R>().await?)
Ok(resp.json().await?)
}

pub async fn get_chunk(
Expand Down
83 changes: 75 additions & 8 deletions snowflake-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ use crate::requests::ExecRequest;
use crate::responses::{ExecResponseRowType, SnowflakeType};
use crate::session::AuthError::MissingEnvArgument;

#[cfg(feature = "browser-auth")]
mod browser;
pub mod connection;
#[cfg(feature = "polars")]
mod polars;
Expand Down Expand Up @@ -196,14 +198,34 @@ pub struct AuthArgs {

impl AuthArgs {
pub fn from_env() -> Result<AuthArgs, SnowflakeApiError> {
let auth_type = if let Ok(password) = std::env::var("SNOWFLAKE_PASSWORD") {
Ok(AuthType::Password(PasswordArgs { password }))
} else if let Ok(private_key_pem) = std::env::var("SNOWFLAKE_PRIVATE_KEY") {
Ok(AuthType::Certificate(CertificateArgs { private_key_pem }))
} else {
Err(MissingEnvArgument(
"SNOWFLAKE_PASSWORD or SNOWFLAKE_PRIVATE_KEY".to_owned(),
))
let authenticator = std::env::var("SNOWFLAKE_AUTHENTICATOR")
.ok()
.map(|s| s.to_lowercase());

let auth_type = match authenticator.as_deref() {
#[cfg(feature = "browser-auth")]
Some("externalbrowser") => Ok(AuthType::ExternalBrowser),
_ => {
// Fall back to password or certificate auth
if let Ok(password) = std::env::var("SNOWFLAKE_PASSWORD") {
Ok(AuthType::Password(PasswordArgs { password }))
} else if let Ok(private_key_pem) = std::env::var("SNOWFLAKE_PRIVATE_KEY") {
Ok(AuthType::Certificate(CertificateArgs { private_key_pem }))
} else {
#[cfg(feature = "browser-auth")]
{
Err(MissingEnvArgument(
"SNOWFLAKE_PASSWORD, SNOWFLAKE_PRIVATE_KEY, or SNOWFLAKE_AUTHENTICATOR=externalbrowser".to_owned(),
))
}
#[cfg(not(feature = "browser-auth"))]
{
Err(MissingEnvArgument(
"SNOWFLAKE_PASSWORD or SNOWFLAKE_PRIVATE_KEY".to_owned(),
))
}
}
}
};

Ok(AuthArgs {
Expand All @@ -223,6 +245,8 @@ impl AuthArgs {
pub enum AuthType {
Password(PasswordArgs),
Certificate(CertificateArgs),
#[cfg(feature = "browser-auth")]
ExternalBrowser,
}

pub struct PasswordArgs {
Expand Down Expand Up @@ -276,6 +300,16 @@ impl SnowflakeApiBuilder {
self.auth.role.as_deref(),
&args.private_key_pem,
),
#[cfg(feature = "browser-auth")]
AuthType::ExternalBrowser => Session::browser_auth(
Arc::clone(&connection),
&self.auth.account_identifier,
self.auth.warehouse.as_deref(),
self.auth.database.as_deref(),
self.auth.schema.as_deref(),
&self.auth.username,
self.auth.role.as_deref(),
),
};

let account_identifier = self.auth.account_identifier.to_uppercase();
Expand Down Expand Up @@ -366,6 +400,39 @@ impl SnowflakeApi {
))
}

/// Initialize object with external browser SSO auth. Authentication happens on the first request.
///
/// This will open a browser window for the user to authenticate via their `IdP`.
/// Requires the `browser-auth` feature.
#[cfg(feature = "browser-auth")]
pub fn with_browser_auth(
account_identifier: &str,
warehouse: Option<&str>,
database: Option<&str>,
schema: Option<&str>,
username: &str,
role: Option<&str>,
) -> Result<Self, SnowflakeApiError> {
let connection = Arc::new(Connection::new()?);

let session = Session::browser_auth(
Arc::clone(&connection),
account_identifier,
warehouse,
database,
schema,
username,
role,
);

let account_identifier = account_identifier.to_uppercase();
Ok(Self::new(
Arc::clone(&connection),
session,
account_identifier,
))
}

pub fn from_env() -> Result<Self, SnowflakeApiError> {
SnowflakeApiBuilder::new(AuthArgs::from_env()?).build()
}
Expand Down
Loading