1

I'm trying to write a netcore console app that connects to multiple Azure SQL Databases, and executes some scripts against them. Our company requires Azure AD with MFA logins for the databases.

I've managed to get it to log in successfully, using the information here:

Setup

static void Main(string[] args)
{
    var provider = new ActiveDirectoryAuthProvider();

    SqlAuthenticationProvider.SetProvider(
        SqlAuthenticationMethod.ActiveDirectoryIntegrated,
        //SC.SqlAuthenticationMethod.ActiveDirectoryInteractive,
        //SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated,  // Alternatives.
        //SC.SqlAuthenticationMethod.ActiveDirectoryPassword,
        provider);
}

public class ActiveDirectoryAuthProvider : SqlAuthenticationProvider
{
    // Program._ more static values that you set!
    private readonly string _clientId = "MyClientID";

    public override async TT.Task<SC.SqlAuthenticationToken>
        AcquireTokenAsync(SC.SqlAuthenticationParameters parameters)
    {
        AD.AuthenticationContext authContext =
            new AD.AuthenticationContext(parameters.Authority);
        authContext.CorrelationId = parameters.ConnectionId;
        AD.AuthenticationResult result;

        switch (parameters.AuthenticationMethod)
        {
             case SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated:
                Console.WriteLine("In method 'AcquireTokenAsync', case_1 == '.ActiveDirectoryIntegrated'.");
                Console.WriteLine($"Resource: {parameters.Resource}");

                result = await authContext.AcquireTokenAsync(
                    parameters.Resource,
                    _clientId,
                    new AD.UserCredential(GlobalSettings.CredentialsSettings.Username));
                break;

            default: throw new InvalidOperationException();
        }           

        return new SC.SqlAuthenticationToken(result.AccessToken, result.ExpiresOn);
    }

    public override bool IsSupported(SC.SqlAuthenticationMethod authenticationMethod)
    {
        return authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated
            || authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryInteractive;
    }
}

Connection

private SqlConnection GetConnection()
{
    var builder = new SqlConnectionStringBuilder();
    builder.DataSource = "MyServer";            
    builder.Encrypt = true;
    builder.TrustServerCertificate = true;
    builder.PersistSecurityInfo = true;
    builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
    builder.InitialCatalog = "MyDatabase";

    var conn = new SqlConnection(builder.ToString());
    conn.Open();

    return conn;        
}

This works, and I am able to run the queries as I like. However, whenever the application connects to a new database (at the same address), it opens up a Browser window to login.microsoftonline.com asking me to select my account/sign in.

Is there any way to require this browser authentication only once for all the databases? They are all on the same Azure SQL instance.

