Проблема при тестировании аутентификации в приложении Мауи с 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
}
Как вы можете видеть, когда разработка выполняется на локальном хосте в режиме отладки, сертификат автоматически становится доверенным по мере необходимости.