I have the following flow Asp.Net Core App (App) that calls Asp.Net Core Web API (API1) that inturn calls another Asp.Net Web API (API2)
I'm using IdentityServer4 with Windows Authentication to Authenticate my users
here is my code:
ApiResources definition:
new ApiResource("api1", "api1", new List<string> { JwtClaimTypes.Name, JwtClaimTypes.Email}),
new ApiResource("api2", "api2", new List<string> { JwtClaimTypes.Name, JwtClaimTypes.Email})
Clients definition
new Client
{
ClientId = "app1",
ClientName = "app1",
ClientSecrets = { new Secret("app1".Sha256())},
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
IdentityServerConstants.StandardScopes.Email,
"api1"
},
RedirectUris = { "https://localhost:44375/signin-oidc" },
FrontChannelLogoutUri = "https://localhost:44375/signout-oidc",
PostLogoutRedirectUris = { "https://localhost:44375/signout-callback-oidc" },
AllowOfflineAccess = true,
RequireConsent = false,
AccessTokenLifetime = 5
},
new Client
{
ClientId = "api1",
ClientSecrets = { new Secret("api1".Sha256())},
AllowedGrantTypes = {"delegation" },
AllowedScopes = {
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
"api2"}
}
Delegation code in API1
public async Task<string> DelegateAsync(string userToken)
{
var client = new HttpClient();
var disco = await client.GetDiscoveryDocumentAsync("https://localhost:44382/");
if (disco.IsError) throw new Exception(disco.Error);
var tokenResponse = await client.RequestTokenAsync(new TokenRequest()
{
Address = disco.TokenEndpoint,
GrantType = "delegation",
ClientId = "api1",
ClientSecret = "api1",
Parameters =
{
{"scope" , "api2 email profile openid" },
{"token", userToken }
}
});
if (tokenResponse.IsError)
{
throw new Exception(tokenResponse.Error);
}
_logger.LogInformation($"new: {tokenResponse.AccessToken}");
return tokenResponse.AccessToken;
}
IdentityServer4 DelegationGrantValidator:
public class DelegationGrantValidator : IExtensionGrantValidator
{
private readonly ITokenValidator _validator;
public DelegationGrantValidator(ITokenValidator validator)
{
_validator = validator;
}
public string GrantType => "delegation";
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var userToken = context.Request.Raw.Get("token");
if (string.IsNullOrEmpty(userToken))
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken);
if (result.IsError)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
// get user's identity
var sub = result.Claims.FirstOrDefault(c => c.Type == "sub").Value;
context.Result = new GrantValidationResult(sub, GrantType);
return;
}
}
in API1 User.Identity.Name = "Domain\UserName"
but in API2 User.Identity.Name = null
is there anything missing that I should do solve this issue?
P.S.: if I call the IdentityServer UserInfo endpoint from API2 I'll get the expected UserName
After a lot of search I have finally found the answer.
I've followed what Rory Braybrook described in this Article and everything is working now
Related
When i'm trying to sign-in with google sign-in from blazor webassembly, the requested claims and the claims returned from identity server is not matching (see output)
BackEnd/Config.cs
namespace BackEnd
{
public static class ServerConfiguration
{
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResource("roles", "User roles", new List<string> { "role" })
};
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("protectedScope", "Protected Scope")
};
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client()
{
ClientId = <confidential>,
ClientName = "client 1",
RequireClientSecret = false,
RequirePkce = true,
AllowedCorsOrigins = { "https://localhost:5001" },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:5001/authentication/login-callback" },
PostLogoutRedirectUris = { "https://localhost:5001/" },
AllowOfflineAccess = true,
AllowedScopes = new List<string>{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
"protectedScope"
}
},
new Client()
{
ClientId = "blazor",
ClientName = "oidcUser",
RequireClientSecret = false,
RequirePkce = true,
RequireConsent = true,
AllowedCorsOrigins = { "https://localhost:5001" },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:5001/authentication/login-callback" },
PostLogoutRedirectUris = { "https://localhost:5001/" },
AllowOfflineAccess = true,
AlwaysIncludeUserClaimsInIdToken = true,
AllowedScopes = new List<string>{
"openid",
"email",
"profile",
"protectedScope"
}
}
};
public static List<TestUser> TestUsers {
get
{
TestUser user1 = new TestUser()
{
SubjectId = "2f47f8f0-bea1-4f0e-ade1-88533a0eaf57",
Username = "John",
Claims = new List<Claim>()
{
new Claim("role", "SignedInUser"),
new Claim("email", "johnsmith#gmail.com"),
new Claim("picture", "https://www.google.com/url?sa=i&url=https%3A%2F%2Fwww.business2community.com%2Fsocial-media%2Fimportance-profile-picture-career-01899604&psig=AOvVaw2LC5T-WZMYnHD9I7PeK7lT&ust=1615219065948000&source=images&cd=vfe&ved=2ahUKEwip1caGxp7vAhV1NbcAHd_2BFwQjRx6BAgAEAc")
}
};
List<TestUser> testUsers = new List<TestUser>();
testUsers.Add(user1);
return testUsers;
}
}
}
}
BackEnd/Startup.cs
namespace BackEnd
{
public class Startup
{
public IWebHostEnvironment Environment { get; }
public IConfiguration Configuration { get; }
private string _clientId = null;
private string _clientSecret = null;
public Startup(IWebHostEnvironment environment, IConfiguration configuration)
{
Environment = environment;
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
var cert = new X509Certificate2(Path.Combine(".", "IdsvCertificate.pfx"), "YouShallNotPass123");
_clientId = Configuration["OAuth:ClientId"];
_clientSecret = Configuration["OAuth:ClientSecret"];
services.AddControllersWithViews();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = false)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddTransient<IProfileService, ProfileService>();
var builder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
options.EmitStaticAudienceClaim = true;
options.UserInteraction = new UserInteractionOptions() { LoginUrl = "/Account/Login", LogoutUrl = "/Account/Logout" };
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddProfileService<ProfileService>()
.AddAspNetIdentity<ApplicationUser>();
builder.AddSigningCredential(cert);
// builder.AddDeveloperSigningCredential();
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddAuthentication()
.AddGoogle("Google", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ClientId = _clientId;
options.ClientSecret = _clientSecret;
options.ClaimActions.MapJsonKey("picture", "picture", "url");
options.SaveTokens = true;
})
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.Authority = "https://accounts.google.com";
options.RequireHttpsMetadata = true;
options.ResponseType = "code";
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("openid");
options.ClientId = _clientId;
options.ClientSecret = _clientSecret;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.ClaimActions.MapJsonKey("picture", "picture", "url");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role",
ValidateIssuer = true
};
});
services.AddAuthorization();
services.AddGrpc();
}
public void Configure(IApplicationBuilder app)
{
if (Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<UserService>();
endpoints.MapDefaultControllerRoute().RequireAuthorization();
});
}
}
}
BackEnd/ProfileService.cs
public class ProfileService : IProfileService
{
public ProfileService()
{
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
context.IssuedClaims.AddRange(roleClaims);
await Task.CompletedTask;
}
public async Task IsActiveAsync(IsActiveContext context)
{
await Task.CompletedTask;
}
}
FrontEnd/Program.cs
namespace FrontEnd
{
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient()
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped(services =>
{
var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
var channel = GrpcChannel.ForAddress("https://localhost:5000", new GrpcChannelOptions
{
HttpHandler = httpHandler
});
return new Greeter.GreeterClient(channel);
});
builder.Services.AddScoped(services =>
{
var baseAddressMessageHandler = services.GetRequiredService<BaseAddressAuthorizationMessageHandler>();
baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
var channel = GrpcChannel.ForAddress("https://localhost:5000", new GrpcChannelOptions
{
HttpHandler = httpHandler
});
return new User.UserClient(channel);
});
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Authentication:Google", options.ProviderOptions);
options.UserOptions.RoleClaim = "SignedInUser";
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
}
}
FrontEnd/wwwroot/appsettings.json
{
"Authentication":{
"Google": {
"Authority": "https://localhost:5000",
"ClientId": <confidential>,
"ClientSecret": "2fxc9srOe8QsRBnhzLIa1pF0",
"DefaultScopes": [
"email",
"profile",
"openid"
],
"PostLogoutRedirectUri": "https://localhost:5001/",
"RedirectUri": "https://localhost:5001/authentication/login-callback",
"ResponseType": "code"
},
}
This is the output from IdentityServer when i'm trying to sign-in
[18:34:28 Debug] IdentityServer4.Validation.TokenValidator
Calling into custom token validator: IdentityServer4.Validation.DefaultCustomTokenValidator
[18:34:28 Debug] IdentityServer4.Validation.TokenValidator
Token validation success
{"ClientId": null, "ClientName": null, "ValidateLifetime": true, "AccessTokenType": "Jwt", "ExpectedScope": "openid", "TokenHandle": null, "JwtId": "8E6167D64F8FEA2FF6D12D17A1CEEBFE", "Claims": {"nbf": 1615548868, "exp": 1615552468, "iss": "https://localhost:5000", "aud": "https://localhost:5000/resources", "client_id": "499675830263-ldcg4fm7kcbjlt48tpaffqdbfnskmi8v.apps.googleusercontent.com", "sub": "81c306df-c1f0-4714-964f-2459b670429e", "auth_time": 1615548849, "idp": "oidc", "jti": "8E6167D64F8FEA2FF6D12D17A1CEEBFE", "sid": "0BF0BA4CA6BD3DE8D158A426A70A91E0", "iat": 1615548868, "scope": ["openid", "profile", "email", "role"], "amr": "external"}, "$type": "TokenValidationLog"}
[18:34:28 Debug] IdentityServer4.ResponseHandling.UserInfoResponseGenerator
Creating userinfo response
[18:34:28 Debug] IdentityServer4.ResponseHandling.UserInfoResponseGenerator
Scopes in access token: openid profile email role
[18:34:28 Debug] IdentityServer4.ResponseHandling.UserInfoResponseGenerator
Requested claim types: sub name family_name given_name middle_name nickname preferred_username profile picture website gender birthdate zoneinfo locale updated_at email email_verified role
[18:34:28 Information] IdentityServer4.ResponseHandling.UserInfoResponseGenerator
Profile service returned the following claim types: sub name preferred_username
[18:34:28 Debug] IdentityServer4.Endpoints.UserInfoEndpoint
End userinfo request
[18:34:29 Debug] IdentityServer4.Hosting.EndpointRouter
Request path /connect/checksession matched to endpoint type Checksession
[18:34:29 Debug] IdentityServer4.Hosting.EndpointRouter
Endpoint enabled: Checksession, successfully created handler: IdentityServer4.Endpoints.CheckSessionEndpoint
[18:34:29 Information] IdentityServer4.Hosting.IdentityServerMiddleware
Invoking IdentityServer endpoint: IdentityServer4.Endpoints.CheckSessionEndpoint for /connect/checksession
[18:34:29 Debug] IdentityServer4.Endpoints.CheckSessionEndpoint
Rendering check session result
From the output, the requested claims and the claims that's returned by the profile service is not matching
From the image, there should be a name after the "hello"
The image is similar except now it shows the name. IdentityServer4 is working fine, it shows everything as i expected
By default, only these claims are mapped from the ID-Token:
options.ClaimActions.MapUniqueJsonKey("sub", "sub");
options.ClaimActions.MapUniqueJsonKey("name", "name");
options.ClaimActions.MapUniqueJsonKey("given_name", "given_name");
options.ClaimActions.MapUniqueJsonKey("family_name", "family_name");
options.ClaimActions.MapUniqueJsonKey("profile", "profile");
options.ClaimActions.MapUniqueJsonKey("email", "email");
To get additional claims mapped, we have to write like:
options.ClaimActions.MapUniqueJsonKey("website", "website");
options.ClaimActions.MapUniqueJsonKey("gender", "gender");
options.ClaimActions.MapUniqueJsonKey("birthdate", "birthdate");
In AddOpenIDConnect. This is called Claims transformation if you want to google for it.
I can`t login to my API via Swagger UI. I using Swashbuckle.AspNetCore 5.2.1.
For some reason identity server cookie stores cookie in API host under identity server host (not API host). Please advice:
This is Client configuration in IdentityServer:
new Client
{
ClientId = Applications.Swagger.ToString(),
ClientName = "Swagger",
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { urlsSettings.Api + "/swagger/oauth2-redirect.html" },
PostLogoutRedirectUris = { urlsSettings.Api + "/swagger" },
AllowedCorsOrigins = { urlsSettings.Api },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
Applications.API.ToString()
},
ClientSecrets = { new Secret("test-secret".Sha256()) },
AllowAccessTokensViaBrowser = true,
RequireConsent = false
}
This is Startup in API service:
services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.AddIdentityServerAuthentication(options =>
{
options.Authority = UrlsSettings.Identity;
options.RequireHttpsMetadata = true;
options.ApiName = Application.ToString();
options.ApiSecret = "test-secret";
options.SaveToken = true;
})
.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Merchant API",
Version = "v1",
Description = "eSales Platform API"
});
options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows()
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri(UrlsSettings.Identity + "/connect/authorize"),
TokenUrl = new Uri(UrlsSettings.Identity + "/connect/token"),
Scopes = new Dictionary<string, string>
{
{ Applications.API.ToString(), "eSalesPlatform API - full access" }
}
}
}
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }
},
new[] { Applications.API.ToString() }
}
});
options.OperationFilter<AuthorizeCheckOperationFilter>();
});
I have working example of JWT Token. It is work good and when I put this token to storage in angularJS I can go to api controller with attribute [Authorize]. But when I generate token with role, I cant go to attribute [Authorize(Roles = "Admin")]. As I know I role save in token and I need`t to change a header of request to api. My code below
public class AuthOptions
{
public const string ISSUER = "MyAuthServer";
public const string AUDIENCE = "http://localhost:51489/";
const string KEY = "mysupersecret_secretkey!123";
public const int LIFETIME = 60;
public static SymmetricSecurityKey GetSymmetricSecurityKey()
{
return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(KEY));
}
}
[HttpPost]
[AllowAnonymous]
[Route("login")]
public async Task Login([FromBody]LoginViewModel model)
{
var identity = await GetIdentity(model.Email, model.Password);
if (identity == null)
{
Response.StatusCode = 400;
await Response.WriteAsync("Invalid username or password.");
return;
}
var now = DateTime.UtcNow;
var jwt = new JwtSecurityToken(
issuer: AuthOptions.ISSUER,
audience: AuthOptions.AUDIENCE,
notBefore: now,
claims: identity.Claims,
expires: now.Add(TimeSpan.FromMinutes(AuthOptions.LIFETIME)),
signingCredentials: new
SigningCredentials(AuthOptions.GetSymmetricSecurityKey(),
SecurityAlgorithms.HmacSha256));
var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
var response = new
{
access_token = encodedJwt,
username = identity.Name,
};
Response.ContentType = "application/json";
await Response.WriteAsync(JsonConvert.SerializeObject(response, new
JsonSerializerSettings { Formatting = Formatting.Indented }));
return;
}
private async Task<ClaimsIdentity> GetIdentity(string username, string
password)
{
var user = _db.User.FirstOrDefault(x => x.Email == username);
if (user != null)
{
var checkPass = _userManager.CheckPasswordAsync(user, password);
if (!checkPass.Result)
return null;
var userRoles = await _userManager.GetRolesAsync(user);
string role = userRoles[0];
var claims = new List<Claim>
{
new Claim(ClaimsIdentity.DefaultNameClaimType, user.Email),
new Claim(ClaimsIdentity.DefaultRoleClaimType, role)
};
ClaimsIdentity claimsIdentity =
new ClaimsIdentity(claims, "Token", ClaimsIdentity.DefaultNameClaimType,
ClaimsIdentity.DefaultRoleClaimType);
return claimsIdentity;
}
return null;
}
Startup
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters =
newTokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = AuthOptions.ISSUER,
ValidateAudience = true,
ValidAudience = AuthOptions.AUDIENCE,
ValidateLifetime = true,
IssuerSigningKey =AuthOptions.GetSymmetricSecurityKey(),
ValidateIssuerSigningKey = true,
};
});
Put to storage with angularJS $cookies
$http.defaults.headers.common['Authorization'] = 'Bearer ' +
response.data.access_token;
With this atribute is working
[Authorize]
With this atribute not working
[Authorize(Roles = "Admin")]
You are storing your Role as a claim in the token.
You will need to create a policy that works of the role claim that you have assigned to your token.
Create a policy in your Startup.cs
services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireClaim("Role", "Admin"));
});
Then you can use this authorization attribute [Authorize(Policy = "Admin")]
There are four clients within the application:
angular.application - resource owner
identity_ms.client - webapi app (.net core 2.1)
IdentityServer4 with AspNetIdentity
AccountController with shared actions to register users, reset password etc.
UserController with secured actions.
The Data action of the UserController has an [Authorize(Policy = "user.data")] attribute
ms_1.client - webapi app (.net core 2.1)
request.client - added specially to send requests from ms_1.client to identity_ms.client's UserController to get some user data.
I'm requesting clients using Postman:
http://localhost:identity_ms_port/connect/token to get access_token
http://localhost:ms_1_port/api/secured/action to get some secured data from ms_1
http://localhost:identity_ms_port/api/user/data to get some secured user data from identity_ms
Everything is working fine.
Also, ms_1 service has a secured action requesting http://localhost:identity_ms_port/api/user/data using System.Net.Http.HttpClient.
// identity_ms configuration
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(/*cors options*/);
services
.AddMvc()
.AddApplicationPart(/*Assembly*/)
.AddJsonOptions(/*SerializerSettings*/)
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.Configure<IISOptions>(iis =>
{
iis.AuthenticationDisplayName = "Windows";
iis.AutomaticAuthentication = false;
});
var clients = new List<Client>
{
new Client
{
ClientId = "angular.application",
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes = { "user.data.scope", "ms_1.scope", "identity_ms.scope" },
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword
},
new Client
{
ClientId = "ms_1.client",
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes = { "user.data.scope", "ms_1.scope" },
AllowedGrantTypes = GrantTypes.ClientCredentials
},
new Client
{
ClientId = "identity_ms.client",
ClientSecrets =
{
new Secret("secret".Sha256())
},
AllowedScopes =
{
"user.data.scope",
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
},
AllowedGrantTypes = GrantTypes.Implicit
},
new Client
{
ClientId = "request.client",
AllowedScopes = { "user.data.scope" },
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
{
new Secret("secret".Sha256())
}
}
};
var apiResources = new List<ApiResource>
{
new ApiResource("ms_1.scope", "MS1 microservice scope"),
new ApiResource("identity_ms.scope", "Identity microservice scope"),
new ApiResource("user.data.scope", "Requests between microservices scope")
};
var identityResources = new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile()
};
services
.AddAuthorization(options => options.AddPolicy("user.data", policy => policy.RequireScope("user.data.scope")))
.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(identityResources)
.AddInMemoryApiResources(apiResources)
.AddInMemoryClients(clients);
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Audience = "identity_ms.scope";
options.RequireHttpsMetadata = false;
options.Authority = "http://localhost:identity_ms_port";
});
services.AddSwaggerGen(/*swagger options*/);
}
public void Configure(IApplicationBuilder app)
{
app.UseMiddleware<CustomMiddleware>();
app.UseIdentityServer();
app.UseAuthentication();
app.UseCors("Policy");
app.UseHttpsRedirection();
app.UseMvc(/*routes*/);
app.UseSwagger();
}
// ms_1.client configuration
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(/*cors options*/);
services
.AddMvc()
.AddJsonOptions(/*SerializerSettings*/)
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Audience = "ms_1.scope";
options.RequireHttpsMetadata = false;
options.Authority = "http://localhost:identity_ms_port";
});
}
public void Configure(IApplicationBuilder app)
{
app.UseMiddleware<CustomMiddleware>();
app.UseAuthentication();
app.UseCors("Policy");
app.UseStaticFiles();
app.UseHttpsRedirection();
app.UseMvc(/*routes*/);
app.UseSwagger();
}
// ms_1.client action using HttpClient
[HttpPost]
public async Task<IActionResult> Post(ViewModel model)
{
//...
using (var client = new TokenClient("http://localhost:identity_ms_port/connect/token", "ms_1.client", "secret"))
{
var response = await client.RequestClientCredentialsAsync("user.data.scope");
if (response.IsError)
{
throw new Exception($"{response.Error}{(string.IsNullOrEmpty(response.ErrorDescription) ? string.Empty : $": {response.ErrorDescription}")}", response.Exception);
}
if (string.IsNullOrWhiteSpace(response.AccessToken))
{
throw new Exception("Access token is empty");
}
var udClient = new HttpClient();
udClient.SetBearerToken(response.AccessToken);
udClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var result = await udClient.GetAsync("http://localhost:identity_ms_port/api/user/data");
}
//...
}
I've tried the following:
To retrieve access_token from the request to ms_1 Authorization header and use it to access user/data.
To get new access_token to access user/data with it.
See public async Task<IActionResult> Post(ViewModel model) code within the code block.
In both cases, I've got the correct token which I can use to request both secured/action and user/data actions from Postman, but HttpClient is getting Unauthorized response (401).
Response headers screenshot
What am I doing wrong?
In your client code with HttpClient you are not requesting any scopes for the API therefore the token that is issued by Identity Server 4 will not contain the API as one of the audiences and then subsequently you will get 401 from API.
Change your token request to ask for the API scope as well.
var response = await client.RequestClientCredentialsAsync("user.data.scope ms_1.scope");
Is there a standard way to ask an MVC client using ID4 OIDC middleware to refresh the ClaimsPrincipal when using cookies? It would be nice to just ask the middleware to refresh the ClaimsPrincipal but I don't think that functionality exists.
The code below does work, however, there is no nonce used in the example below - so I'm not sure if that's secure. I'm not sure how the middleware creates the nonce.
Does anyone have an example of properly refreshing the ClaimsPrincipal in an MVC client application using cookies with ID4 OIDC middleware?
Validate ID token and Return ClaimsPrincipal from ID Token
private ClaimsPrincipal ValidateIdentityToken(string idToken, DiscoveryResponse disco )
{
var keys = new List<SecurityKey>();
foreach (var webKey in disco.KeySet.Keys)
{
var e = Base64Url.Decode(webKey.E);
var n = Base64Url.Decode(webKey.N);
var key = new RsaSecurityKey(new RSAParameters { Exponent = e, Modulus = n });
key.KeyId = webKey.Kid;
keys.Add(key);
}
var parameters = new TokenValidationParameters
{
ValidIssuer = disco.TryGetString(OidcConstants.Discovery.Issuer),
ValidAudience = "mvc.hybrid",
IssuerSigningKeys = keys,
NameClaimType = JwtClaimTypes.Name,
RoleClaimType = JwtClaimTypes.Role
};
var handler = new JwtSecurityTokenHandler();
handler.InboundClaimTypeMap.Clear();
SecurityToken token;
var user = handler.ValidateToken(idToken, parameters, out token);
//var nonce = user.FindFirst("nonce")?.Value ?? "";
//if (!string.Equals(nonce, "random_nonce")) throw new Exception("invalid nonce");
//nonce is always ""
return user;
}
Check cookie expiration. Use the refresh token to refresh ID and Access token. Use the above validation to return ClaimsPrincipal
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationScheme = "Cookies",
AutomaticAuthenticate = true,
ExpireTimeSpan = TimeSpan.FromMinutes(60),
Events = new CookieAuthenticationEvents()
{
OnValidatePrincipal = async cookiecontext =>
{
if (cookiecontext.Properties.Items.ContainsKey(".Token.expires_at"))
{
var expire = DateTime.Parse(cookiecontext.Properties.Items[".Token.expires_at"]);
if (expire <= DateTime.Now.AddMinutes(-5) || DateTime.Now > expire)
{
var disco = await DiscoveryClient.GetAsync("http://localhost:5000");
if (disco.IsError) throw new Exception(disco.Error);
var refreshToken = cookiecontext.Properties.Items[".Token.refresh_token"];
var tokenClient = new TokenClient(disco.TokenEndpoint,
"mvc.hybrid",
"secret");
var response = await tokenClient.RequestRefreshTokenAsync(refreshToken);
if (!response.IsError)
{
cookiecontext.Properties.Items[".Token.access_token"] = response.AccessToken;
cookiecontext.Properties.Items[".Token.refresh_token"] = response.RefreshToken;
cookiecontext.Properties.Items[".Token.expires_at"] = DateTime.Now.AddSeconds((int)response.ExpiresIn).ToString();
cookiecontext.Properties.Items["NextAccessTokenRefresh"] = DateTime.Now.AddMinutes(5).ToString();
var _Princ = ValidateIdentityToken(response.IdentityToken, disco);
cookiecontext.ReplacePrincipal(_Princ);
cookiecontext.ShouldRenew = true;
}
else
{
cookiecontext.RejectPrincipal();
}
}
}
}
}
});
Wireup ID4 middleware
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
AuthenticationScheme = "oidc",
SignInScheme = "Cookies",
Authority = "http://localhost:5000",
RequireHttpsMetadata = false,
ClientId = "mvc.hybrid",
ClientSecret = "secret",
ResponseType = "code id_token",
Scope = { "openid", "profile", "email", "api1", "offline_access", "role" },
GetClaimsFromUserInfoEndpoint = true,
Events = new OpenIdConnectEvents()
{
OnTicketReceived = notification =>
{
notification.Response.Cookies.Append("NextAccessTokenRefresh", DateTime.Now.AddMinutes(5).ToString());
return Task.FromResult(0);
}
},
SaveTokens = true,
TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
NameClaimType = JwtClaimTypes.Name,
RoleClaimType = JwtClaimTypes.Role,
}
});
}