Obsidian Phoenix
  • 4,083
  • 1
  • 22
  • 60
  • Why do you need credentials? If the connection string uses Integrated Security = True than users credentials will be used. – jdweng Jul 09 '20 at 15:14
  • 1
    @jdweng Integrated Security doesn't seem to work at all. Whenever I try it, I get this error: `Windows logins are not supported in this version of SQL Server.` – Obsidian Phoenix Jul 09 '20 at 15:30
  • What database are you using? SQL Server should support Integrated Security = true. The error is with database like Access. See : https://serverfault.com/questions/817126/windows-logins-are-not-supported-in-this-version-of-sql-server – jdweng Jul 09 '20 at 15:34
  • 1
    @jdweng This is Azure SQL Database, not straight SQL. It's also been set up to require AD with MFA. – Obsidian Phoenix Jul 09 '20 at 15:36
  • Still with Azure you can use many different databases. If you are using a SQL Database you should be able to use Integrated Security = true which uses AD and MFA. – jdweng Jul 09 '20 at 15:48
  • 1
    @jdweng I'm not sure you get what I mean: I'm not using a SQL database in Azure (e.g. in a VM). I mean I'm using _Azure SQL Database_ ([link](https://azure.microsoft.com/en-gb/services/sql-database/)) - I had to explicitly configure AD MFA to use Azure Data Studio too. – Obsidian Phoenix Jul 09 '20 at 16:05
  • Azure is just a front end and SQL Server is the backend. See : https://learn.microsoft.com/en-gb/azure/azure-sql/database/features-comparison – jdweng Jul 09 '20 at 16:11
  • @jdweng from the table in your link: `windows authentication? No | No` I think we need to distinguish between "AD with MFA" (aka "windows authentication" aka using your domain account) and "AAD with MFA", where AAD may be federated. But either way, you can't use plain-old-integreated-security AFAIK. If there's a way to do that I'd be very interested in seeing the connection string. – allmhuran Jul 09 '20 at 16:16
  • You have a cloud application and I would think a user would have to connect to cloud using MFA. Once MFA is done than you can use AD to connect to the SQL Server. Your error message looks like there is issues with the AD connection because you need to relink the data source from Azure to SQL Server and nothing to do with the MFA. – jdweng Jul 09 '20 at 16:37
  • 2
    @jdweng WAT? There is no data source connection between azure and sql. Azure SQL Database is a _specific_ product from MS. It’s a host sql database, in azure, maintained by MS. It’s _always_ the most current SQL version. We’re going down a rabbit hole here: I’ve clarified the product and indicated (as per the docs) that windows auth (ie Integrated Auth) _does not work_. – Obsidian Phoenix Jul 09 '20 at 17:06
  • Read answer at bottom of posting : https://serverfault.com/questions/817126/windows-logins-are-not-supported-in-this-version-of-sql-server – jdweng Jul 09 '20 at 17:12
  • 2
    @jdweng ok, we’re clearly done here. It’s _not_ MS Access, and an answer for it from 2016 doesn’t help. It’s an actual (hosted) SQL server db, at the _current_ release version (MS maintains it). Integrated Security _does not work_ per the documentation _you_ linked. – Obsidian Phoenix Jul 09 '20 at 17:18
  • Azure is the Front End that is using Entity to connect to the SQL Server. There are classes in Azure and an EDMX mapping file that maps the classes to the tables in the database. The error message "Windows logins are not supported in this version of SQL Server" is due to the entity connection between the Azure and SQL Server. – jdweng Jul 09 '20 at 17:24
  • 1
    @jdweng Your linked post says "Inside our Azure Windows Server 2012 VM". If it's hosted on a VM, then it's not an "Azure SQL database". An "Azure SQL Database" is a serverless cloud product. This is partly Microsoft's fault for not creating clear terminology. The number of times I've seen confusion between "a SQL instance hosted on an Azure VM" and "an Azure SQL database" is countless. – allmhuran Jul 09 '20 at 17:24
  • @allmhuran : Why is OP getting the error : "Windows logins are not supported in this version of SQL Server". – jdweng Jul 09 '20 at 17:30
  • @jdweng Yes, they were *actually* using Azure SQL. My point is that there is a difference between the two, and the poster in your linked question is confused about the difference. This confusion is common, as seen in this post. Note that the solution for your linked thread had nothing to do with integrated security. They were using a DSN with a SQL username and password. Access was caching the old connection string which *was* using integrated security. Refreshing the linked tables forced it to reevaluate the connection string and use SQL auth instead. – allmhuran Jul 09 '20 at 17:36
  • @allmhuran : Do you agree that Refreshing the link is needed if OP is getting the error "Windows logins are not supported in this version of SQL Server". It may not be exactly the same as the link, but I wanted to show the OP that Azure was Front End and SQL Server was Backend and the error was indicating an issue in the Entity connection. – jdweng Jul 09 '20 at 17:44
  • 1
    @jdweng Refreshing the *linked tables in MS Access* was needed in that specific case because of the *specific behaviour of MS Access*. None of which has anything to do with OP's question. I don't even know what you're trying to say when you say "Azure was Front End". Azure is a cloud platform, not a "front end". And it has nothing to do with the way MS Access caches connection strings for linked tables, so I don't see how it could possibly "show the OP that Azure was Front End and SQL Server was Backend". – allmhuran Jul 09 '20 at 17:49
  • @ObsidianPhoenix With that now hopefully out of the way, let's try to get back to your actual question: Is there any way to do it? Do you get prompted for MFA again if you make a new connection to the *same* database? I don't have an answer for you here, but it seems like it should be possible to reuse the token, because I can connect via SSMS once, then open two new query windows - one for master, one for our DB, without being prompted again after the initial logon. – allmhuran Jul 09 '20 at 17:57
  • @ObsidianPhoenix No, I take that back. Via SSMS, if I go object explorer > connect to database, and do not change any of the options, I create a brand new, MFA prompted connection to master by default. I can then close that connection entirely, and create a new connection, where in the options I change the default database to our database. When I hit connect, I *do* get another MFA prompt. Since SSMS itself can't reuse the token, it seems the claim in the token is only for that specific database resource. – allmhuran Jul 09 '20 at 18:09
  • @allmhuran I think I’ve trusted my browser, cause it just asks me to pick the account each time. Since posting I’ve tried a few things: moving the auth context out to a property for persistence, adding a TokenCache object, and tried AcquireSilently too. None seemed to work. Within an app session, I can connect to the same DB multiple times without reauth, but a new db always opens the browser. – Obsidian Phoenix Jul 09 '20 at 18:13
  • 1
    @allmhuran interestingly, in Azure Data Studio, it _never_ opens the browser window for new dbs. Even between sessions. – Obsidian Phoenix Jul 09 '20 at 18:14
  • 1
    @ObsidianPhoenix I see similar behaviour in SSMS. If I keep using the same application instance of SSMS my token does seem to be reused. But if I open a new instance of SSMS and choose a different database (but same Azure SQL instance), I get a new MFA prompt. Honestly, I am rather baffled by this. – allmhuran Jul 09 '20 at 18:19
  • 1
    @ObsidianPhoenix I would like to do more careful testing - one instance of SSMS, connect, disconnect, change default database, connect. Then two instances of SSMS, using a whole new Azure SQL instance, both SSMS connections to the same DB. Then a whole new Azure SQL instance again, with two instances of SSMS to different DB's. Unfortunately I've "run out" of Azure SQL instances for which I can be sure any tokens will have expired, so I can't get a clean test right now. – allmhuran Jul 09 '20 at 18:20

