Identity Server 4 - Logout - Passing Additional Data - c#

When a user logs out under certain circumstances I want to show them a message on the logged out page. To enable this I want to be able to send an optional parameter from the client to the Identity Server / Authority site on logout.
While I have the standard logout flow working I have hit a brick wall in handling this scenario as information seems thin on the ground and the suggested solutions are not working.
From what I have read the 'state' parameter is the correct way to pass this information but this not coming through currently. AcrValues are only used to send information the other way.
My naive implementation below simply adds a state query string item to the end session endpoint. However, when I check the query string my client uses to go to the identity server instance it is missing.
Redirect(discoveryResponse.EndSessionEndpoint+"&state=foo")
Any help gladly received!
Current flow for MVC client:
Please note; some code has been removed for brevity.
Logout initiated from client controller with state=foo:
public class LogoutController : Controller
{
public ActionResult Index()
{
Request.GetOwinContext().Authentication.SignOut();
var discoveryClient = new DiscoveryClient(clientConfig.Authority) { Policy = {RequireHttps = false} };
var discoveryResponse = discoveryClient.GetAsync().Result;
var tokenClaim = ((ClaimsIdentity)User.Identity).FindFirst("id_token");
return Redirect(discoveryResponse.EndSessionEndpoint+ "?id_token_hint="+ tokenClaim + "&state=foo");
}
}
RedirectToIdentityProvider is called for request:
IdTokenHint and PostLogoutRedirectUri are set and passed correctly.
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = n =>
{
if (n.ProtocolMessage.RequestType != OpenIdConnectRequestType.LogoutRequest)
return Task.FromResult(0);
var idTokenHint = n.OwinContext.Authentication.User.FindFirst(OpenIdConnectClaimType.IdToken);
if (idTokenHint == null) return Task.FromResult(0);
n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
n.OwinContext.Response.Cookies.Append("IdentityServerPostLogoutReturnUri",
n.ProtocolMessage.PostLogoutRedirectUri);
n.ProtocolMessage.PostLogoutRedirectUri =
n.Options.PostLogoutRedirectUri;
return Task.FromResult(0);
}
}
URL Generated (not the lack of 'state' item):
http://localhost:44362/connect/endsession?post_logout_redirect_uri=http%3a%2f%2flocalhost%3a2577%2fpostloginredirect&id_token_hint=removed&x-client-SKU=ID_NET&x-client-ver=1.0.40306.1554
Logout page on the authority site:
This is where I want to be able to access the state parameter.
public class LogoutController : Controller
{
public async Task<ViewResult> Index(string logoutId)
{
if (logoutId == null) throw new Exception("Missing logoutId");
var logoutRequest = await interactionService.GetLogoutContextAsync(logoutId);
var vm = new LoggedOutViewModel(logoutRequest, logoutId);
if (!string.IsNullOrWhiteSpace(httpContextService.GetCookieValue(PostLogoutReturnUriCookieKey)))
{
vm.PostLogoutRedirectUri = httpContextService.GetCookieValue(PostLogoutReturnUriCookieKey);
httpContextService.ClearCookie(PostLogoutReturnUriCookieKey);
}
await httpContextService.SignOutAsync();
return View("Index", vm);
}
}

I've dug a little deeper and found what the issue was being caused by the following lines in the Microsoft.Owin.Security.OpenIdConnect middleware.
protected override async Task ApplyResponseGrantAsync()
{
AuthenticationResponseRevoke signout = Helper.LookupSignOut(Options.AuthenticationType, Options.AuthenticationMode);
if (signout != null)
{
// snip
var notification = new RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions>(Context, Options)
{
ProtocolMessage = openIdConnectMessage
};
await Options.Notifications.RedirectToIdentityProvider(notification);
// This was causing the issue
if (!notification.HandledResponse)
{
string redirectUri = notification.ProtocolMessage.CreateLogoutRequestUrl();
if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute))
{
_logger.WriteWarning("The logout redirect URI is malformed: " + redirectUri);
}
Response.Redirect(redirectUri);
}
}
}
In order to prevent the middleware from overriding the redirect when it detects a sign out message the following line in the 'HandleResponse' method needs to be called in the RedirectToIdentityProvider event.
This allows the original 'state' query string item to be passed to Identity Server and be pulled out using the interaction service.
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
// Snip
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async n =>
{
// Snip
},
RedirectToIdentityProvider = n =>
{
// Snip
n.HandleResponse(); // The magic happens here
}
}

