using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; namespace Tnsu.ApprovalRoute.Api.Auth; public static class AuthSetup { /// /// Две схемы аутентификации вместо одной: помимо пользовательского токена /// (админка) API должен принимать токен ПРИЛОЖЕНИЯ — эндпоинт напоминаний /// вызывает Power Automate по расписанию, там нет ни пользователя, ни /// интерактивного входа. /// /// Сделано по образцу api-tnsu-portal/AuthRegistration.cs, включая настройки /// Entra — приложение регистрируется то же самое. /// public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration) { var tenantId = configuration["Entra:TenantId"]; var audience = configuration["Entra:Audience"]; if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience)) AddEntraAuth(services, configuration, tenantId, audience); else AddDevAuth(services); return services; } private static void AddEntraAuth( IServiceCollection services, IConfiguration configuration, string tenantId, string audience) { // Белый список приложений, которым разрешён app-only доступ. Пустой список // означает «не ограничивать» — иначе на пустой конфигурации сломались бы // вообще все app-only вызовы, что при выкладке выглядит как необъяснимый 401. var allowedAppIds = configuration.GetSection("Entra:AllowedAppIds").Get() ?? []; var validationParameters = () => new TokenValidationParameters { ValidAudiences = [audience, $"api://{audience}"], ValidIssuers = [ $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" ], ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true }; services.AddAuthentication(options => { options.DefaultAuthenticateScheme = AuthSchemes.Bearer; options.DefaultChallengeScheme = AuthSchemes.Bearer; }) .AddJwtBearer(AuthSchemes.Bearer, options => { options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; options.TokenValidationParameters = validationParameters(); }); services.AddAuthentication() .AddJwtBearer(AuthSchemes.AppOnly, options => { options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; options.TokenValidationParameters = validationParameters(); options.Events = new JwtBearerEvents { // Подпись, издатель и аудитория у app-only токена те же, что у // пользовательского, поэтому единственное, что реально отличает // «свой» вызывающий сервис от любого приложения тенанта, — appid. OnTokenValidated = context => { var claims = context.Principal?.Claims; var appId = claims?.FirstOrDefault(c => c.Type == "appid")?.Value ?? claims?.FirstOrDefault(c => c.Type == "azp")?.Value; if (allowedAppIds.Length > 0 && !allowedAppIds.Contains(appId)) { context.HttpContext.RequestServices .GetRequiredService() .CreateLogger("Auth.AppOnly") .LogWarning("Приложение «{AppId}» отклонено — его нет в Entra:AllowedAppIds", appId); context.Fail($"Приложение «{appId}» не входит в список разрешённых."); } return Task.CompletedTask; } }; }); services.AddAuthorization(options => { options.AddPolicy(AuthPolicies.Delegated, policy => { policy.AuthenticationSchemes.Add(AuthSchemes.Bearer); policy.RequireAuthenticatedUser(); }); options.AddPolicy(AuthPolicies.AppOnly, policy => { policy.AuthenticationSchemes.Add(AuthSchemes.AppOnly); policy.RequireAuthenticatedUser(); }); // RequireAssertion, а не RequireAuthenticatedUser: политика с двумя // схемами падает, если ОДНА из них отвергла токен, даже когда вторая // его приняла. Здесь достаточно, чтобы аутентифицировала любая. options.AddPolicy(AuthPolicies.Any, policy => { policy.AddAuthenticationSchemes(AuthSchemes.Bearer, AuthSchemes.AppOnly); policy.RequireAssertion(ctx => ctx.User.Identities.Any(i => i.IsAuthenticated)); }); options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!; }); } private static void AddDevAuth(IServiceCollection services) { services.AddAuthentication(AuthSchemes.Dev) .AddScheme(AuthSchemes.Dev, _ => { }); // Те же имена политик, но поверх Dev-схемы: иначе контроллер с // [Authorize(Policy = ...)] падал бы локально с «политика не найдена». services.AddAuthorization(options => { foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any }) { options.AddPolicy(name, policy => { policy.AuthenticationSchemes.Add(AuthSchemes.Dev); policy.RequireAuthenticatedUser(); }); } options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!; }); } }