1 Answers1

1

So, there's a bit of PEBKAC in the code. Although it's using builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;, the class is actually attempting to use ActiveDirectoryIntegrated. So my AD class was never actually hit. Also, in the example code it would actually have never worked either, because the case statement exists for ActiveDirectoryIntegrated - I've stripped it out on my local copy.

I actually needed to use the proper ActiveDirectoryInteractive code to hook this up. Once I did, it was able to authenticate once against the system. And this allowed all the db connections to work without requiring additional browser checks.

Setup

static void Main(string[] args)
{
    var provider = new ActiveDirectoryAuthProvider();

    SqlAuthenticationProvider.SetProvider(
        SqlAuthenticationMethod.ActiveDirectoryInteractive,
        //SC.SqlAuthenticationMethod.ActiveDirectoryIntegrated,  // Alternatives.
        //SC.SqlAuthenticationMethod.ActiveDirectoryPassword,
        provider);
}

ActiveDirectoryAuthProvider

public class ActiveDirectoryAuthProvider : SqlAuthenticationProvider
{
    private readonly string _clientId = "MyClientID";

    private Uri _redirectURL { get; set; } = new Uri("http://localhost:8089");

    private AD.AuthenticationContext AuthContext { get; set; }

    private TokenCache Cache { get; set; }

    public ActiveDirectoryAuthProvider()
    {
        Cache = new TokenCache();
    }

    public override async TT.Task<SC.SqlAuthenticationToken> AcquireTokenAsync(SC.SqlAuthenticationParameters parameters)
    {
        var authContext = AuthContext ?? new AD.AuthenticationContext(parameters.Authority, Cache);
        authContext.CorrelationId = parameters.ConnectionId;
        AD.AuthenticationResult result;

        try
        {
            result = await authContext.AcquireTokenSilentAsync(
                parameters.Resource,
                _clientId);     
        }
        catch (AdalSilentTokenAcquisitionException)
        {
            result = await authContext.AcquireTokenAsync(
                parameters.Resource,
                _clientId,
                _redirectURL, 
                new AD.PlatformParameters(PromptBehavior.Auto, new CustomWebUi()), 
                new UserIdentifier(parameters.UserId, UserIdentifierType.RequiredDisplayableId));
        }         

        var token = new SC.SqlAuthenticationToken(result.AccessToken, result.ExpiresOn);

        return token;
    }

    public override bool IsSupported(SC.SqlAuthenticationMethod authenticationMethod)
    {
        return authenticationMethod == SC.SqlAuthenticationMethod.ActiveDirectoryInteractive;
    }
}

There are a few things different here:

  1. I've added an in-memory Token Cache
  2. I've moved the AuthContext to a property on the class to leave it in memory between runs
  3. I've set the _redirectURL property = http://localhost:8089
  4. I've added a silent check for the token, before reverting

Finally, I've created my own implementation of the ICustomWebUi that handles loading the browser login and the response:

CustomWebUi

internal class CustomWebUi : ICustomWebUi
{
    public async Task<Uri> AcquireAuthorizationCodeAsync(Uri authorizationUri, Uri redirectUri)
    {
        using (var listener = new SingleMessageTcpListener(redirectUri.Port))
        {
            Uri authCode = null;
            var listenerTask = listener.ListenToSingleRequestAndRespondAsync(u => {
                authCode = u;
                
                return @"
<html>
<body>
    <p>Successfully Authenticated, you may now close this window</p>
</body>
</html>";
            }, System.Threading.CancellationToken.None);

            var ps = new ProcessStartInfo(authorizationUri.ToString())
            { 
                UseShellExecute = true, 
                Verb = "open" 
            };
            Process.Start(ps);

            await listenerTask;

            return authCode;
        }            
    }
}

