Давай дружить. OpenId Connect и Yarp
Предисловие
Сегодня в этой статье я хочу поделиться личным опытом работы и решением конкретного кейса. Работали над ним небольшой командой, но для простоты повествования буду писать от первого лица. Собственно сам кейс: есть Windows сервер с доменом и SSL сертификатом, на нём нужно поднять сервер авторизации с протоколом OpenId Connect и ещё два приложения, которые должны авторизовываться через сервер авторизации. И да, реализовать все нужно по максимуму использовав встроенный функционал.
“Сервер авторизации должен быть вынесен отдельно!” - скажете вы – Но в реальности крутим и вертим как можем. Звучит просто, как два пальца. Как было на деле сейчас расскажу.
Общая схема
Сервер авторизации
Тут все относительно просто, есть готовые примеры и описанная документация. Бери да делай! В качестве основы был использован пример у damienbod . И уже дальше доработан под себя как нужно.
YARP
YARP – обратный прокси сервер, относительно недавно выпущенный в релиз компанией Microsoft. В его настройке нет ничего сложного, есть описанная документация и примеры имплементации. Конкретно мы руководствовались вот этой статьей. Конфигурацию нашего прокси прикладываю ниже.
"ReverseProxy": {
"Routes": {
"minimumroute": {
"ClusterId": "minimumcluster",
"Match": {
"Path": "{**catch-all}"
}
},
"route2": {
"ClusterId": "Server",
"Match": {
"Path": "/Server/{*any}"
},
"Transforms": [
{
"PathRemovePrefix": "/Server"
}
]
},
"route3": {
"ClusterId": "App1",
"Match": {
"Path": "/App1/{*any}"
},
"Transforms": [
{
"PathRemovePrefix": "/app1"
}
]
},
"route4": {
"ClusterId": "App2",
"Match": {
"Path": "/App2/{*any}"
},
"Transforms": [
{
"PathRemovePrefix": "/app2"
}
]
}
]
}
},
"Clusters": {
"minimumcluster": {
"Destinations": {
"first_destination": {
"Address": "http://localhost:5001"
}
}
},
"Server": {
"Destinations": {
"first_destination": {
"Address": "http://localhost:5001"
}
}
},
"App1": {
"Destinations": {
"first_destination": {
"Address": "http://localhost:5002"
}
}
},
"App2": {
"Destinations": {
"first_destination": {
"Address": "http://localhost:5003"
}
}
}
}
}
Вот и всё, готово?
Начинаем все запускать локально и неужели все работает – да? Ага, значит деплоим и вот незадача – не работает. В чем же ошибка?
Дело в том, что у нашего сервера авторизации адрес http:// localhost:5001, а у приложения http:// localhost:5002. Поэтому когда, все это развернуто на сервере происходит ошибка. Для решения этой проблемы нам нужно самим указать такой параметр как RedirectUri
options.Events.OnRedirectToIdentityProvider = ctx =>
{
ctx.ProtocolMessage.RedirectUri = "https://domain/app1/signin-oidc";
return Task.CompletedTask;
};
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
{
ctx.ProtocolMessage.PostLogoutRedirectUri = "https://domain/app1/signout-callback-oidc";
return Task.CompletedTask;
};
Уточнение: signin-oidc и signout-callback-oidc – это встроенные методы, который устанавливается по умолчанию как конечные точки входа и выхода в пакете Microsoft.AspNetCore.Authentication.OpenIdConnect.
С одной проблемой разобрались, теперь редирект правильный. Но встречаем тут же следующую, которая звучит как Corellation failed. Заходим в панель разработчика и начинаем пристально изучать запросы и ответы. И находим предупреждение от браузера, что файлы cookie не установлены, так, как отсутствует атрибут secure. Путей решения этой проблемы два:
Установить для наших приложений самозаверяющиеся SSL сертификаты. Как это сделать описано здесь.
Либо на наших приложениях изменить конфигурацию политики файлов cookie. Для этого нам нужно прописать следующее в program.cs:
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.Secure = CookieSecurePolicy.Always;
});
app.UseCookiePolicy();
Всё, с этой проблемой справились. Заходим, пробуем, и снова тоже самое. Изучаем, значиться, дальше. И наконец находим, что файлы cookie то установились, но по неверному маршруту. Значит теперь самим вручную нужно указать маршрут установки. Делается это следующим образом, в настройках аутентификации OpenIdConnect
options.CorrelationCookie.Path = "/app1/signin-oidc";
options.NonceCookie.Path = "/app1/signin-oidc";
И наконец все работает! Полный код файла program.cs ниже.
var builder = WebApplication.CreateBuilder(args);
IConfiguration configuration = builder.Configuration;
builder.Services.AddHttpClient();
builder.Services.AddOptions();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}).AddCookie()
.AddOpenIdConnect(options =>
{
options.SignInScheme = "Cookies";
options.Authority = "https://domain.com/server";
options.ClientId = "ClientId";
options.ClientSecret = "ClientSecret";
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce= true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CorrelationCookie.Path = "/app1/signin-oidc";
options.NonceCookie.Path = "/app1/signin-oidc";
options.Events.OnRedirectToIdentityProvider = ctx =>
{
ctx.ProtocolMessage.RedirectUri = "https://domain.com/server/signin-oidc";
return Task.CompletedTask;
};
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
{
ctx.ProtocolMessage.PostLogoutRedirectUri = "https://domain.com/server/signout-callback-oidc";
return Task.CompletedTask;
};
});
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.Secure = CookieSecurePolicy.Always;
});
var app = builder.Build();
IWebHostEnvironment env = app.Environment;
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseWebAssemblyDebugging();
}
else
{
app.UseHsts();
}
app.UsePathBase("/App1");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("Index.html");
app.Run();
Небольшое послесловие
Возможно описанный мной способ решения данного кейса не на 100% верный, но разрабатывался он исходя из тех исходных данных, которые были нам предоставлены. В просторах интернета не нашел похожего решения данного кейса, или хотя бы похожего, поэтому решил поделиться. Если у вас есть, вариант как можно усовершенствовать этот способ или сделать по иному но, в тех же рамках, то прошу в комментарии.