diff --git a/.editorconfig b/.editorconfig index 4bd21a8..13d1cfe 100644 --- a/.editorconfig +++ b/.editorconfig @@ -88,6 +88,9 @@ dotnet_naming_style.underscore_camel_case.capitalization = camel_case # Code quality rules dotnet_code_quality_unused_parameters = non_public:warning +# IDE0058: Expression value is never used — require explicit discard +csharp_style_unused_value_expression_statement_preference = discard_variable:warning + # CA2000: Dispose objects before losing scope dotnet_diagnostic.CA2000.severity = warning @@ -189,3 +192,20 @@ indent_size = 2 [*.md] trim_trailing_whitespace = false insert_final_newline = false + +# EF Core migrations and configurations use fluent builder patterns +# where return values are intentionally unused +[SAMA.Data/Migrations/**.cs] +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +[SAMA.Data/Configuration/**.cs] +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +[SAMA.Tests.Unit/**.cs] +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +[SAMA.Tests.Integration/**.cs] +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +[SAMA.Tests.System/**.cs] +csharp_style_unused_value_expression_statement_preference = discard_variable:silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb0ca4..f24d8a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,9 @@ jobs: name: Unit Tests on Linux runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.x' - name: Run unit tests @@ -54,9 +54,9 @@ jobs: SAMA_ENCRYPTION_KEY: test-encryption-key-for-ci-only steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.x' - name: Run integration tests @@ -87,8 +87,22 @@ jobs: ports: - 6467:80 - 6468:25 + oidc: + image: ghcr.io/geigerzaehler/oidc-provider-mock:latest + options: >- + --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:9400/.well-known/openid-configuration')\"" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 9400:9400 + ldap: + image: thoteam/slapd-server-mock:latest + ports: + - 389:389 env: + TMPDIR: /tmp POSTGRES_HOST: localhost POSTGRES_PORT: 5432 POSTGRES_DB: samadb @@ -97,9 +111,15 @@ jobs: SAMA_ENCRYPTION_KEY: test-encryption-key-for-ci-only steps: - - uses: actions/checkout@v4 + - name: Prepare OIDC users (this is done via Docker CMD in local dev) + run: | + curl 'http://localhost:9400/oauth2/authorize?response_type=code&client_id=my-client-id&scope=openid+email&redirect_uri=http://localhost:9400' --data-raw 'sub=alice' + curl -XPUT 'http://localhost:9400/users/alice' --json '{"email": "alice@example.com", "name": "Alice"}' + curl 'http://localhost:9400/oauth2/authorize?response_type=code&client_id=my-client-id&scope=openid+email&redirect_uri=http://localhost:9400' --data-raw 'sub=bob' + curl -XPUT 'http://localhost:9400/users/bob' --json '{"email": "bob@example.com", "name": "Bob", "groups": ["test-editors"]}' + - uses: actions/checkout@v6 - name: Set up dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.x' - name: Build @@ -112,11 +132,18 @@ jobs: run: | cd SAMA.Tests.System dotnet test --logger "console;verbosity=detailed" + - name: Upload Playwright screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-screenshots + path: /tmp/sama-playwright-screenshots/ + retention-days: 7 docker_build: name: Docker Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker image run: docker build -t sama:ci-test . diff --git a/.github/workflows/release-ghcr.yml b/.github/workflows/release-ghcr.yml index 70c673f..c2af362 100644 --- a/.github/workflows/release-ghcr.yml +++ b/.github/workflows/release-ghcr.yml @@ -20,12 +20,12 @@ jobs: outputs: version: ${{ steps.minver.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up dotnet - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.x' @@ -58,19 +58,19 @@ jobs: description: "Service Availability Monitoring and Alerting - A modern uptime monitoring system (with sudo)" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.TAG_NAME }} tags: | @@ -86,14 +86,14 @@ jobs: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ${{ matrix.dockerfile }} diff --git a/Directory.Build.props b/Directory.Build.props index 3e8a719..e32d099 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,8 @@ SAMA Service Availability Monitoring and Alerting Copyright © 2026 SEP + false + true v minimal true diff --git a/README.md b/README.md index 26a9062..458408f 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,12 @@ SAMA is a comprehensive service availability monitoring solution that helps you: ### Authentication & SSO - **LDAP/Active Directory**: Full LDAP authentication with direct bind and search+bind modes, StartTLS, custom Root CA support -- **Group-Based Provisioning**: Just-in-time user provisioning and workspace role assignment via LDAP group mappings +- **OIDC SSO**: OpenID Connect authentication with any OIDC-compliant provider (Azure AD/Entra, Okta, Auth0, Keycloak, etc.) +- **Group-Based Provisioning**: Just-in-time user provisioning and workspace role assignment via LDAP/OIDC group mappings ### Future Enhancements (Phase 2+) - **Geo-Distributed Agents**: Run checks from multiple regions - **Advanced Check Types**: Playwright-based browser automation, Database connectivity checks -- **OIDC SSO**: OpenID Connect authentication (Azure AD/Entra, Okta, Auth0, etc.) - **Audit Logging**: Track all configuration changes ### Security & Compliance diff --git a/SAMA.Data/SAMA.Data.csproj b/SAMA.Data/SAMA.Data.csproj index b08b3e1..5e8d97e 100644 --- a/SAMA.Data/SAMA.Data.csproj +++ b/SAMA.Data/SAMA.Data.csproj @@ -7,13 +7,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SAMA.Data/SamaDbContext.cs b/SAMA.Data/SamaDbContext.cs index 81eb3eb..0c8c220 100644 --- a/SAMA.Data/SamaDbContext.cs +++ b/SAMA.Data/SamaDbContext.cs @@ -43,7 +43,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); // Apply all entity configurations (indexes, relationships, constraints, etc.) - modelBuilder.ApplyConfigurationsFromAssembly(typeof(SamaDbContext).Assembly); + _ = modelBuilder.ApplyConfigurationsFromAssembly(typeof(SamaDbContext).Assembly); // Configure UUIDv7 for all GUID primary keys foreach (var entityType in modelBuilder.Model.GetEntityTypes()) @@ -70,7 +70,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Apply encryption converters (runtime-only, not used during migrations) // These convert C# objects to encrypted JSON strings in the database - modelBuilder.Entity() + _ = modelBuilder.Entity() .Property(nc => nc.ConfigurationJson) .HasConversion( v => _encryptionService.Encrypt(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), _keyProvider.Key), @@ -78,7 +78,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) dictionaryComparer ); - modelBuilder.Entity() + _ = modelBuilder.Entity() .Property(c => c.ConfigurationJson) .HasConversion( v => _encryptionService.Encrypt(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), _keyProvider.Key), diff --git a/SAMA.Shared/Checks/HttpCheckExecutor.cs b/SAMA.Shared/Checks/HttpCheckExecutor.cs index 4e283ed..7409a17 100644 --- a/SAMA.Shared/Checks/HttpCheckExecutor.cs +++ b/SAMA.Shared/Checks/HttpCheckExecutor.cs @@ -166,11 +166,11 @@ private static void ParseAndAddHeaders(HttpRequestMessage request, string header if (string.Equals(headerName, "Content-Type", StringComparison.OrdinalIgnoreCase) && request.Content != null) { - request.Content.Headers.TryAddWithoutValidation(headerName, headerValue); + _ = request.Content.Headers.TryAddWithoutValidation(headerName, headerValue); } else { - request.Headers.TryAddWithoutValidation(headerName, headerValue); + _ = request.Headers.TryAddWithoutValidation(headerName, headerValue); } } } diff --git a/SAMA.Shared/SAMA.Shared.csproj b/SAMA.Shared/SAMA.Shared.csproj index bf448ae..933ddf7 100644 --- a/SAMA.Shared/SAMA.Shared.csproj +++ b/SAMA.Shared/SAMA.Shared.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SAMA.Shared/Wrappers/CustomTlsValidator.cs b/SAMA.Shared/Wrappers/CustomTlsValidator.cs index 15596e9..2272878 100644 --- a/SAMA.Shared/Wrappers/CustomTlsValidator.cs +++ b/SAMA.Shared/Wrappers/CustomTlsValidator.cs @@ -16,7 +16,7 @@ public virtual bool ValidateWithCustomCa(X509Certificate? certificate, X509Chain using var certToValidate = new X509Certificate2(certificate); using var customCaCert = X509Certificate2.CreateFromPem(customCaCertificatePem); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - chain.ChainPolicy.CustomTrustStore.Add(customCaCert); + _ = chain.ChainPolicy.CustomTrustStore.Add(customCaCert); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; return chain.Build(certToValidate); diff --git a/SAMA.Shared/Wrappers/ProcessWrapper.cs b/SAMA.Shared/Wrappers/ProcessWrapper.cs index 71563c1..570e834 100644 --- a/SAMA.Shared/Wrappers/ProcessWrapper.cs +++ b/SAMA.Shared/Wrappers/ProcessWrapper.cs @@ -18,7 +18,7 @@ public virtual void Start(ProcessStartInfo startInfo) ObjectDisposedException.ThrowIf(_disposedValue, this); _process = new Process { StartInfo = startInfo }; - _process.Start(); + _ = _process.Start(); } /// diff --git a/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj b/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj index 0bcdb4e..d950d81 100644 --- a/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj +++ b/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj @@ -13,10 +13,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + all 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..1e8b317 --- /dev/null +++ b/SAMA.Tests.Integration/Web/Services/OidcAuthenticationServiceTests.cs @@ -0,0 +1,242 @@ +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 ProvisionOrUpdateUserShouldUseConfiguredEmailClaimType() + { + _globalSettings.OidcEmailClaimType = "preferred_username"; + + var claims = new List + { + new("sub", "sub-custom-email"), + new("preferred_username", "upn-user@example.com"), + }; + var identity = new ClaimsIdentity(claims, AuthConstants.OidcSource); + var principal = new ClaimsPrincipal(identity); + + var user = await _service.ProvisionOrUpdateUserAsync(principal); + + Assert.AreEqual("upn-user@example.com", user.Email); + } + + [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.System/CleanupTests.cs b/SAMA.Tests.System/CleanupTests.cs index c459969..ebecd3e 100644 --- a/SAMA.Tests.System/CleanupTests.cs +++ b/SAMA.Tests.System/CleanupTests.cs @@ -20,8 +20,9 @@ public async Task CleanupStaleSchemasAndEmails() { var schemasDeleted = await CleanupSchemasAsync(); var emailsDeleted = await CleanupSmtp4DevEmailsAsync(); + var screenshotsDeleted = CleanupScreenshots(); - Console.WriteLine($"Cleanup complete: {schemasDeleted} schemas deleted, {emailsDeleted} emails deleted."); + Console.WriteLine($"Cleanup complete: {schemasDeleted} schemas deleted, {emailsDeleted} emails deleted, {screenshotsDeleted} screenshots deleted."); } private static async Task CleanupSchemasAsync() @@ -98,4 +99,19 @@ private static string GetConnectionString() return $"Host={host};Port={port};Database={database};Username={username};Password={password}"; } + + private static int CleanupScreenshots() + { + var screenshotDir = Path.Combine(Path.GetTempPath(), "sama-playwright-screenshots"); + if (!Directory.Exists(screenshotDir)) + { + return 0; + } + + var files = Directory.GetFiles(screenshotDir); + var count = files.Length; + Directory.Delete(screenshotDir, recursive: true); + Console.WriteLine($"Deleted {count} screenshots from {screenshotDir}"); + return count; + } } diff --git a/SAMA.Tests.System/OidcGroupTests.cs b/SAMA.Tests.System/OidcGroupTests.cs new file mode 100644 index 0000000..0385ba7 --- /dev/null +++ b/SAMA.Tests.System/OidcGroupTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Playwright; + +using static Microsoft.Playwright.Assertions; + +namespace SAMA.Tests.System; + +[TestClass] +[SystemTestCondition] +public class OidcGroupTests : SystemTestBase +{ + [TestMethod] + public async Task ShouldAssignWorkspaceAccessViaOidcGroupClaim() + { + await SetupInitialAdminAsync(); + await LoginAsync(); + + // Create a workspace for group-based access + await Page.GotoAsync($"{BaseUrl}/Workspaces/Create"); + await Page.FillAsync("input[name='Input.Name']", "OIDC Group Workspace"); + await Page.Locator("button[type='submit']:has-text('Create')").ClickAsync(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Configure OIDC to use the mock provider + await Page.GotoAsync($"{BaseUrl}/Admin/Settings/Oidc"); + await Page.CheckAsync("input[name='OidcInput.Enabled']"); + await Page.FillAsync("input[name='OidcInput.Authority']", "http://localhost:9400"); + await Page.FillAsync("input[name='OidcInput.ProviderName']", "Mock OIDC"); + await Page.FillAsync("input[name='OidcInput.ClientId']", "test-client"); + await Page.FillAsync("input[name='OidcInput.ClientSecret']", "test-secret"); + await Page.ClickAsync("button[type='submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Expect(Page.Locator("text=OIDC settings saved successfully")).ToBeVisibleAsync(); + + // Add a group mapping: "test-editors" group → OIDC Group Workspace → Editor + await Page.GotoAsync($"{BaseUrl}/Admin/Settings/GroupMappings"); + await Page.FillAsync("input[name='Input.ExternalGroupId']", "test-editors"); + await Page.Locator("select[name='Input.WorkspaceId']").SelectOptionAsync( + new SelectOptionValue { Label = "OIDC Group Workspace" }); + await Page.Locator("select[name='Input.Role']").SelectOptionAsync("Editor"); + await Page.Locator("select[name='Input.IdentityProvider']").SelectOptionAsync("OIDC"); + await Page.Locator("button:has-text('Add Group Mapping')").ClickAsync(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Verify the mapping was created + await Expect(Page.Locator("text=test-editors")).ToBeVisibleAsync(); + + await LogoutAsync(); + + // Login as bob via OIDC + await Page.GotoAsync($"{BaseUrl}/Account/Login"); + await Page.Locator("a:has-text('Sign in with Mock OIDC')").ClickAsync(); + + // On the mock provider, click the predefined "bob" user button + await Page.Locator("button:has-text('bob')").ClickAsync(); + + // Bob should be redirected back to the app and auto-redirected to the workspace dashboard + // (since he has exactly one accessible workspace via group mapping) + await Page.WaitForURLAsync($"{BaseUrl}/Dashboard**"); + await Expect(Page.Locator("text=OIDC Group Workspace")).ToBeVisibleAsync(); + + // Verify Bob has Editor access (Create Check button is editor-only) + await Expect(Page.Locator("a:has-text('Create Check')")).ToBeVisibleAsync(); + } +} diff --git a/SAMA.Tests.System/OidcTests.cs b/SAMA.Tests.System/OidcTests.cs new file mode 100644 index 0000000..c932f9f --- /dev/null +++ b/SAMA.Tests.System/OidcTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Playwright; + +using static Microsoft.Playwright.Assertions; + +namespace SAMA.Tests.System; + +[TestClass] +[SystemTestCondition] +public class OidcTests : SystemTestBase +{ + [TestMethod] + public async Task ShouldLoginViaOidc() + { + await SetupInitialAdminAsync(); + await LoginAsync(); + + // Configure OIDC to use the mock provider + await Page.GotoAsync($"{BaseUrl}/Admin/Settings/Oidc"); + await Page.CheckAsync("input[name='OidcInput.Enabled']"); + await Page.FillAsync("input[name='OidcInput.Authority']", "http://localhost:9400"); + await Page.FillAsync("input[name='OidcInput.ProviderName']", "Mock OIDC"); + await Page.FillAsync("input[name='OidcInput.ClientId']", "test-client"); + await Page.FillAsync("input[name='OidcInput.ClientSecret']", "test-secret"); + await Page.ClickAsync("button[type='submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Expect(Page.Locator("text=OIDC settings saved successfully")).ToBeVisibleAsync(); + + await LogoutAsync(); + + // Verify OIDC button appears on login page + await Page.GotoAsync($"{BaseUrl}/Account/Login"); + var oidcButton = Page.Locator("a:has-text('Sign in with Mock OIDC')"); + await Expect(oidcButton).ToBeVisibleAsync(); + + // Initiate OIDC login + await oidcButton.ClickAsync(); + + // On the mock provider, click the predefined "alice" user button + await Page.Locator("button:has-text('alice')").ClickAsync(); + + // Should redirect back to the app, logged in as alice + await Page.WaitForURLAsync($"{BaseUrl}/**"); + + // Alice is a new OIDC-provisioned user with no workspaces + await Expect(Page.Locator("text=You don't have access to any workspaces yet")).ToBeVisibleAsync(); + } +} diff --git a/SAMA.Tests.System/README.md b/SAMA.Tests.System/README.md index 485d75c..e916c4d 100644 --- a/SAMA.Tests.System/README.md +++ b/SAMA.Tests.System/README.md @@ -13,7 +13,8 @@ System tests are **skipped by default** to avoid running expensive E2E tests dur ## Prerequisites 1. PostgreSQL running (same as development) -2. Build the solution first: `dotnet build` +2. Support services running (from `SAMA.Web/`): `docker compose up -d` (provides SMTP, OIDC mock provider, LDAP mock) +3. Build the solution first: `dotnet build` 3. Install Playwright browsers (one-time): ```powershell # Windows diff --git a/SAMA.Tests.System/SystemTestBase.cs b/SAMA.Tests.System/SystemTestBase.cs index a0be539..e0b5897 100644 --- a/SAMA.Tests.System/SystemTestBase.cs +++ b/SAMA.Tests.System/SystemTestBase.cs @@ -19,6 +19,8 @@ public abstract class SystemTestBase protected static bool IsDebugging => Debugger.IsAttached; + public TestContext TestContext { get; set; } = null!; + protected IPage Page { get; private set; } = null!; protected string BaseUrl => _context?.BaseUrl ?? throw new InvalidOperationException("Test not initialized."); @@ -57,6 +59,20 @@ public virtual async Task TestInitializeAsync() [TestCleanup] public virtual async Task TestCleanupAsync() { + if (TestContext.CurrentTestOutcome != UnitTestOutcome.Passed) + { + var screenshotDir = Path.Combine(Path.GetTempPath(), "sama-playwright-screenshots"); + Directory.CreateDirectory(screenshotDir); + var fileName = $"{TestContext.FullyQualifiedTestClassName}.{TestContext.TestName}.png"; + var screenshotPath = Path.Combine(screenshotDir, fileName); + await Page.ScreenshotAsync(new PageScreenshotOptions + { + Path = screenshotPath, + FullPage = true, + }); + Console.WriteLine($"Screenshot saved: {screenshotPath}"); + } + await Page.CloseAsync(); await _browser.DisposeAsync(); _playwright.Dispose(); @@ -68,6 +84,7 @@ protected async Task LoginAsync(string email = "admin@example.com", string passw await Page.FillAsync("input[name='Input.EmailOrUsername']", email); await Page.FillAsync("input[name='Input.Password']", password); await Page.ClickAsync("button[type='submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Page.WaitForURLAsync($"{BaseUrl}/**"); } @@ -81,6 +98,7 @@ protected async Task SetupInitialAdminAsync(string email = "admin@example.com", await Page.FillAsync("input[name='Input.ConfirmPassword']", password); await Page.ClickAsync("button[type='submit']"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Page.WaitForURLAsync(BaseUrl); } @@ -88,6 +106,7 @@ protected async Task LogoutAsync() { await Page.Locator("#userDropdown").ClickAsync(); await Page.Locator("button:has-text('Logout')").ClickAsync(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Page.WaitForURLAsync(BaseUrl); } 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/NotificationChannels/EmailChannelHandlerTests.cs b/SAMA.Tests.Unit/Web/Services/NotificationChannels/EmailChannelHandlerTests.cs index 8d0737b..64595ea 100644 --- a/SAMA.Tests.Unit/Web/Services/NotificationChannels/EmailChannelHandlerTests.cs +++ b/SAMA.Tests.Unit/Web/Services/NotificationChannels/EmailChannelHandlerTests.cs @@ -549,7 +549,7 @@ public async Task SendStatusChangeEventAsyncShouldIncludeCorrectSubjectAndBody() { var message = callInfo.ArgAt(0); capturedSubject = message.Subject; - capturedBody = ((TextPart)message.Body).Text; + capturedBody = ((TextPart)message.Body!).Text; return Task.FromResult(string.Empty); }); _mockSmtpClient.DisconnectAsync(Arg.Any(), Arg.Any()) 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..94c4709 --- /dev/null +++ b/SAMA.Tests.Unit/Web/Services/OidcPostConfigureOptionsTests.cs @@ -0,0 +1,124 @@ +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!; + private OpenIdConnectOptions _options = null!; + + [TestInitialize] + public void Setup() + { + _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); + _postConfigure = new OidcPostConfigureOptions(_mockGlobalSettings); + _options = new OpenIdConnectOptions(); + _options.Backchannel = new HttpClient(); + } + + [TestCleanup] + public void Cleanup() + { + _options.Backchannel.Dispose(); + } + + [TestMethod] + public void PostConfigureShouldIgnoreNonOidcSchemes() + { + var originalAuthority = _options.Authority; + + _postConfigure.PostConfigure("Cookies", _options); + + Assert.AreEqual(originalAuthority, _options.Authority); + } + + [TestMethod] + public void PostConfigureShouldSetDummyConfigWhenDisabled() + { + _mockGlobalSettings.OidcEnabled.Returns(false); + + _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("profile email"); + + _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.IsFalse(_options.MapInboundClaims); + Assert.AreEqual("name", _options.TokenValidationParameters.NameClaimType); + Assert.AreEqual("my-client-id", _options.TokenValidationParameters.ValidAudience); + CollectionAssert.Contains(_options.Scope.ToArray(), "openid"); + } + + [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("profile email groups"); + _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("profile email"); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, _options); + + Assert.AreEqual(3, _options.Scope.Count); + CollectionAssert.AreEquivalent( + new[] { "openid", "profile", "email" }, + _options.Scope.ToArray()); + } + + [TestMethod] + public void PostConfigureShouldAlwaysIncludeOpenidScope() + { + _mockGlobalSettings.OidcEnabled.Returns(true); + _mockGlobalSettings.OidcAuthority.Returns("https://login.example.com"); + _mockGlobalSettings.OidcClientId.Returns("client"); + _mockGlobalSettings.OidcClientSecret.Returns(""); + _mockGlobalSettings.OidcScopes.Returns("profile email"); + + _postConfigure.PostConfigure(AuthConstants.OidcSource, _options); + + CollectionAssert.Contains(_options.Scope.ToArray(), "openid"); + } +} diff --git a/SAMA.Web/Extensions/ScheduleExtensions.cs b/SAMA.Web/Extensions/ScheduleExtensions.cs index 701dd96..565227a 100644 --- a/SAMA.Web/Extensions/ScheduleExtensions.cs +++ b/SAMA.Web/Extensions/ScheduleExtensions.cs @@ -31,7 +31,7 @@ public static class ScheduleExtensions try { var cron = new CronExpression(schedule); - cron.GetNextValidTimeAfter(DateTimeOffset.UtcNow); + _ = cron.GetNextValidTimeAfter(DateTimeOffset.UtcNow); return null; } catch diff --git a/SAMA.Web/Middleware/HtmxRedirectMiddleware.cs b/SAMA.Web/Middleware/HtmxRedirectMiddleware.cs index 49ce3e5..2369c1a 100644 --- a/SAMA.Web/Middleware/HtmxRedirectMiddleware.cs +++ b/SAMA.Web/Middleware/HtmxRedirectMiddleware.cs @@ -32,7 +32,7 @@ public async Task InvokeAsync(HttpContext context) { // Convert to HTMX-compatible response context.Response.StatusCode = 200; - context.Response.Headers.Remove("Location"); + _ = context.Response.Headers.Remove("Location"); context.Response.Headers["HX-Redirect"] = location; } } diff --git a/SAMA.Web/Pages/Account/Login.cshtml b/SAMA.Web/Pages/Account/Login.cshtml index fc6c4c3..f5e5a52 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..7f610dc 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,58 @@ 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(IdentityConstants.ExternalScheme); + 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(); + } + + // Verify the external login came from our OIDC scheme + var scheme = authResult.Properties?.Items[".AuthScheme"]; + if (scheme != AuthConstants.OidcSource) + { + logger.LogWarning("External login scheme mismatch: expected {Expected}, got {Actual}", AuthConstants.OidcSource, scheme); + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + 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); + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + logger.LogInformation("User {Email} logged in via OIDC", user.Email); + return LocalRedirect(returnUrl); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during OIDC login"); + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + ModelState.AddModelError(string.Empty, "An error occurred during login. Please try again."); + ReturnUrl = returnUrl; + return Page(); + } + } } 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..1810a6b --- /dev/null +++ b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml @@ -0,0 +1,170 @@ +@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
+
+
+ + +
+ Additional OAuth scopes to request. The openid scope is always included automatically. + Default: profile email. + Add extra scopes if your provider requires them for group claims. +
+
+
+ + +
+ The claim name in the ID token that contains the user's email address. Default: email. + For Azure AD / Entra ID, you may need preferred_username. +
+
+
+ + +
+ 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..a467166 --- /dev/null +++ b/SAMA.Web/Pages/Admin/Settings/Oidc.cshtml.cs @@ -0,0 +1,133 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; +using SAMA.Web.Constants; +using SAMA.Web.Services; + +namespace SAMA.Web.Pages.Admin.Settings; + +[Authorize(Roles = AuthConstants.AdminRole)] +public class OidcModel( + GlobalSettingsService _globalSettings, + IOptionsMonitorCache _oidcOptionsCache, + 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 = "Email Claim Type")] + public string EmailClaimType { get; set; } = "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.OidcEmailClaimType = string.IsNullOrWhiteSpace(OidcInput.EmailClaimType) + ? "email" + : OidcInput.EmailClaimType; + _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(); + _oidcOptionsCache.TryRemove(AuthConstants.OidcSource); + 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.EmailClaimType = _globalSettings.OidcEmailClaimType; + 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 +