Skip to content
Kumar Chandrachooda
.NET

Logging In with CAS When Everyone Uses OAuth

A 2004-vintage SSO protocol wired into ASP.NET Core - ticket validation, roles derived from the database at login time, and single sign-out through a WordPress front door.

By Kumar Chandrachooda 05 May 2025 6 min read
A ticket exchanged at the door for a handful of claims

The attendance platform's login page has no password box. It has a redirect — to a single-sign-on protocol older than half the team that maintained it: CAS, the Central Authentication Service, born at Yale in the early 2000s and still quietly running intranets everywhere. Part one sketched the platform; this part is about how anyone gets into it, because the login flow turned out to be the most reliable component in the estate — and still taught me two durable lessons about where identity ends and authorisation begins.

If your mental model of SSO is OAuth and OpenID Connect — JWTs, scopes, discovery documents — CAS is refreshingly blunt. The application redirects the browser to the CAS server's /login with a service parameter naming where to come back. The user authenticates against the company directory once, centrally. CAS redirects back with a short-lived, single-use ticket in the query string. The application then makes a server-to-server call to validate that ticket, and gets back an XML document of attributes. No tokens to store, no signing keys to rotate, nothing bearer-shaped lingering in the browser beyond the app's own cookie.

The wiring, and the one event that matters

ASP.NET Core has no built-in CAS handler, but the community authentication providers slot into the same AddAuthentication pipeline as everything else. The shape (all code in this series is freshly written illustration):

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "CAS";
})
.AddCookie(options =>
{
    options.Cookie.Name = "Portal.Auth";
    options.LoginPath = "/LogIn";
})
.AddCAS("CAS", options =>
{
    options.CasServerUrlBase = Configuration["Cas:ServerUrlBase"];
    options.Events = new CasEvents
    {
        OnCreatingTicket = async context =>
        {
            var identity = (ClaimsIdentity)context.Principal.Identity;
            var attributes = context.Assertion.Attributes;

            identity.AddClaim(new Claim(ClaimTypes.Name, attributes["cn"]));
            identity.AddClaim(new Claim(ClaimTypes.Email, attributes["mail"]));
            identity.AddClaim(new Claim(ClaimTypes.Sid, attributes["employeenumber"]));

            await AddDatabaseRolesAsync(identity, context.HttpContext);
        }
    };
});

The protocol details — v3 validation with fallback to the older v2 and v1 endpoints for tolerance of server upgrades — live in the handler. Everything interesting happens in OnCreatingTicket, which fires exactly once per login, after the ticket validates and before the cookie is issued. The directory tells you who someone is; it cannot tell you what they are allowed to do in your application. CAS hands over a name, an email, and an employee number. Whether that person is a manager who can approve attendance rows is not the directory's business.

The database says what you are

So the platform derived roles at ticket time, with a query. A method — whose original name, I will admit, misspelled the word Role and shipped that way for years — looked the employee up and reasoned about the org data the sync jobs maintained:

private async Task AddDatabaseRolesAsync(ClaimsIdentity identity, HttpContext http)
{
    var employeeId = int.Parse(identity.FindFirst(ClaimTypes.Sid).Value);
    var accounts = http.RequestServices.GetRequiredService<IAccountData>();
    var profile = await accounts.GetProfileAsync(employeeId);

    if (profile.Department == "Human Resources")
        identity.AddClaim(new Claim(ClaimTypes.Role, "HR"));

    if (profile.HasActiveDirectReports)
        identity.AddClaim(new Claim(ClaimTypes.Role, "Manager"));

    if (profile.HasActiveDelegation)
        identity.AddClaim(new Claim(ClaimTypes.Role, "TeamLead"));

    if (profile.OrgLevel.Contains("X"))
        identity.AddClaim(new Claim(ClaimTypes.Role, "Executive"));

    identity.AddClaim(new Claim("EmployeeLocatorVisible",
        profile.CanSeeLocator ? "true" : "false"));
}

