From c519d8e15f58b5d593a926e1efc4a5516745569c Mon Sep 17 00:00:00 2001 From: Hal Ali Date: Fri, 20 Sep 2024 17:41:46 -0400 Subject: [PATCH] feat: Add browser authentication (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description * Copy code over from target-snowflake https://github.com/MeltanoLabs/target-snowflake/pull/260, now there is parity between how the target & tap can connect to snowflake (3 ways: password, key pair & browser authentications) * This refactor uses `Enum` for `auth_method` for greater type safety. --------- Co-authored-by: Edgar Ramírez Mondragón <16805946+edgarrmondragon@users.noreply.github.com> --- tap_snowflake/client.py | 29 ++++++++++++++++++++++++----- tap_snowflake/tap.py | 10 ++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tap_snowflake/client.py b/tap_snowflake/client.py index 5bf1a63..98e0314 100644 --- a/tap_snowflake/client.py +++ b/tap_snowflake/client.py @@ -54,6 +54,14 @@ def patched_conform( singer_sdk.helpers._typing._conform_primitive_property = patched_conform +class SnowflakeAuthMethod(Enum): + """Supported methods to authenticate to snowflake""" + + BROWSER = 1 + PASSWORD = 2 + KEY_PAIR = 3 + + class ProfileStats(Enum): """Profile Statistics Enum.""" @@ -111,14 +119,23 @@ def get_private_key(self): ) @cached_property - def auth_method(self): + def auth_method(self) -> SnowflakeAuthMethod: """Validate & return the authentication method based on config.""" + if self.config.get("use_browser_authentication"): + return SnowflakeAuthMethod.BROWSER + valid_auth_methods = {"private_key", "private_key_path", "password"} config_auth_methods = [x for x in self.config if x in valid_auth_methods] if len(config_auth_methods) != 1: - msg = f"One of {valid_auth_methods} must be specified" + msg = ( + "Neither password nor private key was provided for " + "authentication. For password-less browser authentication via SSO, " + "set use_browser_authentication config option to True." + ) raise ConfigValidationError(msg) - return config_auth_methods[0] + if config_auth_methods[0] in ["private_key", "private_key_path"]: + return SnowflakeAuthMethod.KEY_PAIR + return SnowflakeAuthMethod.PASSWORD def get_sqlalchemy_url(self, config: dict) -> str: """Concatenate a SQLAlchemy URL for use in connecting to the source.""" @@ -127,7 +144,9 @@ def get_sqlalchemy_url(self, config: dict) -> str: "user": config["user"], } - if self.auth_method == "password": + if self.auth_method == SnowflakeAuthMethod.BROWSER: + params["authenticator"] = "externalbrowser" + elif self.auth_method == SnowflakeAuthMethod.PASSWORD: params["password"] = config["password"] for option in ["database", "schema", "warehouse", "role"]: @@ -143,7 +162,7 @@ def create_engine(self) -> sqlalchemy.engine.Engine: A SQLAlchemy engine. """ connect_args = {} - if self.auth_method in ["private_key", "private_key_path"]: + if self.auth_method == SnowflakeAuthMethod.KEY_PAIR: connect_args["private_key"] = self.get_private_key() return sqlalchemy.create_engine( self.sqlalchemy_url, diff --git a/tap_snowflake/tap.py b/tap_snowflake/tap.py index 6790f5e..c31673d 100644 --- a/tap_snowflake/tap.py +++ b/tap_snowflake/tap.py @@ -59,6 +59,16 @@ class TapSnowflake(SQLTap): secret=True, description="The passprhase used to protect the private key", ), + th.Property( + "use_browser_authentication", + th.BooleanType, + required=False, + default=False, + description=( + "If authentication should be done using SSO (via external browser). " + "See SSO browser authentication." + ) + ), th.Property( "account", th.StringType,