MSAL Issues when integrating Azure AD Authentication and Authorization - c#

I have enabled authentication for azure ad by following some sample apps from MS as shown below. I then use the authorize statements below as well so my application should be locked down unless the user is authenticated. My issue in my testing environment is if i restart IIS express then when it starts back up I am not required to log back in if the browser is still open. I can openly navigate my application but when any function related to MS Graph is called it fails with error: ErrorCode: user_null Microsoft.Identity.Client.MsalUiRequiredException: No account or login hint was passed to the AcquireTokenSilent call. So its as if my application thinks I am still authorized but really I am not because the MS Graph api call fails because of token related issues. If I force myself to logout then log back in everything works. For my live site if a user is inactive for some time but the browser is left open this same issue occurs and they are not forced to relogin so these issues again can happen until they force logout and re log back in. What have I setup wrong or need to add to force relogin before these issues arise? Or can I keep the token working without forcing the user to relogin?
// Add services to the container.
builder.Services.AddRazorPages().AddRazorPagesOptions(options =>
{
options.Conventions.AllowAnonymousToFolder("/Login");
options.Conventions.AuthorizeFolder("/");
options.Conventions.AuthorizeFolder("/files");
});
//authentication pipline
builder.Services.AddHttpContextAccessor();
var initialScopes = builder.Configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ');
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
builder.Configuration.Bind("AzureAd", options);
options.Events = new OpenIdConnectEvents
{
//Tap into this event to add a UserID Claim to a new HttpContext identity
OnTokenValidated = context =>
{
//This query returns the UserID from the DB by sending the email address in the claim from Azure AD
string query = "select dbo.A2F_0013_ReturnUserIDForEmail(#Email) as UserID";
string connectionString = builder.Configuration.GetValue<string>("ConnectionStrings:DBContext");
string signInEmailAddress = context.Principal.FindFirstValue("preferred_username");
using (var connection = new SqlConnection(connectionString))
{
var queryResult = connection.QueryFirst(query, new { Email = signInEmailAddress });
var claims = new List<Claim>
{
new Claim("UserID", queryResult.UserID.ToString())
};
var appIdentity = new ClaimsIdentity(claims);
context.Principal.AddIdentity(appIdentity);
}
return Task.CompletedTask;
},
};
})
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
//Add Transient Services
builder.Services.AddTransient<IOneDrive, OneDrive>();
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
builder.Services.AddRazorPages()
.AddMicrosoftIdentityUI();
Then in all of my controllers I use:
[AuthorizeForScopes(ScopeKeySection = "DownstreamApi:Scopes")]
Adding
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(initialScopes);
before my graph api call fixes my issue. However, I feel as though I shouldnt need to call this everytime but only if the token expires. So is there a way to check if the token expires?

There are two things that worked for me. One was trying to get a token manually if an error occurred using the following:
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(initialScopes);
The other solution that seemed to work the same was to add the following catch as well:
try
{
Console.WriteLine($"{svcex}");
string claimChallenge = WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(svcex.ResponseHeaders);
_consentHandler.ChallengeUser(initialScopes, claimChallenge);
}
catch (Exception ex2)
{
_consentHandler.HandleException(ex2);
}
I do not know enough about what consent handler does to provide more info on why this works but will update my answer once I do.

Related

I just want to access some files from my .net core web application using Microsoft Graph and nothing is current

