Identityserver4 windows authentication failed on IIS but working in VS - iis

I am trying to implement Identityserver4 (version 4.0.0) with windows authentication. While running on visual studio its working correctly. When I deploy this to IIS windows popup is showing continuously (401 status) after entering credentials. Below is my code . I also tried to deploy Duende Software's sample source also but getting the same result. I think there is some configuration missing from my end. Kindly help me.
Program.cs
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
launchSettings.json
"windowsAuthentication": true,
ExternalController.cs
public async Task<IActionResult> Challenge(string scheme, string returnUrl)
{
if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/";
if(scheme == "Windows")
{
return await ChallengeWindowsAsync(returnUrl);
}
// validate returnUrl - either it is a valid OIDC URL or back to a local page
if (Url.IsLocalUrl(returnUrl) == false && _interaction.IsValidReturnUrl(returnUrl) == false)
{
// user might have clicked on a malicious link - should be logged
throw new Exception("invalid return URL");
}
// start challenge and roundtrip the return URL and scheme
var props = new AuthenticationProperties
{
RedirectUri = Url.Action(nameof(Callback)),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", scheme },
}
};
return Challenge(props, scheme);
}
//ChallengeWindowsAsync
private async Task<IActionResult> ChallengeWindowsAsync(string returnUrl)
{
// see if windows auth has already been requested and succeeded
var result = await HttpContext.AuthenticateAsync("Windows");
if (result?.Principal is WindowsPrincipal wp)
{
// we will issue the external cookie and then redirect the
// user back to the external callback, in essence, treating windows
// auth the same as any other external authentication mechanism
var props = new AuthenticationProperties()
{
RedirectUri = Url.Action("Callback"),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", "Windows" },
}
};
var id = new ClaimsIdentity("Windows");
// the sid is a good sub value
id.AddClaim(new Claim(JwtClaimTypes.Subject, wp.FindFirst(ClaimTypes.PrimarySid).Value));
// the account name is the closest we have to a display 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 = wp.Identity as WindowsIdentity;
// translate group SIDs to display names
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);
}
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");
}
}
IIS Configuration
Windows authentication is enabled

Related

Blazor Server App with Azure AD authentication - Token expired and Custom AuthenticationStateProvider

