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)) throw new InvalidOperationException( "Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет."); AddEntraAuth(services, configuration, tenantId, audience); 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(); // Без этого JwtBearerHandler переименовывает короткие имена claim'ов // из токена в длинные ClaimTypes-URI (upn → ClaimTypes.Upn, // unique_name → ClaimTypes.Name — а НЕ ClaimTypes.Email!) ещё до // того, как код увидит ClaimsPrincipal. CurrentUserAccessor ищет // ровно сырые имена ("upn", "unique_name") — с ремаппингом они не // совпадали ни с чем, и email инициатора не находился вообще // (не только у токенов без claim'а email, как этот — у ЛЮБЫХ). options.MapInboundClaims = false; }); services.AddAuthentication() .AddJwtBearer(AuthSchemes.AppOnly, options => { options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; options.TokenValidationParameters = validationParameters(); options.MapInboundClaims = false; 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)!; }); } }