I have a .net core web application and I just want to access some files that are on one of our SharePoint sites, using Microsoft Graph. I've looked at courses on Pluralsight and the most current course has outdated material. I'm looking for a simple code example that gets me from a - z and I can't find any information that exists before mid 2022! On a similar question, I got an answer with code that didn't even work. Apparently I have to get an authorization code, in order to get an access token. BUT, the authorization code pretty much expires as soon as the user is logged into my application. Below is a modified version of the code I was given. I modified it in an effort to try to make it work. As you will see, I tried various version of "scopes" and I'm getting a token that I'm trying to use in the AuthorizationCode Credentials. I don't know if it's the right token to use. I've also seen some examples using PostMan. Getting things to work in postman is absolutely wonderful, but it's not C# code. I apologize if I seem a little rough, I'm just extremally frustrated. It should not be this difficult to find a working code sample. Any help would be appreciated. Here is the code I have that doesn't work:
//var scopes = new[] { "https://mysite.sharepoint.com/.default" };
//var scopes = new[] { "https://graph.microsoft.com/.default" };
var scopes = new[] { "https://graph.microsoft.com/User.ReadWrite.All" };
var tenantId = "tenant";
var clientId = "clientId";
var clientSecret = "shhItsASecret";
var client = new RestClient("https://login.microsoftonline.com/siteId/oauth2/v2.0/token");
var request = new RestRequest();
request.Method = Method.Post;
request.AddHeader("Content-Type", "application/x-www-form-urlencoded");
request.AddParameter("client_id", clientId);
request.AddParameter("client_secret", clientSecret);
request.AddParameter("scope", "https://graph.microsoft.com/User.ReadWrite.All");
request.AddParameter("response_type", "code");
request.AddParameter("grant_type", "client_credentials");
RestResponse response = client.Execute(request);
TokenModel tokenModel = new TokenModel();
JsonConvert.PopulateObject(response.Content, tokenModel);
var authorizationCode = tokenModel.access_token;
// using Azure.Identity;
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
// https://learn.microsoft.com/dotnet/api/azure.identity.authorizationcodecredential
var authCodeCredential = new AuthorizationCodeCredential(tenantId, clientId, clientSecret, authorizationCode, options);
Azure.Core.AccessToken accessToken = new Azure.Core.AccessToken();
try
{
accessToken = await authCodeCredential.GetTokenAsync(new Azure.Core.TokenRequestContext(scopes) { });
}
catch (System.Exception ex)
{
throw;
}
var tok = accessToken;
UPDATE:
I now know that I need to use delegated permissions and I need to use the auth code flow in order to do that. However, we use 2 factor authentication and it seems that by the time I can read anything from a variable, I can only see an access-token. If I understand correctly, the auth code is used to get an access-token and it expires. So, I can't seem to use that. Could I pass that access-token to my code that instantiates the graphService?
Someone else suggested I need to adjust my startup file and my appsettings file. I can't really do that. We have 5 other modules in our web application and this would be a big change to all of that. So, I'm not sure what I should be doing there. Bellow is what is in our startup, as it pertains to authentication:
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<UnitRateContractSystemContext>()
.AddDefaultTokenProviders()
.AddUserStore<UserStore<ApplicationUser, ApplicationRole, UnitRateContractSystemContext, Guid, ApplicationUserClaim<Guid>, ApplicationUserRole, IdentityUserLogin<Guid>, IdentityUserToken<Guid>, IdentityRoleClaim<Guid>>>()
.AddRoleStore<RoleStore<ApplicationRole, UnitRateContractSystemContext, Guid, ApplicationUserRole, IdentityRoleClaim<Guid>>>();
UPDATE 3:
I looked a little further down in my startup file and there was some openID connect information. Not sure why it was moved so far down, but I moved it up. Below is my entire authentication setup. The last 4 lines I added as a result of following one of the examples that someone provided. It builds just fine, but when I run it, I get an error in the Program.cs file: System.InvalidOperationException: 'Scheme already exists: Cookies'. If I go and comment out the "AddCookie()" line I get a similar error, but it says that OpenId Connect exists. So, at this point I'm stuck, but I feel if this can be solved, it might be the solution.
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<UnitRateContractSystemContext>()
.AddDefaultTokenProviders()
.AddUserStore<UserStore<ApplicationUser, ApplicationRole, UnitRateContractSystemContext, Guid, ApplicationUserClaim<Guid>, ApplicationUserRole, IdentityUserLogin<Guid>, IdentityUserToken<Guid>, IdentityRoleClaim<Guid>>>()
.AddRoleStore<RoleStore<ApplicationRole, UnitRateContractSystemContext, Guid, ApplicationUserRole, IdentityRoleClaim<Guid>>>();
#region Authentication
string[] initialScopes = Configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ');
//auth
services.AddAuthentication(options =>
{
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = Configuration["Authentication:Microsoft:OAuth"];
options.RequireHttpsMetadata = true;
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.UsePkce = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("email");
options.SaveTokens = true;
options.CallbackPath = new PathString(Configuration["Authentication:Microsoft:Callback"]);
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
};
// MetadataAddress represents the Active Directory instance used to authenticate users.
options.MetadataAddress = Configuration["Authentication:Microsoft:Meta"];
options.ClientId = Configuration["Authentication:Microsoft:ApplicationId"];
options.ClientSecret = Configuration["Authentication:Microsoft:Password"];
})
.AddMicrosoftIdentityWebApp(Configuration)
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
You have an asp.net core web application, and you want to access some files that are on one of your SharePoint sites. So I think you may want to use this graph api with request /sites/{site-id}/drive/items/{item-id}. If you want to use other APIs, the steps are the same.
First, since the scenario for you is access files in different sites, so if you used delegated permission(require users sign in first and get access token on behalf the user), you may meet an issue that the user is not allowed to this site so that he can't access the site. I'm afraid this is what you want, so you can use application permissions. For this api, the permission is like below, please add api permissions in Azure AD first.
Then, since you have an asp.net core web application, then you can use Azure identity + graph SDK to do this. You can use code below:
using Microsoft.Graph;
using Azure.Identity;
var scopes = new[] { "https://graph.microsoft.com/.default" };
var tenantId = "tenant_name.onmicrosoft.com";
var clientId = "aad_app_id";
var clientSecret = "client_secret";
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(clientSecretCredential, scopes);
var file = await graphClient.Sites["site_id"].Drive.Items["item_id"].Request().GetAsync();
If you want to let user sign in and then list some files which is allowed to the signed in user, the easiest way is adding Microsoft identity platform into your application, which can created by a template, just need to choose the authentication option when creating application in visual studio, then update the configurations. And certainly, you need to give delegated API permission, which is different than above.
Finally here's the official sample, you can see what codes/packages/configurations are added based on a web application.