I have built a Blazor Server App with Azure AD authentication. This server app access a web api written in net core and sends the JWT token to that api. Everything is working, data is gathered, page is displayed accordingly.
The problem is: after some time, when user interacts with some menu option in UI, nothing else is returned from webapi. After some tests I found out that the token has expired, then when it is sent to web api, it is not working. But the AuthenticationState remains same, like it is authenticated and valid irrespective the token is expired.
Thus, I have been trying some suggestions like : Client side Blazor authentication token expired on server side. Actually it is the closest solution I got.
But the problem is that, after implemented a CustomAuthenticationStateProvider class, even after injected it, the default AuthenticationStateProvider of the app remains like ServerAuthenticationStateProvider and not the CustomAuthenticationStateProvider I have implemented. This is part of my code:
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly IConfiguration _configuration;
private readonly ITokenAcquisition _tokenAcquisition;
public CustomAuthenticationStateProvider(IConfiguration configuration, ITokenAcquisition tokenAcquisition)
{
_configuration = configuration;
_tokenAcquisition = tokenAcquisition;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var apiScope = _configuration["DownloadApiStream:Scope"];
var anonymousState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
string savedToken = string.Empty;
try
{
savedToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { apiScope });
}
catch (MsalUiRequiredException)
{
savedToken = string.Empty;
}
catch (Exception)
{
savedToken = string.Empty;
}
if (string.IsNullOrWhiteSpace(savedToken))
{
return anonymousState;
}
var claims = ParseClaimsFromJwt(savedToken).ToList();
var expiry = claims.Where(claim => claim.Type.Equals("exp")).FirstOrDefault();
if (expiry == null)
return anonymousState;
// The exp field is in Unix time
var datetime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expiry.Value));
if (datetime.UtcDateTime <= DateTime.UtcNow)
return anonymousState;
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")));
}
public void NotifyExpiredToken()
{
var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(anonymousUser));
NotifyAuthenticationStateChanged(authState);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var claims = new List<Claim>();
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles);
if (roles != null)
{
if (roles.ToString().Trim().StartsWith("["))
{
var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString());
foreach (var parsedRole in parsedRoles)
{
claims.Add(new Claim(ClaimTypes.Role, parsedRole));
}
}
else
{
claims.Add(new Claim(ClaimTypes.Role, roles.ToString()));
}
keyValuePairs.Remove(ClaimTypes.Role);
}
claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
return claims;
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
This is my Program.cs where I added the services :
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<CustomAuthenticationStateProvider>());
Here in the MainLayou.razor, I inject the service and try to use it :
#inject CustomAuthenticationStateProvider authenticationStateProvider;
protected async override Task OnInitializedAsync()
{
var authState = await authenticationStateProvider.GetAuthenticationStateAsync();
if (authState.User?.Identity == null || !authState.User.Identity.IsAuthenticated)
{
authenticationStateProvider.NotifyExpiredToken();
}
await base.OnInitializedAsync();
}
The problem comes up here, because the authenticationStateProvider is not an instance of the CustomAuthenticationStateProvider , but the instance of ServerAuthenticationStateProvider. It is like AuthenticationStateProvider was not replaced by the custom implementation, therefore I can't use the NotifyAuthenticationStateChanged and inform the CascadingAuthenticationState that it was changed.
If anyone has already been thru this or have any suggestion, it would be appreciated.
Actually I just wanna to change authentication state to not authenticated. So user will be pushed to login again using Azure AD.
Thanks

Blazor Server SignalR Chat works on Local, not on Azure

