I'm getting an access token from AzureAD using managed identity in my calling API. This user-assigned managed identity has an app role defined by the manifest of the called-API's app registration assigned to it. The token has the app role in its contents. So far, so good (I think.)
When I attach the token to a request and call the intended API, I get the following message:
IDX10214: Audience validation failed. Audiences: '{clientId of called API App registration}'. Did not match: validationParameters.ValidAudience: 'api://{clientId of called API App registration}' or validationParameters.ValidAudiences: 'null'.
The difference between the audience on the token and what the built-in token validation is using as the ValidAudience to compare it to is the preceding "api://". The application URI of the app registration defined in Azure Portal is indeed "api://{clientId of called API App registration}"
I have tried many different variations of request contexts when generating my token... prefixing "api://" to the guid, appending "/.default" to the Application URI, but cannot get the token to be accepted as valid.
This is the configuration section I have on my called application to authorize the token presented:
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"ClientId": "the Guid matching the app registration Application ID",
"TenantId": "my tenant id",
"Audience": "api://{the Guid matching the app registration Application ID}"
}
}
This is my Program.cs:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Logging;
var builder = WebApplication.CreateBuilder(args);
IdentityModelEventSource.ShowPII = true;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services
.AddApplicationInsightsTelemetry()
.AddHealthChecks()
.AddApplicationInsightsPublisher(saveDetailedReport: true);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/healthz");
app.Run();
This is my controller:
using theModelNamespace.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace someMoreNamespace.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class NamesController : ControllerBase
{
[HttpGet("ping")]
//[AllowAnonymous]
public IActionResult PingOnly()
{
return Ok("Alive");
}
[HttpGet()]
//[Authorize(Roles = "Api.Read,Api.ReadWrite,Api.OtherUserApp")]
public async Task<IActionResult> GetNames()
{
AuthenticateResult authResult;
try
{
authResult = await HttpContext.AuthenticateAsync("Bearer");
}
catch (Exception ex)
{
var innerException = ex.InnerException != null ? ex.InnerException.Message : String.Empty;
var responseString = $"Error occurred in authentication: {ex.Message} {innerException}.";
return StatusCode(500, responseString);
}
try
{
HttpContext.ValidateAppRole("Api.OtherUserApp");
return Ok(Data.NameList);
}
catch (Exception ex)
{
var innerException = ex.InnerException != null ? ex.InnerException.Message : String.Empty;
var authResults = (authResult != null && authResult.Principal != null) ? $"succeeded: {authResult.Succeeded}, identityName: {authResult.Principal.Identity?.Name}, {authResult.Principal.Claims?.Select(x => $"{x.Type}: {x.Value}")}" : string.Empty;
authResults = authResults == String.Empty && authResult.Failure != null ? authResult.Failure.Message : authResults;
var claimContents = HttpContext != null && HttpContext.User != null ? String.Join(',', HttpContext.User.Claims.Select(x => $"{x.Type}: {x.Value}")) : String.Empty;
var responseString = $"Error occurred in validation: {ex.Message} {innerException}. \n\nClaim contents: {claimContents}\n\nAuthResults: {authResults}";
return StatusCode(500, responseString);
}
}
[HttpPost()]
//[Authorize(Roles = "Api.ReadWrite")]
public IActionResult PostName([FromBody] NamesModel nameModel)
{
Data.NameList.Add(nameModel);
return Ok(Data.NameList);
}
[HttpGet("Unreachable")]
//[Authorize(Roles = "Api.InvalidScope")]
public IActionResult UnreachableName([FromBody] NamesModel nameModel)
{
Data.NameList.Add(nameModel);
return Ok(Data.NameList);
}
}
}
I have the authorize attributes commented out and the HttpContext.AuthenticateAsync("Bearer") added in for troubleshooting and so I can see the output of the authentication result I listed at the beginning of the post.
I've inspected the token, and the "aud" claim is indeed the clientId of the app registration of the called API, and is not prefixed with "api://" The role I need appears to be contained the expected way (roles: [ "Api.OtherUserApp" )] in the token.
The anonymous calls work fine as expected. It is only the get endpoint which calls AuthenticateAsync which has an issue.
What am I missing here to get the token to be accepted by the called API?
I tried to reproduce the same in my environment.
I received the same error:
SecurityTokenInvalidAudienceException: IDX10214: Audience validation failed. Audiences: '50065xxxxx1e6fbd2ed06e'. Did not match: validationParameters.ValidAudience: 'api://xxxxxx1xx06e' or validationParameters.ValidAudiences: 'null'.
Here as the error says , audiences did not match the one we got in token.
Make sure the value in the audience is noted and check the same if it is equal to clientId or not.
Note that ,The valid audiences we can have is either clientId or AppId URI
Here I am getting an audience of value applicationId or clientId in error which it is not matching my code requested .
as you gave AppId URI i.e; api:// ,for audience ,it is invalid. So the correct one to be given here is ClientId.
ValidAudiences = new List<string>
{
“CLIENTID”,
“APPID URI”
}
Appsettings.json
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "testtenant.onmicrosoft.com",
"ClientId": "xxxxxx",
"TenantId": "xxxxxxxxxxxx",
"ClientSecret": "xxxxx",
"Audience": "<clientId>",
"ClientCertificates": [
],
"CallbackPath": "/signin-oidc"
},
"DownstreamApi": {
"BaseUrl": "https://graph.microsoft.com/v1.0",
//"Scopes": "api://<clientId>/.default"
"Scopes": "https://graph.microsoft.com/.default"
},
Here a scope for a V2 endpoint application can be exposed at api:///access_as_user or api://<clientId>/<scope value> for your webapi .
Make sure your accessTokenAcceptedVersion is 2 for v2 endpoint issuer
For v1 scope is <App ID URI>/.default,
Here when my issuer has v1 endpoint
In that case accessTokenAcceptedVersion is null or 1
I tried to get the user display name, through my api using below code in my controller.
HomeController:
[Authorize]
public async Task<IActionResult> Index()
{
var user = await _graphServiceClient.Me.Request().GetAsync();
ViewData["ApiResult"] = user.DisplayName;
ViewData["Givenname"] = user.GivenName;
return View();
}
I could run the application successfully and call my API enpoint without error.
Related
I am having a problem configuring a React client accessing a .NET 5 web API using Azure AD B2C. I reviewed the following documents;
Register apps in Azure Active Directory B2C
Register a single-page application (SPA) in Azure AD B2C
Add a web API application to your Azure AD B2C tenant
Single-page application: Acquire a token to call an API
Enable authentication in your own web API by using Azure AD B2C
I ended up using the examples shown in the last document and using the configuration settings for the node.js API server in my .NET 5 Web API;
React Configuration
import { LogLevel } from "#azure/msal-browser";
export const b2cPolicies = {
names: {
signIn: "b2c_1_signin",
signInStaff: "b2c_1_signupin_staff"
},
authorities: {
signIn: {
authority: "https://<b2cTenantName>.b2clogin.com/<b2cTenantName>.onmicrosoft.com/b2c_1_signin",
},
signInStaff: {
authority: "https://<b2cTenantName>.b2clogin.com/<b2cTenantName>.onmicrosoft.com/b2c_1_signupin_staff"
}
},
authorityDomain: "<b2cTenantName>.b2clogin.com"
}
export const msalConfig = {
auth: {
clientId: "ec0441a4-89ac-1111-1111-111111111111",
authority: b2cPolicies.authorities.signIn.authority,
knownAuthorities: [b2cPolicies.authorityDomain],
redirectUri: "http://localhost:3000",
navigateToLoginRequestUrl: false,
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: false,
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
level = LogLevel.Verbose;
switch (level) {
case LogLevel.Error:
console.error(message);
return;
case LogLevel.Info:
console.info(message);
return;
case LogLevel.Verbose:
console.debug(message);
return;
case LogLevel.Warning:
console.warn(message);
return;
default:
console.log(message);
}
}
}
}
};
export const loginRequest = {
scopes: ["openid", "offline_access"]
};
export const loginRequestStaff = {
scopes: ["openid", "profile"]
};
export const protectedResources = {
portalApi: {
scopes: ["https://<b2cTenantName>.onmicrosoft.com/PortalClient/access_as_user"],
redirectUri: "http://localhost:3000/Dashboard",
},
portalApiStaff: {
scopes: ["https://<b2cTenantName>.onmicrosoft.com/PortalClient/access_as_staff"],
redirectUri: "http://localhost:3000/Dashboard",
}
}
.NET 5 AppSettings.json
{
...
"AzureAd": {
"Instance": "https://<b2cTenantName>.b2clogin.com",
"Domain": "<b2cTenantName>.onmicrosoft.com",
"ClientId": "ec0441a4-89ac-1111-1111-111111111111",
"SignUpSignInPolicyId": "B2C_1_SignIn"
},
...
}
Portal API Registration
Property
Value
Application (Client) ID
d1a138a9-2379-1111-1111-111111111111
Directory (Tenant) ID
6c977334-b859-2222-2222-222222222222
Redirect URIs
http://localhost:3000
Certificates & Secrets
None
API Permissions (all have Admin consent)
Microsoft Graph - offline_access, openid
Application ID URI
https://.onmicrosoft.com/PortalApi
Exposed APIs (scopes)
access_as_staff, access_as_user
Portal Client Registration
Property
Value
Application (Client) ID
ec0441a4-89ac-1111-1111-111111111111
Directory (Tenant) ID
6c977334-b859-2222-2222-222222222222
Redirect URIs
http://localhost:3000/Dashboard
http://localhost:3000
Certificates & Secrets
None
API Permissions (all have Admin consent)
Microsoft Graph: - offline_access, openid
Portal API - access_as_staff, access_as_user
MSAL Login & Access Token Code
import { PublicClientApplication } from "#azure/msal-browser";
import { msalConfig, b2cPolicies, protectedResources, loginRequest, loginRequestStaff } from "./authConfig";
let msalInstance = new PublicClientApplication(msalConfig);
export const getMsalInstance = () => msalInstance;
export const login = () => {
msalInstance.config.auth.authority = b2cPolicies.authorities.signIn.authority;
msalInstance.loginRedirect(loginRequest);
}
export const loginAsStaff = () => {
msalInstance.config.auth.authority = b2cPolicies.authorities.signInStaff.authority;
msalInstance.loginRedirect(loginRequestStaff);
}
export const getAccessToken = async (accessToken, isStaffUser) => {
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) return null;
let _accessToken = {...accessToken};
if (_accessToken.token == null || _accessToken.expires <= Date()) {
const response = await msalInstance.acquireTokenSilent({
account: accounts[0],
scopes: isStaffUser ? protectedResources.portalApiStaff.scopes : protectedResources.portalApi.scopes
});
const expiryDt = new Date(0).setUTCSeconds((response.idTokenClaims.exp));
_accessToken = { token: response.idToken, expires: expiryDt };
}
return _accessToken;
}
.NET 5 Web API Startup.cs
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(options =>
{
Configuration.Bind("AzureAd", options);
options.TokenValidationParameters.NameClaimType = "name";
}, options => { Configuration.Bind("AzureAd", options); });
services.AddAuthorization();
...
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStaticFiles();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
...
}
}
When acquiring the access token in the MSAL code I get the following response in response.idToken;
{
"typ": "JWT",
"alg": "RS256",
"kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk"
}.{
"exp": 1669793111,
"nbf": 1669789511,
"ver": "1.0",
"iss": "https://<b2cTenantName>.b2clogin.com/6c977334-b859-2222-2222-222222222222/v2.0/",
"sub": "3a35cfce-de55-4217-9c33-217f14639578",
"aud": "ec0441a4-89ac-1111-1111-111111111111",
"nonce": "ce9b5c4f-010d-43cc-9dce-861e6a087489",
"iat": 1669789511,
"auth_time": 1669789496,
"idp_access_token": "eyJraWQiOiItVWRTSVB...7bBGxGDzNQWqw",
"idp": "https://<idp>/oauth2/auskoi3basJBCcDOy1t7",
"given_name": "John",
"family_name": "Doe",
"name": "John Doe",
"oid": "3a35cfce-de55-4217-9c33-217f14639578",
"emails": [
"John.Doe#my-company.com.au"
],
"tfp": "B2C_1_SignUpIn_Staff"
}.[Signature]
and if I then decode the idp-access-token I get;
{
"kid": "-UdSIPeUmX7e4pqpXRBb7IXu3bCLsQo0hU67PYDhdfM",
"alg": "RS256"
}.{
"ver": 1,
"jti": "AT.r_KbqlRqqur1gxf9u5S8VReP2awA58YjtIfKiBdLokQ",
"iss": "https://<my-company>/oauth2/auskoi3basJBCcDOy1t7",
"aud": "Company-CustomerPortal",
"iat": 1669789481,
"exp": 1669793081,
"cid": "0oakoicxjrL5IMsmd1t7",
"uid": "00ua37oxlxRdmbFgb1t7",
"scp": [
"groups",
"profile",
"openid"
],
"auth_time": 1669789477,
"sub": "John.Doe#my-company.com.au"
}.[Signature]
Neither of which have the access_as_staff scope.
If I configure the client and API code to use the Portal Client clientId (ec0441a4-89ac-1111-1111-111111111111) and submit the access token to an [Authorize] route on the web API I get the following error;
IDW10201: Neither scope or roles claim was found in the bearer token.
If however I configure the API to use the Portal API clientId (d1a138a9-2379-1111-1111-111111111111) and the client to use the Portal Client clientId (ec0441a4-89ac-1111-1111-111111111111) as shown in the example, I get the following error;
IDX10214: Audience validation failed. Audiences: 'ec0441a4-89ac-1111-1111-111111111111'. Did not match: validationParameters.ValidAudience: 'd1a138a9-2379-1111-1111-111111111111' or validationParameters.ValidAudiences: 'null'.
I tried to reproduce the same in my environment:
I tried to change the scope parameter but still I received .
In my case I am calling graph api:
So my scope must be https://graph.microsoft.com/.default
I got the same error when I tried to call an API from my web app.
Error:
SecurityTokenInvalidAudienceException: IDX10214: Audience validation failed. Audiences: 'xxx'. Did not match: validationParameters.ValidAudience: xxxx or validationParameters.ValidAudiences: xxxx
In my case I have given wrong clientId in my clientApp registration in
appsettings.json
{
"AzureAd": {
"Instance": "....",
"Domain": "xxx",
"ClientId": "xxx",
"TenantId": "xxxf3a0cxxb0",
"ClientSecret": "xxxxxxxxx",
"ClientCertificates": [
],
….
},
Make sure to expose the scopes for your API in your API configuration .
Give that (exposed)API permissions to the APP that is clientAPP .
Also check the issuer endpoint,
"iss": "https://<b2cTenantName>.b2clogin.com/xxxx/v2.0/",
If it has v2 endpoint make sure , the value is 2 in> "accessTokenAcceptedVersion": 2,
With all changes , I could call my Api successfully:
Also check this reference : Azure AD B2C: Call an ASP.NET Web API from an ASP.NET Web App - Code Samples | Microsoft Learn
I have implemented azure ad authentication successfully. I am able to
login, and display the user's name.
I now need to call the graph api to access the user's email address.
I have my token type set to "ID" tokens in the azure portal.
Index.Razor
Code {
private HttpClient _httpClient;
public string Name { get; set; }
public string userDisplayName = "";
//this is what I am using to get the user's name
protected override async Task OnInitializedAsync()
{
var authstate = await Authentication_.GetAuthenticationStateAsync();
var user = authstate.User.Identity.Name;
if (user != null)
{
Name = user;
// 1) this is what I'm trying to use right now.
//The Graph API SDK
var attempt= await GraphServiceClient.Me.Request().GetAsync();
}
else
{
Name = "";
}
/*
// 2)this is what I've tried to use to access the graph api
_httpClient = HttpClientFactory.CreateClient();
// get a token
var token = await TokenAcquisitionService.GetAccessTokenForUserAsync(new string[] { "User.Read" });
// make API call
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var dataRequest = await _httpClient.GetAsync("https://graph.microsoft.com/beta/me");
if (dataRequest.IsSuccessStatusCode)
{
var userData = System.Text.Json.JsonDocument.Parse(await dataRequest.Content.ReadAsStreamAsync());
userDisplayName = userData.RootElement.GetProperty("displayName").GetString();
}
}
Startup.cs
var initialScopes = Configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ');
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
services.AddAuthorization(options =>
{
// By default, all incoming requests will be authorized according to the default policy
options.FallbackPolicy = options.DefaultPolicy;
});
services.AddRazorPages();
services.AddAuthorization();
services.AddServerSideBlazor()
.AddMicrosoftIdentityConsentHandler();
Appsettings.json
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
"TenantId": "xxxxxxxxxxxxxxxxxxxxxxxxx",
"ClientId": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
"CallbackPath": "/.auth/login/aad/callback",
"ClientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"DownstreamApi": {
"BaseUrl": "https://graph.microsoft.com/beta",
"Scopes": "user.read"
},
When I attempt a request via the 1st tried method mentioned in the Index.razor file above (I commented it out with the number 1) I get an error of: "MSAL.Net No account or login hint was passed to the AcquireTokenSilent call"
More details:
This an image of my delegated permissions set in azure portal
Lastly: this is a link the example I followed. https://github.com/wmgdev/BlazorGraphApi
You can add the optional "email" claim if you have control over the Azure AD App Registration:
After doing that, you will have an "emailaddress" claim in authstate.User.Claims
I just tried it in my Blazor app and it works great. I think it is possible for there to not be an email property though, so make sure you null check, etc.
You can use #context.User.Claims to get a login name, in case of, login name is same as an email address, as usual.
< AuthorizeView>
< Authorized>
Hello, #context.User.Claims.First( cl => cl.Type.ToString()=="preferred_username").Value
</Authorized>
<NotAuthorized>
Log in
</NotAuthorized>
</ AuthorizeView>
Retrieving a user's login name in Blazor WASM component
I have the following useful load in a token generated with JWT
{
"sub": "flamelsoft#gmail.com",
"jti": "0bca1034-f3ce-4f72-bd91-65c1a61924c4",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Administrator",
"exp": 1509480891,
"iss": "http://localhost:40528",
"aud": "http://localhost:40528"
}
with this code
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DBContextSCM>(options =>
options.UseMySql(Configuration.GetConnectionString("DefaultConnection"), b =>
b.MigrationsAssembly("FlamelsoftSCM")));
services.AddIdentity<User, Role>()
.AddEntityFrameworkStores<DBContextSCM>()
.AddDefaultTokenProviders();
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddAuthentication()
.AddJwtBearer(cfg =>
{
cfg.RequireHttpsMetadata = false;
cfg.SaveToken = true;
cfg.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = Configuration["Tokens:Issuer"],
ValidAudience = Configuration["Tokens:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Tokens:Key"]))
};
});
services.AddMvc();
}
AccountController.cs
[HttpPost]
[Authorize(Roles="Administrator")]
public async Task<IActionResult> Register([FromBody]RegisterModel model)
{
try
{
var user = new User { UserName = model.Email, Email = model.Email };
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
var role = await _roleManager.FindByIdAsync(model.Role);
result = await _userManager.AddToRoleAsync(user, role.Name);
if (result.Succeeded)
return View(model);
}
return BadRequest($"Error: Could not create user");
}
catch (Exception ex)
{
return BadRequest($"Error: {ex.Message}");
}
}
user.service.ts
export class UserService {
constructor(private http: Http, private config: AppConfig, private currentUser: User) { }
create(user: User) {
return this.http.post(this.config.apiUrl + 'Account/Register', user, this.jwt());
}
private jwt() {
const userJson = localStorage.getItem('currentUser');
this.currentUser = userJson !== null ? JSON.parse(userJson) : new User();
if (this.currentUser && this.currentUser.token) {
let headers = new Headers({ 'Authorization': 'Bearer ' + this.currentUser.token });
return new RequestOptions({ headers: headers });
}
}}
The problem is that the validation of the role does not work, the request arrives at the controller and returns a code 200 in the header, but never enters the class.
When I remove the [Authorize (Roles = "Administrator")] it enters correctly my code.
Is there something badly defined? Or what would be the alternative to define an authorization through roles.
TL;DR
As mentioned in the comments of the original question, changing:
[HttpPost]
[Authorize(Roles = "Administrator")]
public async Task<IActionResult> Register([FromBody]RegisterModel model)
{
// Code
}
to
[HttpPost]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Administrator")]
public async Task<IActionResult> Register([FromBody]RegisterModel model)
{
// Code
}
resolved the issue.
Bearer is the default authentication scheme name when using JWT bearer authentication in ASP.NET Core.
But why do we need to specify the AuthenticationSchemes property on the [Authorize] attribute?
It's because configuring authentication schemes doesn't mean they will run on each HTTP request. If a specific action is accessible to anonymous users, why bother extract user information from a cookie or a token? MVC is smart about this and will only run authentication handlers when it's needed, that is, during requests that are somehow protected.
In our case, MVC discovers the [Authorize] attribute, hence knows it has to run authentication and authorization to determine if the request is authorized or not. The trick lies in the fact that it will only run the authentication schemes handlers which have been specified. Here, we had none, so no authentication was performed, which meant authorization failed since the request was considered anonymous.
Adding the authentication scheme to the attribute instructed MVC to run that handler, which extracted user information from the token in the HTTP request, which lead to the Administrator role being discovered, and the request was allowed.
As a side note, there's another way to achieve this, without resorting to using the AuthenticationSchemes property of the [Authorize] attribute.
Imagine that your application only has one authentication scheme configured, it would be a pain to have to specify that AuthenticationSchemes property on every [Authorize] attribute.
With ASP.NET Core, you can configure a default authentication scheme. Doing so implies that the associated handler will be run for each HTTP request, regardless of whether the resource is protected or not.
Setting this up is done in two parts:
public class Startup
{
public void ConfiguresServices(IServiceCollection services)
{
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme /* this sets the default authentication scheme */)
.AddJwtBearer(options =>
{
// Configure options here
});
}
public void Configure(IApplicationBuilder app)
{
// This inserts the middleware that will execute the
// default authentication scheme handler on every request
app.UseAuthentication();
app.UseMvc();
}
}
Doing this means that by the time MVC evaluates whether the request is authorized or not, authentication will have taken place already, so not specifying any value for the AuthenticationSchemes property of the [Authorize] attribute won't be a problem.
The authorization part of the process will still run and check against the authenticated user whether they're part of the Administrator group or not.
I know this question already has an answer, but something important is left out here. You need to make sure you're actually setting the claims for the logged in user. In my case, I'm using JWT Authentication, so this step is very important:
var claims = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.UserName) });
var roles = await _userManager.GetRolesAsync(user);
if (roles.Count > 0)
{
foreach (var role in roles) { claims.AddClaim(new Claim(ClaimTypes.Role, role)); }
}
var token = new JwtSecurityToken(
issuer: _configuration["JWT:Issuer"],
audience: _configuration["JWT:Audience"],
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: signingCredentials,
claims: claims.Claims);
I was banging my head trying to figure out why HttpContext.User didn't include what I expected trying to narrow down the [Authroization(Roles="Admin")] issue. Turns out, if you're using JWT Auth you need to remember to set the Claims[] to the identity. Maybe this is done automatically in other dotnet ways, but jwt seems to require you to set that manually.
After I set the claims for the user, the [Authorize(Roles = "Whatever")] worked as expected.
still periodically struggling with OpenAuth using OpenIdDict (credentials flow) in ASP.NET Core, I updated to the latest OpenIdDict bits and VS2017 my old sample code you can find at https://github.com/Myrmex/repro-oidang, with a full step-by-step guidance to create an essential startup template. Hope this can be useful to the community to help getting started with simple security scenarios, so any contribution to that simple example code is welcome.
Essentially I followed the credentials flow sample from the OpenIdDict author, and I can get my token back when requesting it like (using Fiddler):
POST http://localhost:50728/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&scope=offline_access profile email roles&resource=http://localhost:4200&username=zeus&password=P4ssw0rd!
Problem is that when I try to use this token, I keep getting a 401, without any other hint: no exception, nothing logged. The request is like:
GET http://localhost:50728/api/values
Content-Type: application/json
Authorization: Bearer ...
Here is my relevant code: first Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
// setup options with DI
// https://docs.asp.net/en/latest/fundamentals/configuration.html
services.AddOptions();
// CORS (note: if using Azure, remember to enable CORS in the portal, too!)
services.AddCors();
// add entity framework and its context(s) using in-memory
// (or use the commented line to use a connection string to a real DB)
services.AddEntityFrameworkSqlServer()
.AddDbContext<ApplicationDbContext>(options =>
{
// options.UseSqlServer(Configuration.GetConnectionString("Authentication")));
options.UseInMemoryDatabase();
// register the entity sets needed by OpenIddict.
// Note: use the generic overload if you need
// to replace the default OpenIddict entities.
options.UseOpenIddict();
});
// register the Identity services
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
});
// 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
// to action methods. Alternatively, you can still use the lower-level
// HttpContext.GetOpenIdConnectRequest() API.
options.AddMvcBinders();
// enable the endpoints
options.EnableTokenEndpoint("/connect/token");
options.EnableLogoutEndpoint("/connect/logout");
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
options.EnableUserinfoEndpoint("/connect/userinfo");
// enable the password flow
options.AllowPasswordFlow();
options.AllowRefreshTokenFlow();
// during development, you can disable the HTTPS requirement
options.DisableHttpsRequirement();
// Note: to use JWT access tokens instead of the default
// encrypted format, the following lines are required:
// options.UseJsonWebTokens();
// options.AddEphemeralSigningKey();
});
// add framework services
services.AddMvc()
.AddJsonOptions(options =>
{
options.SerializerSettings.ContractResolver =
new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();
});
// seed the database with the demo user details
services.AddTransient<IDatabaseInitializer, DatabaseInitializer>();
// swagger
services.AddSwaggerGen();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory,
IDatabaseInitializer databaseInitializer)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
loggerFactory.AddNLog();
// https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling
if (env.IsDevelopment()) app.UseDeveloperExceptionPage();
// to serve up index.html
app.UseDefaultFiles();
app.UseStaticFiles();
// CORS
// https://docs.asp.net/en/latest/security/cors.html
app.UseCors(builder =>
builder.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod());
// add a middleware used to validate access tokens and protect the API endpoints
app.UseOAuthValidation();
app.UseOpenIddict();
app.UseMvc();
// app.UseMvcWithDefaultRoute();
// app.UseWelcomePage();
// seed the database
databaseInitializer.Seed().GetAwaiter().GetResult();
// swagger
// enable middleware to serve generated Swagger as a JSON endpoint
app.UseSwagger();
// enable middleware to serve swagger-ui assets (HTML, JS, CSS etc.)
app.UseSwaggerUi();
}
And then my controller (you can find the whole solution in the repository quoted above):
public sealed class AuthorizationController : Controller
{
private readonly IOptions<IdentityOptions> _identityOptions;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
public AuthorizationController(
IOptions<IdentityOptions> identityOptions,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager)
{
_identityOptions = identityOptions;
_signInManager = signInManager;
_userManager = userManager;
}
private async Task<AuthenticationTicket> CreateTicketAsync(OpenIdConnectRequest request, ApplicationUser user)
{
// Create a new ClaimsPrincipal containing the claims that
// will be used to create an id_token, a token or a code.
ClaimsPrincipal principal = await _signInManager.CreateUserPrincipalAsync(user);
// Create a new authentication ticket holding the user identity.
AuthenticationTicket ticket = new AuthenticationTicket(
principal, new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
// Set the list of scopes granted to the client application.
// Note: the offline_access scope must be granted
// to allow OpenIddict to return a refresh token.
ticket.SetScopes(new[] {
OpenIdConnectConstants.Scopes.OpenId,
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIddictConstants.Scopes.Roles
}.Intersect(request.GetScopes()));
ticket.SetResources("resource-server");
// 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 ticket.Principal.Claims)
{
// Never include the security stamp in the access and identity tokens, as it's a secret value.
if (claim.Type == _identityOptions.Value.ClaimsIdentity.SecurityStampClaimType)
continue;
List<string> destinations = new List<string>
{
OpenIdConnectConstants.Destinations.AccessToken
};
// Only add the iterated claim to the id_token if the corresponding scope was granted to the client application.
// The other claims will only be added to the access_token, which is encrypted when using the default format.
if (claim.Type == OpenIdConnectConstants.Claims.Name &&
ticket.HasScope(OpenIdConnectConstants.Scopes.Profile) ||
claim.Type == OpenIdConnectConstants.Claims.Email &&
ticket.HasScope(OpenIdConnectConstants.Scopes.Email) ||
claim.Type == OpenIdConnectConstants.Claims.Role &&
ticket.HasScope(OpenIddictConstants.Claims.Roles))
{
destinations.Add(OpenIdConnectConstants.Destinations.IdentityToken);
}
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken);
}
return ticket;
}
[HttpPost("~/connect/token"), Produces("application/json")]
public async Task<IActionResult> Exchange(OpenIdConnectRequest request)
{
// if you prefer not to bind the request as a parameter, you can still use:
// OpenIdConnectRequest request = HttpContext.GetOpenIdConnectRequest();
Debug.Assert(request.IsTokenRequest(),
"The OpenIddict binder for ASP.NET Core MVC is not registered. " +
"Make sure services.AddOpenIddict().AddMvcBinders() is correctly called.");
if (!request.IsPasswordGrantType())
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.UnsupportedGrantType,
ErrorDescription = "The specified grant type is not supported."
});
}
ApplicationUser user = await _userManager.FindByNameAsync(request.Username);
if (user == null)
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the user is allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Reject the token request if two-factor authentication has been enabled by the user.
if (_userManager.SupportsUserTwoFactor && await _userManager.GetTwoFactorEnabledAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Ensure the user is not already locked out.
if (_userManager.SupportsUserLockout && await _userManager.IsLockedOutAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the password is valid.
if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
if (_userManager.SupportsUserLockout)
await _userManager.AccessFailedAsync(user);
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
if (_userManager.SupportsUserLockout)
await _userManager.ResetAccessFailedCountAsync(user);
// Create a new authentication ticket.
AuthenticationTicket ticket = await CreateTicketAsync(request, user);
var result = SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
return result;
// return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
[HttpGet("~/connect/logout")]
public async Task<IActionResult> Logout()
{
// Extract the authorization request from the ASP.NET environment.
OpenIdConnectRequest request = HttpContext.GetOpenIdConnectRequest();
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
await _signInManager.SignOutAsync();
// Returning a SignOutResult will ask OpenIddict to redirect the user agent
// to the post_logout_redirect_uri specified by the client application.
return SignOut(OpenIdConnectServerDefaults.AuthenticationScheme);
}
// http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
[Authorize]
[HttpGet("~/connect/userinfo")]
public async Task<IActionResult> GetUserInfo()
{
ApplicationUser user = await _userManager.GetUserAsync(User);
// to simplify, in this demo we just have 1 role for users: either admin or editor
string sRole = await _userManager.IsInRoleAsync(user, "admin")
? "admin"
: "editor";
// http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
return Ok(new
{
sub = user.Id,
given_name = user.FirstName,
family_name = user.LastName,
name = user.UserName,
user.Email,
email_verified = user.EmailConfirmed,
roles = sRole
});
}
}
As mentioned in this blog post, the token format used by OpenIddict slightly changed recently, which makes tokens issued by the latest OpenIddict bits incompatible with the old OAuth2 validation middleware version you're using.
Migrate to AspNet.Security.OAuth.Validation 1.0.0 and it should work.
I am working on a sample application for OpenIddict using AngularJs. I was told that you shouldnt use clientside frameworks like Satellizer, as this isnt recommended, but instead allow the server to deal with logging in server side (locally and using external login providers), and return the access token.
Well i have a demo angularJs application and uses server side login logic and calls back to the angular app, but my problem is, how do i get the access token for the current user?
here is my startup.cs file, so you can see my configuration so far
public void ConfigureServices(IServiceCollection services) {
var configuration = new ConfigurationBuilder()
.AddJsonFile("config.json")
.AddEnvironmentVariables()
.Build();
services.AddMvc();
services.AddEntityFramework()
.AddSqlServer()
.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration["Data:DefaultConnection:ConnectionString"]));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddOpenIddict();
services.AddTransient<IEmailSender, AuthMessageSender>();
services.AddTransient<ISmsSender, AuthMessageSender>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
env.EnvironmentName = "Development";
var factory = app.ApplicationServices.GetRequiredService<ILoggerFactory>();
factory.AddConsole();
factory.AddDebug();
app.UseDeveloperExceptionPage();
app.UseIISPlatformHandler(options => {
options.FlowWindowsAuthentication = false;
});
app.UseOverrideHeaders(options => {
options.ForwardedOptions = ForwardedHeaders.All;
});
app.UseStaticFiles();
// Add a middleware used to validate access
// tokens and protect the API endpoints.
app.UseOAuthValidation();
// comment this out and you get an error saying
// InvalidOperationException: No authentication handler is configured to handle the scheme: Microsoft.AspNet.Identity.External
app.UseIdentity();
// TOO: Remove
app.UseGoogleAuthentication(options => {
options.ClientId = "XXX";
options.ClientSecret = "XXX";
});
app.UseTwitterAuthentication(options => {
options.ConsumerKey = "XXX";
options.ConsumerSecret = "XXX";
});
// Note: OpenIddict must be added after
// ASP.NET Identity and the external providers.
app.UseOpenIddict(options =>
{
options.Options.AllowInsecureHttp = true;
options.Options.UseJwtTokens();
});
app.UseMvcWithDefaultRoute();
using (var context = app.ApplicationServices.GetRequiredService<ApplicationDbContext>()) {
context.Database.EnsureCreated();
// Add Mvc.Client to the known applications.
if (!context.Applications.Any()) {
context.Applications.Add(new Application {
Id = "myClient",
DisplayName = "My client application",
RedirectUri = "http://localhost:5000/signin",
LogoutRedirectUri = "http://localhost:5000/",
Secret = Crypto.HashPassword("secret_secret_secret"),
Type = OpenIddictConstants.ApplicationTypes.Confidential
});
context.SaveChanges();
}
}
}
Now my AccountController is basically the same as the normal Account Controller, although once users have logged in (using local and external signin) i use this function and need an accessToken.
private IActionResult RedirectToAngular()
{
// I need the accessToken here
return RedirectToAction(nameof(AccountController.Angular), new { accessToken = token });
}
As you can see from the ExternalLoginCallback method on the AccountController
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null)
{
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
return RedirectToAction(nameof(Login));
}
// Sign in the user with this external login provider if the user already has a login.
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
if (result.Succeeded)
{
// SHOULDNT THE USER HAVE A LOCAL ACCESS TOKEN NOW??
return RedirectToAngular();
}
if (result.RequiresTwoFactor)
{
return RedirectToAction(nameof(SendCode), new { ReturnUrl = returnUrl });
}
if (result.IsLockedOut)
{
return View("Lockout");
}
else {
// If the user does not have an account, then ask the user to create an account.
ViewData["ReturnUrl"] = returnUrl;
ViewData["LoginProvider"] = info.LoginProvider;
var email = info.ExternalPrincipal.FindFirstValue(ClaimTypes.Email);
return View("ExternalLoginConfirmation", new ExternalLoginConfirmationViewModel { Email = email });
}
}
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
if (result.Succeeded)
{
// SHOULDNT THE USER HAVE A LOCAL ACCESS TOKEN NOW??
return RedirectToAngular();
}
That's not how it's supposed to work. Here's what happens in the classical flow:
The OAuth2/OpenID Connect client application (in your case, your Satellizer JS app) redirects the user agent to the authorization endpoint (/connect/authorize by default in OpenIddict) with all the mandatory parameters: client_id, redirect_uri (mandatory in OpenID Connect), response_type and nonce when using the implicit flow (i.e response_type=id_token token). Satellizer should do that for you, assuming you've correctly registered your authorization server (1).
If the user is not already logged in, the authorization endpoint redirects the user to the login endpoint (in OpenIddict, it's done for you by an internal controller). At this point, your AccountController.Login action is invoked and the user is displayed a login form.
When the user is logged in (after a registration process and/or an external authentication association), he/she MUST be redirected back to the authorization endpoint: you can't redirect the user agent to your Angular app at this stage. Undo the changes made to ExternalLoginCallback and it should work.
Then, the user is displayed a consent form indicating he/she's about to allow your JS app to access his personal data on his/her behalf. When the user submits the consent form, the request is handled by OpenIddict, an access token is generated and the user agent is redirected back to the JS client app, with the token appended to the URI fragment.
[1]: according to the Satellizer documentation, it should be something like that:
$authProvider.oauth2({
name: 'openiddict',
clientId: 'myClient',
redirectUri: window.location.origin + '/done',
authorizationEndpoint: window.location.origin + '/connect/authorize',
responseType: 'id_token token',
scope: ['openid'],
requiredUrlParams: ['scope', 'nonce'],
nonce: function() { return "TODO: implement appropriate nonce generation and validation"; },
popupOptions: { width: 1028, height: 529 }
});