Authorize Google analytics data from a web application

I am trying to authorize an ASP.NET Core 6 MVC web app to Google analytics data API.
[GoogleScopedAuthorize("https://www.googleapis.com/auth/analytics.readonly")]
public async Task<IActionResult> Index([FromServices] IGoogleAuthProvider auth)
{
var cred = await auth.GetCredentialAsync();
var client = await BetaAnalyticsDataClient.CreateAsync(CancellationToken.None);
var request = new RunReportRequest
{
Property = "properties/" + XXXXX,
Dimensions = {new Dimension {Name = "date"},},
Metrics = {new Metric {Name = "totalUsers"},new Metric {Name = "newUsers"}},
DateRanges = {new DateRange {StartDate = "2021-04-01", EndDate = "today"},},
};
var response = await client.RunReportAsync(request);
}
The authorization goes though as would be expected; I am getting an access token back.
I cant seem to figure out how to apply the credentials to the BetaAnalyticsDataClient.
When I run it without applying it to the BetaAnalyticsDataClient, I get the following error:
InvalidOperationException: The Application Default Credentials are not available. They are available if running in Google Compute Engine. Otherwise, the environment variable GOOGLE_APPLICATION_CREDENTIALS must be defined pointing to a file defining the credentials. See https://developers.google.com/accounts/docs/application-default-credentials for more information.
I am not currently using GOOGLE_APPLICATION_CREDENTIALS as it is configured in programs.cs. I don't see the need to have client id and secret configured in program.cs plus having an added env var.
Why isn't it just picking up the authorization already supplied with the controller runs?
builder.Services
.AddAuthentication(o =>
{
// This forces challenge results to be handled by Google OpenID Handler, so there's no
// need to add an AccountController that emits challenges for Login.
o.DefaultChallengeScheme = GoogleOpenIdConnectDefaults.AuthenticationScheme;
// This forces forbid results to be handled by Google OpenID Handler, which checks if
// extra scopes are required and does automatic incremental auth.
o.DefaultForbidScheme = GoogleOpenIdConnectDefaults.AuthenticationScheme;
// Default scheme that will handle everything else.
// Once a user is authenticated, the OAuth2 token info is stored in cookies.
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogleOpenIdConnect(options =>
{
options.ClientId = builder.Configuration["Google:ClientId"];
options.ClientSecret = builder.Configuration["Google:ClientSecret"];
});
Is there an alternate method for authorizing with a web app that I have not been able to find. I did do some dinging in the source code I can't seem to find a method to apply this.
After quite a bit of digging i managed to find that it was possible to create my own client builder and apply the credentials there.
var clientBuilder = new BetaAnalyticsDataClientBuilder()
{
Credential = await auth.GetCredentialAsync()
};
var client = await clientBuilder.BuildAsync();
Hope this helps someone else.

Windows Authentication with IdentityServer4 on IIS

I am trying to use Windows Authentication credentials to connect with my native (Winforms, console app) client to Identity Server hosted on IIS. The point is for user to be authenticated by AD and with those credentials get the right claims and roles from the Identity Server (which is run through commercial https://commercial.abp.io/ platform).
EDIT:
I found out it is not client related issue since i cannot use my External login (Windows credentials) even directly on hosted site.
The thing worked locally while hosted by IISExpress, then i published it to IIS and enabled the Anonymous and Windows Authentication in the IIS settings and here is where problems began.
When i run it and click the External Login (Windows Credentials) button i usually get a redirect to https://myserver/Error?httpStatusCode=401
and i get prompt for my windows credentials (which even if i insert correctly, just repeat prompt again).
From time to time i get logged in with my Windows credentials (which is the goal). Login with username and password works fine.
I saw the similar issue mentioned by someone here:
https://github.com/IdentityServer/IdentityServer4/issues/4937 without any solution\answer.
My client is basically the sample NativeConsolePKCEClient from this https://github.com/damienbod/AspNetCoreWindowsAuth
static string _authority = "https://myserver/";
string redirectUri = "https://127.0.0.1:45656";
var options = new OidcClientOptions
{
Authority = _authority,
ClientId = "native.code",
ClientSecret = "secret",
RedirectUri = redirectUri,
Scope = "openid profile",
FilterClaims = false,
Browser = browser,
Flow = OidcClientOptions.AuthenticationFlow.AuthorizationCode,
ResponseMode = OidcClientOptions.AuthorizeResponseMode.Redirect,
LoadProfile = true
};
_oidcClient = new OidcClient(options);
var result = await _oidcClient.LoginAsync();
and on server side the startup configuration services:
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration)
{
context.Services.Configure<IISOptions>(iis => // IISOptions
{
iis.AuthenticationDisplayName = "Windows";
iis.AutomaticAuthentication = false;
});
context.Services.AddAuthentication()
.AddJwtBearer(options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]); ;
options.Audience = "ABPIdentityServer";
});
}
Here is the ProcessWindowsLoginAsync challenge method:
private async Task<IActionResult> ProcessWindowsLoginAsync(string returnUrl)
{
// see if windows auth has already been requested and succeeded
var result = await HttpContext.AuthenticateAsync(Microsoft.AspNetCore.Server.IISIntegration.IISDefaults.AuthenticationScheme);
if (result?.Principal is WindowsPrincipal wp)
{
// we will issue the external cookie and then redirect the
// user back to the external callback, in essence, tresting windows
// auth the same as any other external authentication mechanism
var props = new AuthenticationProperties()
{
RedirectUri = "./ExternalLoginCallback",
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", Microsoft.AspNetCore.Server.IISIntegration.IISDefaults.AuthenticationScheme },
}
};
var id = new ClaimsIdentity(Microsoft.AspNetCore.Server.IISIntegration.IISDefaults.AuthenticationScheme);
id.AddClaim(new Claim(JwtClaimTypes.Subject, wp.Identity.Name));
id.AddClaim(new Claim(JwtClaimTypes.Name, wp.Identity.Name));
// add the groups as claims -- be careful if the number of groups is too large
{
var wi = (WindowsIdentity)wp.Identity;
var groups = wi.Groups.Translate(typeof(NTAccount));
var roles = groups.Select(x => new Claim(JwtClaimTypes.Role, x.Value));
id.AddClaims(roles);
}
await HttpContext.SignInAsync(IdentityConstants.ExternalScheme, new ClaimsPrincipal(id), props);
return Redirect(props.RedirectUri);
}
else
{
// trigger windows auth
// since windows auth don't support the redirect uri,
// this URL is re-triggered when we call challenge
return Challenge("Windows");
}
}
I am suspecting that this piece of code when calling Challenge somehow returns up redirecting to error page, but i am not sure and i do now why.
So what am i missing? Is it even possible to run both Windows and Anonymous authentication on IIS?
Here i also found similar issue:
identity server 4 windows authentication
but the presented answers did not help me.
I strongly suspect that it's not the client issue it's the token provider's issue (Not the ID4 library but one where you have installed the ID4 library).
I believe that you have added the below code in the AccountController->Login action but make sure that you have added a success check in it, if you miss that then your app will go infinite loop.
[HttpGet] public async Task<IActionResult> Login(string returnUrl)
{
if(loginViewModel.ExternalLoginScheme == "Windows")
{
var authenticationResult = await HttpContext.AuthenticateAsync("Windows").ConfigureAwait(false);
if (authenticationResult.Succeeded && authenticationResult?.Principal is WindowsPrincipal windowsPrinciple)
{
// Add your custom code here
var authProps = new AuthenticationProperties()
{
RedirectUri = Url.Action("Callback"),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", "Windows"},
}
};
await HttpContext.SignInAsync();
return Redirect(RedirectUri);
}
else
{
return Challenge("Windows");
}
}
}
I hope this will help you to fix your issue.
Happy Coding!!
Just for anybody who might be interested. I found out what was causing the redirect error.
It is somehow connected with the ABP Suite i used for generating the base application.
there in the ApplicationInitialization there was a middleware called
app.UseErrorPage();
Which when the Windows credentials were challenged took it as an Error and redirected to https://myserver/Error?httpStatusCode=401.
I am not sure how this middleware works and why sometimes login worked, but removing this part solved my issue.
I hope this helps somebody, somehow, sometime..

