The Backstory
I have an existing ASP.NET MVC5 app that I am wiring up to use oauth2/Azure AD authentication. I actually have this already working in multiple environments (dev/qa/prod/localhost).
In Azure I have an app registration with multiple return URIs, examples:
My deployment pipeline sets a config variable to change the ReturnURI for the oauth code and everything is working just fine.
Here is how the code is set up. My internal controllers have an Authorize attribute that, when a user is not authenticated, redirects them to /Account/Index where an oauth challenge is made. Here's how I make the oauth challenge:
HttpContext.GetOwinContext().Authentication.Challenge(
new AuthenticationProperties
{
RedirectUri = Url.Action("ValidateOAuth", "Account", new { returnUrl })
},
OpenIdConnectAuthenticationDefaults.AuthenticationType);
The user is redirected to Microsoft for login and they are returned to /Account/ValidateOAuth. In that action I check Request.IsAuthenticated and if it's true I build a local session variable for extra info about the user and push them into the internal pages.
The Problem
I am running into an issue in a specific scenario. In my QA environment I have two copies of the website hosted. One at the base URI (https://mywebsite.qa.com) and one as a sub-application in IIS located at https://mywebsite.qa.com/staging. This is so that our testers can deploy branches to the /staging site and test them without disrupting our usual QA users.
When a user visits the staging site they are redirected to my /staging/Account/Index action and the challenge redirects them to the Azure AD login site. There, once they authenticate they are redirected back to my site. HOWEVER, they are redirected not to /staging/Account/ValidateOAuth as expected. They are instead redirected to /. This causes them to go through the same authentication cycle of the main QA site.
I am able to reproduce this when running locally by setting up IIS Express to host the site at http://localhost:43000/staging rather than at the base url of http://localhost:43000. I see the exact same behavior where it will redirect to / and I get an error because I have no site hosted there locally.
Here is my Startup.cs with the oauth configuration:
public class Startup
{
// The Client ID is used by the application to uniquely identify itself to Azure AD.
string clientId = ConfigurationManager.AppSettings["aad:ClientId"];
string clientSecret = ConfigurationManager.AppSettings["aad:ClientSecret"];
// RedirectUri is the URL where the user will be redirected to after they sign in.
string redirectUri = ConfigurationManager.AppSettings["aad:RedirectUri"];
// Tenant is the tenant ID (e.g. contoso.onmicrosoft.com, or 'common' for multi-tenant)
static string tenant = ConfigurationManager.AppSettings["aad:Tenant"];
// Authority is the URL for authority, composed by Microsoft identity platform endpoint and the tenant name (e.g. https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0)
string authority = string.Format(CultureInfo.InvariantCulture, ConfigurationManager.AppSettings["aad:Authority"], tenant);
static string graphScopes = ConfigurationManager.AppSettings["aad:GraphScopes"];
/// <summary>
/// Configure OWIN to use OpenIdConnect
/// </summary>
/// <param name="app"></param>
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
CookieManager = new SystemWebCookieManager()
});
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
ClientId = clientId,
Authority = authority,
Scope = $"openid email profile offline_access {graphScopes}",
RedirectUri = redirectUri,
PostLogoutRedirectUri = redirectUri,
TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true
},
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthenticationFailed = OnAuthenticationFailed,
AuthorizationCodeReceived = OnAuthorizationCodeReceivedAsync
}
}
);
}
/// <summary>
/// Handle failed authentication requests by redirecting the user to the home page with an error in the query string
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private Task OnAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage,
OpenIdConnectAuthenticationOptions> notification)
{
notification.HandleResponse();
notification.Response.Redirect("/Errors/Error?message=" + notification?.Exception?.Message);
return Task.FromResult(0);
}
private async Task OnAuthorizationCodeReceivedAsync(AuthorizationCodeReceivedNotification notification)
{
var secret = clientSecret;
if (string.IsNullOrEmpty(secret))
{
string filePath = HostingEnvironment.MapPath(@"~/local.aadclientsecret.txt");
if (!File.Exists(filePath))
{
throw new FileNotFoundException("AAD Client Secret not found in Web.Config and local.aadclientsecret.txt not found on server!");
}
secret = File.ReadAllText(filePath);
if (string.IsNullOrEmpty(secret))
{
throw new SettingsPropertyNotFoundException("AAD Client not found in Web.Config and local.aadclientsecret.txt was empty!");
}
}
var idClient = ConfidentialClientApplicationBuilder.Create(clientId)
.WithTenantId(tenant)
.WithRedirectUri(redirectUri)
.WithClientSecret(secret)
.Build();
try
{
string[] scopes = graphScopes.Split(' ');
var result = await idClient.AcquireTokenByAuthorizationCode(
scopes, notification.Code).ExecuteAsync();
var userDetails = await GraphHelper.GetUserDetailsAsync(result.AccessToken);
string alias = "";
object aliasObj = null;
if (userDetails.AdditionalData.TryGetValue(GraphHelper.Attributes.Alias, out aliasObj))
{
alias = aliasObj.ToString();
}
// Create a new identity and copy all the claims.
// Add in extra claims that are needed.
var id = new ClaimsIdentity(notification.AuthenticationTicket.Identity.AuthenticationType);
id.AddClaims(notification.AuthenticationTicket.Identity.Claims);
id.AddClaim(new Claim("alias", alias));
notification.AuthenticationTicket = new AuthenticationTicket
(
new ClaimsIdentity(id.Claims, notification.AuthenticationTicket.Identity.AuthenticationType),
notification.AuthenticationTicket.Properties
);
}
catch (MsalException ex)
{
string message = "AcquireTokenByAuthorizationCodeAsync threw an exception";
notification.HandleResponse();
notification.Response.Redirect($"/Errors/Error?message={message}&&debug={ex.Message}");
}
catch (Microsoft.Graph.ServiceException ex)
{
string message = "GetUserDetailsAsync threw an exception";
notification.HandleResponse();
notification.Response.Redirect($"/Errors/Error?message={message}&debug={ex.Message}");
}
}
}
If I put breakpoints in the notifications neither are hit (as expected) since the redirect from Azure doesn't even return me to my website hosted at /staging.
Here are my config values (sensitive ones omitted):
<add key="aad:ClientId" value="*************" />
<add key="aad:Tenant" value="***********" />
<add key="aad:ClientSecret" value="***********" />
<add key="aad:Authority" value="https://login.microsoftonline.com/{0}/v2.0" />
<add key="aad:RedirectUri" value="http://localhost:44300" />
<add key="aad:GraphScopes" value="User.Read"/>
Attempted Solution
If I add a new Redirect URI to the Azure app registration to point to http://localhost:44300/staging, it will successfully return me to my locally hosted site at the /staging subdirectory but the Request.IsAuthenticated is always false and I get stuck in an infinite redirect loop. I am sent to Azure to login and it auto-redirects me back to my site which redirects me back to the Azure login page, repeat forever.
I also tried to do this in my live QA environment. I added a Redirect URI in Azure to go to https://mywebsite.qa.com/staging and then altered the RedirectUri in my config to the same. When I visit the /staging site now, it has the same behavior. I get redirected to Azure for login and then back to / instead of /staging/Account/ValidateOAuth.
Help!
I have spent the day searching and shotgun debugging but have no idea what is causing this. What am I doing wrong?