skip to Main Content

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

Answers


  1. Chosen as 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.


  2. 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>
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search