IdentityServer4 as external provider, how to avoid logout prompt?

I am working with two identity providers, both implemented using IdentityServer4 in ASP.NET MVC Core 2.2. One of them is used as an external provider by the other. Let's call them "primary" and "external". The primary provider is referenced directly by the web application. The external provider is an optional login method provided by the primary provider.
The web application uses the oidc-client-js library to implement authentication. The logout operation in the web app calls UserManager.signoutRedirect. This works fine when the primary identity provider is used (no logout confirmation prompt is shown). However, when the external provider is used, the user is prompted to sign out from the external provider.
The sequence of requests when logging out are:
GET http://{primary}/connect/endsession?id_token_hint=...&post_logout_redirect_uri=http://{webapp}
GET http://{primary}/Account/Logout?logoutId=...
GET http://{external}/connect/endsession?state=...&post_logout_redirect_uri=http://{primary}/signout-callback-{idp}&x-client-SKU=ID_NETSTANDARD2_0&x-client-ver=5.3.0.0
GET http://{external}/Account/Logout?logoutId=...
This last request above shows the logout confirmation screen from the external provider.
The code for the /Account/Logout page on the primary provider is almost identical to the sample code in the documentation:
[HttpGet]
public async Task<IActionResult> Logout(string logoutId)
{
var vm = await BuildLogoutViewModelAsync(logoutId);
if (!vm.ShowLogoutPrompt)
{
// If the request is authenticated don't show the prompt,
// just log the user out by calling the POST handler directly.
return Logout(vm);
}
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout(LogoutInputModel model)
{
var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);
if (User?.Identity.IsAuthenticated)
{
// delete local authentication cookie
await _signInManager.SignOutAsync();
// raise the logout event
await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
}
// check if we need to trigger sign-out at an upstream identity provider
if (vm.TriggerExternalSignout)
{
// build a return URL so the upstream provider will redirect back
// to us after the user has logged out. this allows us to then
// complete our single sign-out processing.
var url = Url.Action("Logout", new { logoutId = vm.LogoutId });
// this triggers a redirect to the external provider for sign-out
var ap = new AuthenticationProperties { RedirectUri = url };
return SignOut(ap, vm.ExternalAuthenticationScheme);
}
return View("LoggedOut", vm);
}
The BuildLogoutViewModelAsync method calls GetLogoutContextAsync to check if the logout is authenticated, like so:
public async Task<LogoutViewModel> BuildLogoutViewModelAsync(string logoutId)
{
var vm = new LogoutViewModel
{
LogoutId = logoutId,
ShowLogoutPrompt = true
};
var context = await _interaction.GetLogoutContextAsync(logoutId);
if (context?.ShowSignoutPrompt == false)
{
// It's safe to automatically sign-out
vm.ShowLogoutPrompt = false;
}
return vm;
}
The BuildLoggedOutViewModelAsync method basically just checks for an external identity provider and sets the TriggerExternalSignout property if one was used.
I hate to make this a wall of code, but I'll include the ConfigureServices code used to configure the primary identity server because it is probably relevant:
var authenticationBuilder = services.AddAuthentication();
authenticationBuilder.AddOpenIdConnect(openIdConfig.Scheme, "external", ConfigureOptions);
void ConfigureOptions(OpenIdConnectOptions opts)
{
opts.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
opts.SignOutScheme = IdentityServerConstants.SignoutScheme;
opts.Authority = openIdConfig.ProviderAuthority;
opts.ClientId = openIdConfig.ClientId;
opts.ClientSecret = openIdConfig.ClientSecret;
opts.ResponseType = "code id_token";
opts.RequireHttpsMetadata = false;
opts.CallbackPath = $"/signin-{openIdConfig.Scheme}";
opts.SignedOutCallbackPath = $"/signout-callback-{openIdConfig.Scheme}";
opts.RemoteSignOutPath = $"/signout-{openIdConfig.Scheme}";
opts.Scope.Clear();
opts.Scope.Add("openid");
opts.Scope.Add("profile");
opts.Scope.Add("email");
opts.Scope.Add("phone");
opts.Scope.Add("roles");
opts.SaveTokens = true;
opts.GetClaimsFromUserInfoEndpoint = true;
var mapAdditionalClaims = new[] { JwtClaimTypes.Role, ... };
foreach (string additionalClaim in mapAdditionalClaims)
{
opts.ClaimActions.MapJsonKey(additionalClaim, additionalClaim);
}
opts.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = JwtClaimTypes.Name,
RoleClaimType = JwtClaimTypes.Role
};
}
My understanding is that the id_token_hint parameter passed to the first /connect/endsession endpoint will "authenticate" the logout request, which allows us to bypass the prompt based on the ShowSignoutPrompt property returned by GetLogoutContextAsync. However, this does not happen when the user is redirected to the external provider. The call to SignOut generates the second /connect/endsession URL with a state parameter, but no id_token_hint.
The logout code in the external provider is basically the same as the code shown above. When it calls GetLogoutContextAsync, that method does not see the request as authenticated, so the ShowSignoutPrompt property is true.
Any idea how to authenticate the request to the external provider?
The final block of code, you hate, but luckily added, contains one significant row:
opts.SaveTokens = true;
That allows you later to restore the id_token you got from the external provider.Then you can use it as a "second level hint".
if (vm.TriggerExternalSignout)
{
var url = Url.Action("Logout", new { logoutId = vm.LogoutId });
var props = new AuthenticationProperties {RedirectUri = url};
props.SetParameter("id_token_hint", HttpContext.GetTokenAsync("id_token"));
return SignOut(props, vm.ExternalAuthenticationScheme);
}
I had the exact same problem as OP and was able to correct it by explicitly stating that the ID Token is to be added on to the logout request as per this Github Issue
https://github.com/IdentityServer/IdentityServer4/issues/3510
options.SaveTokens = true; // required for single sign out
options.Events = new OpenIdConnectEvents // required for single sign out
{
OnRedirectToIdentityProviderForSignOut = async (context) => context.ProtocolMessage.IdTokenHint = await context.HttpContext.GetTokenAsync("id_token")
};
I have come up with a solution, though it seems to contradict what is done in the samples.
The problem seems to be caused by two lines of code that were both from the IdentityServer samples that we used as a basis for our IDP implementations. The problem code is in the "primary" IDP.
The first line is in ConfigureServices in Startup.cs:
var authenticationBuilder = services.AddAuthentication();
authenticationBuilder.AddOpenIdConnect(openIdConfig.Scheme, "external", ConfigureOptions);
void ConfigureOptions(OpenIdConnectOptions opts)
{
opts.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
opts.SignOutScheme = IdentityServerConstants.SignoutScheme; // this is a problem
The second place is in ExternalController.cs, in the Callback method. Here we diverged from the samples, using IdentityServerConstants.ExternalCookieAuthenticationScheme instead of IdentityConstants.ExternalScheme:
// Read external identity from the temporary cookie
var result = await this.HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
// ...
// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(
IdentityServerConstants.ExternalCookieAuthenticationScheme); // this is a problem
What happens at logout is: since the SignOutScheme is overridden, it is looking for a cookie that doesn't exist. Simply removing that doesn't fix it because the call to SignOutAsync has deleted the cookie that contains the information required for the identity code to authenticate the scheme. Since it can't authenticate the scheme, it does not include the id_token_hint in the request to the "external" IDP.
I've been able to fix this by removing the code that overrides SignOutScheme in Startup.cs, and moving the code that deletes the ExternalCookieAuthenticationScheme cookie to the Logout endpoint in AccountController.cs:
// check if we need to trigger sign-out at an upstream identity provider
if (vm.TriggerExternalSignout)
{
// delete temporary cookie used during external authentication
await this.HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
// build a return URL so the upstream provider will redirect back...
This way the "temporary" external cookie is left around until it is needed, but is deleted when the user logs out.
I'm not sure if this is the "correct" solution, but it does seem to work correctly in all cases that I've tested. I'm not really sure why we deviated from the sample in ExternalController.cs, either, but I suspect it is because we have two standalone IDP rather than a site with a single standalone IDP. Also, the sample appears to be using implicit flow while we are using hybrid flow.

Azure AD federated logout not redirecting to client application

I am using Identity Server 3 for a central authentication server to a .Net MVC web application I am building.
I have configured the authentication server to use the Open ID Connect identity provider in order to allow users to authenticate against a multi-tenant Azure Active Directory account, using the Hybrid flow.
Currently, sign in works as expected with my client application redirecting to the authentication server which in turn redirects to Microsoft for login before returning back to my client application with a correctly populated Access Token.
However, when I try to logout I am redirected to Microsoft correctly, but the page stops when it arrives back at the authentication server, rather than continuing back to my client application.
I believe I have setup the post logout redirect correctly as outlined here and I think all of my settings are ok.
When I pull the Identity Server 3 code down and debug it, it is correctly setting the signOutMessageId onto the query string, but hits the following error inside the UseAutofacMiddleware method when it is trying to redirect to my mapped signoutcallback location:
Exception thrown: 'System.InvalidOperationException' in mscorlib.dll
Additional information: Headers already sent
My Authentication Server setup:
app.Map("identity", idsrvApp => {
var idSvrFactory = new IdentityServerServiceFactory();
var options = new IdentityServerOptions
{
SiteName = "Site Name",
SigningCertificate = <Certificate>,
Factory = idSvrFactory,
AuthenticationOptions = new AuthenticationOptions
{
IdentityProviders = ConfigureIdentityProviders,
EnablePostSignOutAutoRedirect = true,
PostSignOutAutoRedirectDelay = 3
}
};
idsrvApp.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
idsrvApp.UseIdentityServer(options);
idsrvApp.Map("/signoutcallback", cb => {
cb.Run(async ctx => {
var state = ctx.Request.Cookies["state"];
ctx.Response.Cookies.Append("state", ".", new Microsoft.Owin.CookieOptions { Expires = DateTime.UtcNow.AddYears(-1) });
await ctx.Environment.RenderLoggedOutViewAsync(state);
});
});
});
My Open Id Connect setup to connect to Azure AD:
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
AuthenticationType = "aad",
SignInAsAuthenticationType = signInAsType,
Authority = "https://login.microsoftonline.com/common/",
ClientId = <Client ID>,
AuthenticationMode = AuthenticationMode.Active,
TokenValidationParameters = new TokenValidationParameters
{
AuthenticationType = Constants.ExternalAuthenticationType,
ValidateIssuer = false,
},
Notifications = new OpenIdConnectAuthenticationNotifications()
{
AuthorizationCodeReceived = (context) =>
{
var code = context.Code;
ClientCredential credential = new ClientCredential(<Client ID>, <Client Secret>);
string tenantId = context.AuthenticationTicket.Identity.FindFirst("tid").Value;
AuthenticationContext authContext = new AuthenticationContext($"https://login.microsoftonline.com/{tenantId}");
AuthenticationResult result = authContext.AcquireTokenByAuthorizationCode(
code, new Uri(<Identity Server URI>/aad/"), credential, "https://graph.windows.net");
return Task.FromResult(0);
},
RedirectToIdentityProvider = (context) =>
{
string appBaseUrl = context.Request.Scheme + "://" + context.Request.Host + context.Request.PathBase;
context.ProtocolMessage.RedirectUri = appBaseUrl + "/aad/";
context.ProtocolMessage.PostLogoutRedirectUri = appBaseUrl + "/signoutcallback";
if (context.ProtocolMessage.RequestType == Microsoft.IdentityModel.Protocols.OpenIdConnectRequestType.LogoutRequest)
{
var signOutMessageId = context.OwinContext.Environment.GetSignOutMessageId();
if (signOutMessageId != null)
{
context.OwinContext.Response.Cookies.Append("state", signOutMessageId);
}
}
return Task.FromResult(0);
}
});
I cannot find any information about the cause of or solution to this problem. How do I configure this to correctly redirect back to my client application?
Edit:
Related discussion on GitHub: https://github.com/IdentityServer/IdentityServer3/issues/2657
I have also tried this with the latest version of Identity Server on MyGet (v2.4.1-build00452) with the same problem.
I have also created a repository that reproduces the issue for me here: https://github.com/Steve887/IdentityServer-Azure/
My Azure AD setup:
I believe you were experiencing a bug that is fixed in 2.5 (not yet released as of today): https://github.com/IdentityServer/IdentityServer3/issues/2678
Using current source from Git, I still see this problem. It appears to me that AuthenticationController.Logout is hit twice during the logout. Once prior to the external provider's logout page is displayed, and once after. The initial call Queues and clears the signout cookie so that the second time it is not available when rendering the logout page.

Categories