I have a working Blazor Server application with a Chat component running locally correctly, using signal R hub connection.
When deploying to Azure, I receive error Invalid negotiation response received.---> System.Text.Json.JsonReaderException: '0x1F' is an invalid start of a value.
Here is a similar linked ticket, but it was never answered: Blazor Server SignalR hub fails on StartAsync due to Azure ADB2C
Goal: Create a private chat feature for Blazor server application. I am unable to use singleton service because all users cant share the same instance of the service.
I have yet to find a sample where there is a messaging feature between users/usergroups in blazor server.
Since I am using Azure B2C auth with OIDC default authentication scheme, I have to manually pass the cookies and the headers.
Like I mentioned, this sample is working perfectly on localhost, when I open up two browsers (one in incognito), i am able to send messages between logged in users. When I publish to Azure App Service, however, I am unable to connect to the hub.
Code:
private HubConnection _hubConnection;
private User user;
ObservableCollection<Message> messages = new ObservableCollection<Message>();
SfTextBox MessageBox;
SfTextBox SendTo;
public class Message
{
public string Id { get; set; }
public string UserName { get; set; }
public string MessageText { get; set; }
public string Chat { get; set; }
}
protected override async Task OnInitializedAsync()
{
var state = await authenticationStateProvider.GetAuthenticationStateAsync();
user = state.ToUser();
_hubConnection = new HubConnectionBuilder()
.WithUrl(navigationManager.ToAbsoluteUri("/chatHub"), options =>
{
options.UseDefaultCredentials = true;
var httpContext = HttpContextAccessor.HttpContext;
var cookieCount = httpContext.Request.Cookies.Count();
var cookieContainer = new System.Net.CookieContainer(cookieCount);
foreach (var cookie in httpContext.Request.Cookies)
cookieContainer.Add(new System.Net.Cookie(
cookie.Key,
WebUtility.UrlEncode(cookie.Value),
path: httpContext.Request.Path,
domain: httpContext.Request.Host.Host));
options.Cookies = cookieContainer;
NameValueHeaderValue headers = null;
foreach (var header in httpContext.Request.Headers)
{
if (header.Key != ":method")
options.Headers.Add(header.Key, header.Value);
}
options.HttpMessageHandlerFactory = (input) =>
{
var clientHandler = new HttpClientHandler
{
PreAuthenticate = true,
CookieContainer = cookieContainer,
UseCookies = true,
UseDefaultCredentials = true,
};
return clientHandler;
};
})
.WithAutomaticReconnect()
.Build();
_hubConnection.On<string, string, string, string>("ReceiveMessage", (userName, from, to, message) =>
{
if (user.Email == to || user.Id == from)
{
messages.Add(new Message()
{
Id = Guid.NewGuid().ToString(),
MessageText = message,
Chat = user.Id == from ? "sender" : "receive",
UserName = user.Id == from ? "You" : userName
});
StateHasChanged();
}
});
await _hubConnection.StartAsync();
}
public async void Send()
{
if (MessageBox.Value != "" && SendTo.Value != "")
{
var userName = user.DisplayName;
var to = SendTo.Value;
var message = MessageBox.Value;
var from = user.Id;
_hubConnection.SendAsync("SendMessage", userName, from, to, message);
}
}
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
}
The issue is with authentication on azure platform. As you used manually giving cookies it worked fine on local, and when it comes to Azure platform, we need to provide authentication.
Follow the below link for support.
https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-invalid-json?pivots=dotnet-6-0
https://learn.microsoft.com/en-us/azure/azure-signalr/signalr-tutorial-build-blazor-server-chat-app
HttpContextAccessor.HttpContext is where the problem is.
It works on your local (IIS Express), but it does not work when the application is deployed to Azure (or IIS).
I posted a solution here: Solution: Custom SignalR Endpoints (Hubs) in a Blazor Server Application using Azure B2C When Deployed to Azure
Basically:
Grab all the Cookies at this point and put them in a collection of Cookies so they can be passed to the app.razor control.
<body>
#{
var CookieCollection = HttpContext.Request.Cookies;
Dictionary<string, string> Cookies = new Dictionary<string, string>();
foreach (var cookie in CookieCollection)
{
Cookies.Add(cookie.Key, cookie.Value);
}
}
<component type="typeof(App)" render-mode="ServerPrerendered" param-Cookies="Cookies" />
Set them as a CascadingValue that will be passed to all other Blazor controls.
Retrieve the collection of Cookies as a CascadingParameter and use those Cookies to manually set the Header and Cookies when creating the SignalR client.
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/chathub"), options =>
{
options.UseDefaultCredentials = true;
var cookieCount = Cookies.Count();
var cookieContainer = new CookieContainer(cookieCount);
foreach (var cookie in Cookies)
cookieContainer.Add(new Cookie(
cookie.Key,
WebUtility.UrlEncode(cookie.Value),
path: "/",
domain: Navigation.ToAbsoluteUri("/").Host));
options.Cookies = cookieContainer;
foreach (var header in Cookies)
options.Headers.Add(header.Key, header.Value);
options.HttpMessageHandlerFactory = (input) =>
{
var clientHandler = new HttpClientHandler
{
PreAuthenticate = true,
CookieContainer = cookieContainer,
UseCookies = true,
UseDefaultCredentials = true,
};
return clientHandler;
};
})
.WithAutomaticReconnect()
.Build();
hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
{
var encodedMsg = $"{user}: {message}";
messages.Add(encodedMsg);
InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();

How does Azure AD MutiTenant authentication works?

I want to enable Multitenant Authentication. My Code is in ASP.Net Webforms and Here is the StartUp.cs file code.
public partial class Startup
{
const string MSATenantId = "XXXXXXXXXXXXXXX";
private static string clientId = ConfigurationManager.AppSettings["ida:ClientID"];
private static string aadInstance = EnsureTrailingSlash(ConfigurationManager.AppSettings["ida:AADInstance"]);
private static string authority = aadInstance + "common";
public void ConfigureAuth(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions { });
// instead of using the default validation (validating against a single issuer value, as we do in line of business apps),
// we inject our own multitenant validation logic
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
ClientId = clientId,
Authority = authority,
TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new List<string>()
{
"https://sts/windows.net/XXXXXXXXXXXX"
}
// If the app needs access to the entire organization, then add the logic
// of validating the Issuer here.
// IssuerValidator
},
Notifications = new OpenIdConnectAuthenticationNotifications()
{
SecurityTokenValidated = (context) =>
{
//if (context.AuthenticationTicket.Identity.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value != MSATenantId)
//{
// context.HandleResponse();
// context.Response.Redirect("InvalidUser.aspx");
//}
// If your authentication logic is based on users
return Task.FromResult(0);
},
AuthenticationFailed = (context) =>
{
// Pass in the context back to the app
context.HandleResponse();
// Suppress the exception
return Task.FromResult(0);
}
},
});
// This makes any middleware defined above this line run before the Authorization rule is applied in web.config
app.UseStageMarker(PipelineStage.Authenticate);
}
//private Task OnSecurityTokenValidatedAsync(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
//{
// // Make sure that the user didn't sign in with a personal Microsoft account
// if (notification.AuthenticationTicket.Identity.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value == MSATenantId)
// {
// notification.HandleResponse();
// notification.Response.Redirect("/Account/UserMismatch");
// }
// return Task.FromResult(0);
//}
}
I want only the user with the MSATenantId should able to access the application for that I have read there are multiple ways I have tried below two though both are not working:
In this the application doesn't redirect to the Home page
TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new List<string>()
{
"https://sts/windows.net/XXXXXXXXXX"
}
// If the app needs access to the entire organization, then add the logic
// of validating the Issuer here.
// IssuerValidator
},
In this it doesn't redirect to invalid page.
SecurityTokenValidated = (context) =>
{
if (context.AuthenticationTicket.Identity.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value != MSATenantId)
{
context.HandleResponse();
context.Response.Redirect("InvalidUser.aspx");
}
If your authentication logic is based on users
return Task.FromResult(0);
},
Am I missing anything or do I need to add something in the above scenarios. I want to just test with one Tenant first and then I'll add more tenant.
Also, how does the 1 and 2 are different ?
This works if I don't use any of the above option. I am able to login with Azure account.
Your question has been resolved, add it as the answer to the end of the question.
Your issuer is set incorrectly, you should change it to: https://sts.windows.net/XXXXXXXXXXXX/.
I am able to resolve the issue. Issue was this url was incorrect https://sts/windows.net/XXXXXXXXXXXX
The correct URL is - https://sts.windows.net/XXXXXXXXXXXX/
TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new List<string>()
{
"https://sts.windows.net/XXXXXXXXXX/"
}
// If the app needs access to the entire organization, then add the logic
// of validating the Issuer here.
// IssuerValidator
},