Related

IdentityServer shows blank PostLogoutRedirectUri for Android Native App

I have created an OAuth Server using IdentityServer4 and .Net Core Signin Manager. The Login works great and returns to my app. The Logout doesn't seem to know who is logging out. The Logout Razor Page code is as follows:
public async Task<IActionResult> OnGet(string logoutId)
{
var logout = await _interaction.GetLogoutContextAsync(logoutId);
PostLogoutRedirectUri = logout?.PostLogoutRedirectUri;
AutomaticRedirectAfterSignOut = (PostLogoutRedirectUri != null);
ClientName = string.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName;
SignOutIframeUrl = logout?.SignOutIFrameUrl;
LogoutId = logoutId;
if (User?.Identity.IsAuthenticated == true)
{
var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value;
if (idp != null && idp != IdentityServer4.IdentityServerConstants.LocalIdentityProvider)
{
var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(idp);
if (providerSupportsSignout)
{
if (LogoutId == null)
{
// if there's no current logout context, we need to create one
// this captures necessary info from the current logged in user
// before we signout and redirect away to the external IdP for signout
LogoutId = await _interaction.CreateLogoutContextAsync();
}
ExternalAuthenticationScheme = idp;
}
}
// 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 (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.
string url = Url.Action("Logout", new { logoutId = LogoutId });
// this triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, ExternalAuthenticationScheme);
}
if (AutomaticRedirectAfterSignOut)
return Redirect(PostLogoutRedirectUri);
else
return Page();
}
When it gets called, there is a logoutId. It gets the context, but PostLogoutRedirectUri is blank. ClientId and ClientName are also blank, but the context has a field called ClientIds and the first entry is the correct ClientId for my app. The log shows as follows:
IdentityServer4.Validation.EndSessionRequestValidator: Information: End session request validation success
{
"SubjectId": "6841dc6c-0bd7-4f72-8f1c-f7czzzzzzzzz",
"Raw": {
"post_logout_redirect_uri": "mps.mobile.app://callback"
}
}
IdentityServer4.Hosting.IdentityServerMiddleware: Information: Invoking IdentityServer endpoint: IdentityServer4.Endpoints.EndSessionCallbackEndpoint for /connect/endsession/callback
IdentityServer4.Endpoints.EndSessionCallbackEndpoint: Information: Successful signout callback.
I am using IdentityModel for the Client App. I have the logout coded as follows:
_options = new OidcClientOptions
{
Authority = MPSOidc.Authority,
ClientId = MPSOidc.ClientID,
Scope = "openid profile myapi offline_access email",
RedirectUri = MPSOidc.RedirectUri,
PostLogoutRedirectUri = MPSOidc.RedirectUri,
ResponseMode = OidcClientOptions.AuthorizeResponseMode.Redirect,
Browser = new ChromeCustomTabsBrowser(this)
};
var oidcClient = new OidcClient(_options);
var r = new LogoutRequest();
await oidcClient.LogoutAsync(r);
It seems like the PostLogoutRedirectUri should show up here. Does anyone know a way to make this happen? If not, can the ClientId be used to get the Client information to find the PostLogoutRedirectUri there?
Thanks,
Jim
Here is what it was. When I logged out on the OidcClient, I didn't pass the ID Token. On my client Android app, I had to add the ID Token to the logout request:
var r = new LogoutRequest()
{
IdTokenHint = MPSOidc.Tokens.IdentityToken
};
That's all it took.
Cheers.

User.FindFirst(ClaimTypes.NameIdentifier) - NullReferenceException from Angular but OK in Postman?

