I am trying to figure out how to authorize using groups in Azure Active Directory B2C. I can Authorize via User, for example:
[Authorize(Users="Bill")]
However, this is not very effective and I see very few use-cases for this. An alternate solution would be Authorizing via Role. However for some reason that does not seem to work. It does not work if I give a user the Role "Global Admin" for example, and try:
[Authorize(Roles="Global Admin")]
Is there a way to authorize via Groups or Roles?
Obtaining group memberships for a user from Azure AD requires quite a bit more than just "a couple lines of code", so I thought I'd share what finally worked for me to save others a few days worth of hair-pulling and head-banging.
Let's begin by adding the following dependencies to project.json:
"dependencies": {
...
"Microsoft.IdentityModel.Clients.ActiveDirectory": "3.13.8",
"Microsoft.Azure.ActiveDirectory.GraphClient": "2.0.2"
}
The first one is necessary as we need to authenticate our application in order for it to be able to access AAD Graph API.
The second one is the Graph API client library we'll be using to query user memberships.
It goes without saying that the versions are only valid as of the time of this writing and may change in the future.
Next, in the Configure() method of the Startup class, perhaps just before we configure OpenID Connect authentication, we create the Graph API client as follows:
var authContext = new AuthenticationContext("https://login.microsoftonline.com/<your_directory_name>.onmicrosoft.com");
var clientCredential = new ClientCredential("<your_b2c_app_id>", "<your_b2c_secret_app_key>");
const string AAD_GRAPH_URI = "https://graph.windows.net";
var graphUri = new Uri(AAD_GRAPH_URI);
var serviceRoot = new Uri(graphUri, "<your_directory_name>.onmicrosoft.com");
this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential));
WARNING: DO NOT hard-code your secret app key but instead keep it in a secure place. Well, you already knew that, right? :)
The asynchronous AcquireGraphAPIAccessToken() method that we handed to the AD client constructor will be called as necessary when the client needs to obtain authentication token. Here's what the method looks like:
private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential)
{
AuthenticationResult result = null;
var retryCount = 0;
var retry = false;
do
{
retry = false;
try
{
// ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
}
catch (AdalException ex)
{
if (ex.ErrorCode == "temporarily_unavailable")
{
retry = true;
retryCount++;
await Task.Delay(3000);
}
}
} while (retry && (retryCount < 3));
if (result != null)
{
return result.AccessToken;
}
return null;
}
Note that it has a built-in retry mechanism for handling transient conditions, which you may want to tailor to your application's needs.
Now that we have taken care of application authentication and AD client setup, we can go ahead and tap into OpenIdConnect events to finally make use of it.
Back in the Configure() method where we'd typically call app.UseOpenIdConnectAuthentication() and create an instance of OpenIdConnectOptions, we add an event handler for the OnTokenValidated event:
new OpenIdConnectOptions()
{
...
Events = new OpenIdConnectEvents()
{
...
OnTokenValidated = SecurityTokenValidated
},
};
The event is fired when access token for the signing-in user has been obtained, validated and user identity established. (Not to be confused with the application's own access token required to call AAD Graph API!)
It looks like a good place for querying Graph API for user's group memberships and adding those groups onto the identity, in the form of additional claims:
private Task SecurityTokenValidated(TokenValidatedContext context)
{
return Task.Run(async () =>
{
var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
{
var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync();
do
{
var directoryObjects = pagedCollection.CurrentPage.ToList();
foreach (var directoryObject in directoryObjects)
{
var group = directoryObject as Group;
if (group != null)
{
((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
}
}
pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null;
}
while (pagedCollection != null);
}
});
}
Used here is the Role claim type, however you could use a custom one.
Having done the above, if you're using ClaimType.Role, all you need to do is decorate your controller class or method like so:
[Authorize(Role = "Administrators")]
That is, of course, provided you have a designated group configured in B2C with a display name of "Administrators".
If, however, you chose to use a custom claim type, you'd need to define an authorization policy based on the claim type by adding something like this in the ConfigureServices() method, e.g.:
services.AddAuthorization(options => options.AddPolicy("ADMIN_ONLY", policy => policy.RequireClaim("<your_custom_claim_type>", "Administrators")));
and then decorate a privileged controller class or method as follows:
[Authorize(Policy = "ADMIN_ONLY")]
Ok, are we done yet? - Well, not exactly.
If you ran your application and tried signing in, you'd get an exception from Graph API claiming "Insufficient privileges to complete the operation".
It may not be obvious, but while your application authenticates successfully with AD using its app_id and app_key, it doesn't have the privileges required to read the details of users from your AD.
In order to grant the application such access, I chose to use the Azure Active Directory Module for PowerShell
The following script did the trick for me:
$tenantGuid = "<your_tenant_GUID>"
$appID = "<your_app_id>"
$userVal = "<admin_user>#<your_AD>.onmicrosoft.com"
$pass = "<admin password in clear text>"
$Creds = New-Object System.Management.Automation.PsCredential($userVal, (ConvertTo-SecureString $pass -AsPlainText -Force))
Connect-MSOLSERVICE -Credential $Creds
$msSP = Get-MsolServicePrincipal -AppPrincipalId $appID -TenantID $tenantGuid
$objectId = $msSP.ObjectId
Add-MsolRoleMember -RoleName "Company Administrator" -RoleMemberType ServicePrincipal -RoleMemberObjectId $objectId
And now we're finally done!
How's that for "a couple lines of code"? :)
This will work, however you have to write a couple of lines of code in your authentication logic in order to achieve what you're looking for.
First of all, you have to distinguish between Roles and Groups in Azure AD (B2C).
User Role is very specific and only valid within Azure AD (B2C) itself. The Role defines what permissions a user does have inside Azure AD .
Group (or Security Group) defines user group membership, which can be exposed to the external applications. The external applications can model Role based access control on top of Security Groups. Yes, I know it may sound a bit confusing, but that's what it is.
So, your first step is to model your Groups in Azure AD B2C - you have to create the groups and manually assign users to those groups. You can do that in the Azure Portal (https://portal.azure.com/):
Then, back to your application, you will have to code a bit and ask the Azure AD B2C Graph API for users memberships once the user is successfully authenticated. You can use this sample to get inspired on how to get users group memberships. It is best to execute this code in one of the OpenID Notifications (i.e. SecurityTokenValidated) and add users role to the ClaimsPrincipal.
Once you change the ClaimsPrincipal to have Azure AD Security Groups and "Role Claim" values, you will be able to use the Authrize attribute with Roles feature. This is really 5-6 lines of code.
Finally, you can give your vote for the feature here in order to get group membership claim without having to query Graph API for that.
i implmented this as written , but as of May 2017 the line
((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
needs to be changed to
((ClaimsIdentity)context.Ticket.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role, group.DisplayName));
To make it work with latest libs
Great work to the author
Also if your having a problem with Connect-MsolService giving bad username and password update to latest lib
Alex's answer is essential to figure out a working solution, thanks for pointing to the right direction.
However it uses app.UseOpenIdConnectAuthentication() which was long time depreciated already in Core 2 and completely removed in Core 3 (Migrate authentication and Identity to ASP.NET Core 2.0)
The fundamental task we must implement is attach an event handler to OnTokenValidated using OpenIdConnectOptions which is used by ADB2C Authentication under the hood. We must do this without interfering any other configuration of ADB2C.
Here is my take:
// My (and probably everyone's) existing code in Startup:
services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
.AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options));
// This adds the custom event handler, without interfering any existing functionality:
services.Configure<OpenIdConnectOptions>(AzureADB2CDefaults.OpenIdScheme,
options =>
{
options.Events.OnTokenValidated =
new AzureADB2CHelper(options.Events.OnTokenValidated).OnTokenValidated;
});
All implementation is encapsulated in a helper class to keep Startup class clean. The original event handler is saved and called in case if it is not null (it is not btw)
public class AzureADB2CHelper
{
private readonly ActiveDirectoryClient _activeDirectoryClient;
private readonly Func<TokenValidatedContext, Task> _onTokenValidated;
private const string AadGraphUri = "https://graph.windows.net";
public AzureADB2CHelper(Func<TokenValidatedContext, Task> onTokenValidated)
{
_onTokenValidated = onTokenValidated;
_activeDirectoryClient = CreateActiveDirectoryClient();
}
private ActiveDirectoryClient CreateActiveDirectoryClient()
{
// TODO: Refactor secrets to settings
var authContext = new AuthenticationContext("https://login.microsoftonline.com/<yourdomain, like xxx.onmicrosoft.com>");
var clientCredential = new ClientCredential("<yourclientcredential>", #"<yourappsecret>");
var graphUri = new Uri(AadGraphUri);
var serviceRoot = new Uri(graphUri, "<yourdomain, like xxx.onmicrosoft.com>");
return new ActiveDirectoryClient(serviceRoot,
async () => await AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential));
}
private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl,
AuthenticationContext authContext,
ClientCredential clientCredential)
{
AuthenticationResult result = null;
var retryCount = 0;
var retry = false;
do
{
retry = false;
try
{
// ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
}
catch (AdalException ex)
{
if (ex.ErrorCode != "temporarily_unavailable")
{
continue;
}
retry = true;
retryCount++;
await Task.Delay(3000);
}
} while (retry && retryCount < 3);
return result?.AccessToken;
}
public Task OnTokenValidated(TokenValidatedContext context)
{
_onTokenValidated?.Invoke(context);
return Task.Run(async () =>
{
try
{
var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
{
var pagedCollection = await _activeDirectoryClient.Users.GetByObjectId(oidClaim.Value).MemberOf
.ExecuteAsync();
do
{
var directoryObjects = pagedCollection.CurrentPage.ToList();
foreach (var directoryObject in directoryObjects)
{
if (directoryObject is Group group)
{
((ClaimsIdentity) context.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role,
group.DisplayName, ClaimValueTypes.String));
}
}
pagedCollection = pagedCollection.MorePagesAvailable
? await pagedCollection.GetNextPageAsync()
: null;
} while (pagedCollection != null);
}
}
catch (Exception e)
{
Debug.WriteLine(e);
}
});
}
}
You will need the appropriate packages I am using the following ones:
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" />
<PackageReference Include="Microsoft.Azure.ActiveDirectory.GraphClient" Version="2.1.1" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.3" />
Catch: You must give your application permission to read AD. As of Oct 2019 this application must be a 'legacy' app and not the newest B2C application. Here is a very good guide: Azure AD B2C: Use the Azure AD Graph API
There is an official sample: Azure AD B2C: Role-Based Access Control
available here from the Azure AD team.
But yes, the only solution seems to be a custom implementation by reading user groups with the help of MS Graph.
Based on all the amazing answers here, getting user groups using the new Microsoft Graph API
IConfidentialClientApplication confidentialClientApplication = ConfidentialClientApplicationBuilder
.Create("application-id")
.WithTenantId("tenant-id")
.WithClientSecret("xxxxxxxxx")
.Build();
ClientCredentialProvider authProvider = new ClientCredentialProvider(confidentialClientApplication);
GraphServiceClient graphClient = new GraphServiceClient(authProvider);
var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();
I really like the answer from #AlexLobakov but I wanted an updated answer for .NET 6 and also something that was testable but still implemented the caching features. I also wanted the roles to be sent to my front end, be compatible with any SPA like React and use standard Azure AD B2C User flows for Role-based access control (RBAC) in my application.
I also missed a start to finish guide, so many variables that can go wrong and you end up with an application not working.
Start with creating a new ASP.NET Core Web API in Visual Studio 2022 with the following settings:
You should get a dialogue like this after creation:
If you don't see this then right click on the project in Visual Studio and click on Overview and then Connected services.
Create a new App registration in your Azure AD B2C or use an existing. I registered a new one for this demo purpose.
After creating the App registration Visual Studio got stuck on Dependency configuration progress so the rest will be configured manually:
Log on to https://portal.azure.com/, Switch directory to your AD B2C, select your new App registration and then click on Authentication. Then click on Add a platform and select Web.
Add a Redirect URI and Front-channel logout URL for localhost.
Example:
https://localhost:7166/signin-oidc
https://localhost:7166/logout
If you choose Single-page application instead it will look nearly the same. However you then need to add a code_challenge as described below. A full example for this will not be shown.
Is Active Directory not supporting Authorization Code Flow with PKCE?
Authentication should look something like this:
Click on Certificates & secrets and create a new Client secret.
Click on Expose an API and then edit Application ID URI.
Default value should look something like this api://11111111-1111-1111-1111-111111111111. Edit it to be https://youradb2c.onmicrosoft.com/11111111-1111-1111-1111-111111111111. There should be a scope named access_as_user. Create if it is not there.
Now click on API permissions:
Four Microsoft Graph permissions are needed.
Two Application:
GroupMember.Read.All
User.Read.All
Two Delegated:
offline_access
openid
You also need your access_as_user permission from My APIs. When this is done click on Grant admin consent for .... Should look like this:
If you don't have a User Flow already then create either a Sign up and sign in or a Sign in and select Recommended. My user flow is default B2C_1_signin.
Verify that your AD B2C user is a member of the group you want to authenticate against:
Now you can go back to your application and verify that you can get a code to login. Use this sample and it should redirect with a code:
https://<tenant-name>.b2clogin.com/tfp/<tenant-name>.onmicrosoft.com/<user-flow-name>/oauth2/v2.0/authorize?
client_id=<application-ID>
&nonce=anyRandomValue
&redirect_uri=https://localhost:7166/signin-oidc
&scope=https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-1111-111111111111/access_as_user
&response_type=code
If it works you should be redirected to something like this after login:
https://localhost:7166/signin-oidc?code=
If you get an error that says:
AADB2C99059: The supplied request must present a code_challenge
Then you have probably selected platform Single-page application and needs to add a code_challenge to the request like: &code_challenge=123. This is not enough because you also need to validate the challenge later otherwise you will get the error below when running my code.
AADB2C90183: The supplied code_verifier is invalid
Now open your application and appsettings.json. Default should look something like this:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "qualified.domain.name",
"TenantId": "22222222-2222-2222-2222-222222222222",
"ClientId": "11111111-1111-1111-11111111111111111",
"Scopes": "access_as_user",
"CallbackPath": "/signin-oidc"
},
We need a few more values so it should look like this in the end:
"AzureAd": {
"Instance": "https://<tenant-name>.b2clogin.com/",
"Domain": "<tenant-name>.onmicrosoft.com",
"TenantId": "22222222-2222-2222-2222-222222222222",
"ClientId": "11111111-1111-1111-11111111111111111",
"SignUpSignInPolicyId": "B2C_1_signin",
"ClientSecret": "--SECRET--",
"ApiScope": "https://<tenant-name>.onmicrosoft.com/11111111-1111-1111-11111111111111111/access_as_user",
"TokenUrl": "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/B2C_1_signin/oauth2/v2.0/token",
"Scopes": "access_as_user",
"CallbackPath": "/signin-oidc"
},
I store ClientSecret in Secret Manager.
https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#manage-user-secrets-with-visual-studio
Now create these new classes:
AppSettings:
namespace AzureADB2CWebAPIGroupTest
{
public class AppSettings
{
public AzureAdSettings AzureAd { get; set; } = new AzureAdSettings();
}
public class AzureAdSettings
{
public string Instance { get; set; }
public string Domain { get; set; }
public string TenantId { get; set; }
public string ClientId { get; set; }
public string IssuerSigningKey { get; set; }
public string ValidIssuer { get; set; }
public string ClientSecret { get; set; }
public string ApiScope { get; set; }
public string TokenUrl { get; set; }
}
}
Adb2cTokenResponse:
namespace AzureADB2CWebAPIGroupTest
{
public class Adb2cTokenResponse
{
public string access_token { get; set; }
public string id_token { get; set; }
public string token_type { get; set; }
public int not_before { get; set; }
public int expires_in { get; set; }
public int ext_expires_in { get; set; }
public int expires_on { get; set; }
public string resource { get; set; }
public int id_token_expires_in { get; set; }
public string profile_info { get; set; }
public string scope { get; set; }
public string refresh_token { get; set; }
public int refresh_token_expires_in { get; set; }
}
}
CacheKeys:
namespace AzureADB2CWebAPIGroupTest
{
public static class CacheKeys
{
public const string GraphApiAccessToken = "_GraphApiAccessToken";
}
}
GraphApiService:
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Graph;
using System.Text.Json;
namespace AzureADB2CWebAPIGroupTest
{
public class GraphApiService
{
private readonly IHttpClientFactory _clientFactory;
private readonly IMemoryCache _memoryCache;
private readonly AppSettings _settings;
private readonly string _accessToken;
public GraphApiService(IHttpClientFactory clientFactory, IMemoryCache memoryCache, AppSettings settings)
{
_clientFactory = clientFactory;
_memoryCache = memoryCache;
_settings = settings;
string graphApiAccessTokenCacheEntry;
// Look for cache key.
if (!_memoryCache.TryGetValue(CacheKeys.GraphApiAccessToken, out graphApiAccessTokenCacheEntry))
{
// Key not in cache, so get data.
var adb2cTokenResponse = GetAccessTokenAsync().GetAwaiter().GetResult();
graphApiAccessTokenCacheEntry = adb2cTokenResponse.access_token;
// Set cache options.
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromSeconds(adb2cTokenResponse.expires_in));
// Save data in cache.
_memoryCache.Set(CacheKeys.GraphApiAccessToken, graphApiAccessTokenCacheEntry, cacheEntryOptions);
}
_accessToken = graphApiAccessTokenCacheEntry;
}
public async Task<List<string>> GetUserGroupsAsync(string oid)
{
var authProvider = new AuthenticationProvider(_accessToken);
GraphServiceClient graphClient = new GraphServiceClient(authProvider, new HttpClientHttpProvider(_clientFactory.CreateClient()));
//Requires GroupMember.Read.All and User.Read.All to get everything we want
var groups = await graphClient.Users[oid].MemberOf.Request().GetAsync();
if (groups == null)
{
return null;
}
var graphGroup = groups.Cast<Microsoft.Graph.Group>().ToList();
return graphGroup.Select(x => x.DisplayName).ToList();
}
private async Task<Adb2cTokenResponse> GetAccessTokenAsync()
{
var client = _clientFactory.CreateClient();
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "client_credentials"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default"));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
var req = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_settings.AzureAd.Domain}/oauth2/v2.0/token")
{ Content = new FormUrlEncodedContent(kvpList) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation
using var httpResponse = await client.SendAsync(req);
var response = await httpResponse.Content.ReadAsStringAsync();
httpResponse.EnsureSuccessStatusCode();
var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);
return adb2cTokenResponse;
}
}
public class AuthenticationProvider : IAuthenticationProvider
{
private readonly string _accessToken;
public AuthenticationProvider(string accessToken)
{
_accessToken = accessToken;
}
public Task AuthenticateRequestAsync(HttpRequestMessage request)
{
request.Headers.Add("Authorization", $"Bearer {_accessToken}");
return Task.CompletedTask;
}
}
public class HttpClientHttpProvider : IHttpProvider
{
private readonly HttpClient http;
public HttpClientHttpProvider(HttpClient http)
{
this.http = http;
}
public ISerializer Serializer { get; } = new Serializer();
public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300);
public void Dispose()
{
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
return http.SendAsync(request);
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
return http.SendAsync(request, completionOption, cancellationToken);
}
}
}
At the moment only accessToken for GraphServiceClient is stored in memorycache but if the application requires better performance a users groups could also be cached.
Add a new class:
Adb2cUser:
namespace AzureADB2CWebAPIGroupTest
{
public class Adb2cUser
{
public Guid Id { get; set; }
public string GivenName { get; set; }
public string FamilyName { get; set; }
public string Email { get; set; }
public List<string> Roles { get; set; }
public Adb2cTokenResponse Adb2cTokenResponse { get; set; }
}
}
and struct:
namespace AzureADB2CWebAPIGroupTest
{
public struct ADB2CJwtRegisteredClaimNames
{
public const string Emails = "emails";
public const string Name = "name";
}
}
And now add a new API Controller
LoginController:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
namespace AzureADB2CWebAPIGroupTest.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class LoginController : ControllerBase
{
private readonly ILogger<LoginController> _logger;
private readonly IHttpClientFactory _clientFactory;
private readonly AppSettings _settings;
private readonly GraphApiService _graphApiService;
public LoginController(ILogger<LoginController> logger, IHttpClientFactory clientFactory, AppSettings settings, GraphApiService graphApiService)
{
_logger = logger;
_clientFactory = clientFactory;
_settings = settings;
_graphApiService=graphApiService;
}
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult<Adb2cUser>> Post([FromBody] string code)
{
var redirectUri = "";
if (HttpContext != null)
{
redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host + "/signin-oidc";
}
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
kvpList.Add(new KeyValuePair<string, string>("code", code));
kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
return await UserLoginAndRefresh(kvpList);
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<Adb2cUser>> Refresh([FromBody] string token)
{
var redirectUri = "";
if (HttpContext != null)
{
redirectUri = HttpContext.Request.Scheme + "://" + HttpContext.Request.Host;
}
var kvpList = new List<KeyValuePair<string, string>>();
kvpList.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
kvpList.Add(new KeyValuePair<string, string>("client_id", _settings.AzureAd.ClientId));
kvpList.Add(new KeyValuePair<string, string>("scope", "openid offline_access " + _settings.AzureAd.ApiScope));
kvpList.Add(new KeyValuePair<string, string>("refresh_token", token));
kvpList.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
kvpList.Add(new KeyValuePair<string, string>("client_secret", _settings.AzureAd.ClientSecret));
return await UserLoginAndRefresh(kvpList);
}
private async Task<ActionResult<Adb2cUser>> UserLoginAndRefresh(List<KeyValuePair<string, string>> kvpList)
{
var user = await TokenRequest(kvpList);
if (user == null)
{
return Unauthorized();
}
//Return access token and user information
return Ok(user);
}
private async Task<Adb2cUser> TokenRequest(List<KeyValuePair<string, string>> keyValuePairs)
{
var client = _clientFactory.CreateClient();
#pragma warning disable SecurityIntelliSenseCS // MS Security rules violation
var req = new HttpRequestMessage(HttpMethod.Post, _settings.AzureAd.TokenUrl)
{ Content = new FormUrlEncodedContent(keyValuePairs) };
#pragma warning restore SecurityIntelliSenseCS // MS Security rules violation
using var httpResponse = await client.SendAsync(req);
var response = await httpResponse.Content.ReadAsStringAsync();
httpResponse.EnsureSuccessStatusCode();
var adb2cTokenResponse = JsonSerializer.Deserialize<Adb2cTokenResponse>(response);
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(adb2cTokenResponse.access_token);
var id = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.Sub).Value;
var groups = await _graphApiService.GetUserGroupsAsync(id);
var givenName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.GivenName).Value;
var familyName = jwtSecurityToken.Claims.First(claim => claim.Type == JwtRegisteredClaimNames.FamilyName).Value;
//Unless Alternate email have been added in Azure AD there will only be one email here.
//TODO Handle multiple emails
var emails = jwtSecurityToken.Claims.First(claim => claim.Type == ADB2CJwtRegisteredClaimNames.Emails).Value;
var user = new Adb2cUser()
{
Id = Guid.Parse(id),
GivenName = givenName,
FamilyName = familyName,
Email = emails,
Roles = groups,
Adb2cTokenResponse = adb2cTokenResponse
};
return user;
}
}
}
Now it is time to edit Program.cs. Should look something like this for the new minimal hosting model in ASP.NET Core 6.0:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
Notice that ASP.NET Core 6.0 are using JwtBearerDefaults.AuthenticationScheme and not AzureADB2CDefaults.AuthenticationScheme or AzureADB2CDefaults.OpenIdScheme.
Edit so Program.cs looks like this:
using AzureADB2CWebAPIGroupTest;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Identity.Web;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
//Used for debugging
//IdentityModelEventSource.ShowPII = true;
var settings = new AppSettings();
builder.Configuration.Bind(settings);
builder.Services.AddSingleton(settings);
var services = new ServiceCollection();
services.AddMemoryCache();
services.AddHttpClient();
var serviceProvider = services.BuildServiceProvider();
var memoryCache = serviceProvider.GetService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
var graphApiService = new GraphApiService(httpClientFactory, memoryCache, settings);
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(options => {
builder.Configuration.Bind("AzureAd", options);
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.ValidateIssuerSigningKey = true;
options.TokenValidationParameters.ValidateLifetime = true;
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.ValidateLifetime = true;
options.TokenValidationParameters.ValidateTokenReplay = true;
options.Audience = settings.AzureAd.ClientId;
options.Events = new JwtBearerEvents()
{
OnTokenValidated = async ctx =>
{
//Runs on every request, cache a users groups if needed
var oidClaim = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)ctx.SecurityToken).Claims.FirstOrDefault(c => c.Type == "oid");
if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
{
var groups = await graphApiService.GetUserGroupsAsync(oidClaim.Value);
foreach (var group in groups)
{
((ClaimsIdentity)ctx.Principal.Identity).AddClaim(new Claim(ClaimTypes.Role.ToString(), group));
}
}
}
};
},
options => {
builder.Configuration.Bind("AzureAd", options);
});
builder.Services.AddTransient<GraphApiService>();
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Now you can run your application and use the code from earlier in a request like this:
POST /api/login/ HTTP/1.1
Host: localhost:7166
Content-Type: application/json
"code"
You will then receieve a response like this with an access_token:
{
"id": "31111111-1111-1111-1111-111111111111",
"givenName": "Oscar",
"familyName": "Andersson",
"email": "oscar.andersson#example.com",
"roles": [
"Administrator",
],
"adb2cTokenResponse": {
}
}
Adding [Authorize(Roles = "Administrator")] to WeatherForecastController.cs we can now verify that only a user with the correct role is allowed to access this resource using the access_token we got earlier:
If we change to [Authorize(Roles = "Administrator2")] we get a HTTP 403 with the same user:
LoginController can handle refresh tokens as well.
With NuGets Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio and Moq we can also test LoginController and in turn also GraphApiService used for ClaimsIdentity in Program.cs. Unfortunately due body being limited to 30000 charcters the entire test can not be shown.
It basically looks like this:
LoginControllerTest:
using AzureADB2CWebAPIGroupTest.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Moq.Protected;
using System.Net;
using Xunit;
namespace AzureADB2CWebAPIGroupTest
{
public class LoginControllerTest
{
[Theory]
[MemberData(nameof(PostData))]
public async Task Post(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
{
var controller = GetLoginController(response);
var result = await controller.Post(code);
var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
Assert.Equal(returnValue.Email, expectedEmail);
Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
}
[Theory]
[MemberData(nameof(RefreshData))]
public async Task Refresh(string code, string response, string expectedEmail, string expectedFamilyName, string expectedGivenName)
{
var controller = GetLoginController(response);
var result = await controller.Refresh(code);
var actionResult = Assert.IsType<ActionResult<Adb2cUser>>(result);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returnValue = Assert.IsType<Adb2cUser>(okResult.Value);
Assert.Equal(returnValue.Email, expectedEmail);
Assert.Equal(returnValue.Roles[1], GraphApiServiceMock.DummyGroup2Name);
}
//PostData and RefreshData removed for space
private LoginController GetLoginController(string expectedResponse)
{
var mockFactory = new Mock<IHttpClientFactory>();
var settings = new AppSettings();
settings.AzureAd.TokenUrl = "https://example.com";
var mockMessageHandler = new Mock<HttpMessageHandler>();
GraphApiServiceMock.MockHttpRequests(mockMessageHandler);
mockMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.AbsoluteUri.Contains(settings.AzureAd.TokenUrl)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(expectedResponse)
});
var httpClient = new HttpClient(mockMessageHandler.Object);
mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);
var logger = Mock.Of<ILogger<LoginController>>();
var services = new ServiceCollection();
services.AddMemoryCache();
var serviceProvider = services.BuildServiceProvider();
var memoryCache = serviceProvider.GetService<IMemoryCache>();
var graphService = new GraphApiService(mockFactory.Object, memoryCache, settings);
var controller = new LoginController(logger, mockFactory.Object, settings, graphService);
return controller;
}
}
}
A GraphApiServiceMock.cs is also needed but it just adds more values like the example with mockMessageHandler.Protected() and static values like public static string DummyUserExternalId = "11111111-1111-1111-1111-111111111111";.
There are other ways to do this but they usually depend on Custom Policies:
https://learn.microsoft.com/en-us/answers/questions/469509/can-we-get-and-edit-azure-ad-b2c-roles-using-ad-b2.html
https://devblogs.microsoft.com/premier-developer/using-groups-in-azure-ad-b2c/
https://learn.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview
First of all, thank you all for the previous responses. I've spent the entire day to put this to work. I'm using ASPNET Core 3.1 and I was getting the following error when using the solution from previous response:
secure binary serialization is not supported on this platform
I've replaces to REST API queries and I was able to get the groups:
public Task OnTokenValidated(TokenValidatedContext context)
{
_onTokenValidated?.Invoke(context);
return Task.Run(async () =>
{
try
{
var oidClaim = context.SecurityToken.Claims.FirstOrDefault(c => c.Type == "oid");
if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
{
HttpClient http = new HttpClient();
var domainName = _azureADSettings.Domain;
var authContext = new AuthenticationContext($"https://login.microsoftonline.com/{domainName}");
var clientCredential = new ClientCredential(_azureADSettings.ApplicationClientId, _azureADSettings.ApplicationSecret);
var accessToken = AcquireGraphAPIAccessToken(AadGraphUri, authContext, clientCredential).Result;
var url = $"https://graph.windows.net/{domainName}/users/" + oidClaim?.Value + "/$links/memberOf?api-version=1.6";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
HttpResponseMessage response = await http.SendAsync(request);
dynamic json = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());
foreach(var group in json.value)
{
dynamic x = group.url.ToString();
request = new HttpRequestMessage(HttpMethod.Get, x + "?api-version=1.6");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
response = await http.SendAsync(request);
dynamic json2 = JsonConvert.DeserializeObject<dynamic>(await response.Content.ReadAsStringAsync());
((ClaimsIdentity)((ClaimsIdentity)context.Principal.Identity)).AddClaim(new Claim(ClaimTypes.Role.ToString(), json2.displayName.ToString()));
}
}
}
catch (Exception e)
{
Debug.WriteLine(e);
}
});
}
Related
Summary
I have ASP.NET MVC 5 web app with Identity authentication and I have to develop an API with "grant_type" = "authorization_code". This API will be to provide users data to another "well-known" web service that needs a custom error responses. My IDE is Visual Studio Professional 2017. I use Postman to make requests to my Web API.
Documentation I read
In the OWIN and Katana documentation the OWIN OAuth 2.0 Authorization Server link redirects again to main OWIN and Katana page, but I think that I found the source on GitHub: OWIN OAuth 2.0 Authorization Server. I tried to follow this documentation, but there are no examples about this question.
Problem
I can create a new authorization code in my AuthorizationCodeProvider class (with Create() method) when a user authenticates and authorizes the "well-known" web service client to access user's resources. I store this code in a database. When I request a Token AuthorizationCodeProvider.Receive() method is called and the token is deserialized correctly. Then GrantAuthorizationCode() method is called, Postman receives OK response (200 status code) but without token information in body (.AspNet.ApplicationCookie is in cookies).
Detailed explanation and code
This is the Startup class:
public partial class Startup
{
public static OAuthAuthorizationServerOptions OAuthOptions { get; private set; }
public void ConfigureAuth(IAppBuilder app)
{
app.CreatePerOwinContext(ApplicationDbContext.Create);
app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login"),
Provider = new CookieAuthenticationProvider
{
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(30),
regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager)),
OnApplyRedirect = (context =>
{
// This code is to return custom error response
string path = null;
if (context.Request.Path.HasValue)
path = context.Request.Path.Value;
if (!(path != null && path.Contains("/api"))) // Don't redirect to login page
context.Response.Redirect(context.RedirectUri);
})
}
});
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5));
app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);
this.ConfigureAuthorization(app);
}
private void ConfigureAuthorization(IAppBuilder app)
{
app.UseCors(Microsoft.Owin.Cors.CorsOptions.AllowAll);
OAuthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = false,
TokenEndpointPath = new PathString("/api/token"),
AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
Provider = new TokenAuthorizationServerProvider(),
AuthorizationCodeProvider = new AuthorizationCodeProvider()
};
app.Use<AuthenticationMiddleware>(); //Customize responses in Token middleware
app.UseOAuthAuthorizationServer(OAuthOptions);
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions());
}
}
ConfigureAuthorization() method configures the authorization. It uses classes implemented by me:
AuthenticationMiddleware: the well-known web service wants 401 status responses with custom error JONS instead of the usual 400 status response. It is based on the answer of the question Replace response body using owin middleware.
public class AuthenticationMiddleware : OwinMiddleware
{
public AuthenticationMiddleware(OwinMiddleware next) : base(next) { }
public override async Task Invoke(IOwinContext context)
{
var owinResponse = context.Response;
var owinResponseStream = owinResponse.Body;
var responseBuffer = new MemoryStream();
owinResponse.Body = responseBuffer;
await Next.Invoke(context);
if (context.Response.StatusCode == (int)HttpStatusCode.BadRequest &&
context.Response.Headers.ContainsKey(BearerConstants.CustomUnauthorizedHeaderKey))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
string headerValue = context.Response.Headers.Get(BearerConstants.CustomUnauthorizedHeaderKey);
context.Response.Headers.Remove(BearerConstants.CustomUnauthorizedHeaderKey);
ErrorMessage errorMessage = new ErrorMessage(headerValue);
string json = JsonConvert.SerializeObject(errorMessage, Formatting.Indented);
var customResponseBody = new StringContent(json);
var customResponseStream = await customResponseBody.ReadAsStreamAsync();
await customResponseStream.CopyToAsync(owinResponseStream);
owinResponse.ContentType = "application/json";
owinResponse.ContentLength = customResponseStream.Length;
owinResponse.Body = owinResponseStream;
}
}
}
When ErrorMessage is serialized to JSON returns an array of errors:
{
"errors":
[
"message": "the error message"
]
}
I set the BearerConstants.CustomUnauthorizedHeaderKey header in TokenAuthorizationServerProvider.ValidateClientAuthentication() method using a extension method:
public static void Rejected(this OAuthValidateClientAuthenticationContext context, string message)
{
Debug.WriteLine($"\t\t{message}");
context.SetError(message);
context.Response.Headers.Add(BearerConstants.CustomUnauthorizedHeaderKey, new string[] { message });
context.Rejected();
}
This is how TokenAuthorizationServerProvider is implemented:
public class TokenAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
public override Task AuthorizeEndpoint(OAuthAuthorizeEndpointContext context)
{
// Only for breakpoint. Never stops.
return base.AuthorizeEndpoint(context);
}
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
// Check if grant_type is authorization_code
string grantType = context.Parameters[BearerConstants.GrantTypeKey];
if (string.IsNullOrEmpty(grantType) || grantType != BearerConstants.GrantTypeAuthorizationCode)
{
context.Rejected("Invalid grant type"); // Sets header for custom response
return;
}
// Check if client_id and client_secret are in the request
string clientId = context.Parameters[BearerConstants.ClientIdKey];
string clientSecret = context.Parameters[BearerConstants.ClientSecretKey];
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
{
context.Rejected("Client credentials missing"); // Sets header for custom response
return;
}
//Check if client_id and client_secret are valid
ApiClient client = await (new ApiClientService()).ValidateClient(clientId, clientSecret);
if (client != null)
{
// Client has been verified.
Debug.WriteLine($"\t\tClient has been verified");
context.OwinContext.Set<ApiClient>("oauth:client", client);
context.Validated(clientId);
}
else
{
// Client could not be validated.
context.Rejected("Invalid client"); // Sets header for custom response
}
}
public override async Task GrantAuthorizationCode(OAuthGrantAuthorizationCodeContext context)
{
TokenRequestParameters parameters = await context.Request.GetBodyParameters();
using (IUserService userService = new UserService())
{
ApplicationUser user = await userService.ValidateUser(parameters.Code);
if (user == null)
{
context.Rejected("Invalid code");
return;
}
// Initialization.
var claims = new List<Claim>();
// Setting
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
// Setting Claim Identities for OAUTH 2 protocol.
ClaimsIdentity oAuthClaimIdentity = new ClaimsIdentity(claims, OAuthDefaults.AuthenticationType);
ClaimsIdentity cookiesClaimIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationType);
// Setting user authentication.
IDictionary<string, string> data = new Dictionary<string, string>{ { "userName", user.UserName } };
AuthenticationProperties properties = new AuthenticationProperties(data);
AuthenticationTicket ticket = new AuthenticationTicket(oAuthClaimIdentity, properties);
// Grant access to authorize user.
context.Validated(ticket);
context.Request.Context.Authentication.SignIn(cookiesClaimIdentity);
}
}
}
ApiClientService.ValidateClient() checks on database that cliend ID and Secret are correct.
GrantAuthorizationCode() is based on the step 8 from ASP.NET MVC - OAuth 2.0 REST Web API Authorization Using Database First Approach tutorial. But this tutorial for grant_type = password and I think that something is wrong in here.
And the AuthorizationCodeProvider class:
public class AuthorizationCodeProvider : AuthenticationTokenProvider
{
public override void Create(AuthenticationTokenCreateContext context)
{
AuthenticationTicket ticket = context.Ticket;
string serializedTicket = context.SerializeTicket();
context.SetToken(serializedTicket);
}
public override void Receive(AuthenticationTokenReceiveContext context)
{
context.DeserializeTicket(context.Token);
// At this point context.Ticket.Identity.IsAuthenticated is true
}
}
I call to create method from the AuthorizationController that shows the Allow/Deny view. It is decorated with System.Web.Mvc.Authorize attribute, so if the user isn't authenticated he or she has to login using the default login page from MVC template project (/account/login):
[Authorize]
public class AuthorizationController : Controller
{
private const string ServiceScope = "service-name";
[HttpGet]
public async Task<ActionResult> Index(string client_id, string response_type, string redirect_uri, string scope, string state)
{
AuthorizationViewModel vm = new AuthorizationViewModel()
{
ClientId = client_id,
RedirectUri = redirect_uri,
Scope = scope,
State = state
};
if (scope == ServiceScope)
{
var authentication = HttpContext.GetOwinContext().Authentication;
authentication.SignIn(
new AuthenticationProperties { IsPersistent = true, RedirectUri = redirect_uri },
new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, User.Identity.Name) },
"Bearer"));
}
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
[MultiButton(MatchFormKey = "authorization", MatchFormValue = "Allow")]
public async Task<ActionResult> Allow(AuthorizationViewModel vm)
{
if (ModelState.IsValid)
{
string code = await this.SetAuthorizationCode(vm.ClientId, vm.RedirectUri);
if (vm.Scope == ServiceScope)
{
string url = $"{vm.RedirectUri}?code={code}&state={vm.State}";
return Redirect(url);
}
else
{
return Redirect(vm.RedirectUri);
}
}
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
[MultiButton(MatchFormKey = "authorization", MatchFormValue = "Deny")]
public async Task<ActionResult> Deny(AuthorizationViewModel vm)
{
// Removed for brevity
return View(vm);
}
private async Task<string> SetAuthorizationCode(string clientId, string redirectUri)
{
string userId = User.Identity.GetUserId();
ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(clientId, OAuthDefaults.AuthenticationType));
AuthenticationTokenCreateContext authorizeCodeContext = new AuthenticationTokenCreateContext(
HttpContext.GetOwinContext(),
Startup.OAuthOptions.AuthorizationCodeFormat,
new AuthenticationTicket(
identity,
new AuthenticationProperties(new Dictionary<string, string>
{
{ "user_id", userId },
{ "client_id", clientId },
{ "redirect_uri", redirectUri }
})
{
IssuedUtc = DateTimeOffset.UtcNow,
ExpiresUtc = DateTimeOffset.UtcNow.Add(Startup.OAuthOptions.AuthorizationCodeExpireTimeSpan)
}));
Startup.OAuthOptions.AuthorizationCodeProvider.Create(authorizeCodeContext);
string code = authorizeCodeContext.Token;
IUserService userService = new UserService();
await userService.SetAuthorization(userId, true, code); // save to database
userService.Dispose();
return code;
}
}
The authorization code is created in SetAuthorizationCode() method, which is called in Allow() action. This SetAuthorizationCode() method code is based on this answer.
Questions
I now that is very long with a lot of code, but I'm stuck for some days and I didn't find the solution. I don't know the complete flow of the authorization, I think that I'm missing something.
What happens when I call /api/token? I mean, what are the steps in this part of the authentication/authorization flow?
What happens after AuthorizationCodeProvider.GrantAuthorizationCode()?
Why a cookie returned instead of token in the body?
I found the solution of the problem, it was the AuthenticationMiddleware. Once the body of the response is read, it remains empty and does not reach the client. So you have to rewrite the response body.
public class AuthenticationMiddleware : OwinMiddleware
{
public AuthenticationMiddleware(OwinMiddleware next) : base(next) { }
public override async Task Invoke(IOwinContext context)
{
var owinResponse = context.Response;
var owinResponseStream = owinResponse.Body;
var responseBuffer = new MemoryStream();
owinResponse.Body = responseBuffer;
await Next.Invoke(context);
if (context.Response.StatusCode == (int)HttpStatusCode.BadRequest &&
context.Response.Headers.ContainsKey(BearerConstants.CustomUnauthorizedHeaderKey))
{
// Customize the response
}
else
{
// Set body again with the same content
string body = Encoding.UTF8.GetString(responseBuffer.ToArray());
StringContent customResponseBody = new StringContent(body);
Stream customResponseStream = await customResponseBody.ReadAsStreamAsync();
await customResponseStream.CopyToAsync(owinResponseStream);
}
}
}
I've got Asp.Net Identity 2 all set up and running with a custom user store backed by SQL Server via Dapper. At this point in my dev/testing I'm only concerned with local accounts (but will be adding in external login providers). I have a custom user that includes the standard properties that Asp.Net Identity wants, and added a few of my own (FirstName, LastName):
public class AppUser : IUser<Guid>
{
public Guid Id { get; set; }
public string UserName { get; set; }
public string PasswordHash { get; set; }
public string SecurityStamp { get; set; }
public string Email { get; set; }
public bool EmailConfirmed { get; set; }
public bool LockoutEnabled { get; set; }
public DateTimeOffset LockoutEndDate { get; set; }
public int AccessFailedCount { get; set; }
// Custom User Properties
public string FirstName { get; set; }
public string LastName { get; set; }
}
In my MVC web app, I configure the OIDC like so:
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Authority = ConfigurationManager.AppSettings["OpenIdConnectAuthenticationOptions.Authority"],
ClientId = "MVC.Web",
Scope = "openid profile email",
RedirectUri = ConfigurationManager.AppSettings["OpenIdConnectAuthenticationOptions.RedirectUri"],
ResponseType = "id_token",
SignInAsAuthenticationType = "Cookies"
});
Since I included profile as a requested scope, I get:
preferred_username: testuser
And since I included email as a requested scope, I get:
email: user#test.com
email_verified: true
I'm not explicitly telling my AspNetIdentityUserService how to map the UserName property in my AppUser to the preferred_username claim and I'm not sure how that happens. Therefore, I don't understand how to get the FirstName property mapped to the given_name claim so that it will be returned with the id_token.
What I've researched:
So if you look at the IdentityServer3 AspNetIdentity sample here I found this ClaimsIdentityFactory which looked like it should do the trick:
public override async Task<ClaimsIdentity> CreateAsync(UserManager<User, string> manager, User user, string authenticationType)
{
var ci = await base.CreateAsync(manager, user, authenticationType);
if (!String.IsNullOrWhiteSpace(user.FirstName))
{
ci.AddClaim(new Claim("given_name", user.FirstName));
}
if (!String.IsNullOrWhiteSpace(user.LastName))
{
ci.AddClaim(new Claim("family_name", user.LastName));
}
return ci;
}
So I added this in to my app and wired it up in my custom UserManager. And I do hit a breakpoint when the class is instantiated, but I don't hit a breakpoint ever on the CreateAsync method and my claims aren't returned.
I also saw this IdentityServer3 Custom User sample here, and I found this GetProfileDataAsync method that looked like it might be the right thing (but it seems like I'm digging deeper than I should be for something seemingly so simple/common):
public override Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// issue the claims for the user
var user = Users.SingleOrDefault(x => x.Subject == context.Subject.GetSubjectId());
if (user != null)
{
context.IssuedClaims = user.Claims.Where(x => context.RequestedClaimTypes.Contains(x.Type));
}
return Task.FromResult(0);
}
I had the same problem here, in that a breakpoint in this method was never tripped. I even went so far as to look at the IdentityServer3 source code, and see that this only gets called if the scope has the IncludeAllClaimsForUser flag set. But I'm using the standard profile scope here, so I started to question whether I needed to make my own definition for a profile scope that has the IncludAllClaimsForUser flag set, or if there was a way to add that flag to the standard scope.
And to add to all of this... This only needs to be done when using a local account. When I implement external login providers, I'll ask for the profile there, and expect to be able to get a first and last name. So then I wonder what happens once I've already got those claims (or how to determine whether I need to pull them from my user store or not). Seems like I need to hook in to something that only runs when doing a local login.
And then I started to really question whether I'm going about this the right way since I'm seeing/finding so little info on this (I would have expected this to be a fairly common scenario that others have already implemented, and expected to find docs/samples). Been trying to resolve this for a day now. Hopefully someone has a quick answer/pointer!
I use OpenIdConnectAuthenticationNotifications to achieve this, you could connect to ASP.NET Identity database or do anything in there, here is a sample code I use for one of my project:
This is a complete source code from my Startup.cs, but what you really need is just the SecurityTokenValidated section ...
using System.Configuration;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Helpers;
using IdentityServer3.Core;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
namespace MyProject
{
public partial class Startup
{
public static string AuthorizationServer => ConfigurationManager.AppSettings["security.idserver.Authority"];
public void ConfigureOAuth(IAppBuilder app)
{
AntiForgeryConfig.UniqueClaimTypeIdentifier = Constants.ClaimTypes.Subject;
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
jwtSecurityTokenHandler.InboundClaimTypeMap.Clear();
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "Cookies"
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
SecurityTokenValidator = jwtSecurityTokenHandler,
Authority = AuthorizationServer,
ClientId = ConfigurationManager.AppSettings["security.idserver.clientId"],
PostLogoutRedirectUri = ConfigurationManager.AppSettings["security.idserver.postLogoutRedirectUri"],
RedirectUri = ConfigurationManager.AppSettings["security.idserver.redirectUri"],
ResponseType = ConfigurationManager.AppSettings["security.idserver.responseType"],
Scope = ConfigurationManager.AppSettings["security.idserver.scope"],
SignInAsAuthenticationType = "Cookies",
#if DEBUG
RequireHttpsMetadata = false, //not recommended in production
#endif
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = n =>
{
if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout)
{
var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");
if (idTokenHint != null)
{
n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
}
}
return Task.FromResult(0);
},
SecurityTokenValidated = n =>
{
var id = n.AuthenticationTicket.Identity;
//// we want to keep first name, last name, subject and roles
//var givenName = id.FindFirst(Constants.ClaimTypes.GivenName);
//var familyName = id.FindFirst(Constants.ClaimTypes.FamilyName);
//var sub = id.FindFirst(Constants.ClaimTypes.Subject);
//var roles = id.FindAll(Constants.ClaimTypes.Role);
//// create new identity and set name and role claim type
var nid = new ClaimsIdentity(
id.AuthenticationType,
Constants.ClaimTypes.Name,
Constants.ClaimTypes.Role);
nid.AddClaims(id.Claims);
nid.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
nid.AddClaim(new Claim("access_Token", n.ProtocolMessage.AccessToken));
////nid.AddClaim(givenName);
////nid.AddClaim(familyName);
////nid.AddClaim(sub);
////nid.AddClaims(roles);
////// add some other app specific claim
// Connect to you ASP.NET database for example
////nid.AddClaim(new Claim("app_specific", "some data"));
//// keep the id_token for logout
//nid.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
n.AuthenticationTicket = new AuthenticationTicket(
nid,
n.AuthenticationTicket.Properties);
return Task.FromResult(0);
}
}
});
//app.UseResourceAuthorization(new AuthorizationManager());
}
}
}
The (A) correct answer to this question is to override the GetProfileDataAsync method in the AspNetIdentityUserService class like so:
public class AppUserService : AspNetIdentityUserService<AppUser, Guid>
{
private AppUserManager _userManager;
public AppUserService(AppUserManager userManager)
: base(userManager)
{
_userManager = userManager;
}
public async override Task GetProfileDataAsync(ProfileDataRequestContext ctx)
{
var id = Guid.Empty;
if (Guid.TryParse(ctx.Subject.GetSubjectId(), out id))
{
var user = await _userManager.FindByIdAsync(id);
if (user != null)
{
var claims = new List<Claim>
{
new Claim(Constants.ClaimTypes.PreferredUserName, user.UserName),
new Claim(Constants.ClaimTypes.Email, user.Email),
new Claim(Constants.ClaimTypes.GivenName, user.FirstName),
new Claim(Constants.ClaimTypes.FamilyName, user.LastName)
};
ctx.IssuedClaims = claims;
}
}
}
}
But as I had discovered, this wasn't enough. Looking at the source code for IdentityServer, you will find this bit:
if (scopes.IncludesAllClaimsForUserRule(ScopeType.Identity))
{
Logger.Info("All claims rule found - emitting all claims for user.");
var context = new ProfileDataRequestContext(
subject,
client,
Constants.ProfileDataCallers.ClaimsProviderIdentityToken);
await _users.GetProfileDataAsync(context);
var claims = FilterProtocolClaims(context.IssuedClaims);
if (claims != null)
{
outputClaims.AddRange(claims);
}
return outputClaims;
}
Notice that GetProfileDataAsync won't be called unless there is a flag set to include all claims (not sure why they chose to do it this way, but obviously there must be a good reason!). So I thought that meant that I needed to completely redefine the profile scope, but with further digging in the source I found this was not the case. The StandardScopes has a method that creates the scopes with the always include flag set. Instead of setting your scopes doing this:
factory.UseInMemoryScopes(StandardScopes.All);
Do this:
factory.UseInMemoryScopes(StandardScopes.AllAlwaysInclude);
THEN your GetProfileDataAsync will be run and you will get all of your claims!
Note: My first try using ClaimsIdentityFactory wasn't ever going to work, as I'm not logging in to Asp.Net Identity, and it makes sense that this wouldn't ever get called unless that is what I was doing.
Note: #Rosdi Kasim's answer is certainly valid if you desire to add claims (app specific claims especially) after you have already received your id_token from Identity Server.
Problem Statement
I am using .NET Core, and I'm trying to make a web application talk to a web API. Both require authentication using the [Authorize] attribute on all of their classes. In order to be able to talk between them server-to-server, I need to retrieve the validation token. I've been able to do that thanks to a Microsoft tutorial.
Problem
In the tutorial, they use a call to AcquireTokenByAuthorizationCodeAsync in order to save the token in the cache, so that in other places, the code can just do a AcquireTokenSilentAsync, which doesn't require going to the Authority to validate the user.
This method does not lookup token cache, but stores the result in it, so it can be looked up using other methods such as AcquireTokenSilentAsync
The issue comes in when the user is already logged in. The method stored at OpenIdConnectEvents.OnAuthorizationCodeReceived never gets called, since there is no authorization being received. That method only gets called when there's a fresh login.
There is another event called: CookieAuthenticationEvents.OnValidatePrincipal when the user is only being validated via a cookie. This works, and I can get the token, but I have to use AcquireTokenAsync, since I don't have the authorization code at that point. According to the documentation, it
Acquires security token from the authority.
This makes calling AcquireTokenSilentAsync fail, since the token has not been cached. And I'd rather not always use AcquireTokenAsync, since that always goes to the Authority.
Question
How can I tell the token gotten by AcquireTokenAsync to be cached so that I can use AcquireTokenSilentAsync everywhere else?
Relevant code
This all comes from the Startup.cs file in the main, Web Application project.
This is how the event handling is done:
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
Events = new CookieAuthenticationEvents()
{
OnValidatePrincipal = OnValidatePrincipal,
}
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
ClientId = ClientId,
Authority = Authority,
PostLogoutRedirectUri = Configuration["AzureAd:PostLogoutRedirectUri"],
ResponseType = OpenIdConnectResponseType.CodeIdToken,
CallbackPath = Configuration["Authentication:AzureAd:CallbackPath"],
GetClaimsFromUserInfoEndpoint = false,
Events = new OpenIdConnectEvents()
{
OnRemoteFailure = OnAuthenticationFailed,
OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
}
});
And these are the events behind:
private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
string userObjectId = (context.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
AuthenticationResult authResult = await authContext.AcquireTokenAsync(ClientResourceId, clientCred);
// How to store token in authResult?
}
private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
{
// Acquire a Token for the Graph API and cache it using ADAL. In the TodoListController, we'll use the cache to acquire a token to the Todo List API
string userObjectId = (context.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
context.ProtocolMessage.Code, new Uri(context.Properties.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey]), clientCred, GraphResourceId);
// Notify the OIDC middleware that we already took care of code redemption.
context.HandleCodeRedemption();
}
// Handle sign-in errors differently than generic errors.
private Task OnAuthenticationFailed(FailureContext context)
{
context.HandleResponse();
context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
return Task.FromResult(0);
}
Any other code can be found in the linked tutorial, or ask and I will add it to the question.
(Note: I had been struggling with this exact issue for several days. I followed the same Microsoft Tutorial as the one linked in the question, and tracked various problems like a wild goose chase; it turns out the sample contains a whole bunch of seemingly unnecessary steps when using the latest version of the Microsoft.AspNetCore.Authentication.OpenIdConnect package.).
I eventually had a breakthrough moment when I read this page:
http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html
The solution essentially involves letting OpenID Connect auth put the various tokens (access_token, refresh_token) into the cookie.
Firstly, I'm using a Converged Application created at https://apps.dev.microsoft.com and v2.0 of the Azure AD endpoint. The App has an Application Secret (password/public key) and uses Allow Implicit Flow for a Web platform.
(For some reason it seems as if v2.0 of the endpoint doesn't work with Azure AD only applications. I'm not sure why, and I'm not sure if it really matters anyway.)
Relevant lines from the Startup.Configure method:
// Configure the OWIN pipeline to use cookie auth.
app.UseCookieAuthentication(new CookieAuthenticationOptions());
// Configure the OWIN pipeline to use OpenID Connect auth.
var openIdConnectOptions = new OpenIdConnectOptions
{
ClientId = "{Your-ClientId}",
ClientSecret = "{Your-ClientSecret}",
Authority = "http://login.microsoftonline.com/{Your-TenantId}/v2.0",
ResponseType = OpenIdConnectResponseType.CodeIdToken,
TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
},
GetClaimsFromUserInfoEndpoint = true,
SaveTokens = true,
};
openIdConnectOptions.Scope.Add("offline_access");
app.UseOpenIdConnectAuthentication(openIdConnectOptions);
And that's it! No OpenIdConnectOptions.Event callbacks. No calls to AcquireTokenAsync or AcquireTokenSilentAsync. No TokenCache. None of those things seem to be necessary.
The magic seems to happen as part of OpenIdConnectOptions.SaveTokens = true
Here's an example where I'm using the access token to send an e-mail on behalf of the user using their Office365 account.
I have a WebAPI controller action which obtains their access token using HttpContext.Authentication.GetTokenAsync("access_token"):
[HttpGet]
public async Task<IActionResult> Get()
{
var graphClient = new GraphServiceClient(new DelegateAuthenticationProvider(async requestMessage =>
{
var accessToken = await HttpContext.Authentication.GetTokenAsync("access_token");
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
}));
var message = new Message
{
Subject = "Hello",
Body = new ItemBody
{
Content = "World",
ContentType = BodyType.Text,
},
ToRecipients = new[]
{
new Recipient
{
EmailAddress = new EmailAddress
{
Address = "email#address.com",
Name = "Somebody",
}
}
},
};
var request = graphClient.Me.SendMail(message, true);
await request.Request().PostAsync();
return Ok();
}
Side Note #1
At some point you might also need to get hold of the refresh_token too, in case the access_token expires:
HttpContext.Authentication.GetTokenAsync("refresh_token")
Side Note #2
My OpenIdConnectOptions actually includes a few more things which I've omitted here, for example:
openIdConnectOptions.Scope.Add("email");
openIdConnectOptions.Scope.Add("Mail.Send");
I've used these for working with the Microsoft.Graph API to send an e-mail on behalf of the currently logged in user.
(Those delegated permissions for Microsoft Graph are set up on the app too).
Update - How to 'silently' Refresh the Azure AD Access Token
So far, this answer explains how to use the cached access token but not what to do when the token expires (typically after 1 hour).
The options seem to be:
Force the user to sign in again. (Not silent)
POST a request to the Azure AD service using the refresh_token to obtain a new access_token (silent).
How to Refresh the Access Token using v2.0 of the Endpoint
After more digging, I found part of the answer in this SO Question:
How to handle expired access token in asp.net core using refresh token with OpenId Connect
It seems like the Microsoft OpenIdConnect libraries do not refresh the access token for you. Unfortunately the answer in the question above is missing the crucial detail about precisely how to refresh the token; presumably because it depends on specific details about Azure AD which OpenIdConnect doesn't care about.
The accepted answer to the above question suggests sending a request directly to the Azure AD Token REST API instead of using one of the Azure AD libraries.
Here's the relevant documentation (Note: this covers a mix of v1.0 and v2.0)
https://developer.microsoft.com/en-us/graph/docs/concepts/rest
https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code#refreshing-the-access-tokens
Here's a proxy based on the API docs:
public class AzureAdRefreshTokenProxy
{
private const string HostUrl = "https://login.microsoftonline.com/";
private const string TokenUrl = $"{Your-Tenant-Id}/oauth2/v2.0/token";
private const string ContentType = "application/x-www-form-urlencoded";
// "HttpClient is intended to be instantiated once and re-used throughout the life of an application."
// - MSDN Docs:
// https://msdn.microsoft.com/en-us/library/system.net.http.httpclient(v=vs.110).aspx
private static readonly HttpClient Http = new HttpClient {BaseAddress = new Uri(HostUrl)};
public async Task<AzureAdTokenResponse> RefreshAccessTokenAsync(string refreshToken)
{
var body = $"client_id={Your-Client-Id}" +
$"&refresh_token={refreshToken}" +
"&grant_type=refresh_token" +
$"&client_secret={Your-Client-Secret}";
var content = new StringContent(body, Encoding.UTF8, ContentType);
using (var response = await Http.PostAsync(TokenUrl, content))
{
var responseContent = await response.Content.ReadAsStringAsync();
return response.IsSuccessStatusCode
? JsonConvert.DeserializeObject<AzureAdTokenResponse>(responseContent)
: throw new AzureAdTokenApiException(
JsonConvert.DeserializeObject<AzureAdErrorResponse>(responseContent));
}
}
}
The AzureAdTokenResponse and AzureAdErrorResponse classes used by JsonConvert:
[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdTokenResponse
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "token_type", Required = Required.Default)]
public string TokenType { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_in", Required = Required.Default)]
public int ExpiresIn { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_on", Required = Required.Default)]
public string ExpiresOn { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "resource", Required = Required.Default)]
public string Resource { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "access_token", Required = Required.Default)]
public string AccessToken { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "refresh_token", Required = Required.Default)]
public string RefreshToken { get; set; }
}
[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdErrorResponse
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error", Required = Required.Default)]
public string Error { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_description", Required = Required.Default)]
public string ErrorDescription { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_codes", Required = Required.Default)]
public int[] ErrorCodes { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "timestamp", Required = Required.Default)]
public string Timestamp { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "trace_id", Required = Required.Default)]
public string TraceId { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "correlation_id", Required = Required.Default)]
public string CorrelationId { get; set; }
}
public class AzureAdTokenApiException : Exception
{
public AzureAdErrorResponse Error { get; }
public AzureAdTokenApiException(AzureAdErrorResponse error) :
base($"{error.Error} {error.ErrorDescription}")
{
Error = error;
}
}
Finally, my modifications to Startup.cs to refresh the access_token
(Based on the answer I linked above)
// Configure the OWIN pipeline to use cookie auth.
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = OnValidatePrincipal
},
});
The OnValidatePrincipal handler in Startup.cs (Again, from the linked answer above):
private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
if (context.Properties.Items.ContainsKey(".Token.expires_at"))
{
if (!DateTime.TryParse(context.Properties.Items[".Token.expires_at"], out var expiresAt))
{
expiresAt = DateTime.Now;
}
if (expiresAt < DateTime.Now.AddMinutes(-5))
{
var refreshToken = context.Properties.Items[".Token.refresh_token"];
var refreshTokenService = new AzureAdRefreshTokenService();
var response = await refreshTokenService.RefreshAccessTokenAsync(refreshToken);
context.Properties.Items[".Token.access_token"] = response.AccessToken;
context.Properties.Items[".Token.refresh_token"] = response.RefreshToken;
context.Properties.Items[".Token.expires_at"] = DateTime.Now.AddSeconds(response.ExpiresIn).ToString(CultureInfo.InvariantCulture);
context.ShouldRenew = true;
}
}
}
Finally, a solution with OpenIdConnect using v2.0 of the Azure AD API.
Interestingly, it seems that v2.0 does not ask for a resource to be included in the API request; the documentation suggests it's necessary, but the API itself simply replies that resource is not supported. This is probably a good thing - presumably it means that the access token works for all resources (it certainly works with the Microsoft Graph API)
I have a requirement like below to implement REST API using OAuth 2.0 and Web Api.
REST API should allow
- to create, update, view and delete orders
- to create, update, view and delete inventories
API should be able to used by any type of external client such as web application, mobile application, windows/web services, etc.
Roles allowed for external clients : Order Management , Inventory Management
User data (roles, permissions) of external clients will not be managed by our system.
Note: There can be another two roles like Internal , External. Because delete functions can't be allowed for external users.
Order and Inventory data will be managed in a SQL Server DB which is already used by current windows/desktop applications. Orders, inventories comes via new API should save in same database.
Questions:
Which grant type I can use?
How should I mange external client's data (allowed roles, client id, tokens) ? Do I need to use separate membership database for this? Can I used my existing database with new tables for this?
You can use Microsoft.Owin.Security.OAuth provider. Please have a look on following sample.
Create new Owin Startup file and change the Configuration method as following
public void Configuration(IAppBuilder app)
{
var oauthProvider = new OAuthAuthorizationServerProvider
{
OnGrantClientCredentials = async context =>
{
var claimsIdentity = new ClaimsIdentity(context.Options.AuthenticationType);
// based on clientId get roles and add claims
claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, "Developer"));
claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, "Developer2"));
context.Validated(claimsIdentity);
},
OnValidateClientAuthentication = async context =>
{
string clientId;
string clientSecret;
// use context.TryGetBasicCredentials in case of passing values in header
if (context.TryGetFormCredentials(out clientId, out clientSecret))
{
if (clientId == "clientId" && clientSecret == "secretKey")
{
context.Validated(clientId);
}
}
}
};
var oauthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = true,
TokenEndpointPath = new PathString("/accesstoken"),
Provider = oauthProvider,
AuthorizationCodeExpireTimeSpan = TimeSpan.FromMinutes(1),
AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(3),
SystemClock = new SystemClock()
};
app.UseOAuthAuthorizationServer(oauthOptions);
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions());
var config = new HttpConfiguration();
config.MapHttpAttributeRoutes();
app.UseWebApi(config);
}
And authorize your API like this
[Authorize(Roles = "Developer")]
// GET: api/Tests
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
you can consume it like following,
string baseAddress = "http://localhost/";
var client = new HttpClient();
// you can pass the values in Authorization header or as form data
//var authorizationHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes("clientId:secretKey"));
//client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authorizationHeader);
var form = new Dictionary<string, string>
{
{"grant_type", "client_credentials"},
{"client_id", "clientId"},
{"client_secret", "secretKey"},
};
var tokenResponse = client.PostAsync(baseAddress + "accesstoken", new FormUrlEncodedContent(form)).Result;
var token = tokenResponse.Content.ReadAsAsync<Token>(new[] { new JsonMediaTypeFormatter() }).Result;
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
var authorizedResponse = client.GetAsync(baseAddress + "/api/Tests").Result;
Token.cs
internal class Token
{
[JsonProperty("access_token")]
public string AccessToken { get; set; }
[JsonProperty("token_type")]
public string TokenType { get; set; }
[JsonProperty("expires_in")]
public int ExpiresIn { get; set; }
[JsonProperty("refresh_token")]
public string RefreshToken { get; set; }
}
answers to your questions
You can use client_credentials
Maintain roles in your own database for each client and inside OnGrantClientCredentials just get roles by client id and assign as claims.
Here is a starting point which Grant to choose for which Client. In addition, if you build a SPA (even it is a first party client according to the wording from the link), I would prefer Implicit Grant. If you have a question about a particular Grant for a particular Client, create a new question on stackoverflow.
You can use IdentityServer3 with IdentityServer3.EntityFramework and IdentityServer3.AspNetIdentity. You can place IdentityServer tables in an existing database but I would not recommend it for production.
I have successfully added OAuth to my WebAPI 2 project using OWIN. I receive tokens and can use them in the HTTP Header to access resources.
Now I want to use those tokens also on other channels for authentication that are not the standard HTTP requests that the OWIN template is made for. For example, I am using WebSockets where the client has to send the OAuth Bearer Token to authenticate.
On the server side, I receive the token through the WebSocket. But how can I now put this token into the OWIN pipeline to extract the IPrincipal and ClientIdentifier from it? In the WebApi 2 template, all this is abstracted for me, so there is nothing I have to do to make it work.
So, basically, I have the token as a string and want to use OWIN to access the user information encoded in that token.
Thank you in advance for the help.
I found a part of the solution in this blog post: http://leastprivilege.com/2013/10/31/retrieving-bearer-tokens-from-alternative-locations-in-katanaowin/
So I created my own Provider as follows:
public class QueryStringOAuthBearerProvider : OAuthBearerAuthenticationProvider
{
public override Task RequestToken(OAuthRequestTokenContext context)
{
var value = context.Request.Query.Get("access_token");
if (!string.IsNullOrEmpty(value))
{
context.Token = value;
}
return Task.FromResult<object>(null);
}
}
Then I needed to add it to my App in Startup.Auth.cs like this:
OAuthBearerOptions = new OAuthBearerAuthenticationOptions()
{
Provider = new QueryStringOAuthBearerProvider(),
AccessTokenProvider = new AuthenticationTokenProvider()
{
OnCreate = create,
OnReceive = receive
},
};
app.UseOAuthBearerAuthentication(OAuthBearerOptions);
With a custom AuthenticationTokenProvider, I can retrieve all other values from the token early in the pipeline:
public static Action<AuthenticationTokenCreateContext> create = new Action<AuthenticationTokenCreateContext>(c =>
{
c.SetToken(c.SerializeTicket());
});
public static Action<AuthenticationTokenReceiveContext> receive = new Action<AuthenticationTokenReceiveContext>(c =>
{
c.DeserializeTicket(c.Token);
c.OwinContext.Environment["Properties"] = c.Ticket.Properties;
});
And now, for example in my WebSocket Hander, I can retrieve ClientId and others like this:
IOwinContext owinContext = context.GetOwinContext();
if (owinContext.Environment.ContainsKey("Properties"))
{
AuthenticationProperties properties = owinContext.Environment["Properties"] as AuthenticationProperties;
string clientId = properties.Dictionary["clientId"];
...
}
By default, OWIN use ASP.NET machine key data protection to protect the OAuth access token when hosted on IIS. You can use MachineKey class in System.Web.dll to unprotect the tokens.
public class MachineKeyProtector : IDataProtector
{
private readonly string[] _purpose =
{
typeof(OAuthAuthorizationServerMiddleware).Namespace,
"Access_Token",
"v1"
};
public byte[] Protect(byte[] userData)
{
throw new NotImplementedException();
}
public byte[] Unprotect(byte[] protectedData)
{
return System.Web.Security.MachineKey.Unprotect(protectedData, _purpose);
}
}
Then, construct a TicketDataFormat to get the AuthenticationTicket object where you can get the ClaimsIdentity and AuthenticationProperties.
var access_token="your token here";
var secureDataFormat = new TicketDataFormat(new MachineKeyProtector());
AuthenticationTicket ticket = secureDataFormat.Unprotect(access_token);
To unprotect other OAuth tokens, you just need to change the _purpose content. For detailed information, see OAuthAuthorizationServerMiddleware class here:
http://katanaproject.codeplex.com/SourceControl/latest#src/Microsoft.Owin.Security.OAuth/OAuthAuthorizationServerMiddleware.cs
if (Options.AuthorizationCodeFormat == null)
{
IDataProtector dataProtecter = app.CreateDataProtector(
typeof(OAuthAuthorizationServerMiddleware).FullName,
"Authentication_Code", "v1");
Options.AuthorizationCodeFormat = new TicketDataFormat(dataProtecter);
}
if (Options.AccessTokenFormat == null)
{
IDataProtector dataProtecter = app.CreateDataProtector(
typeof(OAuthAuthorizationServerMiddleware).Namespace,
"Access_Token", "v1");
Options.AccessTokenFormat = new TicketDataFormat(dataProtecter);
}
if (Options.RefreshTokenFormat == null)
{
IDataProtector dataProtecter = app.CreateDataProtector(
typeof(OAuthAuthorizationServerMiddleware).Namespace,
"Refresh_Token", "v1");
Options.RefreshTokenFormat = new TicketDataFormat(dataProtecter);
}
in addition to johnny-qian answer, using this method is better to create DataProtector. johnny-qian answer, depends on IIS and fails on self-hosted scenarios.
using Microsoft.Owin.Security.DataProtection;
var dataProtector = app.CreateDataProtector(new string[] {
typeof(OAuthAuthorizationServerMiddleware).Namespace,
"Access_Token",
"v1"
});
What is your token like, is it an encrypt string or a formatted string, what is it format?
I my code:
public static Action<AuthenticationTokenReceiveContext> receive = new Action<AuthenticationTokenReceiveContext>(c =>
{
if (!string.IsNullOrEmpty(c.Token))
{
c.DeserializeTicket(c.Token);
//c.OwinContext.Environment["Properties"] = c.Ticket.Properties;
}
});
The c.Ticket is always null.