My single page application uses OidcTokenManager to connect to an IdentityServer3 STS using implicit flow. The client presents the IDS3 access token to a ASP.NET Core (WebApi) web service as a Bearer Token; the web service application is configured to use IDS3 middleware and restricts access to its methods using an Authorize attribute.
SPA client configuration:
function configureTokenManager() {
console.log("configureTokenManager()");
var config = {
authority: $config.authority,
client_id: "BNRegistry",
redirect_uri: $config.webRoot + "/#/authorised/",
post_logout_redirect_uri: $config.webRoot + "/#/",
response_type: "id_token token",
scope: "openid profile email BNApi",
silent_redirect_uri: $config.webRoot + "/#/renew/",
silent_renew: true,
filter_protocol_claims: false
};
return new OidcTokenManager(config);
};
Scope configuration in STS:
new Scope
{
Name = "BNApi",
DisplayName = "BN Api",
Enabled = true,
Type = ScopeType.Resource,
Claims = new List<ScopeClaim>
{
new ScopeClaim(Constants.ClaimTypes.Name),
new ScopeClaim(Constants.ClaimTypes.Role)
}
}
WebApi configuration:
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions
{
Authority = Configuration["Authority"],
RequiredScopes = new[] {"BNApi"},
NameClaimType = IdentityModel.JwtClaimTypes.Name,
RoleClaimType = IdentityModel.JwtClaimTypes.Role
});
WebApi method:
[Authorize]
public IActionResult Get()
{
...
}
This works as expected, rejecting an unauthenticated user with a 401. If I examine the claims for the user in the api controller method, (eg. User.Claims.ToList()), it contains entries for any roles to which the user has been assigned.
However, if I examine the User.Identity.Name property it is always null, and if I query User.IsInRole("Administrator") it is always false, even when the user is assigned to that role. Further, if I add a role name to the Authorize attribute ([Authorize(Role="Administrator")]), users are rejected with a 401 whether or not they belong to the stated role.
How can I get IdentityServer3 to play nicely with ASP.NET Role authorisation?
Have you tried resetting the InboundClaimTypeMap?
From the IdentityServer3 documentation page here:
When you inspect the claims on the about page, you will notice two
things: some claims have odd long type names and there are more claims
than you probably need in your application.
The long claim names come from Microsoft’s JWT handler trying to map
some claim types to .NET's ClaimTypes class types.
Unfortunately this mapping ends up breaking the specific claim names you have defined as name and role, because their names get transformed and no longer map to what you were expecting. This results in the [Authorize(Roles = "")] and User.IsInRole("") not working as expected.
In your API Startup.cs you should add the following:
JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions()
{
...
});
EDIT: The below information was incorrect!. As pointed out by #Paul Taylor "the AlwaysInclude property ensures that the relevant claim is always present in an identity token (which is used with the client, not the API). This is a resource scope so the property has no effect.". Thanks for helping me understand a little more about how IdentityServer works :-)
For the Name and Role claims to be included when accessing the API, you need to specifically mark them as alwaysInclude in your ScopeClaim list.
new Scope
{
Name = "BNApi",
DisplayName = "BN Api",
Enabled = true,
Type = ScopeType.Resource,
Claims = new List<ScopeClaim>
{
new ScopeClaim(Constants.ClaimTypes.Name, true), //<-- Add true here
new ScopeClaim(Constants.ClaimTypes.Role, true) // and here!
}
}
Related
I'm using Azure Active Directory to provide authentication to the Backoffice on my website running Umbraco version 11.0.
This is working nicely and I can log in but I want to improve the experience by using app roles within Azure to manage the user's group within Umbraco.
My Azure setup
I've created an App Registration within Azure with the following configuration:
Added a Redirection URI:
URI: https://localhost:44391/umbraco-signin-microsoft/
Enabled Access tokens (used for implicit flows)
Enabled ID tokens (used for implicit and hybrid flows)
Supported account types: Accounts in this organizational directory only (Example only - Single tenant)
Added App Roles
Administrator
Editor
In Enterprise Applications, I've also added the App Roles above to my users:
My code
Login Provider
namespace Example.Api.Features.Authentication.Extensions;
public static class UmbracoBuilderExtensions
{
public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder)
{
builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
builder.AddBackOfficeExternalLogins(logins =>
{
const string schema = MicrosoftAccountDefaults.AuthenticationScheme;
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
backOfficeAuthenticationBuilder.AddMicrosoftAccount(
// the scheme must be set with this method to work for the back office
backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName) ?? string.Empty,
options =>
{
//By default this is '/signin-microsoft' but it needs to be changed to this
options.CallbackPath = "/umbraco-signin-microsoft/";
//Obtained from the AZURE AD B2C WEB APP
options.ClientId = "CLIENT_ID";
//Obtained from the AZURE AD B2C WEB APP
options.ClientSecret = "CLIENT_SECRET";
options.TokenEndpoint = $"https://login.microsoftonline.com/TENANT/oauth2/v2.0/token";
options.AuthorizationEndpoint = $"https://login.microsoftonline.com/TENANT/oauth2/v2.0/authorize";
});
});
});
return builder;
}
}
Auto-linking accounts
namespace Example.Api.Features.Configuration;
public class OpenIdConnectBackOfficeExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
public const string SchemeName = "OpenIdConnect";
public void Configure(string name, BackOfficeExternalLoginProviderOptions options)
{
if (name != "Umbraco." + SchemeName)
{
return;
}
Configure(options);
}
public void Configure(BackOfficeExternalLoginProviderOptions options)
{
options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(
// must be true for auto-linking to be enabled
autoLinkExternalAccount: true,
// Optionally specify default user group, else
// assign in the OnAutoLinking callback
// (default is editor)
defaultUserGroups: new[] { Constants.Security.EditorGroupAlias },
// Optionally you can disable the ability to link/unlink
// manually from within the back office. Set this to false
// if you don't want the user to unlink from this external
// provider.
allowManualLinking: false
)
{
// Optional callback
OnAutoLinking = (autoLinkUser, loginInfo) =>
{
// You can customize the user before it's linked.
// i.e. Modify the user's groups based on the Claims returned
// in the externalLogin info
autoLinkUser.IsApproved = true;
},
OnExternalLogin = (user, loginInfo) =>
{
// You can customize the user before it's saved whenever they have
// logged in with the external provider.
// i.e. Sync the user's name based on the Claims returned
// in the externalLogin info
return true; //returns a boolean indicating if sign in should continue or not.
}
};
// Optionally you can disable the ability for users
// to login with a username/password. If this is set
// to true, it will disable username/password login
// even if there are other external login providers installed.
options.DenyLocalLogin = true;
// Optionally choose to automatically redirect to the
// external login provider so the user doesn't have
// to click the login button. This is
options.AutoRedirectLoginToExternalProvider = true;
}
}
In this file, I'd ideally do as the comment says and i.e. Modify the user's groups based on the Claims returned in the externalLogin info.
Also registered in my Startup file
services.AddUmbraco(_env, _config)
.AddBackOffice()
.AddWebsite()
.AddComposers()
.ConfigureAuthentication()
.Build();
I've attempted to give the following permissions to the application, with no luck:
Current state of play is that I can login just fine but if I debug externalInfo, there's nothing in there about the users having either the Administrator or Editor App Role as configured above.
My gut feeling is that I'm missing something with the Azure Active Directory setup but I've tried a few different configurations and can't seem to get the App Roles to come back.
Thanks,
Ben
EDIT - 15.02.2023:
I can see that the roles come back when I hit the https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token endpoint using client_credentials as the grant_type. It looks like the .NET application using authorization_code instead. I've decoded the token retrieved from this and it doesn't contain the roles.
I wonder if there's some kind of configuration on the .NET application that allows me to add the roles.
To get App Roles in token claims, you can use client credentials flow to generate access tokens by granting admin consent.
I tried to reproduce the same in my environment via Postman and got the below results:
I registered one Azure AD web application and created App roles like below:
Now I assigned these App roles to users under it's Enterprise application like below:
Add these App roles in API permissions of application like below:
You can see App roles under Application permissions like below:
Make sure to grant admin consent to above permissions like this:
While generating access token, scope should be your Application ID URI ending with /.default
Now, I generated access token using client credentials flow via Postman with below parameters:
POST https://login.microsoftonline.com/<tenantID>/oauth2/v2.0/token
client_id: <appID>
grant_type:client_credentials
scope: api://<appID>/.default
client_secret: secret
Response:
When I decoded the above token in jwt.ms, I got App roles in roles claim successfully like below:
Note that App roles are Application permissions that will work only with flows like client credentials which do not involve user interaction.
So, if you use delegated flows like authorization code flow, username password flow, etc..., you won't get App roles in token claims.
UPDATE:
You can use below c# code in getting access token from client credentials flow like this:
using Microsoft.Identity.Client;
var clientID = "bbb739ad-98a4-4566-8408-dxxxxxxxx3b";
var clientSecret = "K.k8Q~hwtxxxxxxxxxxxxxxxU";
var tenantID = "fb134080-e4d2-45f4-9562-xxxxxx";
var authority = $"https://login.microsoftonline.com/{tenantID}";
var clientApplication = ConfidentialClientApplicationBuilder.Create(clientID)
.WithClientSecret(clientSecret)
.WithAuthority(authority)
.Build();
var scopes = new string[] { "api://bbb739ad-98a4-4566-8408-xxxxxx/.default" };
var authenticationResult = await clientApplication.AcquireTokenForClient(scopes)
.ExecuteAsync()
.ConfigureAwait(false);
var accesstoken = authenticationResult.AccessToken;
Console.WriteLine(accesstoken);
Response:
When I decoded the above token, it has roles claim with App roles like below:
To solve this, I ended up swapping out the AddMicrosoftAccount AuthenticationBuilder in favour of AddOpenIdConnect. This appears to respect the claims in the tokens.
This is the code I am now using in the ConfigureAuthentication method.
public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder)
{
// Register OpenIdConnectBackOfficeExternalLoginProviderOptions here rather than require it in startup
builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
builder.AddBackOfficeExternalLogins(logins =>
{
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
backOfficeAuthenticationBuilder.AddOpenIdConnect(
// The scheme must be set with this method to work for the back office
backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName),
options =>
{
options.CallbackPath = "/umbraco-signin-microsoft/";
// use cookies
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// pass configured options along
options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0";
options.ClientId = "{clientId}";
options.ClientSecret = "{clientSecret}";
// Use the authorization code flow
options.ResponseType = OpenIdConnectResponseType.Code;
options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
// map claims
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
options.RequireHttpsMetadata = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.UsePkce = true;
options.Scope.Add("email");
});
});
});
return builder;
}
I've created a C# ASP .Net Core 6.0 application, and trying to implement SSO with Azure AD using Sustainsys.Saml2, specifically with the Sustainsys.Saml2.AspNetCore2 package. Having tested the implementation on my development machine with localhost, I can see it works as expected and authenticates the user, populates the Identity model, and redirects to correct URL.
However, when deployed into the test environment, using a dockerized version, the behaviour changes. When triggering SSO, the user is authenticated successfully in Azure, but when returning to the app, it returns an Error 500 at the Saml2/Acs endpoint. Reviewing the logs show no indication of any errors, and instead report successful authentication for the user.
The Program.cs configuration:
builder.Services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme = "Saml2";
})
.AddSaml2(options =>
{
var logger = new LoggerFactory();
options.SPOptions.Logger = new AspNetCoreLoggerAdapter(logger.CreateLogger<Saml2Handler>());
options.SPOptions.EntityId = new EntityId(AppConfig.Saml_EntityID);
options.IdentityProviders.Add(
new IdentityProvider(new EntityId(AppConfig.Saml_AzureID), options.SPOptions)
{
Binding = Saml2BindingType.HttpRedirect,
LoadMetadata = true,
MetadataLocation = AppConfig.Saml_Metadata,
DisableOutboundLogoutRequests = false,
AllowUnsolicitedAuthnResponse = true
});
options.SPOptions.PublicOrigin = new Uri(AppConfig.BaseUrl);
options.SPOptions.ReturnUrl = new Uri(AppConfig.BaseUrl);
options.SPOptions.WantAssertionsSigned = true;
options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always;
options.SPOptions.ServiceCertificates.Add(new X509Certificate2(AppConfig.Saml_Cert_Path));
})
.AddCookie();
While troubleshooting the issue, I stumbled across some confusing behaviour that may or may not indicate what may be the issue. If I follow the following steps, I can end up at a point where the user is authenticated and can use the applications:
Click 'Login' to trigger the Saml authentication.
Hit the Error 500 at Saml2/Acs.
Click 'refresh', and 'continue' to resubmit the request.
The browser then continues to the intended URL, but says 'Connection Refused'
Use the browser back buttons to return to the application home screen, and refresh the page... Viola! Logged in!
Furthermore, when inspecting the request headers on the Saml2/Acs endpoint, I can see a Saml response is returned, which I can manually decode from base64 and read the correct information!
As mentioned, the logs don't mention any errors, just:
Initiating login to https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/
and
Successfully processed SAML response _ba082bb8-7d2c-4aa4-a7dc-b1520312d084 and authenticated a*******#********.com
Any assistance, or guidance to a resolution would be much appreciated!
Maybe this is not relevant for .Net Core, but for .net framework 4.8
there was the following issues:
ReturnUrl of Service Provider was wrong: http://locahost/mysite/saml2/acs instead of correct one http://locahost/mysite/ (with trailing slash). Because of this, there was indefinite loop to http://locahost/mysite/saml2/acs`.
SPOptions spOptions = new SPOptions() { EntityId = new EntityId(spMetadataUrl), ReturnUrl = new Uri(hostUrl + "/"), DiscoveryServiceUrl = new Uri(hostUrl + #"/DiscoveryService"), Organization = organization, AuthenticateRequestSigningBehavior = SigningBehavior.Never, RequestedAuthnContext = requestedAuthnContext, Logger = logger, PublicOrigin = hostUri };
DO NOT USE UseExternalSignInCookie mehod, otherwise ClaimPrincipal will not set for current Thread (cookies will be parsed to claims, although latter will not be set, this can be checked with code below):
Saml2AuthenticationOptions options = CreateSaml2Options(configuration, certificate); options.SPOptions.Saml2PSecurityTokenHandler = new MySaml2PSecurityTokenHandler();
public class MySaml2PSecurityTokenHandler : Sustainsys.Saml2.Saml2P.Saml2PSecurityTokenHandler
{
protected override ClaimsIdentity CreateClaimsIdentity(Saml2SecurityToken samlToken, string issuer, TokenValidationParameters validationParameters)
{
ClaimsIdentity identity = base.CreateClaimsIdentity(samlToken, issuer, validationParameters);
Claim claim = new Claim("Name", "jon.doe");
Claim[] claims = new Claim[] { claim };
identity.AddClaims(claims);
return identity;
}
}
Additionally only for Net Framework 4.8, because of owin vs System.Web cookies bug, CookieManager should be used. Code:
var cookieManager = new Microsoft.Owin.Host.SystemWeb.SystemWebCookieManager();
Saml2AuthenticationOptions options = CreateSaml2Options(configuration, certificate);
CookieAuthenticationOptions cookieAuthentication = new CookieAuthenticationOptions()
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString(configuration.ServiceProviderSignOnUrl),
CookieManager = сookieManager,
ReturnUrlParameter = GetBase() + "/",
Provider = new CookieAuthenticationProvider()
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = (context) =>
{
var newIdentity = new ClaimsIdentity(context.Identity);
int newcount = 1;
newIdentity.AddClaim(new Claim("SIMPLECOUNT", newcount.ToString()));
context.ReplaceIdentity(newIdentity);
return Task.FromResult<object>(null);
}
}
};
app.UseCookieAuthentication(cookieAuthentication);
app.SetDefaultSignInAsAuthenticationType(cookieAuthentication.AuthenticationType);
//app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
app.UseSaml2Authentication(options);
I'm building a web application which uses the cookie authentication built into ASP.NET Core 2.1.
I have my own sign in method which queries my own custom password verification and setting of claims. Roughly it looks like this:
public async Task<ActionResult<LoginResponse>> DoLogin([FromBody] LoginRequest req)
{
// fetch account and verify password
var claims = new List<Claim>
{
new Claim(ClaimTypes.Sid, account.AccountId.ToString(), ClaimValueTypes.Integer),
new Claim(ClaimTypes.Email, account.EmailAddress, ClaimValueTypes.Email),
new Claim(ClaimTypes.Role, "member", ClaimValueTypes.String)
};
var identity = new ClaimsIdentity(claims, "password");
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return new LoginResponse
{
Success = true
};
}
I would like to conditionally render a "Log Out" button on various parts of the site if the user has the cookie which authenticates the user. Additionally I'd like to fetch the Sid claim so I can deliver personalized messaging on some public parts of the site.
The problem I have is that the way I have been fetching the Sid only works if my controller or controller action has an [Authorize] attribute on it. Without the [Authorize] attribute, the claim is missing.
Code:
public static int? GetNullableAccountId(this ClaimsPrincipal principal)
{
var claim = principal.FindFirst((Claim c) => { return c.Type == ClaimTypes.Sid; });
if (claim == null)
return null;
return int.Parse(claim.Value);
}
// then in the controller I try to get the account id:
var accountId = accessor.HttpContext.User.GetNullableAccountId();
// always null even when I have a valid cookie
I swear that I didn't need the [Authorize] attribute for this to work in prior versions of ASP.NET Core, but I couldn't find anything meaningful in change logs.
Is there some trick to getting ASP.NET Core to build the user identity on all calls or am I taking the wrong approach all together?
It seems it was a silly mistake. I was invoking app.UseAuthentication() after app.UseMvc() when configuring my application builder.
The documentation actually explicitly states the following:
Call the UseAuthentication method before calling UseMvcWithDefaultRoute or UseMvc
Source: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-2.2#configuration
I am using ASP.NET Core 2.0, with Azure AD v2.0 endpoint.
I am getting claims like this:
var currentUser = User;
var displayName = currentUser.FindFirst("name").Value;
var claims = currentUser.Claims;
I am not used to using this User to get claims, but could not get the old way with System.Security.Claims to work. So my first question is, is this how I should be getting my claims? And my second question is, how do I add claims to this User?
is this how I should be getting my claims?
AFAIK, you could leverage ControllerBase.HttpContext.User or ControllerBase.User for retrieving the System.Security.Claims.ClaimsPrincipal for current user. Details you could follow the similar issue1 and issue2.
And my second question is, how do I add claims to this User?
As you said you are using ASP.NET Core 2.0, with Azure AD v2.0. I assumed that when using UseOpenIdConnectAuthentication, you could add the additional claims under OnTokenValidated as follows:
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
ClientId = Configuration["AzureAD:ClientId"],
Authority = string.Format(CultureInfo.InvariantCulture, Configuration["AzureAd:AadInstance"], "common", "/v2.0"),
ResponseType = OpenIdConnectResponseType.IdToken,
PostLogoutRedirectUri = Configuration["AzureAd:PostLogoutRedirectUri"],
Events = new OpenIdConnectEvents
{
OnRemoteFailure = RemoteFailure,
OnTokenValidated = TokenValidated
},
TokenValidationParameters = new TokenValidationParameters
{
// 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
ValidateIssuer = false,
NameClaimType = "name"
}
});
private Task TokenValidated(TokenValidatedContext context)
{
/* ---------------------
// Replace this with your logic to validate the issuer/tenant
---------------------
// Retriever caller data from the incoming principal
string issuer = context.SecurityToken.Issuer;
string subject = context.SecurityToken.Subject;
string tenantID = context.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
// Build a dictionary of approved tenants
IEnumerable<string> approvedTenantIds = new List<string>
{
"<Your tenantID>",
"9188040d-6c67-4c5b-b112-36a304b66dad" // MSA Tenant
};
o
if (!approvedTenantIds.Contains(tenantID))
throw new SecurityTokenValidationException();
--------------------- */
var claimsIdentity=(ClaimsIdentity)context.Ticket.Principal.Identity;
//add your custom claims here
claimsIdentity.AddClaim(new Claim("test", "helloworld!!!"));
return Task.FromResult(0);
}
Then, I used the following code to retrieve the user claims:
public IActionResult UserInfo()
{
return Json(User.Claims.Select(c=>new {type=c.Type,value=c.Value}).ToList());
}
Test:
Moreover, you could refer to this sample Integrating Azure AD (v2.0 endpoint) into an ASP.NET Core web app.
I've asked a question before and the answer that was given was correct but the farther I go down this rabbit hole the more I realize; I don't think I was asking the right question.
Let me just explain this in the most simple terms I can... I have a AngularJS single page app (client), that points at an asp.net webapi (OWIN) site (Resource server?), and a separate asp.net "authorization/authentiation" server.
The auth server will provide authentication and authorization for multiple applications. I need to be able to use the Authorize attribute in the resource server, as well as get a token from from angular. I also need to use windows authentication (integrated) for everything, no usernames or passwords. The claims information is stored in a database and needs to be added to the token.
I've done a SSO style claims authoriztion implementation in asp.net core using openiddict with JwtBearerToken and 'password flow?' And wanted to try to do something similar (token, etc). I have a basic understanding of how that works from my previous implmentation, but I am completely lost trying to figure out how to get JWT working with Windows Auth. The answer to my previous question provided some good suggestions but I am having a hard time seeing how that applies in this scenario.
Currently I have been trying to get IdentityServer3 to do this, using the WindowsAuthentication extensions, mainly pulled from the samples. But I am really struggling to tie this together with the client and actually get something working. The current client and server code is below, mind you I really don't know if this is even close to the correct solution.
Client:
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Passive,
AuthenticationType = "windows",
Authority = "http://localhost:21989",
ClientId = "mvc.owin.implicit",
ClientSecret = "api-secret",
RequiredScopes = new[] { "api" }
});
AuthServer:
app.Map("/windows", ConfigureWindowsTokenProvider);
app.Use(async (context, next) =>
{
if (context.Request.Uri.AbsolutePath.EndsWith("/token", StringComparison.OrdinalIgnoreCase))
{
if (context.Authentication.User == null ||
!context.Authentication.User.Identity.IsAuthenticated)
{
context.Response.StatusCode = 401;
return;
}
}
await next();
});
var factory = new IdentityServerServiceFactory()
.UseInMemoryClients(Clients.Get())
.UseInMemoryScopes(Scopes.Get());
var options = new IdentityServerOptions
{
SigningCertificate = Certificate.Load(),
Factory = factory,
AuthenticationOptions = new AuthenticationOptions
{
EnableLocalLogin = false,
IdentityProviders = ConfigureIdentityProviders
},
RequireSsl = false
};
app.UseIdentityServer(options);
private static void ConfigureWindowsTokenProvider(IAppBuilder app)
{
app.UseWindowsAuthenticationService(new WindowsAuthenticationOptions
{
IdpReplyUrl = "http://localhost:21989",
SigningCertificate = Certificate.Load(),
EnableOAuth2Endpoint = false
});
}
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
var wsFederation = new WsFederationAuthenticationOptions
{
AuthenticationType = "windows",
Caption = "Windows",
SignInAsAuthenticationType = signInAsType,
MetadataAddress = "http://localhost:21989",
Wtrealm = "urn:idsrv3"
};
app.UseWsFederationAuthentication(wsFederation);
}
EDIT: I see the auth endpoints requests for "/.well-known/openid-configuration" as well as "/.well-known/jwks" and I have the Authorize attribute on a controller action which is being called, but I dont see anything else happening on the auth side. I also added a ICustomClaimsProvider implmentation to the usewindowsauthservice WindowsAuthenticationOptions but that doesnt even get called.
I've done a SSO style claims authoriztion implementation in asp.net core using openiddict with JwtBearerToken and 'password flow?'
If you were to use OpenIddict with Windows authentication, it would be quite easy to implement using the OAuth2/OpenID Connect implicit flow (which is the most appropriate flow for a JS app), without needing any WS-Federation proxy:
Startup configuration:
public void ConfigureServices(IServiceCollection services)
{
// Register the OpenIddict services.
services.AddOpenIddict(options =>
{
// Register the Entity Framework stores.
options.AddEntityFrameworkCoreStores<ApplicationDbContext>();
// Register the ASP.NET Core MVC binder used by OpenIddict.
// Note: if you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
options.AddMvcBinders();
// Enable the authorization endpoint.
options.EnableAuthorizationEndpoint("/connect/authorize");
// Enable the implicit flow.
options.AllowImplicitFlow();
// During development, you can disable the HTTPS requirement.
options.DisableHttpsRequirement();
// Register a new ephemeral key, that is discarded when the application
// shuts down. Tokens signed using this key are automatically invalidated.
// This method should only be used during development.
options.AddEphemeralSigningKey();
});
// Note: when using WebListener instead of IIS/Kestrel, the following lines must be uncommented:
//
// services.Configure<WebListenerOptions>(options =>
// {
// options.ListenerSettings.Authentication.AllowAnonymous = true;
// options.ListenerSettings.Authentication.Schemes = AuthenticationSchemes.Negotiate;
// });
}
Authorization controller:
public class AuthorizationController : Controller
{
// Warning: extreme caution must be taken to ensure the authorization endpoint is not included in a CORS policy
// that would allow an attacker to force a victim to silently authenticate with his Windows credentials
// and retrieve an access token using a cross-domain AJAX call. Avoiding CORS is strongly recommended.
[HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request)
{
// Retrieve the Windows principal: if a null value is returned, apply an HTTP challenge
// to allow IIS/WebListener to initiate the unmanaged integrated authentication dance.
var principal = await HttpContext.Authentication.AuthenticateAsync(IISDefaults.Negotiate);
if (principal == null)
{
return Challenge(IISDefaults.Negotiate);
}
// Note: while the principal is always a WindowsPrincipal object when using Kestrel behind IIS,
// a WindowsPrincipal instance must be manually created from the WindowsIdentity with WebListener.
var ticket = CreateTicket(request, principal as WindowsPrincipal ?? new WindowsPrincipal((WindowsIdentity) principal.Identity));
// Immediately return an authorization response without displaying a consent screen.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
private AuthenticationTicket CreateTicket(OpenIdConnectRequest request, WindowsPrincipal principal)
{
// Create a new ClaimsIdentity containing the claims that
// will be used to create an id_token, a token or a code.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme);
// Note: the JWT/OIDC "sub" claim is required by OpenIddict
// but is not automatically added to the Windows principal, so
// the primary security identifier is used as a fallback value.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, principal.GetClaim(ClaimTypes.PrimarySid));
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims)
{
// In this sample, every claim is serialized in both the access and the identity tokens.
// In a real world application, you'd probably want to exclude confidential claims
// or apply a claims policy based on the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
// Copy the claim from the Windows principal to the new identity.
identity.AddClaim(claim);
}
// Create a new authentication ticket holding the user identity.
return new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
}
}
A similar scenario can be implemented in legacy ASP.NET apps using the OWIN/Katana version of ASOS, the OpenID Connect server middleware behind OpenIddict:
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.UseOpenIdConnectServer(options =>
{
// Register a new ephemeral key, that is discarded when the application
// shuts down. Tokens signed using this key are automatically invalidated.
// This method should only be used during development.
options.SigningCredentials.AddEphemeralKey();
// Enable the authorization endpoint.
options.AuthorizationEndpointPath = new PathString("/connect/authorize");
// During development, you can disable the HTTPS requirement.
options.AllowInsecureHttp = true;
// Implement the ValidateAuthorizationRequest event to validate the response_type,
// the client_id and the redirect_uri provided by the client application.
options.Provider.OnValidateAuthorizationRequest = context =>
{
if (!context.Request.IsImplicitFlow())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedResponseType,
description: "The provided response_type is invalid.");
return Task.FromResult(0);
}
if (!string.Equals(context.ClientId, "spa-application", StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "The provided client_id is invalid.");
return Task.FromResult(0);
}
if (!string.Equals(context.RedirectUri, "http://spa-app.com/redirect_uri", StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "The provided redirect_uri is invalid.");
return Task.FromResult(0);
}
context.Validate();
return Task.FromResult(0);
};
// Implement the HandleAuthorizationRequest event to return an implicit authorization response.
options.Provider.OnHandleAuthorizationRequest = context =>
{
// Retrieve the Windows principal: if a null value is returned, apply an HTTP challenge
// to allow IIS/SystemWeb to initiate the unmanaged integrated authentication dance.
var principal = context.OwinContext.Authentication.User as WindowsPrincipal;
if (principal == null)
{
context.OwinContext.Authentication.Challenge();
return Task.FromResult(0);
}
// Create a new ClaimsIdentity containing the claims that
// will be used to create an id_token, a token or a code.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationType);
// Note: the JWT/OIDC "sub" claim is required by OpenIddict
// but is not automatically added to the Windows principal, so
// the primary security identifier is used as a fallback value.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, principal.GetClaim(ClaimTypes.PrimarySid));
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims)
{
// In this sample, every claim is serialized in both the access and the identity tokens.
// In a real world application, you'd probably want to exclude confidential claims
// or apply a claims policy based on the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
// Copy the claim from the Windows principal to the new identity.
identity.AddClaim(claim);
}
context.Validate(identity);
return Task.FromResult(0);
};
});
}
}
The client-side code shouldn't be different from any other JS application using the implicit flow. You can take a look at this sample to see how you can implement it with the oidc-client JS library: https://github.com/openiddict/openiddict-samples/tree/master/samples/ImplicitFlow/AureliaApp
So ultimately the whole point here was to augment claims on the existing ClaimsPrincipal with claims from the database and hopefully be able to use JWT's in the javascript. I was unable to get that to work using IdentityServer3. I ended up rolling my own rudimentary solution by implementing IAuthenticationFilter and IAuthorizationFilter using an attribute on the actions to supply the claim name.
First the authorize attribute does nothing but take the name of the claim that the user should have to access the action.
public class AuthorizeClaimAttribute : Attribute
{
public string ClaimValue;
public AuthorizeClaimAttribute(string value)
{
ClaimValue = value;
}
}
Then the Authorize filter which does nothing but check to see if the user has the claim from the attribute.
public class AuthorizeClaimFilter : AuthorizeAttribute, IAuthorizationFilter
{
private readonly string _claimValue;
public AuthorizeClaimFilter(string claimValue)
{
_claimValue = claimValue;
}
public override async Task OnAuthorizationAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
{
var p = actionContext.RequestContext.Principal as ClaimsPrincipal;
if(!p.HasClaim("process", _claimValue))
HandleUnauthorizedRequest(actionContext);
await Task.FromResult(0);
}
protected override void HandleUnauthorizedRequest(HttpActionContext actionContext)
{
actionContext.Response = new HttpResponseMessage(HttpStatusCode.Forbidden);
}
}
The Authentication filter which calls the webapi endpoint (which is using windows authentication) to get the users list of custom "claims" from the database. The WebAPI is just a standard webapi instance, nothing special at all.
public class ClaimAuthenticationFilter : ActionFilterAttribute, IAuthenticationFilter
{
public ClaimAuthenticationFilter()
{
}
public async Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken)
{
if (context.Principal != null && context.Principal.Identity.IsAuthenticated)
{
var windowsPrincipal = context.Principal as WindowsPrincipal;
var handler = new HttpClientHandler()
{
UseDefaultCredentials = true
};
HttpClient client = new HttpClient(handler);
client.BaseAddress = new Uri("http://localhost:21989");// to be stored in config
var response = await client.GetAsync("/Security");
var contents = await response.Content.ReadAsStringAsync();
var claimsmodel = JsonConvert.DeserializeObject<List<ClaimsModel>>(contents);
if (windowsPrincipal != null)
{
var name = windowsPrincipal.Identity.Name;
var identity = new ClaimsIdentity();
foreach (var claim in claimsmodel)
{
identity.AddClaim(new Claim("process", claim.ClaimName));
}
var claimsPrincipal = new ClaimsPrincipal(identity);
context.Principal = claimsPrincipal;
}
}
await Task.FromResult(0);
}
public async Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken)
{
var challenge = new AuthenticationHeaderValue("Negotiate");
context.Result = new ResultWithChallenge(challenge, context.Result);
await Task.FromResult(0);
}
}
The filters are bound to the attribute using my DI framework (ninject in this case).
this.BindHttpFilter<AuthorizeClaimFilter>(FilterScope.Action)
.WhenActionMethodHas<AuthorizeClaimAttribute>()
.WithConstructorArgumentFromActionAttribute<AuthorizeClaimAttribute>("claimValue", o => o.ClaimValue);
This works for my purposes, and the web api endpoint consumable both in the WebAPI instance and in the AngularJS app. However it is obviously NOT ideal. I really would have preferred to use 'real' authentication/authorization processes. I hesitate to say this is the answer to the question, but it is the only solution I could come up with the time that I had to make something work.