I am trying to debug a NullReferenceException in a .NET Core API and Angular application and I'm out of ideas.
I am trying to update a property of a User (the "About" section)
Update text area screengrab
In the backend code, in AuthController, I have a Login method that creates the claims and it seems to work fine:
[HttpPost("login")]
public async Task<IActionResult> Login(LoginViewModel loginViewModel)
{
// login the user
var userFromRepo = await _authRepository.Login(loginViewModel.Email.ToLower(), loginViewModel.Password);
// check that user is logged in
if (userFromRepo == null)
return Unauthorized();
// create claims using user id and main email
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userFromRepo.Id),
new Claim(ClaimTypes.Name, userFromRepo.MainEmail),
new Claim(ClaimTypes.Name, userFromRepo.FirstName)
};
// generate key from secret token
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config.GetSection("AppSettings:Token").Value));
// generate hash and credentials
var cred = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature);
// create token descriptions
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.Now.AddDays(1),
SigningCredentials = cred
};
// instantiate token handler
var tokenHandler = new JwtSecurityTokenHandler();
// create token
var token = tokenHandler.CreateToken(tokenDescriptor);
// write token and return request
return Ok(new
{
token = tokenHandler.WriteToken(token),
});
}
I then have a method that updates a property of a User:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(string id, UserForUpdateDto userForUpdateDto)
{
// this is a check if user ID that is updating the profile matches the ID in the token
if (id != User.FindFirst(ClaimTypes.NameIdentifier).Value)
{
return Unauthorized();
}
var userFromRepo = await _doMoreRepo.GetUser(id);
userFromRepo.About = userForUpdateDto.About;
await _doMoreRepo.UpdateUser(id);
return NoContent();
}
During debugging I get an 500 error with a message
System.NullReferenceException: Object reference not set to an instance of an object
on the line of code:
User.FindFirst(ClaimTypes.NameIdentifier).Value
It works OK in Postman so my guess is that somehow I am not getting anything from the NameIdentifier but I don't know why?
I'm just not sure where to look any more.
My front end Angular code is as follows - Login method that adds the token to the storage:
login(model: any) {
return this.http.post(this.url + 'login', model)
.pipe(
map((response: any) => {
const user = response;
if (user) {
localStorage.setItem('token', user.token);
this.decodedToken = this.jwtHelper.decodeToken(user.token);
console.log('This is decoded token');
console.log(this.decodedToken);
}
})
);
}
Update Profile method:
updateProfile() {
this.userService.updateUser(this.authService.decodedToken.nameid, this.user).subscribe(next => {
this.alertify.success('Profile updated');
this.editForm.reset(this.user);
}, error => {
console.log(error);
this.alertify.error(error);
});
}
I looked at very similar issue here so I double checked the solution but in my case I do have the tokenGetter() included in my app.module.ts
export function tokenGetter() {
return localStorage.getItem('token');
}
and the import:
JwtModule.forRoot({
config: {
tokenGetter,
}
})
To investigate it more and to make sure that I have narrowed down to possible problem area, in my Controller when I replace the:
public async Task<IActionResult> UpdateUser(string id, UserForUpdateDto userForUpdateDto)
{
// this is a check if user ID that is updating the profile matches the ID in the token
if (id != User.FindFirst(ClaimTypes.NameIdentifier).Value)
{
return Unauthorized();
}
var userFromRepo = await _doMoreRepo.GetUser(id);
userFromRepo.About = userForUpdateDto.About;
await _doMoreRepo.UpdateUser(id);
return NoContent();
}
with actual value like this:
public async Task<IActionResult> UpdateUser(string id, UserForUpdateDto userForUpdateDto)
{
if (id != "user ID value")
{
return Unauthorized();
}
var userFromRepo = await _doMoreRepo.GetUser(id);
userFromRepo.About = userForUpdateDto.About;
await _doMoreRepo.UpdateUser(id);
return NoContent();
}
This works ok without any errors and the property is updated ok.
What am I missing?
EDIT:
This is my updateUser() method where the http.put request is made:
updateUser(id: string, user: User) {
// console.log('user ID is: ' + id);
// console.log('User object passed to updateUser() is: ');
// console.log(user);
return this.http.put(this.baseUrl + 'user/' + id, user);
}
Which hits the UpdateUser() in the UserController.cs at the back-end.
Thanks to the information provided by Panagiotis Kanavos I investigated it further and the request was missing the authentication header.
Because I am using JwtModule:
export function tokenGetter() {
return localStorage.getItem('token');
}
JwtModule.forRoot({
config: {
tokenGetter,
}
})
I thought that my token should be added to the headers automatically without having to add it manually.
The mistake I made was not including correct whitelistedDomains options inside the JwtModule configuration and that is why it was not added to the request header.
The solution was to whitelist the correct domains like this:
JwtModule.forRoot({
config: {
tokenGetter: tokenGetter,
whitelistedDomains: ['localhost:5000', 'localhost:5001'],
blacklistedRoutes: ['localhost:5000/auth']
}
})

