+ }
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.
+ 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)
+ {
+
+
+
+ @TempData["OidcSuccess"]
+
+
+
+ }
+
+ @if (TempData["OidcError"] != null)
+ {
+
+
+
+ @TempData["OidcError"]
+
+
+
+ }
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+ Setup Guide
+
+
+
Register an application in your OIDC provider.
+
+ Set the Redirect URI to:
+ @($"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}/signin-oidc")
+
+
Copy the Client ID and Client Secret here.
+
Enter the Authority URL (issuer) for your provider.
+
Enable OIDC and save.
+
Optionally, configure Group Mappings to auto-assign workspace access.