Problem testing authentication on Maui app with IdentityServer running on localhost

1.9k views Asked by At

I need to build a .NET 7 MAUI app which authenticates on a .NET 7 ASP.NET Core app running Duende IdentityServer (version 6.2.3). I'm starting with a proof of concept app but I'm having trouble testing it when I run IdentityServer on localhost.

My code is based on an example app for doing this which is found here https://github.com/DuendeSoftware/Samples/tree/main/various/clients/Maui/MauiApp2. And the IdentityServer code is pretty much an out of the box IdentityServer with a standard ui done with ASP.NET Core razor pages code.

I've tried testing using an android emulator that calls the IDP using a url generated by ngrok but I get the following error:

System.InvalidOperationException: 'Error loading discovery document: Endpoint is on a different host than authority: https://localhost:5001/.well-known/openid-configuration/jwks'

I.e. my authority is something like https://4cec-81-134-5-170.ngrok.io but all the urls on the discovery document still use the localhost urls and so don't match.

I've tried testing on an android emulator and using the authority https://10.0.2.2 but this fails with the following:

System.InvalidOperationException: 'Error loading discovery document: Error connecting to https://10.0.2.2/.well-known/openid-configuration. java.security.cert.CertPathValidatorException: Trust anchor for certification path not found..'

Since I'm only testing in development here I set up the local IDP to work with http (not https) and tested with http://10.0.2.2 but this failed with the following:

System.InvalidOperationException: 'Error loading discovery document: Error connecting to http://10.0.2.2/.well-known/openid-configuration. HTTPS required.'

I'd like to know if there is a way I can get my code to work via testing through localhost (using an emulator for the mobile app or a device). When I say I work I mean that when _client.LoginAsync() is called on the main page the 3 errors mentioned above don't happen and you see the success message. I think this can be achieved either through a solution to the ngrok problem or getting Android to trust the ASP.NET Core localhost certificate or something else. I found this https://learn.microsoft.com/en-us/dotnet/maui/data-cloud/local-web-services?view=net-maui-7.0#bypass-the-certificate-security-check. This explains how you can bypass the certificate security check when you are connecting to localhost by passing a custom HttpMessageHandler to the httpclient. Can something similar be done when using the OidcClient?

Source code for OidcClient found here

I also found the solutions here https://github.com/dotnet/maui/discussions/8131 but I can't make any of the 4 options work for me. Either they don't enable localhost testing or they don't work.

Below are the key parts of my code:

IDP code

I add identity server in my Program.cs code like this

builder.Services.AddIdentityServer(options =>
        {             
            options.EmitStaticAudienceClaim = true;
        })
        .AddInMemoryIdentityResources(Config.IdentityResources)
        .AddInMemoryApiScopes(Config.ApiScopes)
        .AddInMemoryClients(Config.Clients)
        .AddTestUsers(TestUsers.Users);

Here is the Config class that is being referenced

using Duende.IdentityServer;
using Duende.IdentityServer.Models;

namespace MyApp.IDP;

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
        new IdentityResource[]
        { 
            new IdentityResources.OpenId(),
            new IdentityResources.Profile()
        };

    public static IEnumerable<ApiScope> ApiScopes =>
        new ApiScope[]
            { };

    public static IEnumerable<Client> Clients =>
        new Client[] 
            { 
                new Client()
                {
                    ClientName = My App Mobile",
                    ClientId = "myappmobile.client",
                    AllowedGrantTypes = GrantTypes.Code,
                    RedirectUris = {
                        "myapp://callback" 
                    },
                    PostLogoutRedirectUris = { 
                        "myapp://callback"
                    },
                    AllowedScopes = new List<string>
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile                       
                    }
                }
            };
}

Client mobile code

I register my OidcClient like this

var options = new OidcClientOptions
{       
    Authority = "https://10.0.2.2",
    ClientId = "myappmobile.client",        
    RedirectUri = "myapp://callback",
    Browser = new MauiAuthenticationBrowser()
};

builder.Services.AddSingleton(new OidcClient(options));

The code for MauiAuthenticationBrowser is this

using IdentityModel.Client;
using IdentityModel.OidcClient.Browser;

namespace MyFirstAuth;