IdentityServer4 Windows Sign-On under IIS 10 fails to authenticate successfully

Using IIS Express on my local machine, I'm able to run the IdentityServer4 QuickStart UI project and successfully sign in. However, once it is deployed to production, I'm unable to get it to work.
On the Application Pool for the site, I have a domain account setup (with just about every permission possible granted). I have tried every variation of having "anonymous authentication" toggled. I've gone as far as recreating the entire application from scratch in multiple different ways (no-SSL, only-SSL, fully open CORS, all security policies disabled), and even the most basic version of the application seems to suffer from the exact same issue.
After slapping some logging on the application, I can see that I'm grabbing the Subject ID and name from AD just fine.
Here's the ProcessWindowsLoginAsync method, with only minimal logging changes.
private async Task<IActionResult> ProcessWindowsLoginAsync(string returnUrl)
{
var result = await HttpContext.AuthenticateAsync(AccountOptions.WindowsAuthenticationSchemeName);
if (result?.Principal is WindowsPrincipal wp)
{
var props = new AuthenticationProperties
{
RedirectUri = Url.Action("Callback"),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", AccountOptions.WindowsAuthenticationSchemeName },
}
};
var id = new ClaimsIdentity(AccountOptions.WindowsAuthenticationSchemeName);
var sub = wp.FindFirst(ClaimTypes.PrimarySid).Value;
id.AddClaim(new Claim(JwtClaimTypes.Subject, sub));
id.AddClaim(new Claim(JwtClaimTypes.Name, wp.Identity.Name));
_logger.LogInformation("Assigning claims. Subject {#Subject}. Name {#Name}", sub, wp.Identity.Name);
if (AccountOptions.IncludeWindowsGroups)
{
var wi = wp.Identity as WindowsIdentity;
var groups = wi!.Groups!.Translate(typeof(NTAccount));
var roles = groups!.Select(x => new Claim(JwtClaimTypes.Role, x.Value));
id.AddClaims(roles);
}
await HttpContext.SignInAsync(
IdentityServerConstants.ExternalCookieAuthenticationScheme,
new ClaimsPrincipal(id),
props);
return Redirect(props.RedirectUri);
}
return Challenge(AccountOptions.WindowsAuthenticationSchemeName);
}
The above code spits out something akin to (with identifying information stripped):
Assigning claims. Subject S-0-0-00-0000000000-0000000000-0000000000-00000. Name DOMAIN\NAME
Once the above has executed, the external callback method is called and it immediately throws an exception:
[HttpGet]
public async Task<IActionResult> Callback()
{
var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
{
_logger.LogInformation("We were not successfully able to sign in. Failure: {#Failure}. None: {#None}", result?.Failure, result?.None);
if (result?.Failure != null)
throw result.Failure;
throw new Exception("External authentication error");
}
if (_logger.IsEnabled(LogLevel.Debug))
{
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {#claims}", externalClaims);
}
var (user, provider, providerUserId, claims) = FindUserFromExternalProvider(result);
if (user == null)
user = AutoProvisionUser(provider, providerUserId, claims);
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties();
ProcessLoginCallbackForOidc(result, additionalLocalClaims, localSignInProps);
var issuer = new IdentityServerUser(user.SubjectId)
{
DisplayName = user.Username,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims
};
await HttpContext.SignInAsync(issuer, localSignInProps);
await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.ClientId));
if (context != null)
if (await _clientStore.IsPkceClientAsync(context.ClientId))
return this.LoadingPage("Redirect", returnUrl);
return Redirect(returnUrl);
}
From the logs, I can tell that it's immediately failing after attempting to authenticate. There's no other errors, but a few interesting logs of note (in order):
Performing protect operation to key {xxxxxxxx-xxxx-xxxx-xxxx-b7e4d6dd250a} with purposes ('C:\websites\identity.ourdomain.com', 'Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware', 'idsrv.external', 'v2').
AuthenticationScheme: idsrv.external signed in.
Executing RedirectResult, redirecting to /External/Callback
Executing action method IdentityServer4.Quickstart.UI.ExternalController.Callback (Idsvr.Api) - Validation state: Valid
AuthenticationScheme: idsrv.external was not authenticated.
(Exception)
One of the possible root cause is that the callback cookie doesn't set properly.
Try to capture the network traffic, and check if idsrv.external cookie has been set correctly during Challenge.
In my case, setting cookie failed because SameSite=None is there without Secure=true.