Azure AD Authentication redirects to home page

I'm building an Azure AD authentication which works fine, the only issue is that if a user clicks on a link that looks like host.com/xxx/bbb if they're not authenticated then they get redirected to root.
I need them to still be redirected to the original URL they entered in the browser. Can can this be achieved? Below is a snippet of the code I use in app startup:
public void ConfigureAuth(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());
app.UseOpenIdConnectAuthentication(
new OpenIdConnectAuthenticationOptions
{
ClientId = XXX,
Authority = string.Format("https://login.microsoftonline.com/{0}", YYY,
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = async n => { await Task.Run(() => SetRedirectUrl(n)); }
}
});
}
private static void SetRedirectUrl(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
{
notification.ProtocolMessage.RedirectUri = notification.Request.Uri.AbsoluteUri;
}
None of the properties of OwinRequest contain a full path I'm looking for. I've also tried looking at
HttpContext.Current.Request.Url
but this also does not have the full address.
I need them to still be redirected to the original URL they entered in
the browser
You need to set the SignIn() method in AccountController.cs. You could use “Request.UrlReferrer.ToString();” to get the entered url:
public void SignIn()
{
// var host = Request.UserHostName.ToString();
var ori = Request.UrlReferrer.ToString();
var index = ori.LastIndexOf('/');
var action = ori.Substring(index);
// Send an OpenID Connect sign-in request.
if (!Request.IsAuthenticated)
{
HttpContext.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = action },
OpenIdConnectAuthenticationDefaults.AuthenticationType);
}
}

