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,
}
});
}
Related
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 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
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");
I'm getting a "You do not have permission to view this directory or page." error when I try to LoginAsync with an access token and MobileServiceAuthenticationProvider.WindowsAzureActiveDirectory. This works with the equivalent form with MobileServiceAuthenticationProvider.MicrosoftAccount. I'm not sure why this isn't working. Is there a configuration I'm missing?
var msaProvider = await WebAuthenticationCoreManager.FindAccountProviderAsync(
"https://login.microsoft.com",
"https://login.microsoftonline.com/3dd13bb9-5d0d-dd2e-9d1e-7a966131bf85");
string clientId = "6d15468d-9dbe-4270-8d06-a540dab3252f";
WebTokenRequest request1 = new WebTokenRequest(msaProvider, "User.Read", clientId);
request1.Properties.Add("resource", "https://graph.microsoft.com");
WebTokenRequestResult result =
await WebAuthenticationCoreManager.RequestTokenAsync(request1);
if (result.ResponseStatus == WebTokenRequestStatus.Success)
{
var token = result.ResponseData[0].Token;
var token1 = new JObject
{
{ "access_token", token }
};
var user = await App.mobileServiceClient.LoginAsync(
MobileServiceAuthenticationProvider.WindowsAzureActiveDirectory, token1);
I was able to get MSAL.NET to work for this per code below. The key is the { resourceId + "/user_impersonation" } scope.
PublicClientApplication pca = new PublicClientApplication(clientId)
{
RedirectUri = redirectUri
};
string[] scopes = { resourceId + "/user_impersonation" };
var users = await pca.GetAccountsAsync();
var user = users.FirstOrDefault();
AuthenticationResult msalar = await pca.AcquireTokenAsync(
scopes, user, UIBehavior.ForceLogin, "domain_hint=test.net");
payload = new JObject
{
["access_token"] = msalar.AccessToken
};
mobileServiceClient.LoginAsync(MobileServiceAuthenticationProvider.WindowsAzureActiveDirectory, payload);
Reference: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/660#issuecomment-433831737