I am plugging in OpenID authentication to an old .NET Framework 4.8 web app. This is mostly working. As a test I have created a claims.aspx cloned from one of my existing pages, but almost all the content stripped out and only displaying OpenID claims information from keycloak as a proof of concept.
Here is my current code (note: this includes a few edits to try and solve this problem none of which have worked so far)
Startup.cs:
public void ConfigureAuth(IAppBuilder app)
{
var notificationHandlers = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async (context) => {
// Sign in the user here
},
RedirectToIdentityProvider = (context) => {
if (context.OwinContext.Request.Path.Value != "/Account/SignInWithOpenId")
{
context.OwinContext.Response.Redirect("/Account/Login");
context.HandleResponse();
}
return Task.FromResult(0);
}
};
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
ClientId = _clientId,
ClientSecret = _clientSecret,
Authority = _authority,
RedirectUri = _redirectUri,
ResponseType = OpenIdConnectResponseType.CodeIdToken,
Scope = OpenIdConnectScope.OpenIdProfile,
TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", },
AuthenticationMode = AuthenticationMode.Active,
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async n =>
{
// Exchange code for access and ID tokens
var tokenClientOptions = new TokenClientOptions
{
ClientId = _clientId,
ClientSecret = _clientSecret,
Address = $"{_authority}/protocol/openid-connect/token"
};
var tokenClient = new TokenClient(new HttpClient() { BaseAddress = new Uri($"{_authority}/protocol/openid-connect/token") }, tokenClientOptions);
var client = new HttpClient();
var tokenResponse = await client.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest
{
Address = $"{_authority}/protocol/openid-connect/token",
ClientId = _clientId,
ClientSecret = _clientSecret,
Code = n.Code,
RedirectUri = _redirectUri,
});
var client2 = new HttpClient();
var userInfoResponse = await client2.GetUserInfoAsync(new UserInfoRequest
{
Address = $"{_authority}protocol/openid-connect/userinfo",
Token = tokenResponse.AccessToken
});
var claims = new List<Claim>(userInfoResponse.Claims)
{
new Claim("id_token", tokenResponse.IdentityToken),
new Claim("access_token", tokenResponse.AccessToken)
};
n.AuthenticationTicket.Identity.AddClaims(claims);
},
RedirectToIdentityProvider = notification =>
{
if (notification.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout)
{
var logoutUri = $"https://{_redirectUri}/v2/logout?client_id={_clientId}";
var postLogoutUri = notification.ProtocolMessage.PostLogoutRedirectUri;
if (!string.IsNullOrEmpty(postLogoutUri))
{
if (postLogoutUri.StartsWith("/"))
{
// transform to absolute
var request = notification.Request;
postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
}
logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}";
}
notification.Response.Redirect(logoutUri);
notification.HandleResponse();
}
notification.OwinContext.Response.Redirect("/Account/Logiasdfn");
notification.HandleResponse();
return Task.FromResult(0);
}
},
});
// Set Cookies as default authentication type
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
LoginPath = new PathString("/teset.aspx"),
CookieSameSite = SameSiteMode.Lax,
// More information on why the CookieManager needs to be set can be found here:
// https://github.com/aspnet/AspNetKatana/wiki/System.Web-response-cookie-integration-issues
CookieManager = new SameSiteCookieManager(new Microsoft.Owin.Host.SystemWeb.SystemWebCookieManager())
});
}
}
I then created a bare-bones page from a clone of an existing aspx page in this project and stripped it down to bare minimums to display the claims:
Claims.aspx:
<%@ Page Title="" Language="C#" MasterPageFile="~/MasterMainTest.master" AutoEventWireup="true" CodeBehind="Claims.aspx.cs" Inherits="RootNamespace.Web.Claims" %>
<%@ Import Namespace="System.Security.Claims" %>
<asp:Content ID="Content1" ContentPlaceHolderID="ContentPlaceHolder23" runat="server">
<h2>OpenID Connect Claims</h2>
<dl>
<asp:DataList runat="server" ID="dlClaims">
<ItemTemplate>
<dt><%# ((Claim) Container.DataItem).Type %></dt>
<dd><%# ((Claim) Container.DataItem).Value %></dd>
</ItemTemplate>
</asp:DataList>
</dl>
</asp:Content>
Claims.aspx.cs:
public partial class Claims : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
if (!Request.IsAuthenticated)
{
HttpContext.Current.GetOwinContext().Authentication.Challenge();
}
var claims = ClaimsPrincipal.Current.Claims;
dlClaims.DataSource = claims;
dlClaims.DataBind();
}
}
Once I did this, it worked perfect. If client visits claims.aspx, a 302 redirect is issued and the user is taken to Keycloak to perform authorization (the previous behaviour was to redirect to login.aspx for Forms Authentication). After passing auth, keycloak then redirects back to claims.aspx and the page displays information and Response.IsAuthenticated = true.
I am now trying to back this into my "real" pages and added if (!Request.IsAuthenticated) { HttpContext.Current.GetOwinContext().Authentication.Challenge(); } to its Page_Load just like I have in claims.aspx.cs, expecting it to "just work". However, it does not. Instead, I can see during debug that despite the Challenge() being hit - the page continues to load. On my claims.aspx page - the request is immediately returned with a 302 to keycloak.
This led me to discover that all the other pages (and sorry, I get a little lost here) are derived from AuthPage instead of just System.Web.UI.Page. As a novice dev, this is the first time while getting this implemented that I've seen requests hit this AuthPage class (and Global.asax.cs's Application_AuthenticateRequest method but maybe I didn't notice before).
AuthPage.cs
private void CheckSession(object sender, EventArgs ea)
{
/*OpenID authentication */
if (!Request.IsAuthenticated)
{
HttpContext.Current.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "~/test.aspx", AllowRefresh = true, IsPersistent = true });
}
/* Forms authentication */ /*
object o = Session["Admin"];
if ((o is Administrator) == false)
{
// OK, not logged in, save the address of this page
// so we can return to it if/when the admin logs in
Session["GoToWhenLoggedIn"] = Request.Url.PathAndQuery;
Response.Redirect(Config.LoginPageName);
} */
}
In here I find this magic CheckSession which all "the other pages I need to make this work on" are inheriting and commented out there is the former forms authentication method.
In its place I again tried to put in HttpContext.Current.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "~/test.aspx", AllowRefresh = true, IsPersistent = true });
But the result is the same. Once this line runs, the HttpContext contains the Response it is returning and indeed upon examining it yields no "Redirect URL" that I can see. Clearly, in the old implementation it was the Response.Redirect.... doing this.
I expected OWin on these pages to behave just like it did on my claims.aspx.
I note that the summary for the Challenge Method reads as follows:
Summary:
Add information into the response environment that will cause
the authentication middleware to challenge the caller to authenticate.
This also changes the status code of the response to 401.
The nature of that challenge varies greatly, and
ranges from adding a response header or
changing the 401 status code to a 302 redirect.
Clearly I can not inherit AuthPage anymore but I want to touch "the existing app" as little as possible for fear of breaking anything else. Would appreciate any advice. Thanks all.
Edit:
Forgot to include this code snippet. app.UseExternalSignInCookie in Startup.Cs is actually this class with AuthentiationMode set to Active explicitly:
public static void UseExternalSignInCookie(this IAppBuilder app)
{
UseExternalSignInCookie(app, DefaultAuthenticationTypes.ExternalCookie);
}
public static void UseExternalSignInCookie(this IAppBuilder app, string externalAuthenticationType)
{
if (app == null)
{
throw new ArgumentNullException("app");
}
app.SetDefaultSignInAsAuthenticationType(externalAuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = externalAuthenticationType,
AuthenticationMode = AuthenticationMode.Active,
CookieName = CookiePrefix + externalAuthenticationType,
ExpireTimeSpan = TimeSpan.FromMinutes(5),
});
}
So I got to the bottom of this and yes its embarrassing. Essentially, haven't gone thru setting up a clean demo page and then porting over that to a "real" page - I made the mental mistake of assuming that when the Challenge() was hit, all threads were interrupted and off to redirect land you go. With no other page on the clean demo page, I can see how I got there. What was actually happening is that even when the Challenge() is hit on the top of the page, its just the middleware setting up the response - the rest of the page still needs to finish running to code end, even if its not sent to or drawn in the clients DOM.
Given old .NET Framework legacy code touched by every junior out there, it had a lot of elements that would return null and definitely all of them returning NullExceptions when there is no Session (which they self handled instead of a DbContext) to pull data from. So I was getting exceptions and assuming I broke something.
I solved it by catching NullExceptions only coming from System.Web on the MasterMain all pages inherit from. Ugly, I know - but there are hundreds of pages and fixing them all up would have been a royal pain. Besides, while I was in there I moved session checking to "CheckSession" since I no longer have a need for login.aspx.