Because I've set the redirect back to localhost, and this code lives inside a console application, I need to listen on the port for the response and capture it in the app, then display a value to the browser to indicate it all worked.

To listen to the port, I used a listener class cribbed from the MS Github:

SingleMessageTcpListener

/// <summary>
/// This object is responsible for listening to a single TCP request, on localhost:port, 
/// extracting the uri, parsing 
/// </summary>
/// <remarks>
/// The underlying TCP listener might capture multiple requests, but only the first one is handled.
///
/// Cribbed this class from https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/9e0f57b53edfdcf027cbff401d3ca6c02e95ef1b/tests/devapps/NetCoreTestApp/Experimental/SingleMessageTcpListener.cs
/// </remarks>
internal class SingleMessageTcpListener : IDisposable
{
    private readonly int _port;
    private readonly System.Net.Sockets.TcpListener _tcpListener;

    public SingleMessageTcpListener(int port)
    {
        if (port < 1 || port == 80)
        {
            throw new ArgumentOutOfRangeException("Expected a valid port number, > 0, not 80");
        }

        _port = port;
        _tcpListener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, _port);
        

    }

    public async Task ListenToSingleRequestAndRespondAsync(
        Func<Uri, string> responseProducer,
        CancellationToken cancellationToken)
    {
        cancellationToken.Register(() => _tcpListener.Stop());
        _tcpListener.Start();

        TcpClient tcpClient = null;
        try
        {
            tcpClient =
                await AcceptTcpClientAsync(cancellationToken)
                .ConfigureAwait(false);

            await ExtractUriAndRespondAsync(tcpClient, responseProducer, cancellationToken).ConfigureAwait(false);

        }
        finally
        {
            tcpClient?.Close();
        }
    }

    /// <summary>
    /// AcceptTcpClientAsync does not natively support cancellation, so use this wrapper. Make sure
    /// the cancellation token is registered to stop the listener.
    /// </summary>
    /// <remarks>See https://stackoverflow.com/questions/19220957/tcplistener-how-to-stop-listening-while-awaiting-accepttcpclientasync</remarks>
    private async Task<TcpClient> AcceptTcpClientAsync(CancellationToken token)
    {
        try
        {
            return await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false);
        }
        catch (Exception ex) when (token.IsCancellationRequested)
        {
            throw new OperationCanceledException("Cancellation was requested while awaiting TCP client connection.", ex);
        }
    }

    private async Task ExtractUriAndRespondAsync(
        TcpClient tcpClient,
        Func<Uri, string> responseProducer,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        string httpRequest = await GetTcpResponseAsync(tcpClient, cancellationToken).ConfigureAwait(false);
        Uri uri = ExtractUriFromHttpRequest(httpRequest);

        // write an "OK, please close the browser message" 
        await WriteResponseAsync(responseProducer(uri), tcpClient.GetStream(), cancellationToken)
            .ConfigureAwait(false);
    }

    private Uri ExtractUriFromHttpRequest(string httpRequest)
    {
        string regexp = @"GET \/\?(.*) HTTP";
        string getQuery = null;
        Regex r1 = new Regex(regexp);
        Match match = r1.Match(httpRequest);
        if (!match.Success)
        {
            throw new InvalidOperationException("Not a GET query");
        }

        getQuery = match.Groups[1].Value;
        UriBuilder uriBuilder = new UriBuilder();
        uriBuilder.Query = getQuery;
        uriBuilder.Port = _port;

        return uriBuilder.Uri;
    }

    private static async Task<string> GetTcpResponseAsync(TcpClient client, CancellationToken cancellationToken)
    {
        NetworkStream networkStream = client.GetStream();

        byte[] readBuffer = new byte[1024];
        StringBuilder stringBuilder = new StringBuilder();
        int numberOfBytesRead = 0;

        // Incoming message may be larger than the buffer size. 
        do
        {
            numberOfBytesRead = await networkStream.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken)
                .ConfigureAwait(false);

            string s = Encoding.ASCII.GetString(readBuffer, 0, numberOfBytesRead);
            stringBuilder.Append(s);

        }
        while (networkStream.DataAvailable);

        return stringBuilder.ToString();
    }

    private async Task WriteResponseAsync(
        string message,
        NetworkStream stream,
        CancellationToken cancellationToken)
    {
        string fullResponse = $"HTTP/1.1 200 OK\r\n\r\n{message}";
        var response = Encoding.ASCII.GetBytes(fullResponse);
        await stream.WriteAsync(response, 0, response.Length, cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
    }

    public void Dispose()
    {
        _tcpListener?.Stop();
    }
}

With all this in place, the browser opens when connecting to the first database on a resource, and the token is reused between connections.

Obsidian Phoenix
  • 4,083
  • 1
  • 22
  • 60