public class MauiAuthenticationBrowser : IdentityModel.OidcClient.Browser.IBrowser
{
    public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
    {
        try
        {
            var result = await WebAuthenticator.Default.AuthenticateAsync(
                new Uri(options.StartUrl),
                new Uri(options.EndUrl));

            var url = new RequestUrl("myapp://callback")
                .Create(new Parameters(result.Properties));

            return new BrowserResult
            {
                Response = url,
                ResultType = BrowserResultType.Success
            };
        }
        catch (TaskCanceledException)
        {
            return new BrowserResult
            {
                ResultType = BrowserResultType.UserCancel
            };
        }
    }
}

The app is just a page with a login button on it. Here is the code behind for this page

using IdentityModel.OidcClient;

namespace MyFirstAuth;
public partial class MainPage
{
    private readonly OidcClient _client;

    public MainPage(OidcClient client)
    {
        InitializeComponent();
        _client = client;
    }

    private async void OnLoginClicked(object sender, EventArgs e)
    {
        var result = await _client.LoginAsync();

        if (result.IsError)
        {
            editor.Text = result.Error;
            return;
        }

        editor.Text = "Success!";
    }
}
2

There are 2 answers

0
Dave Barnett On BEST ANSWER

What follows is how to test with https, if you want an answer for http see dreamboatDevs answer.

OidcClient does use HttpClient and hence it is possible to use the approach suggested in the Microsoft docs.

If you inspect the code for OidcClientOptions there is an HttpClientFactory property that looks like this


public Func<OidcClientOptions, HttpClient> HttpClientFactory { get; set; }

therefore you can change your code for registering the OidcClient to this


Func<OidcClientOptions, HttpClient> httpClientFactory = null;

#if DEBUG
        httpClientFactory = (options) =>
        {
            var handler = new HttpsClientHandlerService();
            return new HttpClient(handler.GetPlatformMessageHandler());
        };
#endif

var options = new OidcClientOptions
{       
    Authority = "https://10.0.2.2",
    ClientId = "myappmobile.client",        
    RedirectUri = "myapp://callback",
    Browser = new MauiAuthenticationBrowser(),
    HttpClientFactory = httpClientFactory
};

builder.Services.AddSingleton(new OidcClient(options));



Note the #if DEBUG because this code is only needed in development. When httpClientFactory is null the OidcClient will just new up a normal HttpClient.

The code for HttpsClientHandlerService comes straight from the Microsoft docs and is this


public class HttpsClientHandlerService
{
    public HttpMessageHandler GetPlatformMessageHandler()
    {
#if ANDROID
        var handler = new Xamarin.Android.Net.AndroidMessageHandler();
        handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
        {
            if (cert != null && cert.Issuer.Equals("CN=localhost"))
                return true;
            return errors == System.Net.Security.SslPolicyErrors.None;
        };
        return handler;
#elif IOS
        var handler = new NSUrlSessionHandler
        {
            TrustOverrideForUrl = IsHttpsLocalhost
        };
        return handler;
#else
        throw new PlatformNotSupportedException("Only Android and iOS supported.");
#endif
    }

#if IOS
    public bool IsHttpsLocalhost(NSUrlSessionHandler sender, string url, Security.SecTrust trust)
    {
        if (url.StartsWith("https://localhost"))
            return true;
        return false;
    }
#endif
}

As you can see when development is done on localhost in debug mode the certificate is automatically trusted as required.

8
dreamboatDev On

I would create an additional wrapper in the form of new classes that will configure your service inside. The certificate problem(http or https) is solved using the Policy configuration:

 Policy = new IdentityModel.OidcClient.Policy()
                    {
                        Discovery = new IdentityModel.Client.DiscoveryPolicy()
                        {
                            RequireHttps = config.GetRequiredSection("IdentityServer").GetValue<bool>("RequireHttps")
                        }
                    }

Client mobile example in detail:

//In this class, you can add any additional logic and use it as a kind of decorator

