I'm trying to implement client certificate authentication with ASP.NET and IdentityServer4, but can't seem to make it work. Through Postman I get "Error: invalid_client", in debug console "Client secret validation failed for client: ISCCA.". I'm running the application with Kestrel on localhost.
Based on documentation and examples i've been through, this is my result so far:
Kestrel configuration:
webBuilder.ConfigureKestrel(builderOptions => {
builderOptions.ConfigureHttpsDefaults(httpOptions => {
httpOptions.AllowAnyClientCertificate();
httpOptions.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
httpOptions.CheckCertificateRevocation = false;
});
});
Identity server configuration with in memory clients, resources and scopes:
services
.AddIdentityServer(options => {
// MTLS for client certificate authentication endpoints with default scheme set as Certificate
options.MutualTls.Enabled = true;
options.MutualTls.ClientCertificateAuthenticationScheme = CertificateAuthenticationDefaults.AuthenticationScheme;
// Use subdomain endpoints (mtls.host)
options.MutualTls.DomainName = "mtls";
})
.AddMutualTlsSecretValidators() // So that Identity Server knows to validate thumbprint or certificate name
.AddDeveloperSigningCredential()
.AddInMemoryApiResources(new List<ApiResource> {
new ApiResource(
name: "MyAPI", // Api resource name
displayName: "My API Set", // Display name
userClaims: new List<string> { "access" } // Claims to be included in access token
)
})
.AddInMemoryIdentityResources(GetIdentityResources()) // Contains only IdentityResources.OpenId()
.AddInMemoryClients(new List<Client>() {
new Client {
Enabled = true,
ClientId = "ISCCA",
ClientSecrets = {
// Testing env client certificate thumbprint secret
new Secret() {
Value = "<thumbprint>",
Type = SecretTypes.X509CertificateThumbprint
}
},
AccessTokenLifetime = 60 * 60 * 24,
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "MyAPI" }
}
})
.AddInMemoryApiScopes(new List<ApiScope> {
new ApiScope {
Name = "MyAPI",
DisplayName = "Some API",
UserClaims = { "access" }
}
});
Authentication and authorization:
services
.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options => {
options.AllowedCertificateTypes = CertificateTypes.All;
options.RevocationMode = X509RevocationMode.NoCheck;
})
.AddIdentityServerJwt();
services.AddAuthorization(options => {
options.AddPolicy("ApiScope", policy => {
policy.RequireAuthenticatedUser();
policy.RequireClaim("access");
});
});
If i use a secret without defined type, the token is returned as expected, but when i want it to use the thumbprint, i get errors above.
I have set up the certificate in Postman and it is included in request, but i'm not sure if it comes to the server (everything is run localy on the same PC). As for token request and server response, below are screenshots of what is in auth header and response and Kestrel log:
I don't know what i did wrong. Also i have included
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
in Configure method.
Related
We have a ASP.Net Core application that authenticates against a standalone Identity Server 4. The ASP.Net Core app implements a few SignalR Hubs and is working fine when we use the self hosted SignalR Service. When we try to use the Azure SignalR Service, it always returns 401 in the negotiation requests. The response header also states that
"Bearer error="invalid_token", error_description="The signature key
was not found"
I thought the JWT-Configuration is correct because it works in the self hosted mode but it looks like, our ASP.Net Core application needs information about the signature key (certificate) that our identity server uses to sign the tokens. So I tried to use the same method like our identity server, to create the certificate and resolve it. Without luck :-(
This is what our JWT-Configuration looks like right now:
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options => {
var appSettings = Configuration.Get<AppSettingsModel>();
options.Authority = appSettings.Authority;
options.RefreshOnIssuerKeyNotFound = true;
if (environment.IsDevelopment()) {
options.RequireHttpsMetadata = false;
}
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters {
ValidateAudience = false,
IssuerSigningKey = new X509SecurityKey(getSigningCredential()),
IssuerSigningKeyResolver = (string token, SecurityToken securityToken, string kid, TokenValidationParameters validationParameters) =>
new List<X509SecurityKey> { new X509SecurityKey(getSigningCredential()) }
};
options.Events = new JwtBearerEvents {
OnMessageReceived = context => {
var accessToken = "";
var headerToken = context.Request.Headers[HeaderNames.Authorization].ToString().Replace("Bearer ", "");
if (!string.IsNullOrEmpty(headerToken) && headerToken.Length > 0) {
accessToken = headerToken;
}
var queryStringToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(queryStringToken) && queryStringToken.ToString().Length > 0) {
accessToken = queryStringToken;
}
// If the request is for our hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) {
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
Update:
We also have a extended the signalR.DefaultHttpClient in our Angular Client and after playing around a bit, I noticed the application is working fine without it:
export class CustomSignalRHttpClientService extends signalR.DefaultHttpClient {
userSubscription: any;
token: string = "";
constructor(private authService: AuthorizeService) {
super(console); // the base class wants a signalR.ILogger
this.userSubscription = this.authService.accessToken$.subscribe(token => {
this.token = token
});
}
public async send(
request: signalR.HttpRequest
): Promise<signalR.HttpResponse> {
let authHeaders = {
Authorization: `Bearer ${this.token}`
};
request.headers = { ...request.headers, ...authHeaders };
try {
const response = await super.send(request);
return response;
} catch (er) {
if (er instanceof signalR.HttpError) {
const error = er as signalR.HttpError;
if (error.statusCode == 401) {
console.log('customSignalRHttpClient -> 401 -> TokenRefresh')
//token expired - trying a refresh via refresh token
this.token = await this.authService.getAccessToken().toPromise();
authHeaders = {
Authorization: `Bearer ${this.token}`
};
request.headers = { ...request.headers, ...authHeaders };
}
} else {
throw er;
}
}
//re try the request
return super.send(request);
}
}
The problem is, when the token expires while the application is not open (computer is in sleep mode e.g.), the negotiaton process is failing again.
I finally found and solved the problem. The difference of the authentication between "self hosted" and "Azure SignalR Service" is in the negotiation process.
Self Hosted:
SignalR-Javascript client authenticates against our own webserver with
the same token that our Javascript (Angular) app uses. It sends the
token with the negotiation request and all coming requests of the
signalR Http-Client.
Azure SignalR Service:
SignalR-Javascript client sends a negotiation request to our own
webserver and receives a new token for all coming requests against the
Azure SignalR Service.
So our problem was in the CustomSignalRHttpClientService. We changed the Authentication header to our own API-Token for all requests, including the requests against the Azure SignalR Service -> Bad Idea.
So we learned that the Azure SignalR Service is using it's own token. That also means the token can invalidate independently with our own token. So we have to handle 401 Statuscodes in a different way.
This is our new CustomSignalRHttpClientService:
export class CustomSignalRHttpClientService extends signalR.DefaultHttpClient {
userSubscription: any;
token: string = "";
constructor(private authService: AuthorizeService, #Inject(ENV) private env: IEnvironment, private router: Router,) {
super(console); // the base class wants a signalR.ILogger
this.userSubscription = this.authService.accessToken$.subscribe(token => {
this.token = token
});
}
public async send(
request: signalR.HttpRequest
): Promise<signalR.HttpResponse> {
if (!request.url.startsWith(this.env.apiUrl)) {
return super.send(request);
}
try {
const response = await super.send(request);
return response;
} catch (er) {
if (er instanceof signalR.HttpError) {
const error = er as signalR.HttpError;
if (error.statusCode == 401 && !this.router.url.toLowerCase().includes('onboarding')) {
this.router.navigate([ApplicationPaths.Login], {
queryParams: {
[QueryParameterNames.ReturnUrl]: this.router.url
}
});
}
} else {
throw er;
}
}
//re try the request
return super.send(request);
}
}
Our login-Route handles the token refresh (if required). But it could also happen, that our own api-token is still valid, but the Azure SignalR Service token is not. Therefore we handle some reconnection logic inside the service that creates the SignalR Connections like this:
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe(async (page: NavigationEnd) => {
if (page.url.toLocaleLowerCase().includes(ApplicationPaths.Login)) {
await this.restartAllConnections();
}
});
hope this helps somebody
The issue I am facing is after successful login from my MVC app to ID Server, I am not getting redirected to the applications secure page. instead, the identity server login page reappears.
I created the ID server using hte following template.
dotnet new is4inmem -n IdentityServerSample
My ID Server runs in port 5000 (http only)
I have another MVC application, which runs in port 5006 (http only).
my client settings and scope settings in ID Server config file is as below..
..., new Client
{
ClientId = "AuthCode_Flow_Client",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "http://localhost:5006/signin-oidc" },
FrontChannelLogoutUri = "https://localhost:5006/signout-oidc",
PostLogoutRedirectUris = { "https://localhost:5006/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "ValuesAPI_ReadOnly" }
},...
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("scope1"),
new ApiScope("scope2"),
new ApiScope("ValuesAPI_ReadOnly"),
};
The configure serivice method in my MVC is as below.
services.AddAuthentication(opts =>
{
opts.DefaultScheme = "Cookies";
opts.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", opts =>
{
opts.Authority = "http://localhost:5000";
opts.RequireHttpsMetadata = false;
opts.ClientId = "AuthCode_Flow_Client";
opts.ClientSecret = "secret";
opts.SaveTokens = true;
opts.ResponseType = "code";
});
In my pipeline, I have added the UseAuthentication and UseAuthorization() middlewares.
ISSUE
When I hit a secure page in my MVC application, I get redirected to ID SErver login page. After giving valid credentials (bob / bob), I dont see any errors in id server console log but still I am getting redirected ot login page itself.
Any suggestions.
I have a typical Blazor WASM project, Server, Client and Shared. Authentication with IdentityServer is setup and working correctly. I'm receiving the JWT when I login with a user and I can get the discovery document.
Aside from normal users, I want to connect devices. These devices are not users so it seems wrong to create a user for each device. If I create a user then I can login and get the token. But I added InMemoryClients, so I was under the assumption that I could login as a client with id/secret using the device authentication endpoint of the discovery document.
I added a console application that is running on the device. But authentication fails returning "invalid_client".
In the server app I defined a Client and ApiScope:
public static IEnumerable<ApiScope> ApiScopes =>
new List<ApiScope>
{
new ApiScope("api", "My API")
};
public static IEnumerable<IdentityServer4.Models.Client> Clients =>
new List<IdentityServer4.Models.Client>
{
new IdentityServer4.Models.Client
{
ClientId = "device",
// no interactive user, use the clientid/secret for authentication
AllowedGrantTypes = GrantTypes.ClientCredentials,
// secret for authentication
ClientSecrets =
{
new Secret("secret")
},
// scopes that client has access to
AllowedScopes = { "api" }
}
};
They are added with the AddInMemory functions in the Startup.Configure method:
services.AddIdentityServer()
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options =>
{
options.IdentityResources["openid"].UserClaims.Add("name");
options.ApiResources.Single().UserClaims.Add("name");
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://localhost:44316";
options.Audience = "api";
})
.AddIdentityServerJwt();
In the console app I send a DeviceAuthenticationRequest:
static IDiscoveryCache _cache = new DiscoveryCache("https://localhost:44316");
var disco = await _cache.GetAsync();
if (disco.IsError) throw new Exception(disco.Error);
var client = new HttpClient();
response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{
//Address = disco.TokenEndPoint, // same result
Address = disco.DeviceAuthorizationEndpoint,
ClientId = "device",
ClientSecret = "secret",
Scope = "api"
});
The response in the client contains an error, "invalid_client". And the server complains also:
IdentityServer4.Validation.ClientSecretValidator: Error: No client with id 'client' found. aborting
I hope my question is clear, thanks in advance.
EDIT
Obviously the typo was a problem. But I managed to fix it by changing the AllowedGrantTypes to:
AllowedGrantTypes = { GrantTypes.ClientCredentials, GrantTypes.DeviceFlow };
However! I had to removed api authorization, need to figure out how to add it back:
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options =>
{
options.IdentityResources["openid"].UserClaims.Add("name");
options.ApiResources.Single().UserClaims.Add("name");
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
And instead of specifying options for and adding JwtBearer:
//services.AddAuthentication("Bearer")
// .AddJwtBearer("Bearer", options =>
// {
// options.Authority = "https://localhost:44316";
// options.Audience = "api";
// })
// .AddIdentityServerJwt();
I just have this:
services.AddAuthentication()
.AddIdentityServerJwt();
I think you have just a small typo. In the Startup.cs the ClientId = "device". In your client, you set ClientId = "client".
In general, you are right. You don't need to create a client for each user and their potential devices. The definition of one client for all possible users and devices is enough. You could even debate if a client's password is necessary and increase security. In the official docs, the secret is optional.
In a simplified view: the "client part" of this authentication flow is mainly used to generate the user code. In a second (asynchronous) step, a user with different device logins to authenticate the device by entering the previously generated code.
For more details of a look at this post.
I have 2 ASP.NET Core 2.1 applications (Auth and API) using Identity Server 4.
On API application Startup I have the following:
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(x =>
x.ApiName = "api";
x.Authority = "https://localhost:5005";
x.RequireHttpsMetadata = false;
});
On Auth application Startup I have the following configuration:
services
.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients())
.AddTestUsers(Config.GetTestUsers());
Where Config class is the following:
public class Config {
public static List<ApiResource> GetApiResources() {
return new List<ApiResource> {
new ApiResource("api", "API Resource")
};
}
public static List<IdentityResource> GetIdentityResources() {
return new List<IdentityResource> {
new IdentityResources.OpenId(),
new IdentityResources.Profile()
};
}
public static List<Client> GetClients() {
return new List<Client> {
new Client {
ClientId = "app",
ClientName = "APP Client",
ClientSecrets = { new Secret("pass".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = {
"api"
}
}
}
public static List<TestUser> GetTestUsers() {
return new List<TestUser> {
new TestUser {
SubjectId = "1",
Username = "john",
Password = "john",
Claims = new List<Claim> { new Claim("name", "John") } },
};
}
}
With Postman I am able to access .well-known/openid-configuration and create an Access Token.
However, when I call the API I get the following error:
Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[3]
Exception occurred while processing message.
System.InvalidOperationException: IDX20803: Unable to obtain configuration from: '[PII is hidden by default.
Set the 'ShowPII' flag in IdentityModelEventSource.cs to true to reveal it.]'. ---> System.IO.IOException: IDX20804: Unable to retrieve document from: '[PII is hidden by default.
Set the 'ShowPII' flag in IdentityModelEventSource.cs to true to reveal it.]'. ---> System.Net.Http.HttpRequestException:
The SSL connection could not be established, see inner exception. ---> System.Security.Authentication.AuthenticationException: The remote certificate is invalid according to the validation procedure.
I ran dotnet dev-certs https --trust; and the localhost certificate appears on Mac Keychain.
Important
On a Windows computer the code I posted works fine.
I am able to access .well-known/openid-configuration and the API endpoint.
What am I missing?
I'm protecting a Web API with Identity Server 4.
If an external app tries to access it using client credentials but does not pass in the access token, I get, as expected, the Unauthorized response.
The problem here is that the response does not include the WWW-Authenticate header as I was expecting, as stated in the OAuth spec.
Am I missing some config in Identity Server? Or is it something wrong with the Identity Server implementation?
The relevant code parts follow:
Client registration on Identity Server:
new Client()
{
ClientId = "datalookup.clientcredentials",
ClientName = "Data Lookup Client with Client Credentials",
AlwaysIncludeUserClaimsInIdToken = true,
AlwaysSendClientClaims = true,
AllowOfflineAccess = false,
ClientSecrets =
{
new Secret("XXX".Sha256())
},
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes =
{
Scopes.DataLookup.Monitoring,
Scopes.DataLookup.VatNumber
},
ClientClaimsPrefix = "client-",
Claims =
{
new Claim("subs", "1000")
}
}
ApiResource registration on Identity Server:
new ApiResource()
{
Name = "datalookup",
DisplayName = "Data Lookup Web API",
ApiSecrets =
{
new Secret("XXX".Sha256())
},
UserClaims =
{
JwtClaimTypes.Name,
JwtClaimTypes.Email,
JwtClaimTypes.Profile,
"user-subs"
},
Scopes =
{
new Scope()
{
Name = Scopes.DataLookup.Monitoring,
DisplayName = "Access to the monitoring endpoints",
},
new Scope()
{
Name = Scopes.DataLookup.VatNumber,
DisplayName = "Access to the VAT Number lookup endpoints",
Required = true
}
}
}
Authentication configuration in the Web API:
public void ConfigureServices(IServiceCollection services)
{
(...)
services.AddMvc();
services
.AddAuthorization(
(options) =>
{
options.AddPolicy(
Policies.Monitoring,
(policy) =>
{
policy.RequireScope(Policies.Scopes.Monitoring);
});
options.AddPolicy(
Policies.VatNumber,
(policy) =>
{
policy.RequireScope(Policies.Scopes.VatNumber);
policy.RequireClientSubscription();
});
});
services.AddAuthorizationHandlers();
services
.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(
(options) =>
{
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.ApiName = "datalookup";
});
(...)
}
Client accessing the Web API:
using (HttpClient client = new HttpClient())
{
// client.SetBearerToken(accessToken);
using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, Constants.WebApiEndpoint))
{
using (HttpResponseMessage response = await client.SendAsync(request).ConfigureAwait(false))
{
if (!response.IsSuccessStatusCode)
{
ConsoleHelper.WriteErrorLine(response);
return;
}
string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
ConsoleHelper.WriteInformationLine(content);
}
}
}
Notice that client.SetBearerToken(accessToken) is commented, so that is why I was expecting the response to include the WWW-Authenticate header.
The whole idea behind this is to implement a feature on a client library to deal with the Http Bearer challenge (as, for example, the Azure KeyVault client library does).