Getting Windows/Active Directory groups as role claims with Identity Server 4

I have got a basic Identity Server setup as per the UI sample project instructions on GitHub. I have it set it up to use Windows authentication with our on site AD. This is working beautifully.
My issue is with adding the users AD groups to the claims. As per the sample project I have enabled the IncludeWindowsGroups option. Which seems to be adding the claims to the ClaimsIdentity. However, on my MVC client, when I print out the claims I only ever get the same 4. They are sid, sub, idp and name. I have tried adding other claims but I can never get any others to show up.
I have the following as my Client Setup:
return new List<Client>
{
// other clients omitted...
// OpenID Connect implicit flow client (MVC)
new Client
{
ClientId = "mvc",
ClientName = "MVC Client",
AllowedGrantTypes = GrantTypes.Implicit,
// where to redirect to after login
RedirectUris = { "http://localhost:5002/signin-oidc" },
// where to redirect to after logout
PostLogoutRedirectUris = { "http://localhost:5002/signout-callback-oidc" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
},
RequireConsent = false
}
};
Hopefully I am just missing something simple but I am struggling for ideas now, so any pointers would be much appreciated.
I managed to get this working with a few changes, beyond setting IncludeWindowsGroups = true in the IdentityServer4 project. Note that I downloaded the IdentityServer4 UI quickstart as of the 2.2.0 tag
Per this comment in GitHub, I modified ExternalController.cs in the quickstart UI:
// this allows us to collect any additonal claims or properties
// for the specific prtotocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var roleClaims = claims.Where(c => c.Type == JwtClaimTypes.Role).ToList();
if (roleClaims.Count > 0)
{
additionalLocalClaims.AddRange(roleClaims);
}
I then created a profile service to copy the claims from Windows Auth into the token being sent back:
public class ProfileService : IProfileService
{
private readonly string[] _claimTypesToMap = {"name", "role"};
public Task GetProfileDataAsync(ProfileDataRequestContext context)
{
foreach (var claimType in _claimTypesToMap)
{
var claims = context.Subject.Claims.Where(c => c.Type == claimType);
context.IssuedClaims.AddRange(claims);
}
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context)
{
context.IsActive = true; //use some sort of actual validation here!
return Task.CompletedTask;
}
}
and registered with IdentityServer4 in Startup.cs
services
.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(StaticConfig.GetIdentityResources())
.AddInMemoryApiResources(StaticConfig.GetApiResources())
.AddInMemoryClients(StaticConfig.GetClients())
.AddTestUsers(StaticConfig.GetUsers())
.AddProfileService<ProfileService>();
In my client config in IdentityServer4, I set user claims to be included in the Id token. I found that if I tried to map the claims in the callback to UserInfo, that context was lost in IdentityServer4, so the claims wouldn't map.
public static class StaticConfig
{
public static IEnumerable<Client> GetClients()
{
return new List<Client>
{
new Client
{
...
AlwaysIncludeUserClaimsInIdToken = true,
...
}
}
}
}
Finally, in Startup.cs for the client website, I did not setup the UserInfo callback; I just made sure that my name and role claims were mapped. Note that if your profile service returns any other claim types, you need to manually map them with a call to a helper method on options.ClaimActions.
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.ClientId = "mvc";
options.ClientSecret = "secret";
options.SaveTokens = true;
options.ResponseType = "code id_token";
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
//map any other app-specific claims we're getting from IdentityServer
options.ClaimActions.MapUniqueJsonKey("someotherclaimname", "someotherclaimname");
};

Resources