Each rule encodes a piece of the business. Department string equals HR — the bluntest possible mapping, and one that breaks the day HR renames itself. Having active direct reports makes you a Manager: a derived role, computed from the org chart rather than assigned, which means nobody ever forgets to grant it — and nobody can revoke it either. An active delegation row (a manager handing approval duty to someone while on leave) confers a temporary lead role. An org-level code containing the letter X marks the executive band. And one claim is not a role at all but a feature flag — whether the employee-locator page is visible to you — riding along in the identity because the identity was the one bag everyone already carried.

The consequential design decision hides in when this runs: once, at login. Roles are then frozen into the cookie. Get promoted, receive a delegation at ten in the morning, have your delegation expire — nothing changes until you sign out and back in. The alternative, an IClaimsTransformation that recomputes on every request, buys freshness with a database hit per request. The platform chose staleness, and mostly got away with it, because the support playbook contained the workaround every intranet developer will recognise: have you tried logging out and back in? When roles are computed at login, “log out and log in again” is not a support incantation — it is your cache-invalidation strategy, admitted out loud.

There is a middle path the platform never took, and it is worth naming because the two extremes are a false choice. You do not have to pick between “compute once at login” and “recompute every request”. A short-lived cache — recompute the roles when the cookie is more than, say, fifteen minutes old, and refresh on demand when a manager grants a delegation — bounds the staleness without paying the per-request tax. The delegation case is the one that actually stung: a manager going on leave hands approval authority to a deputy right now, precisely because something urgent came up, and “the deputy needs to log out and back in before they can approve anything” is exactly the wrong answer at exactly the wrong moment. Freezing roles at login is defensible for slow-moving facts like department; it is a poor fit for a role whose entire purpose is to be granted in a hurry. The lesson I carry: match the refresh interval to how fast the fastest-changing claim actually moves, not to how convenient it is to compute them all at once.

Single sign-out through a WordPress door

Sign-out is where the estate's history showed. The company's CAS server was fronted by the same WordPress installation that ran the intranet portal, so the platform's logout action cleared its own cookie and then redirected the browser to WordPress's wp-login.php?action=logout endpoint with a redirect_to pointing home. Single sign-out for a .NET application, by way of a PHP content-management system.

I want to be fair to this arrangement: it worked, and it reflects a truth about intranets — the SSO server is part of the furniture, installed by a different team in a different era, and your application adapts to it rather than the reverse. But it also means your logout UX has a dependency you will never see in your solution file. When the portal team changed their login theme, our sign-out confirmation page changed with it.

The sharper edge was in how the return URL got built:

var returnUrl = "http://" + Request.GetDisplayUrl() + "/";

A hard-coded http:// scheme, prepended to a display URL that — behind a TLS-terminating load balancer — already carries its own scheme. On a good day this produced a malformed URL the CAS server helpfully mangled back into shape; on a bad day it downgraded the post-logout redirect to plain HTTP. Nobody ever typed this URL, so nobody ever saw it. Redirect URLs that are built by string concatenation are tested by users, not by developers. UriBuilder exists; linkers and proxies are why.

What this part taught me

The ledger for the login system is genuinely positive — years of operation, and authentication was never the component that paged anyone. The lessons are at the seams:

  • Identity and authorisation want different lifetimes. Who you are is stable for a session; what you may do changes with the org chart. Freezing both into one cookie is a decision — make it knowingly, and write the staleness consequence down where support can find it.
  • Derived roles are self-maintaining and self-revoking in exactly the way assigned roles are not. “Manager means has-direct-reports” survived every re-org without a single access-request ticket. The equality check on a department name did not deserve the same confidence.
  • Adaptation beats purity for infrastructure you don't own. CAS-behind-WordPress is nobody's reference architecture, and integrating with it as-is was still the right call.

Those [Authorize(Roles = ...)] attributes that consume all of this deserve a close look of their own — because in Debug builds, they are not there at all. That is the next part.