diff --git a/Snowflake.Data.Tests/IntegrationTests/SFConnectionIT.cs b/Snowflake.Data.Tests/IntegrationTests/SFConnectionIT.cs index 81489877e..be260ae14 100644 --- a/Snowflake.Data.Tests/IntegrationTests/SFConnectionIT.cs +++ b/Snowflake.Data.Tests/IntegrationTests/SFConnectionIT.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. */ @@ -13,6 +13,8 @@ using NUnit.Framework; using Snowflake.Data.Client; using Snowflake.Data.Core; +using Snowflake.Data.Core.CredentialManager; +using Snowflake.Data.Core.CredentialManager.Infrastructure; using Snowflake.Data.Core.Session; using Snowflake.Data.Core.Tools; using Snowflake.Data.Log; @@ -21,7 +23,6 @@ namespace Snowflake.Data.Tests.IntegrationTests { - [TestFixture] class SFConnectionIT : SFBaseTest { @@ -1046,6 +1047,71 @@ public void TestSSOConnectionTimeoutAfter10s() Assert.LessOrEqual(stopwatch.ElapsedMilliseconds, (waitSeconds + 5) * 1000); } + [Test] + [Ignore("This test requires manual interaction and therefore cannot be run in CI")] + public void TestSSOConnectionWithTokenCaching() + { + /* + * This test checks that the connector successfully stores an SSO token and uses it for authentication if it exists + * 1. Login normally using external browser with allow_sso_token_caching enabled + * 2. Login again, this time without a browser, as the connector should be using the SSO token retrieved from step 1 + */ + + using (IDbConnection conn = new SnowflakeDbConnection()) + { + // Set the allow_sso_token_caching property to true to enable token caching + // The specified user should be configured for SSO + conn.ConnectionString + = ConnectionStringWithoutAuth + + $";authenticator=externalbrowser;user={testConfig.user};allow_sso_token_caching=true;"; + + // Authenticate to retrieve and store the token if doesn't exist or invalid + conn.Open(); + Assert.AreEqual(ConnectionState.Open, conn.State); + + // Authenticate using the SSO token (the connector will automatically use the token and a browser should not pop-up in this step) + conn.Open(); + Assert.AreEqual(ConnectionState.Open, conn.State); + + conn.Close(); + Assert.AreEqual(ConnectionState.Closed, conn.State); + } + } + + [Test] + [Ignore("This test requires manual interaction and therefore cannot be run in CI")] + public void TestSSOConnectionWithInvalidCachedToken() + { + /* + * This test checks that the connector will attempt to re-authenticate using external browser if the token retrieved from the cache is invalid + * 1. Create a credential manager and save credentials for the user with a wrong token + * 2. Open a connection which initially should try to use the token and then switch to external browser when the token fails + */ + + using (IDbConnection conn = new SnowflakeDbConnection()) + { + // Set the allow_sso_token_caching property to true to enable token caching + conn.ConnectionString + = ConnectionStringWithoutAuth + + $";authenticator=externalbrowser;user={testConfig.user};allow_sso_token_caching=true;"; + + // Create a credential manager and save a wrong token for the test user + var key = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(testConfig.host, testConfig.user, TokenType.IdToken); + var credentialManager = SFCredentialManagerInMemoryImpl.Instance; + credentialManager.SaveCredentials(key, "wrongToken"); + + // Use the credential manager with the wrong token + SnowflakeCredentialManagerFactory.SetCredentialManager(credentialManager); + + // Open a connection which should switch to external browser after trying to connect using the wrong token + conn.Open(); + Assert.AreEqual(ConnectionState.Open, conn.State); + + // Switch back to the default credential manager + SnowflakeCredentialManagerFactory.UseDefaultCredentialManager(); + } + } + [Test] [Ignore("This test requires manual interaction and therefore cannot be run in CI")] public void TestSSOConnectionWithWrongUser() @@ -2357,6 +2423,40 @@ public void TestOpenAsyncThrowExceptionWhenOperationIsCancelled() } } + [Test] + [Ignore("This test requires manual interaction and therefore cannot be run in CI")] + public void TestSSOConnectionWithTokenCachingAsync() + { + /* + * This test checks that the connector successfully stores an SSO token and uses it for authentication if it exists + * 1. Login normally using external browser with allow_sso_token_caching enabled + * 2. Login again, this time without a browser, as the connector should be using the SSO token retrieved from step 1 + */ + + using (SnowflakeDbConnection conn = new SnowflakeDbConnection()) + { + // Set the allow_sso_token_caching property to true to enable token caching + // The specified user should be configured for SSO + conn.ConnectionString + = ConnectionStringWithoutAuth + + $";authenticator=externalbrowser;user={testConfig.user};allow_sso_token_caching=true;"; + + // Authenticate to retrieve and store the token if doesn't exist or invalid + Task connectTask = conn.OpenAsync(CancellationToken.None); + connectTask.Wait(); + Assert.AreEqual(ConnectionState.Open, conn.State); + + // Authenticate using the SSO token (the connector will automatically use the token and a browser should not pop-up in this step) + connectTask = conn.OpenAsync(CancellationToken.None); + connectTask.Wait(); + Assert.AreEqual(ConnectionState.Open, conn.State); + + connectTask = conn.CloseAsync(CancellationToken.None); + connectTask.Wait(); + Assert.AreEqual(ConnectionState.Closed, conn.State); + } + } + [Test] public void TestCloseSessionWhenGarbageCollectorFinalizesConnection() { diff --git a/Snowflake.Data.Tests/Mock/MockExternalBrowser.cs b/Snowflake.Data.Tests/Mock/MockExternalBrowser.cs new file mode 100644 index 000000000..147a2d1b1 --- /dev/null +++ b/Snowflake.Data.Tests/Mock/MockExternalBrowser.cs @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +using Snowflake.Data.Core; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Snowflake.Data.Tests.Mock +{ + + class MockExternalBrowserRestRequester : IMockRestRequester + { + public string ProofKey { get; set; } + public string SSOUrl { get; set; } + + public T Get(IRestRequest request) + { + throw new System.NotImplementedException(); + } + + public Task GetAsync(IRestRequest request, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public T Post(IRestRequest postRequest) + { + return Task.Run(async () => await (PostAsync(postRequest, CancellationToken.None)).ConfigureAwait(false)).Result; + } + + public Task PostAsync(IRestRequest postRequest, CancellationToken cancellationToken) + { + SFRestRequest sfRequest = (SFRestRequest)postRequest; + if (sfRequest.jsonBody is AuthenticatorRequest) + { + if (string.IsNullOrEmpty(SSOUrl)) + { + var body = (AuthenticatorRequest)sfRequest.jsonBody; + var port = body.Data.BrowserModeRedirectPort; + SSOUrl = $"http://localhost:{port}/?token=mockToken"; + } + + // authenticator + var authnResponse = new AuthenticatorResponse + { + success = true, + data = new AuthenticatorResponseData + { + proofKey = ProofKey, + ssoUrl = SSOUrl, + } + }; + + return Task.FromResult((T)(object)authnResponse); + } + else + { + // login + var loginResponse = new LoginResponse + { + success = true, + data = new LoginResponseData + { + sessionId = "", + token = "", + masterToken = "", + masterValidityInSeconds = 0, + authResponseSessionInfo = new SessionInfo + { + databaseName = "", + schemaName = "", + roleName = "", + warehouseName = "", + } + } + }; + + return Task.FromResult((T)(object)loginResponse); + } + } + + public HttpResponseMessage Get(IRestRequest request) + { + throw new System.NotImplementedException(); + } + + public Task GetAsync(IRestRequest request, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public void setHttpClient(HttpClient httpClient) + { + // Nothing to do + } + } +} diff --git a/Snowflake.Data.Tests/UnitTests/SFExternalBrowserTest.cs b/Snowflake.Data.Tests/UnitTests/SFExternalBrowserTest.cs new file mode 100644 index 000000000..43935171e --- /dev/null +++ b/Snowflake.Data.Tests/UnitTests/SFExternalBrowserTest.cs @@ -0,0 +1,317 @@ +using Moq; +using NUnit.Framework; +using Snowflake.Data.Client; +using Snowflake.Data.Core; +using Snowflake.Data.Core.CredentialManager; +using Snowflake.Data.Core.CredentialManager.Infrastructure; +using Snowflake.Data.Core.Tools; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace Snowflake.Data.Tests.UnitTests +{ + [TestFixture] + class SFExternalBrowserTest + { + [ThreadStatic] + private static Mock t_browserOperations; + + private static HttpClient s_httpClient = new HttpClient(); + + [SetUp] + public void BeforeEach() + { + t_browserOperations = new Mock(); + } + + [Test] + public void TestDefaultAuthentication() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + s_httpClient.GetAsync(url); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Once()); + } catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + + [Test] + public void TestConsoleLogin() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + Uri uri = new Uri(url); + var port = HttpUtility.ParseQueryString(uri.Query).Get("browser_mode_redirect_port"); + var browserUrl = $"http://localhost:{port}/?token=mockToken"; + s_httpClient.GetAsync(browserUrl); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("disable_console_login=false;account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Once()); + } + catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + + [Test] + public void TestSSOToken() + { + try + { + var user = "test"; + var host = $"{user}.okta.com"; + var key = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(host, user, TokenType.IdToken); + var credentialManager = SFCredentialManagerInMemoryImpl.Instance; + credentialManager.SaveCredentials(key, "mockIdToken"); + SnowflakeCredentialManagerFactory.SetCredentialManager(credentialManager); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + SSOUrl = "https://www.mockSSOUrl.com" + }; + var sfSession = new SFSession($"allow_sso_token_caching=true;account=test;user={user};password=test;authenticator=externalbrowser;host={host}", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Never); + } + catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + + [Test] + public void TestThatThrowsTimeoutErrorWhenNoBrowserResponse() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback(async (string url) => { + await Task.Delay(1000).ContinueWith(_ => + { + s_httpClient.GetAsync(url); + }); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession($"browser_response_timeout=0;account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + Assert.Fail("Should fail"); + } + catch (SnowflakeDbException e) + { + Assert.AreEqual(SFError.BROWSER_RESPONSE_TIMEOUT.GetAttribute().errorCode, e.ErrorCode); + } + } + + [Test] + public void TestThatThrowsErrorWhenUrlDoesNotMatchRegex() + { + try + { + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + SSOUrl = "non-matching-regex.com" + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + Assert.Fail("Should fail"); + } + catch (SnowflakeDbException e) + { + Assert.AreEqual(SFError.INVALID_BROWSER_URL.GetAttribute().errorCode, e.ErrorCode); + } + } + + [Test] + public void TestThatThrowsErrorWhenUrlIsNotWellFormedUriString() + { + try + { + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + SSOUrl = "http://localhost:123/?token=mockToken\\\\" + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + Assert.Fail("Should fail"); + } + catch (SnowflakeDbException e) + { + Assert.AreEqual(SFError.INVALID_BROWSER_URL.GetAttribute().errorCode, e.ErrorCode); + } + } + + [Test] + public void TestThatThrowsErrorWhenBrowserRequestMethodIsNotGet() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + s_httpClient.PostAsync(url, new StringContent("")); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + Assert.Fail("Should fail"); + } + catch (SnowflakeDbException e) + { + Assert.AreEqual(SFError.BROWSER_RESPONSE_WRONG_METHOD.GetAttribute().errorCode, e.ErrorCode); + } + } + + [Test] + public void TestThatThrowsErrorWhenBrowserRequestHasInvalidQuery() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + var urlWithoutQuery = url.Substring(0, url.IndexOf("?token=")); + s_httpClient.GetAsync(urlWithoutQuery); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + sfSession.Open(); + Assert.Fail("Should fail"); + } + catch (SnowflakeDbException e) + { + Assert.AreEqual(SFError.BROWSER_RESPONSE_INVALID_PREFIX.GetAttribute().errorCode, e.ErrorCode); + } + } + + [Test] + public void TestDefaultAuthenticationAsync() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + s_httpClient.GetAsync(url); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + Task connectTask = sfSession.OpenAsync(CancellationToken.None); + connectTask.Wait(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Once()); + } + catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + + [Test] + public void TestConsoleLoginAsync() + { + try + { + t_browserOperations + .Setup(b => b.OpenUrl(It.IsAny())) + .Callback((string url) => { + Uri uri = new Uri(url); + var port = HttpUtility.ParseQueryString(uri.Query).Get("browser_mode_redirect_port"); + var browserUrl = $"http://localhost:{port}/?token=mockToken"; + s_httpClient.GetAsync(browserUrl); + }); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + }; + var sfSession = new SFSession("disable_console_login=false;account=test;user=test;password=test;authenticator=externalbrowser;host=test.okta.com", null, restRequester, t_browserOperations.Object); + Task connectTask = sfSession.OpenAsync(CancellationToken.None); + connectTask.Wait(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Once()); + } + catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + + [Test] + public void TestSSOTokenAsync() + { + try + { + var user = "test"; + var host = $"{user}.okta.com"; + var key = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(host, user, TokenType.IdToken); + var credentialManager = SFCredentialManagerInMemoryImpl.Instance; + credentialManager.SaveCredentials(key, "mockIdToken"); + SnowflakeCredentialManagerFactory.SetCredentialManager(credentialManager); + + var restRequester = new Mock.MockExternalBrowserRestRequester() + { + ProofKey = "mockProofKey", + SSOUrl = "https://www.mockSSOUrl.com" + }; + var sfSession = new SFSession($"allow_sso_token_caching=true;account=test;user={user};password=test;authenticator=externalbrowser;host={host}", null, restRequester, t_browserOperations.Object); + Task connectTask = sfSession.OpenAsync(CancellationToken.None); + connectTask.Wait(); + + t_browserOperations.Verify(b => b.OpenUrl(It.IsAny()), Times.Never()); + } + catch (SnowflakeDbException e) + { + Assert.Fail("Should pass without exception", e); + } + } + } +} diff --git a/Snowflake.Data.Tests/UnitTests/SFSessionPropertyTest.cs b/Snowflake.Data.Tests/UnitTests/SFSessionPropertyTest.cs index 044ac5ddc..3a2d189bc 100644 --- a/Snowflake.Data.Tests/UnitTests/SFSessionPropertyTest.cs +++ b/Snowflake.Data.Tests/UnitTests/SFSessionPropertyTest.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2019 Snowflake Computing Inc. All rights reserved. */ @@ -236,6 +236,21 @@ public void TestResolveConnectionArea(string host, string expectedMessage) Assert.AreEqual(expectedMessage, message); } + [Test] + [TestCase("true")] + [TestCase("false")] + public void TestValidateAllowSSOTokenCachingProperty(string expectedAllowSsoTokenCaching) + { + // arrange + var connectionString = $"ACCOUNT=account;USER=test;PASSWORD=test;ALLOW_SSO_TOKEN_CACHING={expectedAllowSsoTokenCaching}"; + + // act + var properties = SFSessionProperties.ParseConnectionString(connectionString, null); + + // assert + Assert.AreEqual(expectedAllowSsoTokenCaching, properties[SFSessionProperty.ALLOW_SSO_TOKEN_CACHING]); + } + public static IEnumerable ConnectionStringTestCases() { string defAccount = "testaccount"; @@ -293,6 +308,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -330,6 +346,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -369,6 +386,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } }, ConnectionString = @@ -410,6 +428,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } }, ConnectionString = @@ -450,6 +469,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -487,6 +507,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -523,6 +544,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } }, ConnectionString = @@ -561,6 +583,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } }, ConnectionString = @@ -601,6 +624,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -638,6 +662,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -675,6 +700,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; @@ -715,6 +741,7 @@ public static IEnumerable ConnectionStringTestCases() { SFSessionProperty.EXPIRATIONTIMEOUT, DefaultValue(SFSessionProperty.EXPIRATIONTIMEOUT) }, { SFSessionProperty.POOLINGENABLED, DefaultValue(SFSessionProperty.POOLINGENABLED) }, { SFSessionProperty.DISABLE_SAML_URL_CHECK, DefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) }, + { SFSessionProperty.ALLOW_SSO_TOKEN_CACHING, DefaultValue(SFSessionProperty.ALLOW_SSO_TOKEN_CACHING) }, { SFSessionProperty.PASSCODEINPASSWORD, DefaultValue(SFSessionProperty.PASSCODEINPASSWORD) } } }; diff --git a/Snowflake.Data.Tests/UnitTests/SFSessionTest.cs b/Snowflake.Data.Tests/UnitTests/SFSessionTest.cs index 3129bb509..540243164 100644 --- a/Snowflake.Data.Tests/UnitTests/SFSessionTest.cs +++ b/Snowflake.Data.Tests/UnitTests/SFSessionTest.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2024 Snowflake Computing Inc. All rights reserved. */ @@ -7,6 +7,8 @@ using NUnit.Framework; using Snowflake.Data.Core.Tools; using Snowflake.Data.Tests.Mock; +using System; +using System.Net; namespace Snowflake.Data.Tests.UnitTests { @@ -106,6 +108,48 @@ public void TestThatConfiguresEasyLogging(string configPath) easyLoggingStarter.Verify(starter => starter.Init(configPath)); } + [Test] + public void TestThatIdTokenIsStoredWhenCachingIsEnabled() + { + // arrange + var expectedIdToken = "mockIdToken"; + var connectionString = $"account=account;user=user;password=test;allow_sso_token_caching=true"; + var session = new SFSession(connectionString, null); + LoginResponse authnResponse = new LoginResponse + { + data = new LoginResponseData() + { + idToken = expectedIdToken, + authResponseSessionInfo = new SessionInfo(), + }, + success = true + }; + + // act + session.ProcessLoginResponse(authnResponse); + + // assert + Assert.AreEqual(expectedIdToken, new NetworkCredential(string.Empty, session._idToken).Password); + } + + [Test] + public void TestThatRetriesAuthenticationForInvalidIdToken() + { + // arrange + var connectionString = "account=test;user=test;password=test;allow_sso_token_caching=true"; + var session = new SFSession(connectionString, null); + LoginResponse authnResponse = new LoginResponse + { + code = SFError.ID_TOKEN_INVALID.GetAttribute().errorCode, + message = "", + success = false + }; + + // assert + Assert.Throws(() => session.ProcessLoginResponse(authnResponse)); + } + + [Test] [TestCase(null, "accountDefault", "accountDefault", false)] [TestCase("initial", "initial", "initial", false)] [TestCase("initial", null, "initial", false)] diff --git a/Snowflake.Data.Tests/UnitTests/Session/SFHttpClientPropertiesTest.cs b/Snowflake.Data.Tests/UnitTests/Session/SFHttpClientPropertiesTest.cs index 18f1ff7d7..0c76fff29 100644 --- a/Snowflake.Data.Tests/UnitTests/Session/SFHttpClientPropertiesTest.cs +++ b/Snowflake.Data.Tests/UnitTests/Session/SFHttpClientPropertiesTest.cs @@ -17,7 +17,8 @@ public class SFHttpClientPropertiesTest [Test] public void TestConvertToMapOnly2Properties( [Values(true, false)] bool validateDefaultParameters, - [Values(true, false)] bool clientSessionKeepAlive) + [Values(true, false)] bool clientSessionKeepAlive, + [Values(true, false)] bool clientStoreTemporaryCredential) { // arrange var proxyProperties = new SFSessionHttpClientProxyProperties() @@ -32,6 +33,7 @@ public void TestConvertToMapOnly2Properties( { validateDefaultParameters = validateDefaultParameters, clientSessionKeepAlive = clientSessionKeepAlive, + _allowSSOTokenCaching = clientStoreTemporaryCredential, connectionTimeout = SFSessionHttpClientProperties.DefaultRetryTimeout, insecureMode = false, disableRetry = false, @@ -45,9 +47,10 @@ public void TestConvertToMapOnly2Properties( var parameterMap = properties.ToParameterMap(); // assert - Assert.AreEqual(2, parameterMap.Count); + Assert.AreEqual(3, parameterMap.Count); Assert.AreEqual(validateDefaultParameters, parameterMap[SFSessionParameter.CLIENT_VALIDATE_DEFAULT_PARAMETERS]); Assert.AreEqual(clientSessionKeepAlive, parameterMap[SFSessionParameter.CLIENT_SESSION_KEEP_ALIVE]); + Assert.AreEqual(clientStoreTemporaryCredential, parameterMap[SFSessionParameter.CLIENT_STORE_TEMPORARY_CREDENTIAL]); } [Test] diff --git a/Snowflake.Data/Core/Authenticator/ExternalBrowserAuthenticator.cs b/Snowflake.Data/Core/Authenticator/ExternalBrowserAuthenticator.cs index 11720c393..7597a8535 100644 --- a/Snowflake.Data/Core/Authenticator/ExternalBrowserAuthenticator.cs +++ b/Snowflake.Data/Core/Authenticator/ExternalBrowserAuthenticator.cs @@ -1,18 +1,17 @@ -/* +/* * Copyright (c) 2012-2019 Snowflake Computing Inc. All rights reserved. */ using System; -using System.Diagnostics; using System.Net; using System.Net.Sockets; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Snowflake.Data.Log; using Snowflake.Data.Client; using System.Text.RegularExpressions; using System.Collections.Generic; +using Snowflake.Data.Core.CredentialManager; namespace Snowflake.Data.Core.Authenticator { @@ -36,6 +35,8 @@ class ExternalBrowserAuthenticator : BaseAuthenticator, IAuthenticator private string _proofKey; // Event for successful authentication. private ManualResetEvent _successEvent; + // Placeholder in case an exception occurs while extracting the token from the browser response. + private Exception _tokenExtractionException; /// /// Constructor of the External authenticator @@ -44,51 +45,26 @@ class ExternalBrowserAuthenticator : BaseAuthenticator, IAuthenticator internal ExternalBrowserAuthenticator(SFSession session) : base(session, AUTH_NAME) { } + /// async Task IAuthenticator.AuthenticateAsync(CancellationToken cancellationToken) { logger.Info("External Browser Authentication"); - - int localPort = GetRandomUnusedPort(); - using (var httpListener = GetHttpListener(localPort)) + var idToken = new NetworkCredential(string.Empty, session._idToken).Password; + if (string.IsNullOrEmpty(idToken)) { - httpListener.Start(); - - logger.Debug("Get IdpUrl and ProofKey"); - string loginUrl; - if (session._disableConsoleLogin) + int localPort = GetRandomUnusedPort(); + using (var httpListener = GetHttpListener(localPort)) { - var authenticatorRestRequest = BuildAuthenticatorRestRequest(localPort); - var authenticatorRestResponse = - await session.restRequester.PostAsync( - authenticatorRestRequest, - cancellationToken - ).ConfigureAwait(false); - authenticatorRestResponse.FilterFailedResponse(); - - loginUrl = authenticatorRestResponse.data.ssoUrl; - _proofKey = authenticatorRestResponse.data.proofKey; + httpListener.Start(); + logger.Debug("Get IdpUrl and ProofKey"); + var loginUrl = await GetIdpUrlAndProofKeyAsync(localPort, cancellationToken); + logger.Debug("Open browser"); + StartBrowser(loginUrl); + logger.Debug("Get the redirect SAML request"); + GetRedirectSamlRequest(httpListener); + httpListener.Stop(); } - else - { - _proofKey = GenerateProofKey(); - loginUrl = GetLoginUrl(_proofKey, localPort); - } - - logger.Debug("Open browser"); - StartBrowser(loginUrl); - - logger.Debug("Get the redirect SAML request"); - _successEvent = new ManualResetEvent(false); - httpListener.BeginGetContext(GetContextCallback, httpListener); - var timeoutInSec = int.Parse(session.properties[SFSessionProperty.BROWSER_RESPONSE_TIMEOUT]); - if (!_successEvent.WaitOne(timeoutInSec * 1000)) - { - logger.Warn("Browser response timeout"); - throw new SnowflakeDbException(SFError.BROWSER_RESPONSE_TIMEOUT, timeoutInSec); - } - - httpListener.Stop(); } logger.Debug("Send login request"); @@ -99,71 +75,109 @@ await session.restRequester.PostAsync( void IAuthenticator.Authenticate() { logger.Info("External Browser Authentication"); - - int localPort = GetRandomUnusedPort(); - using (var httpListener = GetHttpListener(localPort)) + var idToken = new NetworkCredential(string.Empty, session._idToken).Password; + if (string.IsNullOrEmpty(idToken)) { - httpListener.Start(); - - logger.Debug("Get IdpUrl and ProofKey"); - string loginUrl; - if (session._disableConsoleLogin) + int localPort = GetRandomUnusedPort(); + using (var httpListener = GetHttpListener(localPort)) { - var authenticatorRestRequest = BuildAuthenticatorRestRequest(localPort); - var authenticatorRestResponse = session.restRequester.Post(authenticatorRestRequest); - authenticatorRestResponse.FilterFailedResponse(); - - loginUrl = authenticatorRestResponse.data.ssoUrl; - _proofKey = authenticatorRestResponse.data.proofKey; - } - else - { - _proofKey = GenerateProofKey(); - loginUrl = GetLoginUrl(_proofKey, localPort); + httpListener.Start(); + logger.Debug("Get IdpUrl and ProofKey"); + var loginUrl = GetIdpUrlAndProofKey(localPort); + logger.Debug("Open browser"); + StartBrowser(loginUrl); + logger.Debug("Get the redirect SAML request"); + GetRedirectSamlRequest(httpListener); + httpListener.Stop(); } + } - logger.Debug("Open browser"); - StartBrowser(loginUrl); + logger.Debug("Send login request"); + base.Login(); + } - logger.Debug("Get the redirect SAML request"); - _successEvent = new ManualResetEvent(false); - httpListener.BeginGetContext(GetContextCallback, httpListener); - var timeoutInSec = int.Parse(session.properties[SFSessionProperty.BROWSER_RESPONSE_TIMEOUT]); - if (!_successEvent.WaitOne(timeoutInSec * 1000)) - { - logger.Warn("Browser response timeout"); - throw new SnowflakeDbException(SFError.BROWSER_RESPONSE_TIMEOUT, timeoutInSec); - } + private string GetIdpUrlAndProofKey(int localPort) + { + if (session._disableConsoleLogin) + { + var authenticatorRestRequest = BuildAuthenticatorRestRequest(localPort); + var authenticatorRestResponse = session.restRequester.Post(authenticatorRestRequest); + authenticatorRestResponse.FilterFailedResponse(); - httpListener.Stop(); + _proofKey = authenticatorRestResponse.data.proofKey; + return authenticatorRestResponse.data.ssoUrl; + } + else + { + _proofKey = GenerateProofKey(); + return GetLoginUrl(_proofKey, localPort); } + } - logger.Debug("Send login request"); - base.Login(); + private async Task GetIdpUrlAndProofKeyAsync(int localPort, CancellationToken cancellationToken) + { + if (session._disableConsoleLogin) + { + var authenticatorRestRequest = BuildAuthenticatorRestRequest(localPort); + var authenticatorRestResponse = + await session.restRequester.PostAsync( + authenticatorRestRequest, + cancellationToken + ).ConfigureAwait(false); + authenticatorRestResponse.FilterFailedResponse(); + + _proofKey = authenticatorRestResponse.data.proofKey; + return authenticatorRestResponse.data.ssoUrl; + } + else + { + _proofKey = GenerateProofKey(); + return GetLoginUrl(_proofKey, localPort); + } } - private void GetContextCallback(IAsyncResult result) + private void GetRedirectSamlRequest(HttpListener httpListener) { - HttpListener httpListener = (HttpListener) result.AsyncState; + _successEvent = new ManualResetEvent(false); + _tokenExtractionException = null; + httpListener.BeginGetContext(new AsyncCallback(GetContextCallback), httpListener); + var timeoutInSec = int.Parse(session.properties[SFSessionProperty.BROWSER_RESPONSE_TIMEOUT]); + if (!_successEvent.WaitOne(timeoutInSec * 1000)) + { + _successEvent.Set(); + logger.Error("Browser response timeout has been reached"); + throw new SnowflakeDbException(SFError.BROWSER_RESPONSE_TIMEOUT, timeoutInSec); + } + if (_tokenExtractionException != null) + { + throw _tokenExtractionException; + } + } - if (httpListener.IsListening) + private void GetContextCallback(IAsyncResult result) + { + HttpListener httpListener = (HttpListener)result.AsyncState; + if (httpListener.IsListening && !_successEvent.WaitOne(0)) { HttpListenerContext context = httpListener.EndGetContext(result); HttpListenerRequest request = context.Request; _samlResponseToken = ValidateAndExtractToken(request); - HttpListenerResponse response = context.Response; - try + if (!string.IsNullOrEmpty(_samlResponseToken)) { - using (var output = response.OutputStream) + HttpListenerResponse response = context.Response; + try { - output.Write(SUCCESS_RESPONSE, 0, SUCCESS_RESPONSE.Length); + using (var output = response.OutputStream) + { + output.Write(SUCCESS_RESPONSE, 0, SUCCESS_RESPONSE.Length); + } + } + catch + { + // Ignore the exception as it does not affect the overall authentication flow + logger.Warn("External browser response not sent out"); } - } - catch - { - // Ignore the exception as it does not affect the overall authentication flow - logger.Warn("External browser response not sent out"); } } @@ -187,53 +201,33 @@ private static HttpListener GetHttpListener(int port) return listener; } - private static void StartBrowser(string url) + private void StartBrowser(string url) { string regexStr = "^http(s?)\\:\\/\\/[0-9a-zA-Z]([-.\\w]*[0-9a-zA-Z@:])*(:(0-9)*)*(\\/?)([a-zA-Z0-9\\-\\.\\?\\,\\&\\(\\)\\/\\\\\\+&%\\$#_=@]*)?$"; Match m = Regex.Match(url, regexStr, RegexOptions.IgnoreCase); - if (!m.Success) + if (!m.Success || !Uri.IsWellFormedUriString(url, UriKind.Absolute)) { logger.Error("Failed to start browser. Invalid url."); - throw new SnowflakeDbException(SFError.INVALID_BROWSER_URL); + throw new SnowflakeDbException(SFError.INVALID_BROWSER_URL, url); } - if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) - { - logger.Error("Failed to start browser. Invalid url."); - throw new SnowflakeDbException(SFError.INVALID_BROWSER_URL); - } - - // The following code is learnt from https://brockallen.com/2016/09/24/process-start-for-urls-on-net-core/ - // hack because of this: https://github.com/dotnet/corefx/issues/10361 - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - url = url.Replace("&", "^&"); - Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { UseShellExecute = true }); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Process.Start("xdg-open", url); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Process.Start("open", url); - } - else - { - throw new SnowflakeDbException(SFError.UNSUPPORTED_PLATFORM); - } + session._browserOperations.OpenUrl(url); } - private static string ValidateAndExtractToken(HttpListenerRequest request) + private string ValidateAndExtractToken(HttpListenerRequest request) { if (request.HttpMethod != "GET") { - throw new SnowflakeDbException(SFError.BROWSER_RESPONSE_WRONG_METHOD, request.HttpMethod); + logger.Error("Failed to extract token due to invalid HTTP method."); + _tokenExtractionException = new SnowflakeDbException(SFError.BROWSER_RESPONSE_WRONG_METHOD, request.Url.Query); + return null; } if (request.Url.Query == null || !request.Url.Query.StartsWith(TOKEN_REQUEST_PREFIX)) { - throw new SnowflakeDbException(SFError.BROWSER_RESPONSE_INVALID_PREFIX, request.Url.Query); + logger.Error("Failed to extract token due to invalid query."); + _tokenExtractionException = new SnowflakeDbException(SFError.BROWSER_RESPONSE_INVALID_PREFIX, request.Url.Query); + return null; } return Uri.UnescapeDataString(request.Url.Query.Substring(TOKEN_REQUEST_PREFIX.Length)); @@ -247,6 +241,8 @@ private SFRestRequest BuildAuthenticatorRestRequest(int port) AccountName = session.properties[SFSessionProperty.ACCOUNT], Authenticator = AUTH_NAME, BrowserModeRedirectPort = port.ToString(), + DriverName = SFEnvironment.DriverName, + DriverVersion = SFEnvironment.DriverVersion, }; int connectionTimeoutSec = int.Parse(session.properties[SFSessionProperty.CONNECTION_TIMEOUT]); @@ -257,10 +253,19 @@ private SFRestRequest BuildAuthenticatorRestRequest(int port) /// protected override void SetSpecializedAuthenticatorData(ref LoginRequestData data) { - // Add the token and proof key to the Data - data.Token = _samlResponseToken; - data.ProofKey = _proofKey; - SetSecondaryAuthenticationData(ref data); + var idToken = new NetworkCredential(string.Empty, session._idToken).Password; + if (string.IsNullOrEmpty(idToken)) + { + // Add the token and proof key to the Data + data.Token = _samlResponseToken; + data.ProofKey = _proofKey; + SetSecondaryAuthenticationData(ref data); + } + else + { + data.Token = idToken; + data.Authenticator = TokenType.IdToken.GetAttribute().value; + } } private string GetLoginUrl(string proofKey, int localPort) diff --git a/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerInMemoryImpl.cs b/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerInMemoryImpl.cs index ba8d4d9c4..c1f030411 100644 --- a/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerInMemoryImpl.cs +++ b/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerInMemoryImpl.cs @@ -23,24 +23,24 @@ internal class SFCredentialManagerInMemoryImpl : ISnowflakeCredentialManager public string GetCredentials(string key) { - s_logger.Debug($"Getting credentials from memory for key: {key}"); - bool found; - SecureString secureToken; - _lock.EnterReadLock(); - try - { - found = s_credentials.TryGetValue(key, out secureToken); - } - finally - { - _lock.ExitReadLock(); - } - if (found) - { - return SecureStringHelper.Decode(secureToken); - } - s_logger.Info("Unable to get credentials for the specified key"); - return ""; + s_logger.Debug($"Getting credentials from memory for key: {key}"); + bool found; + SecureString secureToken; + _lock.EnterReadLock(); + try + { + found = s_credentials.TryGetValue(key, out secureToken); + } + finally + { + _lock.ExitReadLock(); + } + if (found) + { + return SecureStringHelper.Decode(secureToken); + } + s_logger.Info("Unable to get credentials for the specified key"); + return ""; } public void RemoveCredentials(string key) diff --git a/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerWindowsNativeImpl.cs b/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerWindowsNativeImpl.cs index fb7dfd402..2e6195391 100644 --- a/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerWindowsNativeImpl.cs +++ b/Snowflake.Data/Core/CredentialManager/Infrastructure/SFCredentialManagerWindowsNativeImpl.cs @@ -12,7 +12,6 @@ namespace Snowflake.Data.Core.CredentialManager.Infrastructure { - internal class SFCredentialManagerWindowsNativeImpl : ISnowflakeCredentialManager { private static readonly SFLogger s_logger = SFLoggerFactory.GetLogger(); diff --git a/Snowflake.Data/Core/RestResponse.cs b/Snowflake.Data/Core/RestResponse.cs old mode 100755 new mode 100644 index 4b827ef7f..040e38461 --- a/Snowflake.Data/Core/RestResponse.cs +++ b/Snowflake.Data/Core/RestResponse.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. */ @@ -95,6 +95,9 @@ internal class LoginResponseData [JsonProperty(PropertyName = "masterValidityInSeconds", NullValueHandling = NullValueHandling.Ignore)] internal int masterValidityInSeconds { get; set; } + [JsonProperty(PropertyName = "idToken", NullValueHandling = NullValueHandling.Ignore)] + internal string idToken { get; set; } + [JsonProperty(PropertyName = "mfaToken", NullValueHandling = NullValueHandling.Ignore)] internal string mfaToken { get; set; } } diff --git a/Snowflake.Data/Core/SFError.cs b/Snowflake.Data/Core/SFError.cs index b87dcd97f..ad7a853c6 100644 --- a/Snowflake.Data/Core/SFError.cs +++ b/Snowflake.Data/Core/SFError.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2019 Snowflake Computing Inc. All rights reserved. */ @@ -96,6 +96,9 @@ public enum SFError [SFErrorAttr(errorCode = 270062)] STRUCTURED_TYPE_READ_DETAILED_ERROR, + [SFErrorAttr(errorCode = 390195)] + ID_TOKEN_INVALID, + [SFErrorAttr(errorCode = 390120)] EXT_AUTHN_DENIED, diff --git a/Snowflake.Data/Core/Session/SFSession.cs b/Snowflake.Data/Core/Session/SFSession.cs index 6b7aedd77..eec78e433 100644 --- a/Snowflake.Data/Core/Session/SFSession.cs +++ b/Snowflake.Data/Core/Session/SFSession.cs @@ -70,6 +70,8 @@ public class SFSession private readonly EasyLoggingStarter _easyLoggingStarter = EasyLoggingStarter.Instance; + internal readonly BrowserOperations _browserOperations = BrowserOperations.Instance; + private long _startTime = 0; internal string ConnectionString { get; } internal SecureString Password { get; } @@ -101,6 +103,10 @@ public void SetPooling(bool isEnabled) internal String _queryTag; + internal bool _allowSSOTokenCaching; + + internal SecureString _idToken; + internal SecureString _mfaToken; internal void ProcessLoginResponse(LoginResponse authnResponse) @@ -121,6 +127,12 @@ internal void ProcessLoginResponse(LoginResponse authnResponse) { logger.Debug("Query context cache disabled."); } + if (_allowSSOTokenCaching && !string.IsNullOrEmpty(authnResponse.data.idToken)) + { + _idToken = SecureStringHelper.Encode(authnResponse.data.idToken); + var key = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(properties[SFSessionProperty.HOST], properties[SFSessionProperty.USER], TokenType.IdToken); + SnowflakeCredentialManagerFactory.GetCredentialManager().SaveCredentials(key, authnResponse.data.idToken); + } if (!string.IsNullOrEmpty(authnResponse.data.mfaToken)) { _mfaToken = SecureStringHelper.Encode(authnResponse.data.mfaToken); @@ -139,6 +151,17 @@ internal void ProcessLoginResponse(LoginResponse authnResponse) ""); logger.Error("Authentication failed", e); + + if (e.ErrorCode == SFError.ID_TOKEN_INVALID.GetAttribute().errorCode) + { + logger.Info("SSO Token has expired or not valid. Reauthenticating without SSO token...", e); + _idToken = null; + authenticator.Authenticate(); + } + else + { + throw e; + } if (SFMFATokenErrors.IsInvalidMFATokenContinueError(e.ErrorCode)) { logger.Info($"Unable to use cached MFA token is expired or invalid. Fails with the {e.Message}. ", e); @@ -146,7 +169,6 @@ internal void ProcessLoginResponse(LoginResponse authnResponse) var mfaKey = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(properties[SFSessionProperty.HOST], properties[SFSessionProperty.USER], TokenType.MFAToken); SnowflakeCredentialManagerFactory.GetCredentialManager().RemoveCredentials(mfaKey); } - throw e; } } @@ -212,7 +234,13 @@ internal SFSession( _maxRetryCount = extractedProperties.maxHttpRetries; _maxRetryTimeout = extractedProperties.retryTimeout; _disableSamlUrlCheck = extractedProperties._disableSamlUrlCheck; + _allowSSOTokenCaching = extractedProperties._allowSSOTokenCaching; + if (_allowSSOTokenCaching) + { + var idKey = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(properties[SFSessionProperty.HOST], properties[SFSessionProperty.USER], TokenType.IdToken); + _idToken = SecureStringHelper.Encode(SnowflakeCredentialManagerFactory.GetCredentialManager().GetCredentials(idKey)); + } if (properties.TryGetValue(SFSessionProperty.AUTHENTICATOR, out var _authenticatorType) && _authenticatorType == "username_password_mfa") { var mfaKey = SnowflakeCredentialManagerFactory.GetSecureCredentialKey(properties[SFSessionProperty.HOST], properties[SFSessionProperty.USER], TokenType.MFAToken); @@ -261,6 +289,11 @@ internal SFSession(String connectionString, SecureString password, SecureString this.restRequester = restRequester; } + internal SFSession(String connectionString, SecureString password, IMockRestRequester restRequester, BrowserOperations browserOperations) : this(connectionString, password, restRequester) + { + _browserOperations = browserOperations; + } + internal Uri BuildUri(string path, Dictionary queryParams = null) { UriBuilder uriBuilder = new UriBuilder(); diff --git a/Snowflake.Data/Core/Session/SFSessionHttpClientProperties.cs b/Snowflake.Data/Core/Session/SFSessionHttpClientProperties.cs index 2d818f8c8..1cd2b2c98 100644 --- a/Snowflake.Data/Core/Session/SFSessionHttpClientProperties.cs +++ b/Snowflake.Data/Core/Session/SFSessionHttpClientProperties.cs @@ -40,6 +40,7 @@ internal class SFSessionHttpClientProperties private TimeSpan _waitingForSessionIdleTimeout; private TimeSpan _expirationTimeout; private bool _poolingEnabled; + internal bool _allowSSOTokenCaching; public static SFSessionHttpClientProperties ExtractAndValidate(SFSessionProperties properties) { @@ -207,6 +208,7 @@ internal Dictionary ToParameterMap() var parameterMap = new Dictionary(); parameterMap[SFSessionParameter.CLIENT_VALIDATE_DEFAULT_PARAMETERS] = validateDefaultParameters; parameterMap[SFSessionParameter.CLIENT_SESSION_KEEP_ALIVE] = clientSessionKeepAlive; + parameterMap[SFSessionParameter.CLIENT_STORE_TEMPORARY_CREDENTIAL] = _allowSSOTokenCaching; return parameterMap; } @@ -245,7 +247,8 @@ public SFSessionHttpClientProperties ExtractProperties(SFSessionProperties prope _waitingForSessionIdleTimeout = extractor.ExtractTimeout(SFSessionProperty.WAITINGFORIDLESESSIONTIMEOUT), _expirationTimeout = extractor.ExtractTimeout(SFSessionProperty.EXPIRATIONTIMEOUT), _poolingEnabled = extractor.ExtractBooleanWithDefaultValue(SFSessionProperty.POOLINGENABLED), - _disableSamlUrlCheck = extractor.ExtractBooleanWithDefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK) + _disableSamlUrlCheck = extractor.ExtractBooleanWithDefaultValue(SFSessionProperty.DISABLE_SAML_URL_CHECK), + _allowSSOTokenCaching = Boolean.Parse(propertiesDictionary[SFSessionProperty.ALLOW_SSO_TOKEN_CACHING]), }; } diff --git a/Snowflake.Data/Core/Session/SFSessionParameter.cs b/Snowflake.Data/Core/Session/SFSessionParameter.cs old mode 100755 new mode 100644 index 7d25c6e01..6b2481ea5 --- a/Snowflake.Data/Core/Session/SFSessionParameter.cs +++ b/Snowflake.Data/Core/Session/SFSessionParameter.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2019 Snowflake Computing Inc. All rights reserved. */ @@ -14,6 +14,7 @@ internal enum SFSessionParameter QUERY_CONTEXT_CACHE_SIZE, DATE_OUTPUT_FORMAT, TIME_OUTPUT_FORMAT, + CLIENT_STORE_TEMPORARY_CREDENTIAL, CLIENT_REQUEST_MFA_TOKEN, } } diff --git a/Snowflake.Data/Core/Session/SFSessionProperty.cs b/Snowflake.Data/Core/Session/SFSessionProperty.cs index 5575f7c63..6c5723456 100644 --- a/Snowflake.Data/Core/Session/SFSessionProperty.cs +++ b/Snowflake.Data/Core/Session/SFSessionProperty.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2012-2021 Snowflake Computing Inc. All rights reserved. */ @@ -113,6 +113,8 @@ internal enum SFSessionProperty POOLINGENABLED, [SFSessionPropertyAttr(required = false, defaultValue = "false")] DISABLE_SAML_URL_CHECK, + [SFSessionPropertyAttr(required = false, defaultValue = "false")] + ALLOW_SSO_TOKEN_CACHING, [SFSessionPropertyAttr(required = false, IsSecret = true)] PASSCODE, [SFSessionPropertyAttr(required = false, defaultValue = "false")] diff --git a/Snowflake.Data/Core/Tools/BrowserOperations.cs b/Snowflake.Data/Core/Tools/BrowserOperations.cs new file mode 100644 index 000000000..48ca1baff --- /dev/null +++ b/Snowflake.Data/Core/Tools/BrowserOperations.cs @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +using Snowflake.Data.Client; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Snowflake.Data.Core.Tools +{ + internal class BrowserOperations + { + public static readonly BrowserOperations Instance = new BrowserOperations(); + + public virtual void OpenUrl(string url) + { + // The following code is learnt from https://brockallen.com/2016/09/24/process-start-for-urls-on-net-core/ +#if NETFRAMEWORK + // .net standard would pass here + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); +#else + // hack because of this: https://github.com/dotnet/corefx/issues/10361 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + url = url.Replace("&", "^&"); + Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { UseShellExecute = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + else + { + throw new SnowflakeDbException(SFError.UNSUPPORTED_PLATFORM); + } +#endif + } + } +} diff --git a/doc/Connecting.md b/doc/Connecting.md index 2a2b4cc6c..0809fd353 100644 --- a/doc/Connecting.md +++ b/doc/Connecting.md @@ -50,6 +50,7 @@ The following table lists all valid connection properties: | EXPIRATIONTIMEOUT | No | Timeout for using each connection. Connections which last more than specified timeout are considered to be expired and are being removed from the pool. The default is 1 hour. Usage of units possible and allowed are: e. g. `360000ms` (milliseconds), `3600s` (seconds), `60m` (minutes) where seconds are default for a skipped postfix. Special values: `0` - immediate expiration of the connection just after its creation. Expiration timeout cannot be set to infinity. | | POOLINGENABLED | No | Boolean flag indicating if the connection should be a part of a pool. The default value is `true`. | | DISABLE_SAML_URL_CHECK | No | Specifies whether to check if the saml postback url matches the host url from the connection string. The default value is `false`. | +| ALLOW_SSO_TOKEN_CACHING | No | Specifies whether to cache tokens and use them for SSO authentication. The default value is `false`. | | PASSCODE | No | Passcode from your 2FA application to be used in Multi Factor Authentication. | | PASSCODEINPASSWORD | No | Boolean flag indicating if MFA passcode is added to the password. |