From bd1bbd6d37c4ded9cbfa39a0986781683e591392 Mon Sep 17 00:00:00 2001 From: Sasha Kotlyar <120513939+arktronic-sep@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:28:36 -0400 Subject: [PATCH 01/17] Implement OIDC --- .../LdapAuthenticationServiceTests.cs | 34 +-- .../OidcAuthenticationServiceTests.cs | 224 ++++++++++++++++++ .../Web/Pages/Account/LoginModelTests.cs | 8 +- .../Pages/Admin/Settings/LdapModelTests.cs | 2 +- .../Services/GroupMappingSyncServiceTests.cs | 46 ++++ .../OidcAuthenticationServiceTests.cs | 60 +++++ .../Services/OidcPostConfigureOptionsTests.cs | 106 +++++++++ SAMA.Web/Pages/Account/Login.cshtml | 11 + SAMA.Web/Pages/Account/Login.cshtml.cs | 52 +++- SAMA.Web/Pages/Account/Logout.cshtml.cs | 10 + .../Pages/Admin/Settings/GroupMappings.cshtml | 3 +- SAMA.Web/Pages/Admin/Settings/Oidc.cshtml | 161 +++++++++++++ SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs | 122 ++++++++++ .../Admin/Settings/_SettingsLayout.cshtml | 7 + SAMA.Web/Program.cs | 9 + SAMA.Web/SAMA.Web.csproj | 1 + SAMA.Web/Services/GlobalSettingsService.cs | 55 +++++ SAMA.Web/Services/GroupMappingSyncService.cs | 133 +++++++++++ .../Services/LdapAuthenticationService.cs | 104 +------- .../Services/OidcAuthenticationService.cs | 88 +++++++ SAMA.Web/Services/OidcPostConfigureOptions.cs | 45 ++++ 21 files changed, 1141 insertions(+), 140 deletions(-) create mode 100644 SAMA.Tests.Integration/Web/Services/OidcAuthenticationServiceTests.cs create mode 100644 SAMA.Tests.Unit/Web/Services/GroupMappingSyncServiceTests.cs create mode 100644 SAMA.Tests.Unit/Web/Services/OidcAuthenticationServiceTests.cs create mode 100644 SAMA.Tests.Unit/Web/Services/OidcPostConfigureOptionsTests.cs create mode 100644 SAMA.Web/Pages/Admin/Settings/Oidc.cshtml create mode 100644 SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs create mode 100644 SAMA.Web/Services/GroupMappingSyncService.cs create mode 100644 SAMA.Web/Services/OidcAuthenticationService.cs create mode 100644 SAMA.Web/Services/OidcPostConfigureOptions.cs diff --git a/SAMA.Tests.Integration/Web/Services/LdapAuthenticationServiceTests.cs b/SAMA.Tests.Integration/Web/Services/LdapAuthenticationServiceTests.cs index b2cddb5..a5d01b0 100644 --- a/SAMA.Tests.Integration/Web/Services/LdapAuthenticationServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/LdapAuthenticationServiceTests.cs @@ -29,7 +29,8 @@ public override async Task InitializeTestAsync() await EnsureAdminRoleExistsAsync(); var globalSettings = Substitute.For(null!, null!, null!, null!); - _service = new LdapAuthenticationService(globalSettings, ServiceProvider, Substitute.For>()); + var groupMappingSync = new GroupMappingSyncService(Substitute.For>()); + _service = new LdapAuthenticationService(globalSettings, groupMappingSync, ServiceProvider, Substitute.For>()); } [TestMethod] @@ -522,37 +523,6 @@ public void ValidateWithCustomCaShouldThrowWhenCertIsNull() StringAssert.Contains(ex.Message, "certificate or chain is null"); } - [TestMethod] - public void ExtractCnFromDnShouldParseCnFromFullDn() - { - var result = LdapAuthenticationService.ExtractCnFromDn("CN=Developers,OU=Groups,DC=example,DC=com"); - - Assert.AreEqual("Developers", result); - } - - [TestMethod] - public void ExtractCnFromDnShouldReturnNullForNonCnDn() - { - var result = LdapAuthenticationService.ExtractCnFromDn("OU=Groups,DC=example,DC=com"); - - Assert.IsNull(result); - } - - [TestMethod] - public void ExtractCnFromDnShouldReturnNullForNullOrWhitespace() - { - Assert.IsNull(LdapAuthenticationService.ExtractCnFromDn("")); - Assert.IsNull(LdapAuthenticationService.ExtractCnFromDn(" ")); - } - - [TestMethod] - public void ExtractCnFromDnShouldHandleCnOnly() - { - var result = LdapAuthenticationService.ExtractCnFromDn("CN=Admins"); - - Assert.AreEqual("Admins", result); - } - [TestMethod] public void ResolveBindDnShouldApplyTemplateForPlainUsername() { diff --git a/SAMA.Tests.Integration/Web/Services/OidcAuthenticationServiceTests.cs b/SAMA.Tests.Integration/Web/Services/OidcAuthenticationServiceTests.cs new file mode 100644 index 0000000..d4a7e34 --- /dev/null +++ b/SAMA.Tests.Integration/Web/Services/OidcAuthenticationServiceTests.cs @@ -0,0 +1,224 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using SAMA.Data.Entities; +using SAMA.Web.Constants; +using SAMA.Web.Services; + +namespace SAMA.Tests.Integration.Web.Services; + +[TestClass] +public class OidcAuthenticationServiceTests : IntegrationTestBase +{ + private UserManager _userManager = null!; + private RoleManager> _roleManager = null!; + private OidcAuthenticationService _service = null!; + private GlobalSettingsService _globalSettings = null!; + + [TestInitialize] + public override async Task InitializeTestAsync() + { + await base.InitializeTestAsync(); + + _userManager = ServiceProvider.GetRequiredService>(); + _roleManager = ServiceProvider.GetRequiredService>>(); + _globalSettings = ServiceProvider.GetRequiredService(); + + await EnsureAdminRoleExistsAsync(); + + var groupMappingSync = new GroupMappingSyncService(Substitute.For>()); + _service = new OidcAuthenticationService(_globalSettings, groupMappingSync, ServiceProvider, Substitute.For>()); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldCreateNewUser() + { + var principal = CreatePrincipal("newuser@example.com", "sub-123"); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + Assert.IsNotNull(user); + Assert.AreEqual("newuser@example.com", user.Email); + Assert.IsTrue(user.EmailConfirmed); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldAddOidcLoginToExistingUser() + { + var existingUser = await CreateUserAsync("existing@example.com"); + + var principal = CreatePrincipal("existing@example.com", "sub-456"); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + Assert.AreEqual(existingUser.Id, user.Id); + + var logins = await _userManager.GetLoginsAsync(user); + Assert.IsTrue(logins.Any(l => l.LoginProvider == AuthConstants.OidcSource)); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldNotDuplicateOidcLogin() + { + var principal = CreatePrincipal("repeat@example.com", "sub-789"); + + await _service.ProvisionOrUpdateUserAsync(principal); + await _service.ProvisionOrUpdateUserAsync(principal); + + var user = await _userManager.FindByEmailAsync("repeat@example.com"); + var logins = await _userManager.GetLoginsAsync(user!); + Assert.AreEqual(1, logins.Count(l => l.LoginProvider == AuthConstants.OidcSource)); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldThrowWhenNoEmailClaim() + { + var claims = new List { new("sub", "sub-no-email") }; + var identity = new ClaimsIdentity(claims, AuthConstants.OidcSource); + var principal = new ClaimsPrincipal(identity); + + await Assert.ThrowsAsync(async () => + await _service.ProvisionOrUpdateUserAsync(principal)); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldNotAffectLdapMappings() + { + _globalSettings.OidcGroupClaimType = "groups"; + var workspace = await CreateWorkspaceAsync("Mixed Workspace"); + await CreateGroupMappingAsync(workspace.Id, AuthConstants.LdapSource, "ldap-team", AuthConstants.EditorRole); + + var principal = CreatePrincipal("mixed@example.com", "sub-mixed", ["ldap-team"]); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + var assignments = await DbContext.UserWorkspaces + .Where(uw => uw.UserId == user.Id) + .ToListAsync(); + + Assert.AreEqual(0, assignments.Count); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldUseConfiguredGroupClaimType() + { + _globalSettings.OidcGroupClaimType = "roles"; + await CreateGroupMappingAsync(null, AuthConstants.OidcSource, "admin-role", AuthConstants.AdminRole); + + var claims = new List + { + new("sub", "sub-custom-claim"), + new("email", "customclaim@example.com"), + new("roles", "admin-role"), + }; + var identity = new ClaimsIdentity(claims, AuthConstants.OidcSource); + var principal = new ClaimsPrincipal(identity); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + DbContext.ChangeTracker.Clear(); + var refreshedUser = await _userManager.FindByEmailAsync("customclaim@example.com"); + Assert.IsTrue(await _userManager.IsInRoleAsync(refreshedUser!, AuthConstants.AdminRole)); + } + + [TestMethod] + public async Task ProvisionOrUpdateUserShouldSkipGroupsWhenClaimTypeEmpty() + { + _globalSettings.OidcGroupClaimType = ""; + await CreateGroupMappingAsync(null, AuthConstants.OidcSource, "admins-group", AuthConstants.AdminRole); + + var principal = CreatePrincipal("noclaimtype@example.com", "sub-no-ct", ["admins-group"]); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + DbContext.ChangeTracker.Clear(); + var refreshedUser = await _userManager.FindByEmailAsync("noclaimtype@example.com"); + Assert.IsFalse(await _userManager.IsInRoleAsync(refreshedUser!, AuthConstants.AdminRole)); + } + + private static ClaimsPrincipal CreatePrincipal(string email, string subject, List? groups = null) + { + var claims = new List + { + new("sub", subject), + new("email", email), + new("name", email), + }; + + if (groups != null) + { + claims.AddRange(groups.Select(g => new Claim("groups", g))); + } + + var identity = new ClaimsIdentity(claims, AuthConstants.OidcSource); + return new ClaimsPrincipal(identity); + } + + private async Task EnsureAdminRoleExistsAsync() + { + if (!await _roleManager.RoleExistsAsync(AuthConstants.AdminRole)) + { + await _roleManager.CreateAsync(new IdentityRole(AuthConstants.AdminRole)); + } + } + + private async Task CreateUserAsync(string email) + { + var user = new ApplicationUser + { + UserName = email, + Email = email, + EmailConfirmed = true, + CreatedAt = DateTimeOffset.UtcNow, + }; + + var result = await _userManager.CreateAsync(user, "Test-Password-123456789!"); + Assert.IsTrue(result.Succeeded, $"Failed to create user: {string.Join(", ", result.Errors.Select(e => e.Description))}"); + + DbContext.ChangeTracker.Clear(); + return user; + } + + private async Task CreateWorkspaceAsync(string name) + { + var workspace = new Workspace + { + Name = name, + IsPublic = false, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + DbContext.Workspaces.Add(workspace); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + + return workspace; + } + + private async Task CreateGroupMappingAsync(Guid? workspaceId, string identityProvider, string externalGroupId, string role) + { + DbContext.WorkspaceGroupMappings.Add(new WorkspaceGroupMapping + { + WorkspaceId = workspaceId, + IdentityProvider = identityProvider, + ExternalGroupId = externalGroupId, + Role = role, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + } + + private async Task EnsureRoleExistsAsync(string roleName) + { + if (!await _roleManager.RoleExistsAsync(roleName)) + { + await _roleManager.CreateAsync(new IdentityRole(roleName)); + } + } +} diff --git a/SAMA.Tests.Unit/Web/Pages/Account/LoginModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Account/LoginModelTests.cs index ffd2828..0f54905 100644 --- a/SAMA.Tests.Unit/Web/Pages/Account/LoginModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Account/LoginModelTests.cs @@ -15,6 +15,8 @@ public class LoginModelTests { private SignInManager _mockSignInManager = null!; private LdapAuthenticationService _mockLdapService = null!; + private OidcAuthenticationService _mockOidcService = null!; + private GlobalSettingsService _mockGlobalSettings = null!; private ILogger _mockLogger = null!; private LoginModel _pageModel = null!; @@ -33,10 +35,12 @@ public void Setup() null, null); - _mockLdapService = Substitute.For(null!, null!, null!); + _mockLdapService = Substitute.For(null!, null!, null!, null!); + _mockOidcService = Substitute.For(null!, null!, null!, null!); + _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); _mockLogger = Substitute.For>(); - _pageModel = new LoginModel(_mockSignInManager, _mockLdapService, _mockLogger); + _pageModel = new LoginModel(_mockSignInManager, _mockLdapService, _mockOidcService, _mockGlobalSettings, _mockLogger); PageModelTestHelpers.ConfigurePageModel(_pageModel); } diff --git a/SAMA.Tests.Unit/Web/Pages/Admin/Settings/LdapModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Admin/Settings/LdapModelTests.cs index 95fe962..e934b7a 100644 --- a/SAMA.Tests.Unit/Web/Pages/Admin/Settings/LdapModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Admin/Settings/LdapModelTests.cs @@ -21,7 +21,7 @@ public class LdapModelTests public void Setup() { _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); - _mockLdapService = Substitute.For(null!, null!, null!); + _mockLdapService = Substitute.For(null!, null!, null!, null!); _mockLogger = Substitute.For>(); _pageModel = new LdapModel(_mockGlobalSettings, _mockLdapService, _mockLogger); diff --git a/SAMA.Tests.Unit/Web/Services/GroupMappingSyncServiceTests.cs b/SAMA.Tests.Unit/Web/Services/GroupMappingSyncServiceTests.cs new file mode 100644 index 0000000..2a72a82 --- /dev/null +++ b/SAMA.Tests.Unit/Web/Services/GroupMappingSyncServiceTests.cs @@ -0,0 +1,46 @@ +using SAMA.Web.Services; + +namespace SAMA.Tests.Unit.Web.Services; + +[TestClass] +public class GroupMappingSyncServiceTests +{ + [TestMethod] + public void ExtractCnFromDnShouldParseCnFromFullDn() + { + var result = GroupMappingSyncService.ExtractCnFromDn("CN=Developers,OU=Groups,DC=example,DC=com"); + + Assert.AreEqual("Developers", result); + } + + [TestMethod] + public void ExtractCnFromDnShouldReturnNullForNonCnDn() + { + var result = GroupMappingSyncService.ExtractCnFromDn("OU=Groups,DC=example,DC=com"); + + Assert.IsNull(result); + } + + [TestMethod] + public void ExtractCnFromDnShouldReturnNullForEmptyOrWhitespace() + { + Assert.IsNull(GroupMappingSyncService.ExtractCnFromDn("")); + Assert.IsNull(GroupMappingSyncService.ExtractCnFromDn(" ")); + } + + [TestMethod] + public void ExtractCnFromDnShouldHandleCnOnly() + { + var result = GroupMappingSyncService.ExtractCnFromDn("CN=Admins"); + + Assert.AreEqual("Admins", result); + } + + [TestMethod] + public void ExtractCnFromDnShouldBeCaseInsensitive() + { + var result = GroupMappingSyncService.ExtractCnFromDn("cn=DevOps,OU=Groups,DC=example,DC=com"); + + Assert.AreEqual("DevOps", result); + } +} diff --git a/SAMA.Tests.Unit/Web/Services/OidcAuthenticationServiceTests.cs b/SAMA.Tests.Unit/Web/Services/OidcAuthenticationServiceTests.cs new file mode 100644 index 0000000..231805a --- /dev/null +++ b/SAMA.Tests.Unit/Web/Services/OidcAuthenticationServiceTests.cs @@ -0,0 +1,60 @@ +using NSubstitute; +using SAMA.Web.Services; + +namespace SAMA.Tests.Unit.Web.Services; + +[TestClass] +public class OidcAuthenticationServiceTests +{ + private GlobalSettingsService _mockGlobalSettings = null!; + + [TestInitialize] + public void Setup() + { + _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); + } + + [TestMethod] + public void IsOidcEnabledShouldReturnTrueWhenAllConditionsMet() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com/tenant"); + _mockGlobalSettings.OidcClientId.Returns("my-client-id"); + var service = new OidcAuthenticationService(_mockGlobalSettings, null!, null!, null!); + + Assert.IsTrue(service.IsOidcEnabled); + } + + [TestMethod] + public void IsOidcEnabledShouldReturnFalseWhenDisabled() + { + _mockGlobalSettings.OidcEnabled.Returns(false); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com/tenant"); + _mockGlobalSettings.OidcClientId.Returns("my-client-id"); + var service = new OidcAuthenticationService(_mockGlobalSettings, null!, null!, null!); + + Assert.IsFalse(service.IsOidcEnabled); + } + + [TestMethod] + public void IsOidcEnabledShouldReturnFalseWhenAuthorityEmpty() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns(""); + _mockGlobalSettings.OidcClientId.Returns("my-client-id"); + var service = new OidcAuthenticationService(_mockGlobalSettings, null!, null!, null!); + + Assert.IsFalse(service.IsOidcEnabled); + } + + [TestMethod] + public void IsOidcEnabledShouldReturnFalseWhenClientIdEmpty() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com/tenant"); + _mockGlobalSettings.OidcClientId.Returns(" "); + var service = new OidcAuthenticationService(_mockGlobalSettings, null!, null!, null!); + + Assert.IsFalse(service.IsOidcEnabled); + } +} diff --git a/SAMA.Tests.Unit/Web/Services/OidcPostConfigureOptionsTests.cs b/SAMA.Tests.Unit/Web/Services/OidcPostConfigureOptionsTests.cs new file mode 100644 index 0000000..369a656 --- /dev/null +++ b/SAMA.Tests.Unit/Web/Services/OidcPostConfigureOptionsTests.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using NSubstitute; +using SAMA.Web.Constants; +using SAMA.Web.Services; + +namespace SAMA.Tests.Unit.Web.Services; + +[TestClass] +public class OidcPostConfigureOptionsTests +{ + private GlobalSettingsService _mockGlobalSettings = null!; + private OidcPostConfigureOptions _postConfigure = null!; + + [TestInitialize] + public void Setup() + { + _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); + _postConfigure = new OidcPostConfigureOptions(_mockGlobalSettings); + } + + [TestMethod] + public void PostConfigureShouldIgnoreNonOidcSchemes() + { + var options = new OpenIdConnectOptions(); + var originalAuthority = options.Authority; + + _postConfigure.PostConfigure("Cookies", options); + + Assert.AreEqual(originalAuthority, options.Authority); + } + + [TestMethod] + public void PostConfigureShouldSetDummyConfigWhenDisabled() + { + _mockGlobalSettings.OidcEnabled.Returns(false); + var options = new OpenIdConnectOptions(); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, options); + + Assert.AreEqual("https://localhost", options.Authority); + Assert.AreEqual("disabled", options.ClientId); + Assert.AreEqual(string.Empty, options.MetadataAddress); + Assert.IsNotNull(options.Configuration); + } + + [TestMethod] + public void PostConfigureShouldMapSettingsWhenEnabled() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com/tenant"); + _mockGlobalSettings.OidcClientId.Returns("my-client-id"); + _mockGlobalSettings.OidcClientSecret.Returns("my-secret"); + _mockGlobalSettings.OidcScopes.Returns("openid profile email"); + var options = new OpenIdConnectOptions(); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, options); + + Assert.AreEqual("https://login.example.com/tenant", options.Authority); + Assert.AreEqual("my-client-id", options.ClientId); + Assert.AreEqual("my-secret", options.ClientSecret); + Assert.AreEqual(OpenIdConnectResponseType.Code, options.ResponseType); + Assert.AreEqual("/signin-oidc", options.CallbackPath.Value); + Assert.AreEqual("/signout-callback-oidc", options.SignedOutCallbackPath.Value); + Assert.IsTrue(options.SaveTokens); + Assert.IsFalse(options.MapInboundClaims); + Assert.AreEqual("name", options.TokenValidationParameters.NameClaimType); + } + + [TestMethod] + public void PostConfigureShouldSplitAndSetScopes() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com"); + _mockGlobalSettings.OidcClientId.Returns("client"); + _mockGlobalSettings.OidcClientSecret.Returns(""); + _mockGlobalSettings.OidcScopes.Returns("openid profile email groups"); + var options = new OpenIdConnectOptions(); + options.Scope.Add("pre-existing"); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, options); + + Assert.AreEqual(4, options.Scope.Count); + CollectionAssert.AreEquivalent( + new[] { "openid", "profile", "email", "groups" }, + options.Scope.ToArray()); + } + + [TestMethod] + public void PostConfigureShouldHandleExtraWhitespaceInScopes() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com"); + _mockGlobalSettings.OidcClientId.Returns("client"); + _mockGlobalSettings.OidcClientSecret.Returns(""); + _mockGlobalSettings.OidcScopes.Returns("openid profile email"); + var options = new OpenIdConnectOptions(); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, options); + + Assert.AreEqual(3, options.Scope.Count); + CollectionAssert.AreEquivalent( + new[] { "openid", "profile", "email" }, + options.Scope.ToArray()); + } +} diff --git a/SAMA.Web/Pages/Account/Login.cshtml b/SAMA.Web/Pages/Account/Login.cshtml index fc6c4c3..9fbee90 100644 --- a/SAMA.Web/Pages/Account/Login.cshtml +++ b/SAMA.Web/Pages/Account/Login.cshtml @@ -48,6 +48,17 @@ + + @if (Model.OidcEnabled) + { +
+ + } diff --git a/SAMA.Web/Pages/Account/Login.cshtml.cs b/SAMA.Web/Pages/Account/Login.cshtml.cs index a293f13..e241253 100644 --- a/SAMA.Web/Pages/Account/Login.cshtml.cs +++ b/SAMA.Web/Pages/Account/Login.cshtml.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -14,6 +15,8 @@ namespace SAMA.Web.Pages.Account; public class LoginModel( SignInManager signInManager, LdapAuthenticationService ldapService, + OidcAuthenticationService oidcService, + GlobalSettingsService globalSettings, ILogger logger) : PageModel { [BindProperty] @@ -23,6 +26,10 @@ public class LoginModel( public bool LdapEnabled => ldapService.IsLdapEnabled; + public bool OidcEnabled => oidcService.IsOidcEnabled; + + public string OidcProviderName => globalSettings.OidcProviderName; + public class InputModel { [Required(ErrorMessage = "Email or username is required")] @@ -81,12 +88,12 @@ public async Task OnPostAsync(string? returnUrl = null) } // Fall back to local password authentication - // Block local login for LDAP-sourced users + // Block local login for LDAP or OIDC-sourced users var localUser = await signInManager.UserManager.FindByEmailAsync(identifier); if (localUser != null) { var logins = await signInManager.UserManager.GetLoginsAsync(localUser); - if (logins.Any(l => l.LoginProvider == AuthConstants.LdapSource)) + if (logins.Any(l => l.LoginProvider == AuthConstants.LdapSource || l.LoginProvider == AuthConstants.OidcSource)) { ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); @@ -109,4 +116,45 @@ public async Task OnPostAsync(string? returnUrl = null) ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); } + + public IActionResult OnGetOidcLogin(string? returnUrl = null) + { + returnUrl ??= Url.Content("~/"); + + var properties = new AuthenticationProperties + { + RedirectUri = Url.Page("/Account/Login", "OidcCallback", new { returnUrl }), + }; + + return Challenge(properties, AuthConstants.OidcSource); + } + + public async Task OnGetOidcCallbackAsync(string? returnUrl = null) + { + returnUrl ??= Url.Content("~/"); + + var authResult = await HttpContext.AuthenticateAsync(AuthConstants.OidcSource); + if (!authResult.Succeeded || authResult.Principal == null) + { + logger.LogWarning("OIDC authentication failed: {Failure}", authResult.Failure?.Message); + ModelState.AddModelError(string.Empty, "External login failed. Please try again."); + ReturnUrl = returnUrl; + return Page(); + } + + try + { + var user = await oidcService.ProvisionOrUpdateUserAsync(authResult.Principal); + await signInManager.SignInAsync(user, isPersistent: false); + logger.LogInformation("User {Email} logged in via OIDC", user.Email); + return LocalRedirect(returnUrl); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during OIDC login"); + ModelState.AddModelError(string.Empty, "An error occurred during login. Please try again."); + ReturnUrl = returnUrl; + return Page(); + } + } } diff --git a/SAMA.Web/Pages/Account/Logout.cshtml.cs b/SAMA.Web/Pages/Account/Logout.cshtml.cs index 454a874..3421521 100644 --- a/SAMA.Web/Pages/Account/Logout.cshtml.cs +++ b/SAMA.Web/Pages/Account/Logout.cshtml.cs @@ -1,14 +1,18 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using SAMA.Data.Entities; +using SAMA.Web.Constants; +using SAMA.Web.Services; namespace SAMA.Web.Pages.Account; [AllowAnonymous] public class LogoutModel( SignInManager signInManager, + OidcAuthenticationService oidcService, ILogger logger) : PageModel { @@ -17,6 +21,12 @@ public async Task OnPostAsync(string? returnUrl = null) await signInManager.SignOutAsync(); logger.LogInformation("User logged out"); + // If OIDC is enabled, also sign out from the OIDC provider + if (oidcService.IsOidcEnabled) + { + await HttpContext.SignOutAsync(AuthConstants.OidcSource); + } + if (returnUrl != null) { return LocalRedirect(returnUrl); diff --git a/SAMA.Web/Pages/Admin/Settings/GroupMappings.cshtml b/SAMA.Web/Pages/Admin/Settings/GroupMappings.cshtml index 8c2b044..83ddb47 100644 --- a/SAMA.Web/Pages/Admin/Settings/GroupMappings.cshtml +++ b/SAMA.Web/Pages/Admin/Settings/GroupMappings.cshtml @@ -132,7 +132,7 @@
- The LDAP group DN or CN. Must match what appears in the user's group memberships. Use the LDAP Test Login to discover group names. + The group name, DN, or ID from the identity provider. For LDAP, use the group DN or CN. For OIDC, use the exact value from the group claim (e.g. group name or Object ID).
@@ -157,6 +157,7 @@
The identity provider that supplies group membership.
diff --git a/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml new file mode 100644 index 0000000..8120040 --- /dev/null +++ b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml @@ -0,0 +1,161 @@ +@page +@model SAMA.Web.Pages.Admin.Settings.OidcModel +@{ + Layout = "_SettingsLayout"; + ViewData["Title"] = "System Options - OIDC"; + ViewData["ActiveSettingsTab"] = "Oidc"; +} + +
+
+
+
+
+ + OpenID Connect (OIDC) +
+

+ Configure single sign-on with an OpenID Connect provider such as Azure AD / Entra ID, Okta, Auth0, or Keycloak. + When enabled, a "Sign in with" button appears on the login page. +

+ + @if (TempData["OidcSuccess"] != null) + { + + } + + @if (TempData["OidcError"] != null) + { + + } + +
+ + How OIDC interacts with local accounts: When a user logs in via OIDC, their account is matched by email. + If a local user with the same email exists, it is linked to OIDC and local password login is disabled for that account. + New users are automatically provisioned on first OIDC login. + Use Group Mappings to automatically assign workspace access and admin roles based on OIDC group claims. +
+ +
+
+ + +
+ +
Provider
+
+
+ + +
+ The issuer URL of your OIDC provider. Must expose a /.well-known/openid-configuration endpoint. +
Azure AD: https://login.microsoftonline.com/{tenant-id}/v2.0 +
Okta: https://{domain}.okta.com/oauth2/default +
Keycloak: https://{host}/realms/{realm} +
+
+
+ + +
Display name shown on the login button (e.g. "Azure AD", "Okta", "Company SSO")
+
+
+ +
Client Credentials
+
+
+ + +
The application/client ID from your OIDC provider
+
+
+ + +
+ @if (Model.HasExistingClientSecret) + { + Client secret is set. Leave unmodified to keep existing secret, or enter a new one to change it. + } + else + { + The client secret from your OIDC provider (stored encrypted) + } +
+ @if (Model.HasExistingClientSecret) + { +
+ + +
+ } +
+
+ +
Claims & Scopes
+
+
+ + +
+ Space-separated OAuth scopes. Default: openid profile email. + Add additional scopes if your provider requires them for group claims. +
+
+
+ + +
+ The claim name in the ID token that contains group memberships. Common value: groups. + Most providers require additional configuration to include group claims in tokens. Check your provider's documentation for details. +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+ + Setup Guide +
+
    +
  1. Register an application in your OIDC provider.
  2. +
  3. + Set the Redirect URI to: + @($"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}/signin-oidc") +
  4. +
  5. Copy the Client ID and Client Secret here.
  6. +
  7. Enter the Authority URL (issuer) for your provider.
  8. +
  9. Enable OIDC and save.
  10. +
  11. Optionally, configure Group Mappings to auto-assign workspace access.
  12. +
+
+
+
+
diff --git a/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs new file mode 100644 index 0000000..81b1be6 --- /dev/null +++ b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs @@ -0,0 +1,122 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SAMA.Web.Constants; +using SAMA.Web.Services; + +namespace SAMA.Web.Pages.Admin.Settings; + +[Authorize(Roles = AuthConstants.AdminRole)] +public class OidcModel( + GlobalSettingsService _globalSettings, + ILogger _logger) : PageModel +{ + [BindProperty] + public OidcInputModel OidcInput { get; set; } = new(); + + public bool HasExistingClientSecret { get; set; } + + public class OidcInputModel + { + [Display(Name = "Enable OIDC")] + public bool Enabled { get; set; } + + [Display(Name = "Authority URL")] + public string Authority { get; set; } = string.Empty; + + [Display(Name = "Client ID")] + public string ClientId { get; set; } = string.Empty; + + [Display(Name = "Client Secret")] + [DataType(DataType.Password)] + public string ClientSecret { get; set; } = string.Empty; + + [Display(Name = "Scopes")] + public string Scopes { get; set; } = "openid profile email"; + + [Display(Name = "Group Claim Type")] + public string GroupClaimType { get; set; } = "groups"; + + [Display(Name = "Provider Name")] + public string ProviderName { get; set; } = "OIDC"; + } + + public void OnGet() + { + LoadCurrentSettings(); + } + + public IActionResult OnPost() + { + if (OidcInput.Enabled) + { + if (string.IsNullOrWhiteSpace(OidcInput.Authority)) + { + TempData["OidcError"] = "Authority URL is required when OIDC is enabled."; + return RedirectToPage(); + } + + if (string.IsNullOrWhiteSpace(OidcInput.ClientId)) + { + TempData["OidcError"] = "Client ID is required when OIDC is enabled."; + return RedirectToPage(); + } + + if (!Uri.TryCreate(OidcInput.Authority, UriKind.Absolute, out var authorityUri) + || (authorityUri.Scheme != "https" && authorityUri.Scheme != "http")) + { + TempData["OidcError"] = "Authority URL must be a valid HTTP or HTTPS URL."; + return RedirectToPage(); + } + } + + try + { + _globalSettings.OidcEnabled = OidcInput.Enabled; + _globalSettings.OidcAuthority = OidcInput.Authority.TrimEnd('/'); + _globalSettings.OidcClientId = OidcInput.ClientId; + _globalSettings.OidcScopes = string.IsNullOrWhiteSpace(OidcInput.Scopes) + ? "openid profile email" + : OidcInput.Scopes; + _globalSettings.OidcGroupClaimType = string.IsNullOrWhiteSpace(OidcInput.GroupClaimType) + ? "groups" + : OidcInput.GroupClaimType; + _globalSettings.OidcProviderName = string.IsNullOrWhiteSpace(OidcInput.ProviderName) + ? "OIDC" + : OidcInput.ProviderName; + + if (!string.IsNullOrEmpty(OidcInput.ClientSecret)) + { + _globalSettings.OidcClientSecret = OidcInput.ClientSecret; + } + else if (Request.Form["ClearClientSecret"].ToString() == "true") + { + _globalSettings.OidcClientSecret = string.Empty; + } + + _logger.LogInformation("OIDC settings updated by {User}", User.Identity?.Name ?? "Unknown"); + + _globalSettings.ClearCache(); + TempData["OidcSuccess"] = "OIDC settings saved successfully."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating OIDC settings"); + TempData["OidcError"] = "An error occurred while saving OIDC settings."; + } + + return RedirectToPage(); + } + + private void LoadCurrentSettings() + { + OidcInput.Enabled = _globalSettings.OidcEnabled; + OidcInput.Authority = _globalSettings.OidcAuthority; + OidcInput.ClientId = _globalSettings.OidcClientId; + OidcInput.Scopes = _globalSettings.OidcScopes; + OidcInput.GroupClaimType = _globalSettings.OidcGroupClaimType; + OidcInput.ProviderName = _globalSettings.OidcProviderName; + HasExistingClientSecret = !string.IsNullOrEmpty(_globalSettings.OidcClientSecret); + } +} diff --git a/SAMA.Web/Pages/Admin/Settings/_SettingsLayout.cshtml b/SAMA.Web/Pages/Admin/Settings/_SettingsLayout.cshtml index 4192de3..6673710 100644 --- a/SAMA.Web/Pages/Admin/Settings/_SettingsLayout.cshtml +++ b/SAMA.Web/Pages/Admin/Settings/_SettingsLayout.cshtml @@ -27,6 +27,13 @@ LDAP +