Проблема при тестировании аутентификации в приложении Мауи с IdentityServer, работающим на локальном хосте.

Мне нужно создать приложение .NET 7 MAUI, которое проверяет подлинность в приложении .NET 7 ASP.NET Core, на котором работает Duende IdentityServer (версия 6.2.3). Я начинаю с экспериментального приложения, но у меня возникают проблемы с его тестированием при запуске IdentityServer на локальном хосте.

Мой код основан на примере приложения для этого, которое можно найти здесь https://github.com/DuendeSoftware/Samples/tree/main/various/clients/Maui/MauiApp2 . И код IdentityServer по сути представляет собой готовый IdentityServer со стандартным пользовательским интерфейсом, созданным с помощью кода бритвенных страниц ASP.NET Core.

Я пробовал протестировать с помощью эмулятора Android, который вызывает IDP, используя URL-адрес, сгенерированный ngrok, но получаю следующую ошибку:

System.InvalidOperationException: «Ошибка при загрузке документа обнаружения: конечная точка находится на другом хосте, чем центр управления: https://localhost:5001/.well-known/openid-configuration/jwks»

Т.е. мои полномочия выглядят примерно так : https://4cec-81-134-5-170.ngrok.io , но все URL-адреса в документе обнаружения по-прежнему используют URL-адреса локального хоста и поэтому не совпадают.

Я пробовал тестировать на эмуляторе Android и использовать полномочия https://10.0.2.2 , но это не удалось со следующим:

System.InvalidOperationException: 'Ошибка при загрузке документа обнаружения: ошибка подключения к https://10.0.2.2/.well-known/openid-configuration. java.security.cert.CertPathValidatorException: привязка доверия для пути сертификации не найдена..'

Поскольку я тестирую здесь только разработку, я настроил локального IDP для работы с http (а не https) и протестировал с http://10.0.2.2 , но это не удалось со следующим:

System.InvalidOperationException: 'Ошибка при загрузке документа обнаружения: ошибка подключения к http://10.0.2.2/.well-known/openid-configuration . Требуется HTTPS».

Я хотел бы знать, есть ли способ заставить мой код работать посредством тестирования через localhost (с использованием эмулятора для мобильного приложения или устройства). Когда я говорю, что работаю, я имею в виду, что когда_client.LoginAsync()вызывается на главной странице, 3 ошибки, упомянутые выше, не происходят, и вы видите сообщение об успехе. Я думаю, что этого можно достичь либо путем решения проблемы ngrok, либо за счет того, чтобы Android доверял сертификату локального хоста ASP.NET Core, либо чем-то еще. Я нашел это https://learn.microsoft.com/en-us/dotnet/maui/data-cloud/local-web-services?view=net-maui-7.0#bypass-the-certificate-security-check . Здесь объясняется, как можно обойти проверку безопасности сертификата при подключении к локальному хосту, передав пользовательский HttpMessageHandler в httpclient. Можно ли сделать что-то подобное при использовании OidcClient?

Исходный код OidcClient находится здесь.

Я также нашел решения здесь https://github.com/dotnet/maui/discussions/8131 , но я не могу заставить работать ни один из четырех вариантов. Либо они не включают тестирование локального хоста, либо не работают.

Ниже приведены ключевые части моего кода:

Код МВУ

Я добавляю сервер идентификации в свой код Program.cs следующим образом.

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

Вот класс Config, на который ссылаются

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

Мобильный код клиента

Я регистрирую свой OidcClient вот так

      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));

Код для MauiAuthenticationBrowser таков.

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

Приложение представляет собой просто страницу с кнопкой входа в систему. Вот код для этой страницы

      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 ответа

Я бы создал дополнительную обертку в виде новых классов, которые будут настраивать ваш сервис внутри. Проблема с сертификатом (http или https) решается с помощью конфигурации Policy:

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

Подробный пример мобильного клиента:

      //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."
                };
            }
        }
    }

Настройка служб

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

Использование сервиса

      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("");          
        }

Я также рекомендую использовать secrets.json для хранения настроек (URI и т. д.). На YouTube есть видео, как их подключить к проекту Мауи. Видео называется: «.Net MAUI и Xamarin Forms получают настройки из secrets.json или appsettings.json».

И самое главное, вам будет проще реализовать блоки try-catch в обертке.

Если вы будете внедрять сервис напрямую в конструктор страницы, не забудьте указать и для него зависимости.

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

Добавить в манифест (Android), если используется протокол http.

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

Ниже описано, как протестировать https. Если вам нужен ответ по http, см. ответ dreamboatDevs .

OidcClient использует HttpClient, поэтому можно использовать подход, предложенный .

Если вы проверите код наOidcClientOptionsесть свойство HttpClientFactory, которое выглядит так

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

поэтому вы можете изменить свой код для регистрации OidcClient на этот

      
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));



Обратите внимание на #if DEBUG, поскольку этот код необходим только при разработке. Когда httpClientFactory имеет значение null, OidcClient просто создаст обычный HttpClient.

Код дляHttpsClientHandlerServiceисходит прямо из в документации Microsoftдокументации Microsoft , и это

      
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
}

Как вы можете видеть, когда разработка выполняется на локальном хосте в режиме отладки, сертификат автоматически становится доверенным по мере необходимости.

Другие вопросы по тегам