Let me put the problem with a bit of structure.
Context
We have a web application build with Web Forms and hosted in an Azure Web App that authenticates the users against an Azure Active Directory using the OWIN + OpenId Connect standards.
The authentication process works like a charm and users are able to access the application without any problem.
So, whats the issue?
After struggling for many days with it I'm unable to pass any query string parameter to the application through the authentication process. For example, if I try to access the application for the first time through the URL: https://myapp.azurewebsites.net/Default.aspx?param=value. The reason I need to pass this parameter is that it triggers some specific actions in the main page.
The problem is that after the authentication redirects to the webapp's main page the original query string parameters of the request are gone.
The code
The startup class looks like this:
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
ClientId = Constants.ADTenant.ClientId,
Authority = Constants.ADTenant.Authority,
PostLogoutRedirectUri = Constants.ADTenant.PostLogoutRedirectUri,
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = context =>
{
var code = context.Code;
ClientCredential credential = new ClientCredential(Constants.ADTenant.ClientId,
Constants.ADTenant.AppKey);
string userObjectID = context.AuthenticationTicket.Identity.FindFirst(
Constants.ADTenant.ObjectIdClaimType).Value;
AuthenticationContext authContext = new AuthenticationContext(Constants.ADTenant.Authority,
new NaiveSessionCache(userObjectID));
if (HttpContext.Current != null)
{
AuthenticationResult result = authContext.AcquireTokenByAuthorizationCode(
code, new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path)), credential,
Constants.ADTenant.GraphResourceId);
AuthenticationHelper.token = result.AccessToken;
AuthenticationHelper.refreshToken = result.RefreshToken;
}
return Task.FromResult(0);
}
}
});
And it works properly!
What I already tried
I've got access to the original request Url by adding an overwrite of the RedirectToIdentityProvider notification:
RedirectToIdentityProvider = (context) =>
{
// Ensure the URI is picked up dynamically from the request;
string appBaseUrl = context.Request.Scheme + "://" + context.Request.Host + context.Request.PathBase + context.Request.Uri.PathAndQuery;
context.ProtocolMessage.RedirectUri = appBaseUrl;
return Task.FromResult(0);
}
With this I tried to force the redirect to the main page including the original query string parameter, but then the redirection after authentication breaks and gets stuck in an infinite loop.
I've also tried with changing the redirect url of the application configuration in Azure AD without luck. Also tried to store the query string parameters somewhere else, but the Session is not accessible that early in the process.
Does anyone know what am I doing wrong? Or I'm just asking for something impossible? Any help would be appreciated.
Thank you very much in advance!
I recently had a need to do the exact same thing. My solution may not be the most sophisticated, but simple isn't always bad either.
I have two Authentication Filters...
The first filter is applied to all controllers that could potentially be hit with query string parameters prior to authorization. It checks if the principal is authenticated. If false it caches the complete url string in a cookie. If true it looks for any cookies present and clears them, just for cleanup.
public class AuthCheckActionFilter : ActionFilterAttribute, IAuthenticationFilter
{
public void OnAuthentication(AuthenticationContext filterContext)
{
if (!filterContext.Principal.Identity.IsAuthenticated)
{
HttpCookie cookie = new HttpCookie("OnAuthenticateAction");
cookie.Value = filterContext.HttpContext.Request.Url.OriginalString;
filterContext.HttpContext.Response.Cookies.Add(cookie);
}
else
{
if (filterContext.HttpContext.Request.Cookies.AllKeys.Contains("OnAuthenticateAction"))
{
HttpCookie cookie = filterContext.HttpContext.Request.Cookies["OnAuthenticateAction"];
cookie.Expires = DateTime.Now.AddDays(-1);
filterContext.HttpContext.Response.Cookies.Add(cookie);
}
}
}
public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext)
{
}
}
The second filter is applied only to the default landing page, or in other words where the identity server is redirecting after login. This second filter looks for a cookie and if it exists it calls response.Redirect on cookie value.
public class AutoRedirectFilter : ActionFilterAttribute, IAuthenticationFilter
{
public void OnAuthentication(AuthenticationContext filterContext)
{
if(filterContext.Principal.Identity.IsAuthenticated)
{
if(filterContext.HttpContext.Request.Cookies.AllKeys.Contains("OnAuthenticateAction"))
{
HttpCookie cookie = filterContext.HttpContext.Request.Cookies["OnAuthenticateAction"];
filterContext.HttpContext.Response.Redirect(cookie.Value);
}
}
}
public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext)
{
}
}
Related
I'm operating an ASP.NET MVC application that leverages Microsoft OpenID Connect.
(manual : https://learn.microsoft.com/ko-kr/azure/active-directory/develop/tutorial-v2-asp-webapp)
We began our service in October 2019, and had no problems with it until now. After the last successful log in, we are facing authorization failure in the redirected part and cannot verify the Claim (User.Identity) information, hence resulting in an infinite loop issue.
I hope I can get assistance with this problem.
After logging in successfully, I store the authentication information so that I can share the token value in the RESTful API which is configured separately.
In the earlier version, the token value was stored in the Token table and was stored in the session.
But when I started to used the session from the second attempt, the problem started to occur so I started not to use the session.
At this point, I tried several login attempts and saw that it worked well without a problem.
Initially, the application was properly working, but as time passed by, I can no longer retrieve the authentication information and an infinite loop occurs once again.
Code:
public void SignIn()
{
if (!Request.IsAuthenticated)
{
HttpContext.GetOwinContext().Authentication.Challenge(
new AuthenticationProperties{ RedirectUri = "/Auth/SignedIn" },
OpenIdConnectAuthenticationDefaults.AuthenticationType);
}
}
public void SignOut()
{
this.RemoveSessionToken();
HttpContext.GetOwinContext().Authentication.SignOut(
OpenIdConnectAuthenticationDefaults.AuthenticationType,
CookieAuthenticationDefaults.AuthenticationType);
}
public async Task<ActionResult> SignedIn()
{
var IsLoggedIn = Request.IsAuthenticated;
var claims = (System.Security.Claims.ClaimsIdentity) ClaimsPrincipal.Current.Identity;
if (IsLoggedIn)
{
var userClaims = User.Identity as System.Security.Claims.ClaimsIdentity;
//string sessionToken = Session.SessionID;
string ipAddress = Request.ServerVariables["HTTP_X_FORWARDED_FOR"];
if (String.IsNullOrEmpty(ipAddress))
ipAddress = Request.ServerVariables["REMOTE_ADDR"];
var dbContext = new GetOfficeEduUsersEntities();
var token = new T_APIToken();
string office365Id = userClaims?.FindFirst("preferred_username")?.Value;
token.TokenID = office365Id;
token.IssuerID = office365Id;
token.IssueDT = DateTime.Now;
token.ExpireDT = DateTime.Now.AddMinutes(180);
dbContext.T_APIToken.Add(token);
dbContext.SaveChanges();
return RedirectToAction("Index", "Home");
}
else
{
//Error
return RedirectToAction("SignedIn");
}
}
I have two questions, both of which refer to the code below:
Why is authenticateResult.Succeeded false after I call authenticateResult = await context.AuthenticateAsync();?
Why do I need to call "return" from my custom middleware InvokeAsync method for this to work properly?
I have an asp.net core application using OpenIdConnect. The application has two controller actions; both of them have the [Authorize] attribute, so when the application starts the user is automatically put through the OpenIdConnect process. This works fine.
Here is how I configure my OpenIdConnect middleware, I happen to be using PingOne:
services.AddAuthentication(authenticationOptions =>
{
authenticationOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
authenticationOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(openIdConnectOptions =>
{
openIdConnectOptions.Authority = Configuration["PingOne:Authority"];
openIdConnectOptions.CallbackPath = Configuration["PingOne:CallbackPath"];
openIdConnectOptions.ClientId = Configuration["PingOne:ClientId"];
openIdConnectOptions.ClientSecret = Configuration["PingOne:ClientSecret"];
openIdConnectOptions.ResponseType = Configuration["PingOne:ResponseType"];
openIdConnectOptions.Scope.Clear();
foreach (var scope in scopes.GetChildren())
{
openIdConnectOptions.Scope.Add(scope.Value);
}
});
Immediately after a user authenticates I redirect the user to another website (which uses the same OpenIdConnect authentication). On "OtherWebsite" the user selects various options and then gets redirected back to the "OriginalWebsite" to a special path called "ReturningFromOtherWebsite". On return to OriginalWebSite I read the querystring, load some claims into the user's principal identity based on the querystring, and set a Session variable so that I know I've visited OtherWebSite once.
I do not actually have a Controller method called "ReturningFromOtherWebsite" in OriginalWebSite, so I need to look for that path in my middleware and intercept handling of it.
I decided to wrap this functionality in custom middleware I call "AfterAuthenticationMiddleware", which looks like this. My questions are marked by the comments that start with "//QUESTION:..."
public class AfterAuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration Configuration;
private IMembershipRepository MembershipRepository;
public AfterAuthenticationMiddleware(RequestDelegate next,
IConfiguration configuration)
{
_next = next;
Configuration = configuration;
}
private void SignInWithSelectedIdentity(Guid userId,
ClaimsIdentity claimsIdentity,
AuthenticateResult authenticateResult,
HttpContext context)
{
string applicationName = Configuration["ApplicationName"];
List<string> roles = MembershipRepository.GetRoleNamesForUser(userId, applicationName);
foreach (var role in roles)
{
claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
}
//add the claim to the authentication cookie
context.SignInAsync(authenticateResult.Principal, authenticateResult.Properties);
}
public async Task InvokeAsync(HttpContext context,
IMembershipRepository membershipRepository)
{
MembershipRepository = membershipRepository;
bool isIdentitySelected = context.Session.GetBoolean("IsIdentitySelected").GetValueOrDefault();
if (isIdentitySelected)
{
//I know from existence of Session variable that there is no work to do here.
await _next(context);
return;
}
var authenticateResult = await context.AuthenticateAsync();
ClaimsIdentity claimsIdentity = null;
//the Controller action ReturningFromOtherWebSite does not actually exist.
if (context.Request.Path.ToString().Contains("ReturningFromOtherWebSite"))
{
if (!authenticateResult.Succeeded)
{
//this next line triggers the OpenIdConnect process
await context.ChallengeAsync();
//QUESTION: If I re-fetch the authenticateResult here, why is IsSucceeded false, for example:
//var authenticateResult = await context.AuthenticateAsync();
//QUESTION: why is the next line needed for this to work
return;
}
claimsIdentity = (ClaimsIdentity)authenticateResult.Principal.Identity;
//set the Session variable so that on future requests we can bail out of this method quickly.
context.Session.SetBoolean(Constants.IsIdentitySelected, true);
var request = context.Request;
//load some claims based on what the user selected in "OtherWebSite"
string selectedIdentity = request.Query["selectedIdentity"];
if (!Guid.TryParse(selectedIdentity, out Guid userId))
{
throw new ApplicationException(
$"Unable to parse Guid from 'selectedIdentity':{selectedIdentity} ");
}
SignInWithSelectedIdentity(userId, claimsIdentity, authenticateResult, context);
//redirect user to the page that the user originally requested
string returnUrl = request.Query["returnUrl"];
if (string.IsNullOrEmpty(returnUrl))
throw new ApplicationException(
$"Request is ReturnFromIdentityManagement but missing required parameter 'returnUrl' in querystring:{context.Request.QueryString} ");
string path = $"{request.Scheme}://{request.Host}{returnUrl}";
Log.Logger.Verbose($"AfterAuthentication InvokeAsync Redirect to {path}");
context.Response.Redirect(path);
//I understand why I call "return" here; I just want to send the user on to the page he/she originally requested without any more middleware being invoked
return;
}
if (!authenticateResult.Succeeded)
{
//if the user has not gone through OIDC there is nothing to do here
await _next(context);
return;
}
//if get here it means user is authenticated but has not yet selected an identity on OtherWebSite
claimsIdentity = (ClaimsIdentity)authenticateResult.Principal.Identity;
Log.Logger.Verbose($"AfterAuthentication InvokeAsync check if redirect needed.");
var emailClaim = claimsIdentity.Claims.FirstOrDefault(o => o.Type == ClaimTypes.Email);
if(emailClaim == null)
throw new ApplicationException($"User {authenticateResult.Principal.Identity.Name} lacks an Email claim");
string emailAddress = emailClaim.Value;
if(string.IsNullOrWhiteSpace(emailAddress))
throw new ApplicationException("Email claim value is null or whitespace.");
string applicationName = Configuration["ApplicationName"];
if(string.IsNullOrEmpty(applicationName))
throw new ApplicationException("ApplicationName missing from appsettings.json.");
//if there is just one userid associated with the email address, load the claims. if there is
//more than one the user must redirect to OtherWebSite and select it
List<Guid?> userIds =
MembershipRepository.IsOtherWebsiteRedirectNeeded(emailAddress, applicationName);
if (userIds == null
|| userIds[0] == null
|| userIds.Count > 1)
{
//include the path the user was originally seeking, we will redirect to this path on return
//cannot store in session (we lose session on the redirect to other web site)
string queryString =
$"emailAddress={emailAddress}&applicationName={applicationName}&returnUrl={context.Request.Path}";
context.Response.Redirect($"https://localhost:44301/Home/AuthenticatedUser?{queryString}");
}
else
{
SignInWithSelectedIdentity(userIds[0].Value, claimsIdentity, authenticateResult, context);
}
await _next(context);
}
}
And then I add the middlewares in the Configure method in the usual way:
app.UseAuthentication();
app.UseAfterAuthentication();
app.UseAuthorization();
I added the "return" call out of desperation and was shocked to discover that it fixed the problem, but I won't feel comfortable until I know why it fixed the problem.
I'm going to hazard a guess as to what is happening.
I've hooked up a listener to the OpenIdConnect library at the end of the Configure() method, like so:
IdentityModelEventSource.Logger.LogLevel = EventLevel.Verbose;
IdentityModelEventSource.ShowPII = true;
var listener = new MyEventListener();
listener.EnableEvents(IdentityModelEventSource.Logger, EventLevel.Verbose);
listener.EventWritten += Listener_EventWritten;
and then inside the Listener_EventWritten event I'm logging to a database.
private void Listener_EventWritten(object sender, EventWrittenEventArgs e)
{
foreach (object payload in e.Payload)
{
Log.Logger.Information($"[{e.EventName}] {e.Message} | {payload}");
}
}
I've also added verbose logging throughout the application, to get a sense of what is happening. Unfortunately there does not seem to be any way to attach listeners to the Authentication or Authorization middlewares.
Here is what I believe is happening. Each asp.net core middleware fires sequentially--in forward order during the Request, then in backwards order during the Response. When I hit the bit of code in my custom middleware that confused me:
if (context.Request.Path.ToString().Contains("ReturningFromOtherWebSite"))
{
if (!authenticateResult.Succeeded)
{
//this next line triggers the OpenIdConnect process
await context.ChallengeAsync();
//QUESTION: If I re-fetch the authenticateResult here, why is IsSucceeded false, for example:
//var authenticateResult = await context.AuthenticateAsync();
//QUESTION: why is the next line needed for this to work
return;
}
the call to "await context.ChallengeAsync();" fires the Authentication middleware; I can see from my logging that both the Oidc and Cookie authentication fire at this point. A "return" is needed after this call because I don't want the thread of execution to continue in my custom middleware; instead I want to let the call to "await context.ChallengeAsync();" complete its work and invoke my custom middleware again.
I can see from my logging that my custom middleware is indeed invoked again, and this time the authenticateResult.Succeeded is true.
The call to var "authenticateResult = await context.AuthenticateAsync();" yields a "Succeeded" of false because my custom middleware does not "know" at this point that the user has authenticated. The only way my custom middleware will "know" this is when the Authentication middleware calls it with "await(next)". That means I need to return and simply wait for that invocation.
Again, this is my guess, if anyone knows for certain I'd appreciate a better explanation. I've tried looking at the Oidc source code but I admit I find it bewildering, as I'm new to Core and have not yet fully grasped the whole async business yet.
I've managed to configure my application to authenticate using ADB2C, and it seems to work fine. The ADB2C code implemented is a tweak of one of Microsoft's samples, in which they use a SessionTokenCache class to manage instances of TokenCache. In my application, I retrieve the access token as follows:
private async Task<string> _getAccessToken(IConfidentialClientCredentials credentials)
{
if (this.HasCredentials())
{
var clientCredential = new ClientCredential(credentials.ClientSecret);
var userId = this._getUserIdClaimValue();
var tokenCache = new SessionTokenCache(_httpContextResolver.Context, userId);
var confidentialClientApplication = new ConfidentialClientApplication(
credentials.ClientId,
credentials.Authority,
credentials.RedirectUri,
clientCredential,
tokenCache.GetInstance(),
null);
IAccount account = confidentialClientApplication.GetAccountsAsync().Result.FirstOrDefault();
if (account == null)
{
return "";
}
var authenticationResult = await confidentialClientApplication.AcquireTokenSilentAsync(
credentials.ApiScopes.Split(' '),
account,
credentials.Authority,
false);
return authenticationResult.AccessToken;
}
else
{
return "";
}
}
This method is used to get the access token and pass it in the request header of an HttpClient as follows:
...
using (var request = new HttpRequestMessage(HttpMethod.Get, address.AbsoluteUri))
{
if (this.HasCredentials())
{
string accessToken = await this._getAccessToken(_confidentialClientCredentials);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
using (HttpResponseMessage response = await this.SendAsync(request))
{
//result-processing logic
}
...
The problem is that when the app is restarted, the user remains authenticated through the ADB2C cookie, but confidentialClientApplication.GetAccountsAsync().Result.FirstOrDefault(); returns null. This probably happens because the token cache is destroyed on app restart, so I can probably use a Redis cache to fix.
My main issue however is how to handle the situation of having a null Account but being "authenticated" at the same time. How are my requests to my website being authenticated even though I have a null Account? Shouldn't it fail and redirect me to login page, for example?
I tried looking into Authorization filters, and using the following code to hook up to the auth process and validate if user is null there, but to no avail. The following events are not being called ever (this is in ConfigureServices):
services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
.AddAzureADB2C(options => Configuration.Bind("ActiveDirectoryB2C", options))
.AddAzureADB2CBearer(options => Configuration.Bind("ActiveDirectoryB2C", options))
.AddCookie((options) => new CookieAuthenticationOptions
{
Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = context =>
{
// context.Principal gives you access to the logged-in user
// context.Properties.GetTokens() gives you access to all the tokens
return Task.CompletedTask;
},
OnSignedIn = context =>
{
return Task.CompletedTask;
}
}
});
It all feels a bit too abstracted for me to make any sense of what's going on. Either that, or I'm missing something fundamental.
Note: The error "Null user was passed in AcquiretokenSilent API. Pass in a user object or call acquireToken authenticate.
" is thrown if I try to pass the null account to the confidentialClientApplication.AcquireTokenSilentAsync() method.
I solved with this code:
protected override void OnException(ExceptionContext filterContext)
{
if (filterContext.Exception is Microsoft.Identity.Client.MsalUiRequiredException)
{
RedirectToAction("SignIn", "Account");
}
else {
//Do your logging
// ...
}
}
I'll search for a better solution.
I have a WebApi that I want to authorize my user with his linkedin information (as in create an access token and inject it in to my owin).
So far I have tried to work with Sparkle.Linkedin and this is what I have
public LinkedInLogic() {
// create a configuration object
_config = new LinkedInApiConfiguration(ApiKey, ApiSecret);
// get the APIs client
_api = new LinkedInApi(_config);
}
public Uri GetAuthUrl() {
var scope = AuthorizationScope.ReadBasicProfile;
var state = Guid.NewGuid().ToString();
var redirectUrl = "http://localhost:1510/api/login/RedirectAuth";
return _api.OAuth2.GetAuthorizationUrl(scope, state, redirectUrl);
}
public void GetAccessToken(string code) {
//If I do api.GetAccessToken(code); here I get an access token
var request = System.Net.WebRequest.Create("http://localhost:1510/api/token?grant_type=authorization_code&code=" + code);
request.GetResponse(); // my owin authorization
}
So I first get the Authorization Url -> it opens a popup -> I enter my data and it goes back to a controller which fires up GetAccessToken.
Problem is even if I completely authorize with linkedin I am not sure how to authorize with my own webapi. So I tried to send an http request to my owin token giver but it doesn't like it. There is also doesn't seem to be anyway I can return the access token back to the user so he can use it in his session.
Any ideas?
Not too sure if the sparkle is working anymore since the changes that where made by Linkedin on May 2015
We have a mobile app that wants to access a view on our ASP.NET MVC4 website. In order to do so, the app needs to authenticate by different means than our ADFS login process. The mobile app makes a request to the service and passes in the username and password as Request headers. In the ASP.NET application global.asax file, we have the following:
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
string username = HttpContext.Current.Request.Headers[Mobile.Configuration.iPadUsername];
string password = HttpContext.Current.Request.Headers[Mobile.Configuration.iPadPassword];
string acctID = HttpContext.Current.Request.Headers[Mobile.Configuration.iPadAcctStr];
//bypass adfs
if (!string.IsNullOrEmpty(username)
&& !string.IsNullOrEmpty(password)
&& !HttpContext.Current.Request.IsAuthenticated)
{
//web service call to authenticate the user
if (success)
{
var genIden = new GenericIdentity(usrnm);
var genClaim = new GenericPrincipal(genIden, new string[] { });
HttpContext.Current.User = genClaim;
var token = FederatedAuthentication.SessionAuthenticationModule.CreateSessionSecurityToken(genClaim, "test mobile", DateTime.UtcNow, DateTime.UtcNow.AddDays(1), true);
FederatedAuthentication.SessionAuthenticationModule.AuthenticateSessionSecurityToken(token, true);
Response.Clear();
Response.Status = "301 Moved Permanently";
Response.AddHeader("Location", "/Default/IndexRedirectFromMobile");
Response.End();
}
}
}
What ends up happening is when setting the user to the GeneralPrincipal above, we have HttpContext.Current.Request.IsAuthenticated set to true. However, after we try to redirect the user to the correct landing page, the request is no longer authenticated and they get caught in an infinite loop.
What can we do to prevent this from happening?
A simplest way would be to write the token to a cookie
var token = FederatedAuthentication.SessionAuthenticationModule.CreateSessionSecurityToken(genClaim, "test mobile", DateTime.UtcNow, DateTime.UtcNow.AddDays(1), true);
FederatedAuthentication.SessionAuthenticationModule.WriteSessonTokenToCookie( token );
You need to remember that the client must support cookies. Depending on the actual client technology you use, this can be easier or harder.
However, you don't need to use the SAM module to retain the session. You can use Forms Authentication, a custom authentication header or anything else.