public class Auth0Client
    {
        //Your real service. 
        private readonly OidcClient oidcClient;

        public Auth0Client(Auth0ClientOptions options)
        {
            oidcClient = new OidcClient(new OidcClientOptions
            {
                Authority = options.Authority,
                ClientId = options.ClientId,
                ClientSecret = options.ClientSecret,
                Scope = options.Scope,
                RedirectUri = options.RedirectUri,
                PostLogoutRedirectUri = options.PostLogoutRedirectUri,
                Policy = options.Policy,
                Browser = options.Browser
            });
        }

        public IdentityModel.OidcClient.Browser.IBrowser Browser
        {
            get
            {
                return oidcClient.Options.Browser;
            }
            set
            {
                oidcClient.Options.Browser = value;
            }
        }

        public async Task<LoginResult> LoginAsync()
        {
            return await oidcClient.LoginAsync();
        }

        public async Task<LogoutResult> LogoutAsync(string identityToken)
        {
            LogoutResult logoutResult = await oidcClient.LogoutAsync(new LogoutRequest { IdTokenHint = identityToken });
            return logoutResult;
        }
    }


public class Auth0ClientOptions
    {
        public Auth0ClientOptions()
        {
            Browser = new WebBrowserAuthenticator();
        }

        public string Authority { get; set; }

        public string ClientId { get; set; }
        public string ClientSecret { get; set; }

        public string RedirectUri { get; set; }

        public string PostLogoutRedirectUri { get; set; }

        public string Scope { get; set; }

        public Policy Policy { get; set; }
        public IdentityModel.OidcClient.Browser.IBrowser Browser { get; set; }
    }

public class WebBrowserAuthenticator : IdentityModel.OidcClient.Browser.IBrowser
    {
        public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
        {
            try
            {
                WebAuthenticatorResult result = await WebAuthenticator.Default.AuthenticateAsync(
                    new Uri(options.StartUrl),
                    new Uri(options.EndUrl));

                var url = new RequestUrl(options.EndUrl)
                    .Create(new Parameters(result.Properties));

                return new BrowserResult
                {
                    Response = url,
                    ResultType = BrowserResultType.Success
                };
            }
            catch (TaskCanceledException)
            {
                return new BrowserResult
                {
                    ResultType = BrowserResultType.UserCancel,
                    ErrorDescription = "Login canceled by the user."
                };
            }
        }
    }

Configure services

 builder.Services.AddScoped(new Auth0Client(new Auth0ClientOptions()
            {
                Authority = config.GetRequiredSection("IdentityServer:Authority").Value,
                ClientId = config.GetRequiredSection("IdentityServer:ClientId").Value,
                ClientSecret = config.GetRequiredSection("IdentityServer:ClientSecret").Value,
                Scope = config.GetRequiredSection("IdentityServer:Scope").Value,
                RedirectUri = config.GetRequiredSection("IdentityServer:RedirectUri").Value,
                PostLogoutRedirectUri = config.GetRequiredSection("IdentityServer:PostLogoutRedirectUri").Value,
                Policy = new IdentityModel.OidcClient.Policy()
                {
                    Discovery = new IdentityModel.Client.DiscoveryPolicy()
                    {
                        RequireHttps = config.GetRequiredSection("IdentityServer").GetValue<bool>("RequireHttps")
                    }
                }
            }));

Using the service

public partial class MainPage : ContentPage
    {       
        private readonly Auth0Client auth0Client;

        public MainPage(Auth0Client client)
        {
            InitializeComponent();
            auth0Client = client;    
        }

        private async void OnLoginClicked(object sender, EventArgs e)
        {
            var loginResult = await auth0Client.LoginAsync();                  
        }

        private async void OnLogoutClicked(object sender, EventArgs e)
        {
            var logoutResult = await auth0Client.LogoutAsync("");          
        }

I also recommend using secrets.json to store settings(URI and etc). There is a video on YouTube on how to connect them to the Maui project. The video is called: ".Net MAUI & Xamarin Forms getting settings from secrets.json or appsettings.json"

And most importantly, it will be easier for you to implement try-catch blocks in a wrapper

If you will inject the service directly into the page constructor, do not forget to specify dependencies for it too

builder.Services.AddScoped<MainPage>();

settings.json

{
  "IdentityServer": {
    "Authority": "http://test-site.com",
    "ClientId": "mobile-client",
    "ClientSecret" : "qwerty123*",
    "Scope": "openid profile",
    "RedirectUri": "mauiclient://signin-oidc",
    "PostLogoutRedirectUri": "mauiclient://signout-callback-oidc",
    "RequireHttps" :  "false"
  }
}

Add to manifest(Android) if use http protocol

<application 
    android:usesCleartextTraffic="true">
</application>