Search code examples
c#androidasp.netmauiidentityserver6

Problem testing authentication on Maui app with IdentityServer running on localhost


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!";
    }
}

Solution

  • 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.