Disable Bearer Token validation for a controller method

I'm trying to skip the token validation for a methods I want to make "public" on my API.
In my StartUp I got the below event to check if a call is authorized:
x.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var sessionManager = context.HttpContext.GetService<ISessionManager>();
if (!sessionManager.IsCurrentTokenValid())
{
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
var message = Encoding.UTF8.GetBytes("invalidToken");
context.Response.OnStarting(async () =>
{
await context.Response.Body.WriteAsync(message, 0, message.Length);
});
}
}
};
I've tried to remove [Authorize] attribute form the controller but the above code still triggers
Also tried to add [IgnoreAntiforgeryToken(Order = 1001)] on the method I want to skip the validation but still the above code triggers.
Do you know how can I disable it only for certain methods ?
Usually you decorate controllers or actions which you want to allow w/o authentication with [AllowAnonymous] (see docs).
If you have multiple authentications (Jwt, Cookie) and you want specific endpoints only allowed with a specific authentication, you use the scheme attribute, i.e. [Authorize(Scheme = "Cookie)].
Try to ignore token validation result if the the endpoint implements AllowAnonymous
x.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var sessionManager = context.HttpContext.GetService<ISessionManager>();
var endpoint = context.HttpContext.Features.Get<IEndpointFeature>()?.Endpoint;
var allowAnon = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
if (!allowAnon && !sessionManager.IsCurrentTokenValid())
{
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
var message = Encoding.UTF8.GetBytes("invalidToken");
context.Response.OnStarting(async () =>
{
await context.Response.Body.WriteAsync(message, 0, message.Length);
});
}
}
};

Redirect to IdP with added querystring parameter

I'm using IdentityServer4 and have configured an OpenId Connect provider. What I want to do is pass in a username to the provider as part of the querystring so that the provider pre-fills in the username field. I have both ADFS and Azure AD providers and would like this functionality to work with both. Is this possible and if so how?
In the Challenge method on ExternalController I've added what I think should work but it doesn't do anything:
[HttpGet]
public async Task<IActionResult> Challenge(string provider, string returnUrl, string user)
{
if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/";
if (Url.IsLocalUrl(returnUrl) == false && _interaction.IsValidReturnUrl(returnUrl) == false)
{
throw new Exception("invalid return URL");
}
if (AccountOptions.WindowsAuthenticationSchemeName == provider)
{
return await ProcessWindowsLoginAsync(returnUrl);
}
else
{
var props = new AuthenticationProperties
{
RedirectUri = Url.Action(nameof(Callback)),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", provider },
{ "login_hint", user }
}
};
return Challenge(props, provider);
}
}
You can achieve what you're looking for using the OnRedirectToIdentityProvider property of the OpenIdConnectEvents class:
Invoked before redirecting to the identity provider to authenticate. This can be used to set ProtocolMessage.State that will be persisted through the authentication process. The ProtocolMessage can also be used to add or customize parameters sent to the identity provider.
You hook into this process via the AddOpenIdConnect function, which is called when using services.AddAuthentication in Startup.ConfigureServices. Here's an example of what this might look like for your requirements:
services
.AddAuthentication(...)
.AddOpenIdConnect(options =>
{
...
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = ctx =>
{
if (ctx.HttpContext.Request.Query.TryGetValue("user", out var stringValues))
ctx.ProtocolMessage.LoginHint = stringValues[0];
return Task.CompletedTask;
}
};
});
Most of this is just the boilerplate code for adding authentication, OIDC and registering an event-handler for the event detailed above. The most interesting part is this:
if (ctx.HttpContext.Request.Query.TryGetValue("user", out var stringValues))
ctx.ProtocolMessage.LoginHint = stringValues[0];
As your Challenge action from your question gets user from a query-string parameter, the code above reads out the user query-string parameter from the request (there could be more than one, which is why we have a StringValues here) and sets it as the LoginHint property, if it's found.
Note: I've tested this with https://demo.identityserver.io (which works, of course).

Categories