diff --git a/ModeusSchedule.MSAuth.sln b/ModeusSchedule.MSAuth.sln
new file mode 100644
index 0000000..89dde3b
--- /dev/null
+++ b/ModeusSchedule.MSAuth.sln
@@ -0,0 +1,16 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModeusSchedule.MSAuth.csproj", "src\ModeusSchedule.MSAuth.csproj", "{5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5A8B35A8-618F-4BDD-AD93-F8E1962E7F6A}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/src/BrowserScripts/MicrosoftLoginHelper.cs b/src/BrowserScripts/MicrosoftLoginHelper.cs
new file mode 100644
index 0000000..9a1625d
--- /dev/null
+++ b/src/BrowserScripts/MicrosoftLoginHelper.cs
@@ -0,0 +1,61 @@
+using System.Text.RegularExpressions;
+using Microsoft.Playwright;
+
+namespace ModeusSchedule.MSAuth.BrowserScripts;
+
+public static class MicrosoftLoginHelper
+{
+ public static async Task LoginMicrosoftAsync(IPage page, string username, string password, string loginUrl)
+ {
+ await page.GotoAsync(loginUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
+
+ await page.WaitForURLAsync(new Regex("login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 });
+
+ var useAnotherAccount = page.Locator("div#otherTile, #otherTileText, div[data-test-id='useAnotherAccount']").First;
+ try
+ {
+ await Assertions.Expect(useAnotherAccount).ToBeVisibleAsync(new() { Timeout = 2000 });
+ await useAnotherAccount.ClickAsync();
+ }
+ catch (PlaywrightException)
+ {
+ // Кнопка не появилась — пропускаем
+ }
+
+ var emailInput = page.Locator("input[name='loginfmt'], input#i0116");
+ await emailInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
+ await emailInput.FillAsync(username);
+
+ var nextButton = page.Locator("#idSIButton9, input#idSIButton9");
+ await nextButton.ClickAsync();
+
+ var passwordInput = page.Locator("input[name='passwd'], input#i0118");
+ await passwordInput.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 });
+ await passwordInput.FillAsync(password);
+ await nextButton.ClickAsync();
+
+ await page.WaitForSelectorAsync("button, input[type='submit'], a", new PageWaitForSelectorOptions { Timeout = 8000 });
+
+ var locator = page.Locator("#idSIButton9, #idBtn_Back").First;
+ try
+ {
+ await Assertions.Expect(locator).ToBeVisibleAsync(new() { Timeout = 3000 });
+ var noBtn = page.Locator("#idBtn_Back");
+ if (await noBtn.IsVisibleAsync())
+ await noBtn.ClickAsync();
+ else
+ await page.Locator("#idSIButton9").ClickAsync();
+ }
+ catch (PlaywrightException)
+ {
+ // Кнопки не появились — пропускаем этот шаг
+ }
+
+ await page.WaitForURLAsync(url => !Regex.IsMatch(new Uri(url).Host, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase), new PageWaitForURLOptions { Timeout = 60_000 });
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var currentHost = new Uri(page.Url).Host;
+ if (Regex.IsMatch(currentHost, "login\\.(microsoftonline|live)\\.com", RegexOptions.IgnoreCase))
+ throw new Exception("Авторизация не завершена: остались на странице Microsoft Login");
+ }
+}
diff --git a/src/ModeusSchedule.MSAuth.csproj b/src/ModeusSchedule.MSAuth.csproj
new file mode 100644
index 0000000..b043672
--- /dev/null
+++ b/src/ModeusSchedule.MSAuth.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/Program.cs b/src/Program.cs
new file mode 100644
index 0000000..f0a8fb0
--- /dev/null
+++ b/src/Program.cs
@@ -0,0 +1,39 @@
+using ModeusSchedule.MSAuth.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+if (string.IsNullOrWhiteSpace(builder.Configuration["MODEUS_URL"]))
+{
+ Console.Error.WriteLine("Ошибка: не задан URL для Modeus. Пожалуйста, укажите MODEUS_URL в файле конфигурации или переменных окружения.");
+ Environment.Exit(1);
+}
+
+if (string.IsNullOrWhiteSpace(builder.Configuration["MS_USERNAME"]) || string.IsNullOrWhiteSpace(builder.Configuration["MS_PASSWORD"]))
+{
+ Console.Error.WriteLine("Ошибка: не заданы учетные данные для MicrosoftAuth. Пожалуйста, укажите MS_USERNAME и MS_PASSWORD в файле конфигурации или переменных окружения.");
+ Environment.Exit(1);
+}
+
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+app.MapGet("/auth/ms", async (MicrosoftAuthService mas, CancellationToken ct) =>
+ {
+ try
+ {
+ var token = await mas.GetJwtAsync(ct);
+ return Results.Json(new { jwt = token });
+ }
+ catch (MicrosoftAuthInProgressException)
+ {
+ return Results.StatusCode(StatusCodes.Status429TooManyRequests);
+ }
+ catch (Exception ex)
+ {
+ return Results.Problem(ex.Message, statusCode: 500);
+ }
+ })
+ .WithName("GetMsJwt");
+
+app.Run();
\ No newline at end of file
diff --git a/src/Services/MicrosoftAuthService.cs b/src/Services/MicrosoftAuthService.cs
new file mode 100644
index 0000000..f4ea435
--- /dev/null
+++ b/src/Services/MicrosoftAuthService.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+using Microsoft.Playwright;
+using ModeusSchedule.MSAuth.BrowserScripts;
+
+namespace ModeusSchedule.MSAuth.Services;
+
+public class MicrosoftAuthService(ILogger logger, IConfiguration configuration)
+{
+ private static bool _browsersEnsured;
+ private static readonly SemaphoreSlim EnsureLock = new(1, 1);
+ private static readonly SemaphoreSlim FetchLock = new(1, 1);
+
+ private string? _cachedToken;
+ private DateTime _cachedAtUtc;
+ private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(20);
+
+ public bool HasFreshToken => _cachedToken != null && DateTime.UtcNow - _cachedAtUtc < _cacheTtl;
+
+ public async Task GetJwtAsync(CancellationToken ct = default)
+ {
+ await EnsureBrowsersAsync();
+
+ // Если кэш актуален — вернуть сразу
+ if (HasFreshToken)
+ return _cachedToken!;
+
+ // Пытаемся единолично выполнить авторизацию
+ if (!await FetchLock.WaitAsync(0, ct))
+ {
+ // Если кто-то уже выполняет, а кэша нет — просим повторить позже (429)
+ throw new MicrosoftAuthInProgressException();
+ }
+
+ using var playwright = await Playwright.CreateAsync();
+ await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
+ {
+ Headless = true
+ });
+ var context = await browser.NewContextAsync(new BrowserNewContextOptions
+ {
+ ViewportSize = null
+ });
+ var page = await context.NewPageAsync();
+
+ try
+ {
+ logger.LogInformation("Старт авторизации через Microsoft");
+ await MicrosoftLoginHelper.LoginMicrosoftAsync(page, configuration["MS_USERNAME"]!, configuration["MS_PASSWORD"]!, configuration["MODEUS_URL"]!);
+
+ var sessionStorageJson = await page.EvaluateAsync("JSON.stringify(sessionStorage)");
+
+ string? idToken = ExtractIdToken(sessionStorageJson);
+ if (string.IsNullOrWhiteSpace(idToken))
+ throw new Exception("Не удалось извлечь id_token из sessionStorage");
+
+ // Сохраняем в кэш
+ _cachedToken = idToken;
+ _cachedAtUtc = DateTime.UtcNow;
+ return idToken;
+ }
+ finally
+ {
+ await context.CloseAsync();
+ await browser.CloseAsync();
+
+ if (FetchLock.CurrentCount == 0) FetchLock.Release();
+ }
+ }
+
+ private static string? ExtractIdToken(string sessionStorageJson)
+ {
+ try
+ {
+ var dict = JsonSerializer.Deserialize>(sessionStorageJson);
+ if (dict is null) return null;
+
+ var oidcKey = dict.Keys.FirstOrDefault(k => k.StartsWith("oidc.user:"));
+ if (oidcKey is null) return null;
+
+ var oidcValueJson = dict[oidcKey].ToString();
+ if (string.IsNullOrWhiteSpace(oidcValueJson)) return null;
+
+ using var doc = JsonDocument.Parse(oidcValueJson);
+ if (doc.RootElement.TryGetProperty("id_token", out var tokenEl))
+ return tokenEl.GetString();
+ }
+ catch
+ {
+ // ignore and return null
+ }
+ return null;
+ }
+
+ private static async Task EnsureBrowsersAsync()
+ {
+ if (_browsersEnsured) return;
+ await EnsureLock.WaitAsync();
+ try
+ {
+ if (_browsersEnsured) return;
+ try
+ {
+ // Устанавливаем Chromium, если не установлен
+ Microsoft.Playwright.Program.Main(["install", "chromium"]);
+ }
+ catch
+ {
+ // Игнорируем, если установка уже произведена или нет прав — попробуем дальше запустить браузер
+ }
+ _browsersEnsured = true;
+ }
+ finally
+ {
+ EnsureLock.Release();
+ }
+ }
+}
+
+public class MicrosoftAuthInProgressException : Exception
+{
+}
diff --git a/src/appsettings.Development.json b/src/appsettings.Development.json
new file mode 100644
index 0000000..d838ee7
--- /dev/null
+++ b/src/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "MODEUS_URL": "https://<вуз>.modeus.org/",
+ "MS_USERNAME": "",
+ "MS_PASSWORD": ""
+}
diff --git a/src/appsettings.json b/src/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/src/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}