From c84e2a58b103e7f00b5bc5bddb18e4eb1a1b0a24 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 28 Aug 2026 22:20:27 -0700 Subject: [PATCH] VPR-61 feat(auth): add Entra ID login alongside CAS Campus is retiring CAS in favor of Entra ID. Both providers now sign in to the same cookie, so a session is identical downstream whichever was used, and the cutover becomes a config change rather than a code change. - Register an OpenID Connect handler (auth code + PKCE) as a public client: no client secret, so nothing expires or rotates. Signing keys come from the app-specific discovery document, since the enterprise app's claims mapping signs tokens with its own certificate - Remap Entra claims to the bare, lowercased kerberos id ClaimsTransformer resolves users by, read from the mapped onpremisessamaccountname claim only: preferred_username is the campus email alias, and an alias that collides with someone else's kerberos id would resolve to the wrong user, so a token without the mapped claim is rejected rather than signed in. Casing matters: UserHelper.IsInRole and the emulation cache key both compare login ids with an ordinal ==, so a mixed-case id would break emulation - Source TenantId and ClientId from Parameter Store /Shared/EntraId; one registration serves every environment and neither value is secret - Gate providers on Authentication:EnabledProviders (Cas, EntraId or Both). Development and TEST run both, PROD stays CAS - Widen the default and SVMUser policies, which required a "CAS" authentication-method claim and would have rejected every Entra session - Translate Entra's amr=mfa into the credentialType claim the Duo 2FA policy reads, so both providers satisfy [Authorize(Policy = "2faAuthentication")]. amr only reaches v2.0 id_tokens because the registration manifest requests it via optionalClaims; it stays conditional because campus grants Duo exceptions, and a password-only sign-in must fail the gate - Keep /login as the single entry point every existing sign-in link uses; it picks the provider, or defers to the splash when both are offered - Fall back to CAS when no provider is usable. Throwing killed the host, so a half-finished cutover would have taken CAS down with it - Send an Entra user to the app root on logout once Entra is switched off, rather than to a CAS logout they never had a session for --- DESIGN.md | 4 +- PRODUCT.md | 5 +- .../DuoAuthenticationRequirementTests.cs | 68 ++++ test/Classes/EntraIdClaimMapperTests.cs | 290 ++++++++++++++++++ test/Classes/EntraIdSettingsTests.cs | 86 ++++++ test/Classes/HomeControllerCasUrlTests.cs | 1 + test/Controllers/HomeControllerTests.cs | 274 ++++++++++++++++- web/Areas/RAPS/Controllers/RAPSController.cs | 3 +- web/Classes/AuthenticationSettings.cs | 24 ++ web/Classes/DuoAuthenticationRequirement.cs | 10 +- web/Classes/EntraIdClaimMapper.cs | 158 ++++++++++ web/Classes/EntraIdSettings.cs | 63 ++++ web/Classes/LoginProviders.cs | 21 ++ web/Controllers/HomeController.cs | 180 +++++++++-- web/Program.cs | 148 ++++++++- web/Views/Home/Welcome.cshtml | 53 +++- web/Viper.csproj | 1 + web/appsettings.Development.json | 7 + web/appsettings.Production.json | 5 + web/appsettings.Test.json | 7 + web/appsettings.json | 29 ++ web/wwwroot/css/welcome.css | 47 ++- 22 files changed, 1428 insertions(+), 56 deletions(-) create mode 100644 test/Classes/DuoAuthenticationRequirementTests.cs create mode 100644 test/Classes/EntraIdClaimMapperTests.cs create mode 100644 test/Classes/EntraIdSettingsTests.cs create mode 100644 web/Classes/AuthenticationSettings.cs create mode 100644 web/Classes/EntraIdClaimMapper.cs create mode 100644 web/Classes/EntraIdSettings.cs create mode 100644 web/Classes/LoginProviders.cs diff --git a/DESIGN.md b/DESIGN.md index 5373e0f7e..354523896 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -391,7 +391,9 @@ Tables are the primary working surface. `q-table` headers, tops, and bottoms tak ### Welcome Splash (signature component) -The unauthenticated landing (`web/Views/Home/Welcome.cshtml`, `Layout = null`, backed by `WelcomePageHelper.cs`): a full-bleed hero photograph, one of five randomized per load and served `image-set` AVIF then JPG, under an Aggie Blue gradient, with an editorial column (gold rule, display headline, tagline) and a white sign-in card carrying a single CAS sign-in call to action. The five photographs live at `web/wwwroot/images/login/`: guinea pig, horse and foal, ophthalmology, the SVM building, and vetmed admin. The card is square with a 3px gold top border, 2.5rem padding, and the system's one heavy elevation. Focus rings go gold here. A standalone `welcome.css` redeclares the brand tokens because the page cannot load the Vue and Quasar token files, and its `:root` block is a mirror of `colors.css` that must be updated alongside it. +The unauthenticated landing (`web/Views/Home/Welcome.cshtml`, `Layout = null`, backed by `WelcomePageHelper.cs`): a full-bleed hero photograph, one of five randomized per load and served `image-set` AVIF then JPG, under an Aggie Blue gradient, with an editorial column (gold rule, display headline, tagline) and a white sign-in card carrying the sign-in call to action. The five photographs live at `web/wwwroot/images/login/`: guinea pig, horse and foal, ophthalmology, the SVM building, and vetmed admin. The card is square with a 3px gold top border, 2.5rem padding, and the system's one heavy elevation. Focus rings go gold here. + +The card's sign-in buttons are driven by `Authentication:EnabledProviders` (`Cas`, `EntraId`, or `Both`) while campus migrates from CAS to Entra ID. One provider renders exactly one solid Aggie Blue `.welcome-cta` reading "Sign in", which is the pre-migration appearance. Both providers render two stacked buttons in a `.welcome-cta-group`, named for their provider, with CAS solid and Entra ID as the outlined `.welcome-cta--secondary`: one primary path plus an alternative, never two competing solid blocks. The secondary's gold arrow drops to `currentcolor` at rest, because gold on the white card misses AA, and returns to gold on hover once the fill supplies a dark ground. Reserve `--secondary` for this pairing; a lone provider must not use it. A standalone `welcome.css` redeclares the brand tokens because the page cannot load the Vue and Quasar token files, and its `:root` block is a mirror of `colors.css` that must be updated alongside it. This is the one place in the system that goes drenched, photographic, and display-scale. Nothing else may borrow its vocabulary. diff --git a/PRODUCT.md b/PRODUCT.md index 096108244..205b8a02f 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,7 +8,7 @@ web ## Users -The UC Davis Weill School of Veterinary Medicine community: faculty, clinicians, staff, and students. They authenticate through campus CAS and use VIPER as the internal hub for the school's operational systems: role and permission administration (RAPS), effort reporting, clinical scheduling, competency tracking (CTS), the directory, and CMS-managed content. +The UC Davis Weill School of Veterinary Medicine community: faculty, clinicians, staff, and students. They authenticate through campus single sign-on (CAS, with Entra ID replacing it) and use VIPER as the internal hub for the school's operational systems: role and permission administration (RAPS), effort reporting, clinical scheduling, competency tracking (CTS), the directory, and CMS-managed content. These are mandatory, repeat users, not visitors. They arrive mid-workflow, often under time pressure, to get a specific administrative, academic, or clinical task done. The app is a tool, not a destination. Technical comfort ranges widely, from daily power users to occasional ones, and the same screens are used across desktop and mobile. @@ -28,7 +28,8 @@ Because migration is incremental, every new surface has to be legible next to a ## Operating Context -- **Access is never anonymous.** Every working surface is behind campus CAS single sign-on, gated by RAPS roles through `[Permission(Allow = "SVMSecure.{Area}")]`. The only unauthenticated surface in the product is the login and welcome page. +- **Access is never anonymous.** Every working surface is behind campus single sign-on, gated by RAPS roles through `[Permission(Allow = "SVMSecure.{Area}")]`. The only unauthenticated surface in the product is the login and welcome page. +- **Two sign-on providers, mid-migration.** Campus is retiring CAS in favor of Microsoft Entra ID. Both are supported and both sign in to the same cookie, so a session is identical downstream whichever the user picked. `Authentication:EnabledProviders` (`Cas`, `EntraId`, or `Both`) decides what the welcome page offers; TEST runs both, PROD stays CAS until Entra is proven, and the cutover is that one config value. - **Two sites, one perceived system.** TEST and PROD run VIPER 2 as an IIS sub-application under `/2`, beside legacy VIPER 1 at `/`. Local development has no base path, so subpath bugs surface only on TEST and PROD. - **Areas in the suite today.** Backend areas: CMS, CTS, ClinicalScheduler, Computing, Curriculum, Directory, Effort, RAPS, Scheduler, Students. Vue SPAs: CAHFS, CMS, CTS, ClinicalScheduler, Computing, Effort, Students. - **Release path.** Feature branch off `main`, merged to `Development` to deploy to TEST, then to `main` after approval on TEST. Jenkins runs the deploys. diff --git a/test/Classes/DuoAuthenticationRequirementTests.cs b/test/Classes/DuoAuthenticationRequirementTests.cs new file mode 100644 index 000000000..d58735a9e --- /dev/null +++ b/test/Classes/DuoAuthenticationRequirementTests.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using Web.Authorization; + +namespace Test.Classes +{ + // The "2faAuthentication" policy is the only gate on the app's most sensitive areas, so the + // set of credential types it accepts is worth pinning: too narrow locks out Entra users who + // did complete MFA, too wide lets a password-only sign-in through. + public class DuoAuthenticationRequirementTests + { + private static ClaimsPrincipal PrincipalWith(params (string Type, string Value)[] claims) + => new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth")); + + [Theory] + [InlineData("DuoCredential")] + [InlineData("DuoSecurityUniversalPromptCredential")] + [InlineData("DuoSecurityCredential")] + public void HasDuoAuthentication_CasDuoCredentialTypes_ReturnTrue(string credentialType) + { + var user = PrincipalWith(("credentialType", credentialType)); + + Assert.True(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + + // Entra has no Duo attribute; EntraIdClaimMapper translates an "amr" multifactor into this + // credential type, and the policy has to accept it or every Entra user fails 2FA. + [Fact] + public void HasDuoAuthentication_EntraIdMultifactorCredentialType_ReturnsTrue() + { + var user = PrincipalWith(("credentialType", EntraIdClaimMapper.MultifactorCredentialType)); + + Assert.True(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + + // End-to-end with the mapper, so a rename on either side of the translation fails here. + [Fact] + public void HasDuoAuthentication_PrincipalBuiltByMapperWithMultifactor_ReturnsTrue() + { + var user = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: true, DateTime.Now); + + Assert.True(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + + [Fact] + public void HasDuoAuthentication_PrincipalBuiltByMapperWithoutMultifactor_ReturnsFalse() + { + var user = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, DateTime.Now); + + Assert.False(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + + [Fact] + public void HasDuoAuthentication_SingleFactorCredentialType_ReturnsFalse() + { + var user = PrincipalWith(("credentialType", "UsernamePasswordCredential")); + + Assert.False(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + + [Fact] + public void HasDuoAuthentication_NoCredentialTypeClaim_ReturnsFalse() + { + var user = PrincipalWith((ClaimTypes.Name, "jdoe")); + + Assert.False(DuoAuthenticationRequirement.HasDuoAuthentication(user)); + } + } +} diff --git a/test/Classes/EntraIdClaimMapperTests.cs b/test/Classes/EntraIdClaimMapperTests.cs new file mode 100644 index 000000000..6ca9224d6 --- /dev/null +++ b/test/Classes/EntraIdClaimMapperTests.cs @@ -0,0 +1,290 @@ +using System.Security.Claims; +using Web.Authorization; + +namespace Test.Classes +{ + public class EntraIdClaimMapperTests + { + private static ClaimsPrincipal PrincipalWith(params (string Type, string Value)[] claims) + => new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth")); + + private static EntraIdSettings DefaultSettings() => new(); + + #region ResolveLoginId + + // Default settings read only the mapped kerberos claim. A token carrying just the standard + // claims must not sign anyone in: preferred_username is the campus email alias, and an + // alias that collides with someone else's kerberos id would resolve to the wrong user. + [Fact] + public void ResolveLoginId_DefaultSettings_IgnoresPreferredUsername() + { + var principal = PrincipalWith(("preferred_username", "jdoe@ucdavis.edu")); + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, DefaultSettings()); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_ConfiguredEmailStyleClaim_StripsEmailDomain() + { + var principal = PrincipalWith(("preferred_username", "jdoe@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe", loginId); + } + + // When both arrive, only the configured kerberos claim is read, even though stripping the + // alias would also produce a plausible-looking value. + [Fact] + public void ResolveLoginId_DefaultSettings_ReadsSamAccountNameNotPreferredUsername() + { + var principal = PrincipalWith( + ("preferred_username", "jdoe-alias@ucdavis.edu"), + ("onpremisessamaccountname", "jdoe")); + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, DefaultSettings()); + + Assert.Equal("jdoe", loginId); + } + + [Fact] + public void ResolveLoginId_StripEmailDomainFalse_ReturnsClaimUnchanged() + { + var principal = PrincipalWith(("preferred_username", "jdoe@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username", StripEmailDomain = false }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe@ucdavis.edu", loginId); + } + + [Fact] + public void ResolveLoginId_ConfiguredClaimHasNoAtSign_ReturnsValueUnchanged() + { + var principal = PrincipalWith(("onpremisessamaccountname", "jdoe")); + var settings = new EntraIdSettings { LoginIdClaim = "onpremisessamaccountname" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe", loginId); + } + + // No fallback: an account without the mapped claim (cloud-only, unsynced) is rejected + // rather than signed in as its email alias. + [Fact] + public void ResolveLoginId_ConfiguredClaimAbsent_ReturnsNull() + { + var principal = PrincipalWith(("preferred_username", "jdoe@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "onpremisessamaccountname" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_NoUsableClaims_ReturnsNull() + { + var principal = PrincipalWith(("some_other_claim", "whatever")); + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, DefaultSettings()); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_NullPrincipal_ReturnsNull() + { + var loginId = EntraIdClaimMapper.ResolveLoginId(null, DefaultSettings()); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_ConfiguredClaimBlank_ReturnsNull() + { + // A blank configured claim counts as absent, and no other claim is consulted, even one + // that looks usable. + var principal = PrincipalWith( + ("onpremisessamaccountname", " "), + ("upn", "jdoe@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "onpremisessamaccountname" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_EmptyLocalPart_ReturnsNull() + { + // "@ucdavis.edu" has no local part to strip down to. Returning it whole would sign the + // user in with an id matching nobody in AAUD, so it must resolve to null instead. + var principal = PrincipalWith(("preferred_username", "@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Null(loginId); + } + + [Fact] + public void ResolveLoginId_EmptyLocalPart_StripDisabled_ReturnsValueUnchanged() + { + // With stripping off the value is passed through verbatim, whatever it looks like. + var principal = PrincipalWith(("preferred_username", "@ucdavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username", StripEmailDomain = false }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("@ucdavis.edu", loginId); + } + + [Fact] + public void ResolveLoginId_MixedCaseUpn_IsLowercased() + { + // AaudUser.LoginId is compared with an ordinal == in UserHelper.IsInRole, and the + // emulation cache key is built from this value on one side and from AaudUser.LoginId on + // the other, so Entra casing has to be normalized to what CAS supplies. + var principal = PrincipalWith(("preferred_username", "JDoe@UCDavis.edu")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe", loginId); + } + + [Fact] + public void ResolveLoginId_MixedCaseSamAccountName_IsLowercased() + { + var principal = PrincipalWith(("onpremisessamaccountname", "JDoe")); + var settings = new EntraIdSettings { LoginIdClaim = "onpremisessamaccountname" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe", loginId); + } + + [Fact] + public void ResolveLoginId_SurroundingWhitespace_IsTrimmed() + { + var principal = PrincipalWith(("preferred_username", " jdoe@ucdavis.edu ")); + var settings = new EntraIdSettings { LoginIdClaim = "preferred_username" }; + + var loginId = EntraIdClaimMapper.ResolveLoginId(principal, settings); + + Assert.Equal("jdoe", loginId); + } + + #endregion + + #region HasMultifactorAuthentication + + [Fact] + public void HasMultifactorAuthentication_AmrMfa_ReturnsTrue() + { + var principal = PrincipalWith(("amr", "mfa")); + + Assert.True(EntraIdClaimMapper.HasMultifactorAuthentication(principal)); + } + + [Fact] + public void HasMultifactorAuthentication_AmrNgcMfa_ReturnsTrue() + { + var principal = PrincipalWith(("amr", "ngcmfa")); + + Assert.True(EntraIdClaimMapper.HasMultifactorAuthentication(principal)); + } + + [Fact] + public void HasMultifactorAuthentication_AmrPwdOnly_ReturnsFalse() + { + var principal = PrincipalWith(("amr", "pwd")); + + Assert.False(EntraIdClaimMapper.HasMultifactorAuthentication(principal)); + } + + [Fact] + public void HasMultifactorAuthentication_MultipleAmrClaimsIncludingMfa_ReturnsTrue() + { + // A v2.0 id_token's amr is an array, which arrives as repeated claims. + var principal = PrincipalWith(("amr", "pwd"), ("amr", "mfa")); + + Assert.True(EntraIdClaimMapper.HasMultifactorAuthentication(principal)); + } + + [Fact] + public void HasMultifactorAuthentication_CaseInsensitive_ReturnsTrue() + { + var principal = PrincipalWith(("amr", "MFA")); + + Assert.True(EntraIdClaimMapper.HasMultifactorAuthentication(principal)); + } + + [Fact] + public void HasMultifactorAuthentication_NullPrincipal_ReturnsFalse() + { + Assert.False(EntraIdClaimMapper.HasMultifactorAuthentication(null)); + } + + #endregion + + #region BuildPrincipal + + [Fact] + public void BuildPrincipal_SetsNameAndNameIdentifierToLoginId() + { + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, authenticatedAt: DateTime.Now); + + Assert.Equal("jdoe", principal.FindFirst(ClaimTypes.Name)?.Value); + Assert.Equal("jdoe", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + } + + [Fact] + public void BuildPrincipal_SetsAuthenticationMethod() + { + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, authenticatedAt: DateTime.Now); + + Assert.Equal(EntraIdClaimMapper.AuthenticationMethod, principal.FindFirst(ClaimTypes.AuthenticationMethod)?.Value); + } + + [Fact] + public void BuildPrincipal_HasMultifactorTrue_AddsCredentialTypeClaim() + { + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: true, authenticatedAt: DateTime.Now); + + Assert.Equal(EntraIdClaimMapper.MultifactorCredentialType, principal.FindFirst("credentialType")?.Value); + } + + [Fact] + public void BuildPrincipal_HasMultifactorFalse_AddsNoCredentialTypeClaim() + { + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, authenticatedAt: DateTime.Now); + + Assert.Null(principal.FindFirst("credentialType")); + } + + [Fact] + public void BuildPrincipal_ResultingIdentity_IsAuthenticated() + { + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, authenticatedAt: DateTime.Now); + + Assert.True(principal.Identity?.IsAuthenticated); + } + + [Fact] + public void BuildPrincipal_WritesAuthenticationDateInRoundTripFormat() + { + var authenticatedAt = new DateTime(2026, 8, 6, 12, 34, 56, DateTimeKind.Local); + + var principal = EntraIdClaimMapper.BuildPrincipal("jdoe", hasMultifactor: false, authenticatedAt); + + Assert.Equal(authenticatedAt.ToString("o"), principal.FindFirst("authenticationDate")?.Value); + } + + #endregion + } +} diff --git a/test/Classes/EntraIdSettingsTests.cs b/test/Classes/EntraIdSettingsTests.cs new file mode 100644 index 000000000..183a69380 --- /dev/null +++ b/test/Classes/EntraIdSettingsTests.cs @@ -0,0 +1,86 @@ +using Web.Authorization; + +namespace Test.Classes +{ + // IsConfigured decides at startup whether the OIDC handler is registered at all. When it is + // wrong in the permissive direction the app offers a sign-in button that dead-ends, which is + // exactly the failure the startup guard exists to prevent. + public class EntraIdSettingsTests + { + private static EntraIdSettings Configured() => new() + { + TenantId = "tenant", + ClientId = "client" + }; + + [Fact] + public void IsConfigured_AllRequiredValuesPresent_ReturnsTrue() + { + Assert.True(Configured().IsConfigured); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsConfigured_TenantIdMissing_ReturnsFalse(string? tenantId) + { + var settings = Configured(); + settings.TenantId = tenantId; + + Assert.False(settings.IsConfigured); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsConfigured_ClientIdMissing_ReturnsFalse(string? clientId) + { + var settings = Configured(); + settings.ClientId = clientId; + + Assert.False(settings.IsConfigured); + } + + [Fact] + public void IsConfigured_DefaultSettings_ReturnsFalse() + { + Assert.False(new EntraIdSettings().IsConfigured); + } + + // Tokens for this app are signed with its own certificate (claims-mapping policy), which only + // the app-specific discovery document exposes. Dropping "?appid=" breaks every sign-in with + // IDX10503, so the exact shape is pinned. + [Fact] + public void MetadataAddress_IsAppSpecificDiscoveryDocument() + { + Assert.Equal( + "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration?appid=client", + Configured().MetadataAddress); + } + + [Fact] + public void Authority_UsesV2EndpointForTenant() + { + var settings = new EntraIdSettings { TenantId = "a8046f64-66c0-4f00-9046-c8daf92ff62b" }; + + Assert.Equal( + "https://login.microsoftonline.com/a8046f64-66c0-4f00-9046-c8daf92ff62b/v2.0", + settings.Authority); + } + + // The defaults are the deployed contract: the callback paths are registered as redirect + // URIs in the app registration, and the claim/strip pair is what yields a bare kerberos id. + [Fact] + public void Defaults_MatchRegisteredRedirectUrisAndAaudLoginIdShape() + { + var settings = new EntraIdSettings(); + + Assert.Equal("/signin-entra", settings.CallbackPath); + Assert.Equal("/signout-entra", settings.SignedOutCallbackPath); + Assert.Equal("onpremisessamaccountname", settings.LoginIdClaim); + Assert.True(settings.StripEmailDomain); + } + } +} diff --git a/test/Classes/HomeControllerCasUrlTests.cs b/test/Classes/HomeControllerCasUrlTests.cs index 335de4b6d..1f7bb0f70 100644 --- a/test/Classes/HomeControllerCasUrlTests.cs +++ b/test/Classes/HomeControllerCasUrlTests.cs @@ -178,6 +178,7 @@ private static HomeController CreateController(string host, string pathBase) Substitute.For(), Options.Create(new CasSettings { CasBaseUrl = CasBaseUrl }), publicUrl, + Options.Create(new AuthenticationSettings()), Substitute.For(), Substitute.For(), Substitute.For(), diff --git a/test/Controllers/HomeControllerTests.cs b/test/Controllers/HomeControllerTests.cs index 30d139d83..75230bac0 100644 --- a/test/Controllers/HomeControllerTests.cs +++ b/test/Controllers/HomeControllerTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -38,32 +39,43 @@ public sealed class HomeControllerTests // "/ClinicalScheduler" is a splash-eligible area landing page; "/ClinicalScheduler/rotation" is a deep link. private static readonly string[] _areas = { "ClinicalScheduler", "Effort", "RAPS", "CTS" }; + private readonly IActionDescriptorCollectionProvider _actionProvider; + public HomeControllerTests() { - var actionProvider = Substitute.For(); + _actionProvider = Substitute.For(); var descriptors = _areaControllerTypes .Select(t => new ControllerActionDescriptor { ControllerTypeInfo = t.GetTypeInfo() }) .ToList(); - actionProvider.ActionDescriptors.Returns(new ActionDescriptorCollection(descriptors, version: 1)); + _actionProvider.ActionDescriptors.Returns(new ActionDescriptorCollection(descriptors, version: 1)); + + _controller = CreateController(LoginProviders.Cas); + } - _controller = new HomeController( + // CAS-only is the default so the pre-Entra tests describe the pre-Entra behavior; the + // provider-selection tests pass an explicit combination. + private HomeController CreateController(LoginProviders enabledProviders) + { + return new HomeController( Substitute.For(), Options.Create(new CasSettings { CasBaseUrl = "https://cas.example.edu/" }), new PublicUrlService( Options.Create(new PublicUrlOptions { PublicBaseUrl = "https://viper.example.edu/2" }), Substitute.For()), + Options.Create(new AuthenticationSettings { EnabledProviders = enabledProviders }), Substitute.For(), Substitute.For(), Substitute.For(), - actionProvider); + _actionProvider); } /// /// Wires up a controller context with the requested auth state and a URL helper whose /// IsLocalUrl mirrors framework semantics (local = rooted path, not protocol-relative). /// - private void Arrange(bool authenticated) + private void Arrange(bool authenticated, HomeController? target = null) { + var controller = target ?? _controller; var identity = authenticated ? new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "tester") }, authenticationType: "TestAuth") : new ClaimsIdentity(); @@ -77,9 +89,9 @@ private void Arrange(bool authenticated) httpContext.Request.Host = new HostString("viper.test"); httpContext.Request.Path = "/login"; - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; // View() resolves ITempDataDictionaryFactory from DI unless TempData is already set. - _controller.TempData = new TempDataDictionary(httpContext, Substitute.For()); + controller.TempData = new TempDataDictionary(httpContext, Substitute.For()); var url = Substitute.For(); url.IsLocalUrl(Arg.Any()).Returns(ci => @@ -102,7 +114,7 @@ private void Arrange(bool authenticated) && !candidate.StartsWith("~//") && !candidate.StartsWith("~/\\"); }); - _controller.Url = url; + controller.Url = url; } [Theory] @@ -116,9 +128,14 @@ private void Arrange(bool authenticated) [InlineData("/welcome#frag", true)] [InlineData("/caslogin", true)] // re-entering the ticket handler without a ticket would 403 [InlineData("/CasLogin/", true)] + [InlineData("/entralogin", true)] + [InlineData("/EntraLogin/", true)] + [InlineData("/signin-entra", true)] // OIDC callback: re-entering it without a code would fail + [InlineData("/signout-entra", true)] [InlineData("/RAPS/Roles", false)] [InlineData("/welcomepage", false)] [InlineData("/caslogins", false)] + [InlineData("/entraloginx", false)] public void IsAuthEntryPath_DetectsLoopTargets(string? url, bool expected) { Assert.Equal(expected, HomeController.IsAuthEntryPath(url)); @@ -531,4 +548,245 @@ public void Index_And_Welcome_Anonymous_EmitMatchingNoStoreHeaders() Assert.Equal(indexCacheControl, _controller.Response.Headers["Cache-Control"].ToString()); Assert.Equal(indexPragma, _controller.Response.Headers["Pragma"].ToString()); } + + // ---- Provider selection (VPR-61) ------------------------------------------------------- + // Campus is migrating CAS -> Entra ID. TEST runs both at once, so /login has to decide where + // to send an unqualified request without ever bouncing the user in a loop. + + [Fact] + public void Login_CasOnly_RedirectsToCas() + { + Arrange(authenticated: false); + + var result = Assert.IsType(_controller.Login()); + + Assert.StartsWith("https://cas.example.edu/login?service=", result.Url); + } + + [Fact] + public void Login_EntraIdOnly_RedirectsToEntraLogin() + { + var controller = CreateController(LoginProviders.EntraId); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Login()); + + Assert.Equal(nameof(HomeController.EntraLogin), result.ActionName); + } + + // With both enabled there is no defensible default, so the unqualified /login that every + // existing "Log in" link uses hands off to the splash, which is the chooser. + [Fact] + public void Login_BothProviders_NoExplicitProvider_RedirectsToWelcome() + { + var controller = CreateController(LoginProviders.Both); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Login()); + + Assert.Equal(nameof(HomeController.Welcome), result.ActionName); + } + + // The chooser's own buttons pass provider explicitly. Without that, the CAS button would post + // back to /login and be redirected to /welcome again: an infinite bounce. + [Fact] + public void Login_BothProviders_ExplicitCas_GoesToCasNotBackToWelcome() + { + var controller = CreateController(LoginProviders.Both); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Login(provider: LoginProviders.Cas)); + + Assert.StartsWith("https://cas.example.edu/login?service=", result.Url); + } + + [Fact] + public void Login_BothProviders_ExplicitEntraId_RedirectsToEntraLogin() + { + var controller = CreateController(LoginProviders.Both); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Login(provider: LoginProviders.EntraId)); + + Assert.Equal(nameof(HomeController.EntraLogin), result.ActionName); + } + + // A hand-crafted ?provider= for a provider this environment does not offer must not reach a + // half-configured handler. + [Theory] + [InlineData(LoginProviders.EntraId, LoginProviders.Cas)] + [InlineData(LoginProviders.Cas, LoginProviders.EntraId)] + public void Login_ProviderNotEnabled_ReturnsNotFound(LoginProviders enabled, LoginProviders requested) + { + var controller = CreateController(enabled); + Arrange(authenticated: false, controller); + + Assert.IsType(controller.Login(provider: requested)); + } + + [Fact] + public void EntraLogin_WhenDisabled_ReturnsNotFound() + { + Arrange(authenticated: false); + + Assert.IsType(_controller.EntraLogin()); + } + + [Fact] + public void EntraLogin_WhenEnabled_ChallengesEntraScheme() + { + var controller = CreateController(LoginProviders.EntraId); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.EntraLogin("/Effort")); + + Assert.Equal(EntraIdClaimMapper.AuthenticationScheme, Assert.Single(result.AuthenticationSchemes)); + Assert.Equal("/Effort", result.Properties?.RedirectUri); + } + + // The /api guard is shared by every provider, so it must hold on the Entra path too. + [Theory] + [InlineData("/api/secret")] + [InlineData("~/api/secret")] + public void EntraLogin_RejectsApiReturnUrl_WithUnauthorized(string returnUrl) + { + var controller = CreateController(LoginProviders.EntraId); + Arrange(authenticated: false, controller); + + Assert.IsType(controller.EntraLogin(returnUrl)); + } + + // An /api ReturnUrl must 401 rather than be bounced through an interactive login, and that has + // to be true for every provider configuration. Regression guard for the ordering bug where the + // two-provider hand-off to the chooser ran before the guard and answered with the splash. + [Theory] + [InlineData(LoginProviders.Both, "/api/secret")] + [InlineData(LoginProviders.Both, "~/api/secret")] + [InlineData(LoginProviders.EntraId, "/api/secret")] + [InlineData(LoginProviders.EntraId, "~/api/secret")] + public void Login_RejectsApiReturnUrl_ForEveryProviderConfiguration(LoginProviders enabled, string returnUrl) + { + var controller = CreateController(enabled); + Arrange(authenticated: false, controller); + + Assert.IsType(controller.Login(returnUrl)); + } + + [Fact] + public async Task CasLogin_WhenCasDisabled_ReturnsNotFound() + { + var controller = CreateController(LoginProviders.EntraId); + Arrange(authenticated: false, controller); + + Assert.IsType(await controller.CasLogin(ticket: "ST-1")); + } + + // The /api guard has to hold on /welcome itself, not just on the /login it would otherwise + // delegate to. With both providers enabled the delegating branch is skipped, so without an + // explicit guard the splash renders 200 for an /api ReturnUrl while single-provider mode 401s. + [Theory] + [InlineData(LoginProviders.Cas, "/api/secret")] + [InlineData(LoginProviders.Both, "/api/secret")] + [InlineData(LoginProviders.Both, "~/api/secret")] + [InlineData(LoginProviders.Both, "/API/secret")] + public void Welcome_Anonymous_RejectsApiReturnUrl_WithUnauthorized(LoginProviders enabled, string returnUrl) + { + var controller = CreateController(enabled); + Arrange(authenticated: false, controller); + + Assert.IsType(controller.Welcome(returnUrl)); + } + + [Fact] + public void Welcome_Anonymous_SubpathApiReturnUrl_RejectedInBothMode() + { + var controller = CreateController(LoginProviders.Both); + Arrange(authenticated: false, controller); + controller.HttpContext.Request.PathBase = "/2"; + + Assert.IsType(controller.Welcome("/2/api/secret")); + } + + // Logout is the only action that reaches the authentication stack, so it needs an + // IAuthenticationService in the container that the rest of the suite can do without. + private HomeController ArrangeForLogout(LoginProviders enabled, string authenticationMethod) + { + var controller = CreateController(enabled); + Arrange(authenticated: true, controller); + + var services = new ServiceCollection(); + services.AddSingleton(Substitute.For()); + controller.HttpContext.RequestServices = services.BuildServiceProvider(); + controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.Name, "tester"), + new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod) + ], + authenticationType: "TestAuth")); + + return controller; + } + + [Fact] + public async Task Logout_EntraUser_WhileEntraEnabled_SignsOutOfEntraScheme() + { + var controller = ArrangeForLogout(LoginProviders.Both, EntraIdClaimMapper.AuthenticationMethod); + + var result = Assert.IsType(await controller.Logout()); + + Assert.Equal(EntraIdClaimMapper.AuthenticationScheme, Assert.Single(result.AuthenticationSchemes)); + } + + // An Entra cookie outlives the provider being switched off (12h expiry), e.g. reverting a + // cutover from Both back to Cas. Falling through to the CAS logout redirect would send a user + // who never had a CAS session to CAS's logout page. + [Fact] + public async Task Logout_EntraUser_AfterEntraDisabled_RedirectsLocallyNotToCas() + { + var controller = ArrangeForLogout(LoginProviders.Cas, EntraIdClaimMapper.AuthenticationMethod); + + var result = Assert.IsType(await controller.Logout()); + + Assert.Equal("~/", result.Url); + } + + [Fact] + public async Task Logout_CasUser_StillRedirectsToCasLogout() + { + var controller = ArrangeForLogout(LoginProviders.Cas, "CAS"); + + var result = Assert.IsType(await controller.Logout()); + + Assert.StartsWith("https://cas.example.edu/logout?service=", result.Url); + } + + // Normally a deep link skips the splash and goes straight to the provider. It cannot when both + // are offered, because the splash is the only place to choose, and /login would send it right + // back here. Regression guard for that loop. + [Fact] + public void Welcome_BothProviders_DeepLink_RendersSplashInsteadOfRedirecting() + { + var controller = CreateController(LoginProviders.Both); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Welcome("/ClinicalScheduler/rotation")); + + Assert.Equal("Welcome", result.ViewName); + Assert.Equal("/ClinicalScheduler/rotation", result.ViewData["ReturnUrl"]); + } + + [Theory] + [InlineData(LoginProviders.Cas, true, false)] + [InlineData(LoginProviders.EntraId, false, true)] + [InlineData(LoginProviders.Both, true, true)] + public void Welcome_PassesEnabledProvidersToView(LoginProviders enabled, bool casExpected, bool entraExpected) + { + var controller = CreateController(enabled); + Arrange(authenticated: false, controller); + + var result = Assert.IsType(controller.Welcome()); + + Assert.Equal(casExpected, result.ViewData["CasEnabled"]); + Assert.Equal(entraExpected, result.ViewData["EntraIdEnabled"]); + } } diff --git a/web/Areas/RAPS/Controllers/RAPSController.cs b/web/Areas/RAPS/Controllers/RAPSController.cs index aaaf3c877..fad426f4c 100644 --- a/web/Areas/RAPS/Controllers/RAPSController.cs +++ b/web/Areas/RAPS/Controllers/RAPSController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using NLog; using Viper.Areas.RAPS.Services; using Viper.Classes; @@ -237,7 +236,7 @@ public IActionResult RoleList(string instance) } if (_securityService.IsAllowedTo("ViewAllRoles", instance) || - !_securityService.GetControlledRoleIds(UserHelper.GetCurrentUser()?.MothraId).IsNullOrEmpty()) + _securityService.GetControlledRoleIds(UserHelper.GetCurrentUser()?.MothraId).Count > 0) { return View("~/Areas/RAPS/Views/Roles/List.cshtml"); } diff --git a/web/Classes/AuthenticationSettings.cs b/web/Classes/AuthenticationSettings.cs new file mode 100644 index 000000000..3ec04ef1f --- /dev/null +++ b/web/Classes/AuthenticationSettings.cs @@ -0,0 +1,24 @@ +namespace Web.Authorization +{ + /// + /// Which login provider(s) the app offers, bound from the "Authentication" configuration section. + /// + public class AuthenticationSettings + { + /// + /// Providers offered on the welcome screen. Defaults to CAS so an environment that has not + /// been given an explicit setting keeps its pre-Entra behavior. + /// + public LoginProviders EnabledProviders { get; set; } = LoginProviders.Cas; + + public bool CasEnabled => EnabledProviders.HasFlag(LoginProviders.Cas); + + public bool EntraIdEnabled => EnabledProviders.HasFlag(LoginProviders.EntraId); + + /// + /// True when the user has a choice to make, which is the only case where the welcome + /// screen needs to show two buttons and /login cannot pick a provider on its own. + /// + public bool HasProviderChoice => CasEnabled && EntraIdEnabled; + } +} diff --git a/web/Classes/DuoAuthenticationRequirement.cs b/web/Classes/DuoAuthenticationRequirement.cs index dc927775d..aba702ae6 100644 --- a/web/Classes/DuoAuthenticationRequirement.cs +++ b/web/Classes/DuoAuthenticationRequirement.cs @@ -11,13 +11,19 @@ namespace Web.Authorization public class DuoAuthenticationRequirement : AuthorizationHandler, IAuthorizationRequirement { /// - /// Checks if the user has authenticated with Duo 2FA + /// Checks if the user completed two-factor authentication. /// + /// + /// CAS reports Duo directly in credentialType. Entra ID has no equivalent attribute + /// and reports multifactor in amr instead, which EntraIdClaimMapper translates + /// into the credential type accepted below, so both providers satisfy the same policy. + /// public static bool HasDuoAuthentication(ClaimsPrincipal user) { return user.HasClaim("credentialType", "DuoCredential") || user.HasClaim("credentialType", "DuoSecurityUniversalPromptCredential") - || user.HasClaim("credentialType", "DuoSecurityCredential"); + || user.HasClaim("credentialType", "DuoSecurityCredential") + || user.HasClaim("credentialType", EntraIdClaimMapper.MultifactorCredentialType); } protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DuoAuthenticationRequirement requirement) diff --git a/web/Classes/EntraIdClaimMapper.cs b/web/Classes/EntraIdClaimMapper.cs new file mode 100644 index 000000000..9464f812c --- /dev/null +++ b/web/Classes/EntraIdClaimMapper.cs @@ -0,0 +1,158 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Web.Authorization +{ + /// + /// Translates an Entra ID principal into the claim shape the rest of VIPER already expects + /// from a CAS login. + /// + /// + /// Two contracts have to be honored or downstream code silently breaks: + /// + /// ClaimTypes.Name must be the bare kerberos login id. ClaimsTransformer + /// feeds it straight to UserHelper.GetByLoginId to resolve the AAUD user and its roles, + /// so an email address here means no roles and no permissions. + /// Two-factor is asserted through a credentialType claim. CAS emits a Duo value; + /// Entra reports it in amr instead, so it is translated here and + /// DuoAuthenticationRequirement accepts the translated value. + /// + /// Only these claims are carried over. The raw Entra token is deliberately not copied into the + /// cookie: it is large enough to risk overflowing the 4KB cookie limit, and nothing reads it. + /// + public static class EntraIdClaimMapper + { + /// Authentication scheme name for the OpenID Connect handler. + public const string AuthenticationScheme = "EntraId"; + + /// Value written to for Entra logins. + public const string AuthenticationMethod = "EntraId"; + + /// + /// credentialType value standing in for Duo when Entra reports a multifactor sign-in. + /// + /// + /// Conditional on amr because campus grants Duo exceptions: a password-only sign-in + /// must fail the 2FA policy exactly as a Duo-less CAS session would. + /// + public const string MultifactorCredentialType = "EntraIdMultifactorCredential"; + + /// + /// Resolves the campus kerberos login id from an Entra principal, or null when the + /// configured claim is absent or blank. There is deliberately no fallback to + /// preferred_username, upn or email: at UC Davis those carry the campus email alias, and + /// signing in as an alias yields either zero roles or, on a collision with someone else's + /// kerberos id, the wrong AAUD user. Failing closed is the safer outcome. + /// + public static string? ResolveLoginId(ClaimsPrincipal? principal, EntraIdSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + if (principal == null) + { + return null; + } + + var raw = FirstNonEmptyClaim(principal, settings.LoginIdClaim); + + if (raw == null) + { + return null; + } + + var loginId = raw.Trim(); + + if (settings.StripEmailDomain) + { + var at = loginId.IndexOf('@', StringComparison.Ordinal); + + // A leading "@" leaves no local part to use. Returning it whole would sign the + // user in with an id that matches nobody in AAUD, so treat it as unresolvable. + if (at == 0) + { + return null; + } + + if (at > 0) + { + loginId = loginId[..at]; + } + } + + if (string.IsNullOrWhiteSpace(loginId)) + { + return null; + } + + // Lowercased to match what CAS supplies, because two places compare this value with an + // ordinal, case-sensitive ==: UserHelper.IsInRole against AaudUser.LoginId, and the + // emulation cache key, which ClaimsTransformer builds from this claim but EmulateUser + // builds from AaudUser.LoginId. An Entra UPN with different casing would silently break + // emulation and force a DB round trip on every role check. + return loginId.ToLowerInvariant(); + } + + /// + /// True when Entra reported a multifactor authentication for this sign-in. + /// + /// + /// amr ("authentication methods references") is an array in a v2.0 id_token, so it + /// arrives as repeated claims. "mfa" is the standard value; "ngcmfa" appears for a freshly + /// proofed credential. A Duo-backed campus sign-in was observed to carry ["pwd", "mfa"] + /// (2026-08-31). The claim only exists because the app registration's manifest requests it: + /// optionalClaims.idToken must contain { "name": "amr" }; without that entry v2.0 tokens + /// omit amr entirely and every Entra session would fail the 2FA policy. + /// + public static bool HasMultifactorAuthentication(ClaimsPrincipal? principal) + { + if (principal == null) + { + return false; + } + + return principal.FindAll("amr") + .Any(c => string.Equals(c.Value, "mfa", StringComparison.OrdinalIgnoreCase) + || string.Equals(c.Value, "ngcmfa", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Builds the cookie principal for an Entra login, matching the claim shape + /// AuthenticateCasLogin produces. + /// + public static ClaimsPrincipal BuildPrincipal(string loginId, bool hasMultifactor, DateTime authenticatedAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(loginId); + + var claims = new List + { + new(ClaimTypes.Name, loginId), + new(ClaimTypes.NameIdentifier, loginId), + new(ClaimTypes.AuthenticationMethod, AuthenticationMethod), + // Mirrors the CAS attribute of the same name, which the session UI surfaces. CAS + // supplies a local time with an offset, so this stays local rather than emitting a + // "Z" timestamp the two providers would not share. + new("authenticationDate", authenticatedAt.ToString("o")) + }; + + if (hasMultifactor) + { + claims.Add(new Claim("credentialType", MultifactorCredentialType)); + } + + return new ClaimsPrincipal( + new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); + } + + private static string? FirstNonEmptyClaim(ClaimsPrincipal principal, string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return null; + } + + return principal.FindAll(claimType) + .Select(c => c.Value) + .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + } + } +} diff --git a/web/Classes/EntraIdSettings.cs b/web/Classes/EntraIdSettings.cs new file mode 100644 index 000000000..99238e0db --- /dev/null +++ b/web/Classes/EntraIdSettings.cs @@ -0,0 +1,63 @@ +namespace Web.Authorization +{ + /// + /// Microsoft Entra ID (OpenID Connect) settings, bound from the "EntraId" configuration section. + /// + /// + /// There is no client secret. The app is a public client (authorization code + PKCE), so the + /// only registration identifiers are and , and + /// neither is secret: both travel in the browser's authorize redirect. One registration serves + /// every environment, so both come from AWS Systems Manager Parameter Store at /Shared/EntraId/, + /// and appsettings leaves them empty so a missing parameter fails . + /// + public class EntraIdSettings + { + /// UC Davis Entra tenant id. + public string? TenantId { get; set; } + + /// Application (client) id of the VIPER Enterprise App registration. + public string? ClientId { get; set; } + + /// + /// Redirect path Entra returns to. Registered as a redirect URI in the app registration. + /// ASP.NET prefixes the PathBase automatically, so TEST registers "/2/signin-entra". + /// + public string CallbackPath { get; set; } = "/signin-entra"; + + /// Path Entra returns to after a federated sign-out. + public string SignedOutCallbackPath { get; set; } = "/signout-entra"; + + /// + /// Claim carrying the user's campus kerberos id. Only the configured claim is consulted; + /// a token without it is rejected rather than signed in. The default, + /// "onpremisessamaccountname", is a mapped claim (enterprise app, Attributes & Claims, + /// source user.onpremisessamaccountname, "Expose claim in JWT tokens" ticked) yielding the + /// bare id, e.g. "rexl". Reconfiguring to an email-style claim such as "preferred_username" + /// is possible but unsafe at UC Davis: its local part is the email alias + /// ("rvlorenzo@ucdavis.edu"), which AAUD does not key on. + /// + public string LoginIdClaim { get; set; } = "onpremisessamaccountname"; + + /// + /// Strip "@domain" off the resolved claim so it matches the bare kerberos login id that + /// AAUD stores and ClaimsTransformer looks users up by. + /// + public bool StripEmailDomain { get; set; } = true; + + /// Authority URL for the tenant, derived from . + public string Authority => $"https://login.microsoftonline.com/{TenantId}/v2.0"; + + /// + /// App-specific OIDC discovery document. The enterprise app carries a claims-mapping policy + /// (it is how the kerberos id reaches the token), and Entra signs such tokens with the app's + /// own signing certificate instead of the tenant keys. Only the "?appid=" variant of the + /// discovery document lists that key; the tenant-wide one fails validation with IDX10503. + /// + public string MetadataAddress => $"{Authority}/.well-known/openid-configuration?appid={ClientId}"; + + /// True when there is enough configuration to register the OIDC handler. + public bool IsConfigured => + !string.IsNullOrWhiteSpace(TenantId) + && !string.IsNullOrWhiteSpace(ClientId); + } +} diff --git a/web/Classes/LoginProviders.cs b/web/Classes/LoginProviders.cs new file mode 100644 index 000000000..7581be992 --- /dev/null +++ b/web/Classes/LoginProviders.cs @@ -0,0 +1,21 @@ +namespace Web.Authorization +{ + /// + /// Single sign-on providers the app can offer on the welcome screen. + /// + /// + /// Flags, because campus is mid-migration from CAS to Entra ID and TEST needs to run both + /// side by side. Configuration binding parses the member names, so "Cas", "EntraId", + /// "Both", and "Cas, EntraId" are all valid values for Authentication:EnabledProviders. + /// + [Flags] + public enum LoginProviders + { + None = 0, + Cas = 1, + EntraId = 2, + + /// Both providers offered at once. Named for readability in appsettings. + Both = Cas | EntraId + } +} diff --git a/web/Controllers/HomeController.cs b/web/Controllers/HomeController.cs index d4607adaf..9bcca19dc 100644 --- a/web/Controllers/HomeController.cs +++ b/web/Controllers/HomeController.cs @@ -38,15 +38,17 @@ public class HomeController : AreaController private readonly IHttpClientFactory _clientFactory; private readonly CasSettings _settings; private readonly IPublicUrlService _publicUrl; + private readonly AuthenticationSettings _authSettings; private readonly List _casAttributesToCapture = new() { "authenticationDate", "credentialType" }; private readonly IUserHelper _userHelper; private readonly IActionDescriptorCollectionProvider _actionDescriptorProvider; - public HomeController(IHttpClientFactory clientFactory, IOptions settingsOptions, IPublicUrlService publicUrl, AAUDContext aAUDContext, RAPSContext rapsContext, VIPERContext viperContext, IActionDescriptorCollectionProvider actionDescriptorProvider) + public HomeController(IHttpClientFactory clientFactory, IOptions settingsOptions, IPublicUrlService publicUrl, IOptions authSettingsOptions, AAUDContext aAUDContext, RAPSContext rapsContext, VIPERContext viperContext, IActionDescriptorCollectionProvider actionDescriptorProvider) { this._clientFactory = clientFactory; this._settings = settingsOptions.Value; this._publicUrl = publicUrl; + this._authSettings = authSettingsOptions.Value; this._aAUDContext = aAUDContext; this._rapsContext = rapsContext; this._viperContext = viperContext; @@ -104,10 +106,25 @@ public IActionResult Welcome([FromQuery] string? ReturnUrl = null) return LocalRedirect(string.IsNullOrEmpty(ReturnUrl) ? "~/" : ReturnUrl); } + // An /api ReturnUrl gets a 401 rather than a sign-in page. With one provider the + // deep-link branch below enforces this by way of /login, but when both are offered that + // branch is skipped, so the guard has to be stated here or the two modes disagree. + if (relativeReturnUrl != null && IsApiPath(relativeReturnUrl)) + { + return Unauthorized(); + } + // Only passive arrivals get the splash: the bare site root or a top-level area // landing page (e.g. "/ClinicalScheduler"). A deep link (e.g. "/ClinicalScheduler/rotation") - // skips the interstitial and goes straight to CAS so we don't interrupt a targeted workflow. - if (!IsSplashTarget(relativeReturnUrl, GetAreaNames(_actionDescriptorProvider))) + // skips the interstitial and goes straight to the provider so we don't interrupt a + // targeted workflow. + // + // When both providers are offered there is nothing to skip to: the splash is the only + // place the user can pick one, so every anonymous arrival gets it. This is also what + // keeps /welcome and /login from bouncing off each other, since /login sends the + // two-provider case back here. + if (!_authSettings.HasProviderChoice + && !IsSplashTarget(relativeReturnUrl, GetAreaNames(_actionDescriptorProvider))) { return RedirectToAction(nameof(Login), new { ReturnUrl }); } @@ -126,6 +143,8 @@ private IActionResult WelcomeSplash(string? returnUrl, string? destinationLabel) ViewData["ReturnUrl"] = returnUrl; ViewData["Hero"] = PickRandomHeroKey(); ViewData["DestinationLabel"] = destinationLabel; + ViewData["CasEnabled"] = _authSettings.CasEnabled; + ViewData["EntraIdEnabled"] = _authSettings.EntraIdEnabled; return View("Welcome"); } @@ -173,10 +192,12 @@ private static string PathWithoutQuery(string url) return cut >= 0 ? url[..cut] : url; } - // The auth entry points, which must never be a ReturnUrl: /welcome and /login would - // redirect-loop, and /caslogin would re-enter the ticket handler without a ticket and - // 403 a user who just signed in successfully. - private static readonly string[] _authEntryPaths = ["/welcome", "/login", "/caslogin"]; + // The auth entry points, which must never be a ReturnUrl: /welcome, /login and /entralogin + // would redirect-loop, and the provider callbacks (/caslogin, /signin-entra) would re-enter + // a ticket/code handler with no ticket or code and reject a user who just signed in + // successfully. + private static readonly string[] _authEntryPaths = + ["/welcome", "/login", "/entralogin", "/caslogin", "/signin-entra", "/signout-entra"]; // internal (not private) so the redirect-loop guard is unit-testable via InternalsVisibleTo. internal static bool IsAuthEntryPath(string? url) @@ -352,47 +373,111 @@ private NavMenu Nav() } /// - /// Login function -- redirects to CAS, no VIEW + /// Login function -- sends the user to a sign-in provider, no VIEW /// + /// + /// Provider-aware so every existing "Log in" link keeps working while campus migrates off + /// CAS. With a single provider enabled this goes straight to it; with both enabled there is + /// nothing sensible to pick, so it hands off to the welcome splash, which is the chooser. + /// [Route("/[action]")] [AllowAnonymous] [SearchExclude] - public IActionResult Login([FromQuery] string? ReturnUrl = null) +#pragma warning disable S6967 // Action only reads ReturnUrl and provider, no model binding required + public IActionResult Login([FromQuery] string? ReturnUrl = null, [FromQuery] LoginProviders? provider = null) +#pragma warning restore S6967 { - // Normalize app-relative "~/..." to "/..." before validating, so the - // /api guard below cannot be bypassed and we never forward an invalid - // browser URL to CAS. - ReturnUrl = NormalizeAppRelativeUrl(ReturnUrl); + // An explicit provider (the welcome screen's buttons) always wins, and must, or the + // chooser's own CAS button would bounce straight back to the chooser. + var forcedProvider = provider is LoginProviders.Cas or LoginProviders.EntraId ? provider : null; - if (!IsSafeReturnUrl(ReturnUrl)) + if (forcedProvider != null && !_authSettings.EnabledProviders.HasFlag(forcedProvider.Value)) { - ReturnUrl = null; + return NotFound(); } - // The application root under the deployed PathBase ("" locally, "/2" on TEST/PROD). - // Read from the request rather than derived from GetRootURL(), which now returns the - // configured canonical origin and so no longer cancels against the request authority. - string returnURL = Request.PathBase.Value ?? string.Empty; + // Resolved before dispatching so the /api 401 contract holds identically no matter + // which provider this request ends up at, including the hand-off to the chooser. + if (!TryResolveLoginReturnUrl(ReturnUrl, out var returnUrl)) + { + return Unauthorized(); + } - if (!string.IsNullOrEmpty(ReturnUrl)) + if (forcedProvider == LoginProviders.EntraId + || (forcedProvider == null && !_authSettings.CasEnabled)) { - returnURL = ReturnUrl; + return RedirectToAction(nameof(EntraLogin), new { ReturnUrl }); } - // Strip the PathBase (e.g. "/2") before the /api guard so a base-prefixed - // "/2/api/..." ReturnUrl can't slip past this root-relative check and get - // forwarded to CAS. - var apiCheckUrl = StripPathBase(returnURL, Request.PathBase.Value); - if (apiCheckUrl != null && IsApiPath(apiCheckUrl)) + if (forcedProvider == null && _authSettings.HasProviderChoice) { - return Unauthorized(); + return RedirectToAction(nameof(Welcome), new { ReturnUrl }); } - var authorizationEndpoint = _settings.CasBaseUrl + "login?service=" + WebUtility.UrlEncode(BuildRedirectUri(new PathString("/CasLogin")) + "?ReturnUrl=" + WebUtility.UrlEncode(returnURL)); + var authorizationEndpoint = _settings.CasBaseUrl + "login?service=" + WebUtility.UrlEncode(BuildRedirectUri(new PathString("/CasLogin")) + "?ReturnUrl=" + WebUtility.UrlEncode(returnUrl)); return new RedirectResult(authorizationEndpoint); } + /// + /// Entra ID login -- challenges the OpenID Connect handler, no VIEW + /// + [Route("/[action]")] + [AllowAnonymous] + [SearchExclude] + public IActionResult EntraLogin([FromQuery] string? ReturnUrl = null) + { + if (!_authSettings.EntraIdEnabled) + { + return NotFound(); + } + + if (!TryResolveLoginReturnUrl(ReturnUrl, out var returnUrl)) + { + return Unauthorized(); + } + + // No /CasLogin counterpart is needed: the OIDC handler owns its callback path, carries + // RedirectUri through the OAuth state, and redirects there itself once the shared + // cookie is issued. + return Challenge( + new AuthenticationProperties + { + RedirectUri = string.IsNullOrEmpty(returnUrl) ? Url.Content("~/") : returnUrl + }, + EntraIdClaimMapper.AuthenticationScheme); + } + + // Resolves where to send the user after a successful sign-in, shared by every provider so + // the ReturnUrl rules cannot drift between them. Returns false when the target is an /api + // path, which must get a 401 rather than be bounced through an interactive login. + private bool TryResolveLoginReturnUrl(string? requestedReturnUrl, out string returnUrl) + { + // Normalize app-relative "~/..." to "/..." before validating, so the /api guard below + // cannot be bypassed and we never forward an invalid browser URL to a provider. + requestedReturnUrl = NormalizeAppRelativeUrl(requestedReturnUrl); + + if (!IsSafeReturnUrl(requestedReturnUrl)) + { + requestedReturnUrl = null; + } + + // The application root under the deployed PathBase ("" locally, "/2" on TEST/PROD). + // Read from the request rather than derived from GetRootURL(), which now returns the + // configured canonical origin and so no longer cancels against the request authority. + returnUrl = Request.PathBase.Value ?? string.Empty; + + if (!string.IsNullOrEmpty(requestedReturnUrl)) + { + returnUrl = requestedReturnUrl; + } + + // Strip the PathBase (e.g. "/2") before the /api guard so a base-prefixed + // "/2/api/..." ReturnUrl can't slip past this root-relative check. + var apiCheckUrl = StripPathBase(returnUrl, Request.PathBase.Value); + return apiCheckUrl == null || !IsApiPath(apiCheckUrl); + } + [Route("/[action]")] [SearchExclude] public IActionResult RefreshSession() @@ -409,6 +494,11 @@ public IActionResult RefreshSession() [SearchExclude] public async Task CasLogin([FromQuery] string? ticket = null, [FromQuery] string? ReturnUrl = null) { + if (!_authSettings.CasEnabled) + { + return NotFound(); + } + return await AuthenticateCasLogin(ticket, ReturnUrl); } @@ -529,7 +619,7 @@ public IActionResult Error(int? statusCode = null) } /// - /// Logout function -- redirects to CAS logout, no VIEW + /// Logout function -- clears the local session then signs out of the provider, no VIEW /// /// [Route("/[action]")] @@ -537,8 +627,40 @@ public IActionResult Error(int? statusCode = null) public async Task Logout() { _userHelper.ClearCachedRolesAndPermissions(_userHelper.GetCurrentUser()); + + // Read the provider off the principal before signing out, while the claims still exist. + var signedInWithEntraId = string.Equals( + User.FindFirst(ClaimTypes.AuthenticationMethod)?.Value, + EntraIdClaimMapper.AuthenticationMethod, + StringComparison.Ordinal); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (signedInWithEntraId) + { + if (_authSettings.EntraIdEnabled) + { + // Federated sign-out. Without it the Entra session outlives the VIPER cookie + // and the next sign-in silently reuses it, which looks like logout did nothing. + return SignOut( + new AuthenticationProperties { RedirectUri = Url.Content("~/") }, + EntraIdClaimMapper.AuthenticationScheme); + } + + // Entra was switched off while this cookie was still valid, so its handler is no + // longer registered and the end_session endpoint is unreachable; the upstream Entra + // session has to age out on its own. Falling through to CAS logout would be wrong: + // this user never had a CAS session to end. + return LocalRedirect("~/"); + } + + if (!_authSettings.CasEnabled) + { + // CAS has been switched off, so there is no CAS session left to end. This also + // covers a stale CAS cookie still in flight after the cutover. + return LocalRedirect("~/"); + } + // Send homepage link after CAS logout var returnUrl = WebUtility.UrlEncode(_publicUrl.BaseUrl); return new RedirectResult(_settings.CasBaseUrl + "logout?service=" + returnUrl); diff --git a/web/Program.cs b/web/Program.cs index 261d84c2a..5f7cd1a97 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -12,6 +12,7 @@ using Joonasw.AspNetCore.SecurityHeaders; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Rewrite; @@ -19,6 +20,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using NLog; using NLog.Web; using Polly; @@ -35,6 +37,7 @@ using Viper.Classes.HealthChecks; using Viper.Classes.Scheduler; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; using Viper.EmailTemplates.Services; using Viper.Services; using Web; @@ -136,8 +139,9 @@ options.Cookie.Name = "VIPER.Antiforgery"; }); - // Setup CAS authentication cookie - builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + // Setup the shared sign-in cookie. Both CAS and Entra ID sign in to this same cookie, so a + // session looks identical downstream no matter which provider issued it. + var authenticationBuilder = builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.Cookie.Name = "VIPER.Authentication.UCD"; @@ -157,15 +161,29 @@ builder.Services.AddSingleton, PublicUrlOptionsValidator>(); builder.Services.AddSingleton(); + // Login providers this environment offers. Campus is retiring CAS in favor of Entra ID, so + // TEST runs both at once to exercise the Entra path before it becomes the only option. + builder.Services.Configure(builder.Configuration.GetSection("Authentication")); + builder.Services.Configure(builder.Configuration.GetSection("EntraId")); + + // Re-register the resolved set, which may be narrower than what was configured, so the app + // never advertises a provider that failed to wire up. + var resolvedProviders = ConfigureLoginProviders(builder, authenticationBuilder, logger); + builder.Services.PostConfigure(options => options.EnabledProviders = resolvedProviders); + + // Accepted values for the authentication-method claim. Every provider that signs in to the + // shared cookie must appear here or its users fail the default policy on every request. + string[] acceptedAuthenticationMethods = ["CAS", EntraIdClaimMapper.AuthenticationMethod]; + // Define authorization policies builder.Services.AddAuthorization(options => { - options.AddPolicy("SVMUser", policy => policy.RequireClaim(ClaimTypes.AuthenticationMethod, "CAS")); + options.AddPolicy("SVMUser", policy => policy.RequireClaim(ClaimTypes.AuthenticationMethod, acceptedAuthenticationMethods)); options.AddPolicy("2faAuthentication", policy => policy.RequireAuthenticatedUser().AddRequirements(new DuoAuthenticationRequirement())); options.DefaultPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() - .AddRequirements(new AuthorizationPolicyBuilder().RequireClaim(ClaimTypes.AuthenticationMethod, "CAS").Build().Requirements.ToArray()) + .RequireClaim(ClaimTypes.AuthenticationMethod, acceptedAuthenticationMethods) .Build(); }); @@ -551,6 +569,128 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db LogManager.Shutdown(); } +// Works out which login providers this environment can actually offer, and registers the Entra ID +// handler when it is both enabled and fully configured. Returns the resolved set, which is narrower +// than the configured one when Entra is switched on without the settings to back it. +static LoginProviders ConfigureLoginProviders(WebApplicationBuilder builder, AuthenticationBuilder authenticationBuilder, Logger logger) +{ + var settings = builder.Configuration.GetSection("Authentication").Get() + ?? new AuthenticationSettings(); + var entraIdSettings = builder.Configuration.GetSection("EntraId").Get() + ?? new EntraIdSettings(); + + if (settings.EntraIdEnabled) + { + if (entraIdSettings.IsConfigured) + { + AddEntraIdAuthentication(authenticationBuilder, entraIdSettings); + } + else + { + // Fail loudly at startup rather than serving a sign-in button that dead-ends. + logger.Fatal("Entra ID login is enabled but EntraId configuration is incomplete " + + "(need TenantId and ClientId). The Entra sign-in option will not be offered."); + settings.EnabledProviders &= ~LoginProviders.EntraId; + } + } + + if (settings.EnabledProviders == LoginProviders.None) + { + // Degrade to CAS rather than throw. Throwing here propagates out of the startup try/catch + // and kills the host, so a half-finished Entra cutover (a blank ClientId, say) + // would take CAS down with it and lock everyone out of the site. Serving the provider that + // still works is strictly better than serving nothing. + logger.Fatal("No login provider is usable, so falling back to CAS to keep the site " + + "reachable. Check Authentication:EnabledProviders and the EntraId settings."); + return LoginProviders.Cas; + } + + return settings.EnabledProviders; +} + +// Register the Entra ID (OpenID Connect) handler alongside CAS. It signs in to the same cookie CAS +// uses, and OnTokenValidated rewrites the principal into the claim shape the app already expects, +// so nothing downstream has to know which provider the user picked. +static void AddEntraIdAuthentication(AuthenticationBuilder authenticationBuilder, EntraIdSettings settings) +{ + authenticationBuilder.AddOpenIdConnect(EntraIdClaimMapper.AuthenticationScheme, options => + { + options.Authority = settings.Authority; + options.MetadataAddress = settings.MetadataAddress; + options.ClientId = settings.ClientId; + + // Authorization code + PKCE with no client secret. The redirect URIs are registered on the + // "Mobile and desktop applications" platform, which makes Entra treat the app as a public + // client, so the code exchange is bound by the PKCE verifier alone and there is nothing to + // rotate or leak. (The Web platform would demand a secret: AADSTS7000218.) The implicit and + // hybrid flows are not enabled on the registration and should not be. + options.ResponseType = OpenIdConnectResponseType.Code; + options.UsePkce = true; + options.ResponseMode = OpenIdConnectResponseMode.FormPost; + + // Relative to PathBase, so the URI to register for TEST is "https:///2/signin-entra". + options.CallbackPath = new PathString(settings.CallbackPath); + options.SignedOutCallbackPath = new PathString(settings.SignedOutCallbackPath); + + // Land on the shared cookie so CAS and Entra sessions are indistinguishable afterwards. + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + + // The access/id tokens are not used after sign-in, and keeping them would bloat the cookie. + options.SaveTokens = false; + options.GetClaimsFromUserInfoEndpoint = false; + options.MapInboundClaims = false; + + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + + options.Events = new OpenIdConnectEvents + { + OnTokenValidated = context => + { + var loginId = EntraIdClaimMapper.ResolveLoginId(context.Principal, settings); + + if (string.IsNullOrWhiteSpace(loginId)) + { + // Without a kerberos id the user cannot be resolved in AAUD, so they would sign + // in with no roles at all. Reject instead, and log which claims did arrive. + var received = string.Join(", ", context.Principal?.Claims.Select(c => c.Type) ?? []); + HttpHelper.Logger.Log(NLog.LogLevel.Warn, + "Entra ID login rejected: no login id claim. Configured claim: " + + LogSanitizer.SanitizeString(settings.LoginIdClaim) + + ". Claims received: " + LogSanitizer.SanitizeString(received)); + + context.Fail("Entra ID token did not contain a usable campus login id."); + return Task.CompletedTask; + } + + context.Principal = EntraIdClaimMapper.BuildPrincipal( + loginId, + EntraIdClaimMapper.HasMultifactorAuthentication(context.Principal), + DateTime.Now); + + return Task.CompletedTask; + }, + + OnRemoteFailure = context => + { + // The failure text can carry an error_description straight from the callback POST, + // which anyone can send, so it is sanitized like any other request-supplied value. + HttpHelper.Logger.Log(NLog.LogLevel.Warn, + "Entra ID remote authentication failure (" + + (context.Failure?.GetType().Name ?? "unknown") + "): " + + LogSanitizer.SanitizeString(context.Failure?.Message)); + + // Swallow the raw provider error page and send the user somewhere recoverable. + context.Response.Redirect(context.Request.PathBase + "/Error"); + context.HandleResponse(); + return Task.CompletedTask; + } + }; + }); +} + // Try and parse the AWS credentials XML file and store it in the encrypted JSON void SetAwsCredentials(Logger logger) { diff --git a/web/Views/Home/Welcome.cshtml b/web/Views/Home/Welcome.cshtml index 45529af04..11e455ea5 100644 --- a/web/Views/Home/Welcome.cshtml +++ b/web/Views/Home/Welcome.cshtml @@ -2,9 +2,29 @@ Layout = null; // Controller has already validated ReturnUrl with Url.IsLocalUrl and nulled it if unsafe. var returnUrl = ViewData["ReturnUrl"] as string; - var loginUrl = string.IsNullOrEmpty(returnUrl) - ? Url.Content("~/login") - : $"{Url.Content("~/login")}?ReturnUrl={Uri.EscapeDataString(returnUrl)}"; + + // "provider" is explicit on both buttons: without it /login would send the two-provider case + // back to this page instead of on to CAS. + string SignInUrl(string provider) + { + var url = $"{Url.Content("~/login")}?provider={provider}"; + return string.IsNullOrEmpty(returnUrl) + ? url + : $"{url}&ReturnUrl={Uri.EscapeDataString(returnUrl)}"; + } + + // Which providers to offer. Defaults to CAS alone if the controller didn't say, so a + // misconfiguration degrades to the pre-Entra behavior rather than to no way to sign in. + var casEnabled = ViewData["CasEnabled"] as bool? ?? true; + var entraIdEnabled = ViewData["EntraIdEnabled"] as bool? ?? false; + var showBoth = casEnabled && entraIdEnabled; + + // With one provider there is no choice to explain, so the button keeps the plain "Sign in" + // and the solid primary style. The outlined variant only makes sense against a primary. + var casLabel = showBoth ? "Sign in with CAS" : "Sign in"; + var entraLabel = showBoth ? "Sign in with Entra ID" : "Sign in"; + var entraClass = showBoth ? "welcome-cta welcome-cta--secondary" : "welcome-cta"; + var hero = ViewData["Hero"] as string ?? "ophthalmology"; var destinationLabel = ViewData["DestinationLabel"] as string; var year = DateTime.Now.Year; @@ -53,10 +73,29 @@ You'll be taken to @destinationLabel after signing in.

} - - Sign in - - + @if (showBoth) + { +

+ Campus is moving from CAS to Entra ID. Either option signs you in to the + same account. +

+ } +
+ @if (casEnabled) + { + + @casLabel + + + } + @if (entraIdEnabled) + { + + @entraLabel + + + } +
+ diff --git a/web/appsettings.Development.json b/web/appsettings.Development.json index 293fdde6e..8ef9ecc21 100644 --- a/web/appsettings.Development.json +++ b/web/appsettings.Development.json @@ -22,6 +22,13 @@ "Cas": { "CasBaseUrl": "https://ssodev.ucdavis.edu/cas/" }, + "Authentication": { + // Local development offers both providers. EntraId:TenantId and ClientId come from AWS + // Parameter Store at /Shared/EntraId/ (no secret), so nothing is configured per checkout. + // The registered redirect URI is portless ("https://localhost/signin-entra"): Entra + // ignores the port when matching localhost, so one entry covers every checkout. + "EnabledProviders": "Both" + }, "Vmacs": { "BaseUrl": "https://vmacs-qa.vetmed.ucdavis.edu" }, diff --git a/web/appsettings.Production.json b/web/appsettings.Production.json index 8277341d6..7d188e09b 100644 --- a/web/appsettings.Production.json +++ b/web/appsettings.Production.json @@ -26,6 +26,11 @@ "SIS": "", "VIPER": "" }, + "Authentication": { + // PROD stays CAS-only until Entra ID has been validated on TEST. Cutover is a config + // change here ("Both" to run in parallel, then "EntraId"), not a code change. + "EnabledProviders": "Cas" + }, "Vmacs": { "BaseUrl": "https://vmacs-vmth.vetmed.ucdavis.edu" }, diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index c04521f48..3757e90c1 100644 --- a/web/appsettings.Test.json +++ b/web/appsettings.Test.json @@ -29,6 +29,13 @@ "Cas": { "CasBaseUrl": "https://ssodev.ucdavis.edu/cas/" }, + "Authentication": { + // TEST offers both so the Entra ID path can be exercised before it replaces CAS. + // At cutover this becomes "EntraId". EntraId:TenantId and ClientId come from AWS + // Parameter Store at /Shared/EntraId/ (one registration for every environment, no + // secret); until both exist, startup logs a fatal and falls back to CAS only. + "EnabledProviders": "Both" + }, "Vmacs": { "BaseUrl": "https://vmacs-qa.vetmed.ucdavis.edu" }, diff --git a/web/appsettings.json b/web/appsettings.json index 8e625035d..c1c319089 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -27,6 +27,35 @@ "Cas": { "CasBaseUrl": "https://cas.ucdavis.edu/cas/" }, + "Authentication": { + // Which sign-in provider(s) to offer: "Cas", "EntraId", or "Both". + // Campus is retiring CAS in favor of Entra ID. Default stays CAS so an environment + // without an explicit override is unaffected; TEST overrides this to "Both". + "EnabledProviders": "Cas" + }, + "EntraId": { + // One app registration ("VIPER") serves VIPER 1 and VIPER 2 in every environment, and + // it has no client secret: the app is a public client using authorization code + PKCE. + // TenantId (the UC Davis campus tenant) and ClientId (the registration's Application ID) + // are identical everywhere, so both come from AWS Systems Manager Parameter Store at + // /Shared/EntraId/. Neither is secret; SSM is simply their one home, so a registration + // change is a parameter edit rather than a deploy. Left empty here so an environment + // missing the parameters fails IsConfigured and degrades to CAS instead of offering a + // sign-in button that dead-ends. + "TenantId": "", + "ClientId": "", + // Relative to the PathBase, so the redirect URI registered for TEST is + // "https://secure-test.vetmed.ucdavis.edu/2/signin-entra". + "CallbackPath": "/signin-entra", + "SignedOutCallbackPath": "/signout-entra", + // AAUD keys on the bare kerberos id. Entra only carries it as a mapped claim + // (enterprise app > Attributes & Claims > user.onpremisessamaccountname, exposed in + // JWT tokens). preferred_username is the campus email alias, not the kerberos id, and is + // never read; a token without the mapped claim is rejected. Stripping stays on so an + // "@domain" value can never leak through if the claim is ever reconfigured. + "LoginIdClaim": "onpremisessamaccountname", + "StripEmailDomain": true + }, "EmailNotifications": { "PrimaryEvaluatorRemoved": { "To": ["lgissvoran@ucdavis.edu"], diff --git a/web/wwwroot/css/welcome.css b/web/wwwroot/css/welcome.css index c82276e73..da70d9bb4 100644 --- a/web/wwwroot/css/welcome.css +++ b/web/wwwroot/css/welcome.css @@ -362,6 +362,22 @@ body { font-weight: 700; } +/* Stacks the sign-in buttons while campus runs CAS and Entra ID side by side. A single + provider leaves one child, which renders identically to the pre-Entra single button. */ +.welcome-cta-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.welcome-card__choice { + margin: 0 0 1.25rem; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--welcome-card-muted); + font-weight: 500; +} + .welcome-cta { display: flex; align-items: center; @@ -374,7 +390,11 @@ body { font-size: 0.9375rem; font-weight: 700; letter-spacing: 0.02em; - transition: background-color 120ms ease-out; + /* Transparent border so the outlined variant below doesn't shift the solid one by 2px. */ + border: 0.0625rem solid transparent; + transition: + background-color 120ms ease-out, + color 120ms ease-out; } .welcome-cta:hover, @@ -382,11 +402,36 @@ body { background: var(--ucdavis-blue-90); } +/* Secondary sign-in option, shown only when both providers are offered. Outlined rather than + a second solid block so the pair reads as one primary path plus an alternative. */ +.welcome-cta--secondary { + background: var(--welcome-card-bg); + color: var(--welcome-card-heading); + border-color: var(--welcome-bg); +} + +.welcome-cta--secondary:hover, +.welcome-cta--secondary:focus-visible { + background: var(--welcome-bg); + color: var(--welcome-text-strong); +} + .welcome-cta__arrow { color: var(--welcome-accent); font-weight: 700; } +/* Gold on the white card is too low-contrast to read as an arrow, so inherit the button's + text color until hover fills the button and the gold has a dark ground again. */ +.welcome-cta--secondary .welcome-cta__arrow { + color: currentcolor; +} + +.welcome-cta--secondary:hover .welcome-cta__arrow, +.welcome-cta--secondary:focus-visible .welcome-cta__arrow { + color: var(--welcome-accent); +} + .welcome-help { display: block; margin-top: 1.25rem;