Skip to content

VPR-61 feat(auth): add Entra ID login alongside CAS - #298

Open
rlorenzo wants to merge 1 commit into
feature/VPR-151-dynamic-login-screenfrom
feature/VPR-61-entra-id-login
Open

VPR-61 feat(auth): add Entra ID login alongside CAS#298
rlorenzo wants to merge 1 commit into
feature/VPR-151-dynamic-login-screenfrom
feature/VPR-61-entra-id-login

Conversation

@rlorenzo

@rlorenzo rlorenzo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #216 (feature/VPR-151-dynamic-login-screen) — review that one first; this PR targets it, so the diff here is only the Entra work.

Campus is retiring CAS in favor of Microsoft Entra ID. This adds Entra as a second login provider so TEST can run both side by side, and makes the eventual cutover a config change rather than a code change.

Closes VPR-61.

Config

New Authentication:EnabledProviders, accepting Cas, EntraId, or Both.

Environment Setting Result
Development Cas unchanged
Test Both two buttons on the splash
Production Cas unchanged

Cutover is editing that one value to EntraId.

EntraId:ClientSecret is not committed. It follows the existing secret pattern: AWS SSM Parameter Store at /{Environment}/EntraId/ClientSecret in TEST/PROD, EntraId__ClientSecret in .env.local locally. TenantId and ClientId are not secrets and live in appsettings.{Env}.json.

How it works

Entra is a standard OIDC handler (auth code + PKCE) that signs in to the same cookie CAS already uses, so nothing downstream needs to know which provider a user picked.

OnTokenValidated rewrites the principal into the exact claim shape AuthenticateCasLogin produces. That matters because ClaimsTransformer does principal.Identity.NameUserHelper.GetByLoginId, so ClaimTypes.Name has to be the bare kerberos id. Entra hands back preferred_username as jdoe@ucdavis.edu, so the domain is stripped. Both the source claim and the stripping are configurable, so if the onpremisessamaccountname optional claim gets enabled on the app registration later, that is a config switch. If no usable id resolves, the login is rejected rather than allowed through role-less.

/login stays the single entry point every existing sign-in link already uses (Razor layout, session timeout, profile menu, Vue buildLoginUrl) — none of those call sites changed. It picks the provider when only one is enabled, and defers to the splash when both are. The splash's own buttons pass ?provider= explicitly, which is what stops the CAS button from bouncing back to the splash.

Two things that would have broken every Entra login

  • Program.cs gated both the SVMUser policy and the app-wide DefaultPolicy on AuthenticationMethod == "CAS" literally. Entra sessions would have failed authorization on every request. Both now accept either provider.
  • Duo 2FA ([Authorize(Policy = "2faAuthentication")]) checks a credentialType claim that only CAS emits. Entra reports MFA in amr instead, so that is translated into an accepted credential type.

Failure modes found in review and fixed

This went through an automated review loop plus an adversarial pass. Five real defects came out of it, all fixed here:

  • Logout stranded an Entra session. If EnabledProviders stops including EntraId while Entra cookies are still valid (12h expiry), the user fell through to the CAS logout redirect. They never had a CAS session, so they got the wrong page and their upstream Entra session was never ended. The branch keyed off current config where it should key off how the user actually authenticated.
  • A botched cutover took the whole site down. EnabledProviders = EntraId with any one Entra setting missing narrowed the set to None, which threw, which propagated out of the startup try and killed the host, taking CAS with it. Now it degrades to CAS with a fatal log. Losing the site is a far worse outcome than serving the provider that still works.
  • The /api guard ran after provider dispatch in Login, so an /api/... ReturnUrl reached the Entra challenge or the chooser splash instead of 401ing, whenever Entra was enabled.
  • /welcome?ReturnUrl=/api/... returned 200 in Both mode. That mode skips the deep-link branch, which is what enforced the guard. Now stated explicitly on Welcome, so all provider modes agree.
  • AuthorityOverride was unused and made IsConfigured accept a tenant or an override, letting a tenant-less config register the handler. Removed.

Tests

2922 backend tests pass. New coverage: the claim mapper (domain stripping, fallback ordering, casing, amr handling, principal shape), EntraIdSettings.IsConfigured, the Duo 2FA gate end-to-end through the mapper, and controller tests for provider selection, the /api guard in every provider mode, all four logout paths, and a regression guard for the /welcome/login loop.

Note on the unrelated RAPS one-liner

Adding the OIDC package upgrades Microsoft.IdentityModel.*, which drops the incidental IsNullOrEmpty() extension RAPSController.cs:234 was relying on. Swapped for .Count > 0, matching what RAPSSecurityService.cs:162 already does for the same call. It was a compile break, not optional.

Before TEST can actually use this

Two things are needed, both outside this repo:

  1. Put the client secret in SSM at /Test/EntraId/ClientSecret.
  2. Register https://secure-test.vetmed.ucdavis.edu/2/signin-entra as a redirect URI on the app registration (the /2 PathBase is included automatically by ASP.NET, so it must be part of the registered URI).

Until the secret exists, startup logs a fatal and falls back to CAS-only rather than showing a button that dead-ends.

TenantId is set to the UC Davis campus tenant, verified against the published OIDC discovery document for ucdavis.edu (and matching what VIPER 1's alumni portal already uses).

Not smoke-tested against the real tenant, since the secret isn't provisioned. The claim mapping is reasoned from the OIDC spec and Entra's documented token shape, not observed. The first real login is the thing to watch: specifically that preferred_username arrives in the shape we expect.

One known gap left deliberately: SaveTokens = false, so federated sign-out has no id_token_hint and Entra may show an account picker at logout instead of signing straight out. Storing the id_token would fix it, but the retrieval path is framework-version-specific and I can't verify it without a working tenant, so I'd rather confirm the behavior on TEST first than ship unverified auth plumbing.

Login id casing

Worth a reviewer's eye. UserHelper.cs:94 compares AaudUser.LoginId to User.Identity.Name with an ordinal, case-sensitive ==, and the emulation cache is keyed on this claim in ClaimsTransformer but on AaudUser.LoginId in EmulateUser. A mixed-case Entra UPN would therefore silently break emulation and force a DB round trip on every role check. The resolved id is lowercased to match what CAS supplies. That assumes AAUD login ids are lowercase kerberos ids, which is what CAS has been feeding it all along.

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) and remap Entra claims
  to the bare, lowercased kerberos id ClaimsTransformer resolves users by.
  Casing matters: UserHelper.IsInRole and the emulation cache key both compare
  login ids with an ordinal ==, so a mixed-case UPN would break emulation
- Gate providers on Authentication:EnabledProviders (Cas, EntraId or Both).
  TEST runs 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")]
- 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
@rlorenzo
rlorenzo force-pushed the feature/VPR-61-entra-id-login branch from eebcda2 to 7a9de4a Compare August 6, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant