Cannot retrieve data from Active Directory - azure-active-directory

I created a new Blazor application with Azure AD authentication. Login functionality works as expected. Now I need to retrieve a list of users of a particular group. On Azure AD, I created a Client Secret. Also, I added Microsoft.Graph permissions Directory.Read.All, Group.Read.All, and
GroupMember.Read.All, and granted admin consent for the permissions.
Here are the permissions:
Here is my code I use to retrieve the users of a group:
var scopes = new string[] { "https://graph.microsoft.com/.default" };
var confidentialClient = ConfidentialClientApplicationBuilder
.Create(_adSettings.ClientId)
.WithAuthority($"{_adSettings.Instance}/{_adSettings.TenantId}/ v2.0")
.WithClientSecret(_adSettings.ClientSecret)
.Build();
GraphServiceClient graphServiceClient = new GraphServiceClient(new DelegateAuthenticationProvider(async (requestMessage) => {
// Retrieve an access token for Microsoft Graph (gets a fresh token if needed).
var authResult = await confidentialClient.AcquireTokenForClient(scopes).ExecuteAsync();
// Add the access token in the Authorization header of the API
requestMessage.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
}));
var members = await graphServiceClient.Groups["mygroup#mydomain.com"]
.Members
.Request()
.GetAsync();
The last statement throws an exception:
An error occurred sending the request. HttpStatusCode: 404: NotFound
I tried to replace it with
var users = await graphServiceClient.Users.Request().GetAsync();
But result was the same.

You should specify your group object ID instead of the group name:
My test group:
Test code and result:
UPDATE:
This is a C# console app code for this test, hope it helps :
using Microsoft.Graph;
using Microsoft.Graph.Auth;
using Microsoft.Identity.Client;
using System;
namespace graphsdktest
{
class Program
{
static void Main(string[] args)
{
var clientId = "<Azure AD App ID>";
var clientSecret = "<App secret>";
var tenantID = "<tenant ID>";
IConfidentialClientApplication confidentialClientApplication = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantID)
.WithClientSecret(clientSecret)
.Build();
ClientCredentialProvider authenticationProvider = new ClientCredentialProvider(confidentialClientApplication);
var graphClient = new GraphServiceClient(authenticationProvider);
var result = graphClient.Groups["<group ID>"].Members.Request().GetAsync().GetAwaiter().GetResult();
foreach (var user in result) {
Console.WriteLine(user.Id);
}
}
}
}
UPDATE 2:
If you get some permission exception while you query members of a group, pls go to Azure AD => App registrations => find your app => API permissions => Add a permission => Microsoft graph api => application permission => GroupMember.Read.All :
And click This button to finish the grant process :

Related

Identity Server 4 with Azure AD - "We couldn't sign you in. Please try again."

I'm using .NET Core 3.1 with Identity Server 4 and connecting to Azure AD via OpenIdConnect. I'm using a Vue.js front-end and .NET Core API. IdentityServer, the front-end, and the API are all hosted on-prem on the same server (same domain). Everything uses https. I'm using an Oracle database with EF model first, with fully-customized IdentityServer stores and a custom user store (I implemented the interfaces). I'm using IdentityServer's Quickstart, edited a little to hook up my custom user store instead of the test user. I'm running this in my dev environment.
If I type in the url to the IdentityServer, I'm redirected to Azure AD, signed-in successfully, and shown this page:
Grants - successful login
The claims are coming back from Azure AD and the auto-provisioning is successful. It is written successfully to the database.
Authenticating through my JS client hits IdentityServer, redirects to Azure AD, I sign-in, then it redirects to IdentityServer's ExternalController, then redirects back to a Microsoft url, then proceeds to repeat until it finally fails with this page:
Sign-in failure from Azure AD
My guess is I messed up a redirect uri somewhere. Here is my code and the IdentityServer log:
IdentityServer Log
That block of logging repeats 6-10 times. No errors or anything different at the end.
I had to break up the C# code because the site couldn't handle one of my long options lines.
IdentityServer Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
var builder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
options.UserInteraction.LoginUrl = "/Account/Login";
options.UserInteraction.LogoutUrl = "/Account/Logout";
options.Authentication = new AuthenticationOptions()
{
CookieLifetime = TimeSpan.FromHours(10),
CookieSlidingExpiration = true
};
}).AddClientStore<ClientStore>()
.AddCorsPolicyService<CorsPolicyService>()
.AddResourceStore<ResourceStore>()
.AddPersistedGrantStore<PersistedGrantStore>()
.AddProfileService<UserProfileService>();
services.AddScoped<IUserStore, UserStore>();
if (env.IsDevelopment())
{
// not recommended for production
builder.AddDeveloperSigningCredential();
}
else
{
// TODO: Load Signing Credentials for Production.
}
services.AddAuthentication()
.AddOpenIdConnect("aad", "Azure AD", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.SignOutScheme = IdentityServerConstants.SignoutScheme;
options.Authority = "https://login.windows.net/[authority]";
options.CallbackPath = "/callback-aad";
options.ClientId = "[ClientId]";
options.RemoteSignOutPath = "/signout-aad";
options.RequireHttpsMetadata = true;
options.ResponseType = OpenIdConnectResponseType.IdToken;
options.SaveTokens = true;
options.SignedOutCallbackPath = "/signout-callback-aad";
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
options.UsePkce = true;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
Client OIDC config:
const oidcSettings = {
authority: '[IdentityServerUrl]',
client_id: '[ClientId]',
post_logout_redirect_uri: '[front-end url]/logout-aad',
redirect_uri: '[front-end url]/callback-aad',
response_type: 'code',
save_tokens: true,
scope: 'openid profile',
}
Callback method being hit for ExternalController:
[HttpGet]
public async Task<IActionResult> Callback()
{
// read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
{
throw new Exception("External authentication error");
}
if (_logger.IsEnabled(LogLevel.Debug))
{
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {#claims}", externalClaims);
}
// lookup our user and external provider info
var (user, provider, providerUserId, claims) = await FindUserFromExternalProvider(result);
if (user == null)
{
// this might be where you might initiate a custom workflow for user registration
// in this sample we don't show how that would be done, as our sample implementation
// simply auto-provisions new external user
user = await AutoProvisionUser(provider, providerUserId, claims);
}
// this allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties();
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// issue authentication cookie for user
var isuser = new IdentityServerUser(user.SubjectId)
{
DisplayName = user.Username,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims
};
await HttpContext.SignInAsync(isuser, localSignInProps);
// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
// retrieve return URL
var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
// check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId));
if (context != null)
{
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage("Redirect", returnUrl);
}
}
return Redirect(returnUrl);
}
Azure AD config:
redirect uri: [IdentityServer url]/callback-aad
Database table data:
Client table IMG1
Client table IMG2
ClientScopes table
ClientRedirectUris table
Please let me know if you need any additional information. Thank you
The problem was in my custom UserStore. I was getting the user by the Azure AD SubjectId instead of the UserSubjectId. So in the ExternalController, the ApplicationUser object was coming up as null. Instead of an exception, it kept going back to Azure AD to try to get the user again, but obviously that just creates an infinite loop. I didn't think to look there since my user was successfully provisioned with Id's and claims.

Add tenant claim to access token using IdentityServer 4 based on acr value

In my scenario a user can be linked to different tenants. A user should login in the context of a tenant. That means i would like the access token to contain a tenant claim type to restrict access to data of that tenant.
When the client application tries to login i specify an acr value to indicate for which tenant to login.
OnRedirectToIdentityProvider = redirectContext => {
if (redirectContext.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication) {
redirectContext.ProtocolMessage.AcrValues = "tenant:" + tenantId; // the acr value tenant:{value} is treated special by id4 and is made available in IIdentityServerInteractionService
}
return Task.CompletedTask;
}
The value is received by my identity provider solution and is as well available in the IIdentityServerInteractionService.
The question is now, where can i add a claim to the access token for the requested tenant?
IProfileService
In a IProfileService implementation the only point where acr values would be available is in the IsActiveAsync method when context.Caller == AuthorizeEndpoint in the HttpContext via IHttpContextAccessor.
String acr_values = _context.HttpContext.Request.Query["acr_values"].ToString();
But in IsActiveAsync i can not issue claims.
In the GetProfileDataAsync calls the acr values are not available in the ProfileDataRequestContext nor in the HttpContext. Here i wanted to access acr values when
context.Caller = IdentityServerConstants.ProfileDataCallers.ClaimsProviderAccessToken. If i would have access i could issue the tenant claim.
Further i analyzed CustomTokenRequestValidator, IClaimsService and ITokenService without success. It seems like the root problem is, that the token endpoint does not receive/process acr values. (event though here acr is mentioned)
I have a hard time figure this one out. Any help appreciated. Is it maybe completely wrong what i am trying? After figuring this one out i will have as well to understand how this affects access token refresh.
Since you want the user to login for each tenant (bypassing sso) makes this solution possible.
When logging in, you can add a claim to the local user (IdentityServer) where you store the tenant name:
public async Task<IActionResult> Login(LoginViewModel model, string button)
{
// take returnUrl from the query
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.ClientId != null)
{
// acr value Tenant
if (context.Tenant == null)
await HttpContext.SignInAsync(user.Id, user.UserName);
else
await HttpContext.SignInAsync(user.Id, user.UserName, new Claim("tenant", context.Tenant));
When the ProfileService is called you can use the claim and pass it to the access token:
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Only add the claim to the access token
if (context.Caller == "ClaimsProviderAccessToken")
{
var tenant = context.Subject.FindFirstValue("tenant");
if (tenant != null)
claims.Add(new Claim("tenant", tenant));
}
The claim is now available in the client.
Problem is, that with single sign-on the local user is assigned to the last used tenant. So you need to make sure the user has to login again, ignoring and overwriting the cookie on IdentityServer.
This is the responsibility from the client, so you can set prompt=login to force a login. But originating from the client you may want to make this the responsibility of the server. In that case you may need to override the interaction response generator.
However, it would make sense to do something like this when you want to add tenant specific claims. But it seems you are only interested in making a distinction between tenants.
In that case I wouldn't use above implementation but move from perspective. I think there's an easier solution where you can keep the ability of SSO.
What if the tenant identifies itself at the resource? IdentityServer is a token provider, so why not create a custom token that contains the information of the tenant. Use extension grants to create an access token that combines tenant and user and restricts access to that combination only.
To provide some code for others who want to use the extension grant validator as one suggested option by the accepted answer.
Take care, the code is quick and dirty and must be properly reviewed.
Here is a similar stackoverflow answer with extension grant validator.
IExtensionGrantValidator
using IdentityServer4.Models;
using IdentityServer4.Validation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace IdentityService.Logic {
public class TenantExtensionGrantValidator : IExtensionGrantValidator {
public string GrantType => "Tenant";
private readonly ITokenValidator _validator;
private readonly MyUserManager _userManager;
public TenantExtensionGrantValidator(ITokenValidator validator, MyUserManager userManager) {
_validator = validator;
_userManager = userManager;
}
public async Task ValidateAsync(ExtensionGrantValidationContext context) {
String userToken = context.Request.Raw.Get("AccessToken");
String tenantIdRequested = context.Request.Raw.Get("TenantIdRequested");
if (String.IsNullOrEmpty(userToken)) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken).ConfigureAwait(false);
if (result.IsError) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
if (Guid.TryParse(tenantIdRequested, out Guid tenantId)) {
var sub = result.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var claims = result.Claims.ToList();
claims.RemoveAll(x => x.Type == "tenantid");
IEnumerable<Guid> tenantIdsAvailable = await _userManager.GetTenantIds(Guid.Parse(sub)).ConfigureAwait(false);
if (tenantIdsAvailable.Contains(tenantId)) {
claims.Add(new Claim("tenantid", tenantId.ToString()));
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
context.Result = new GrantValidationResult(principal);
return;
}
}
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
}
}
}
Client config
new Client {
ClientId = "tenant.client",
ClientSecrets = { new Secret("xxx".Sha256()) },
AllowedGrantTypes = new [] { "Tenant" },
RequireConsent = false,
RequirePkce = true,
AccessTokenType = AccessTokenType.Jwt,
AllowOfflineAccess = true,
AllowedScopes = new List<String> {
IdentityServerConstants.StandardScopes.OpenId,
},
},
Token exchange in client
I made a razor page which receives as url parameter the requested tenant id, because my test app is a blazor server side app and i had problems to do a sign in with the new token (via _userStore.StoreTokenAsync). Note that i am using IdentityModel.AspNetCore to manage token refresh. Thats why i am using the IUserTokenStore. Otherwise you would have to do httpcontext.signinasync as Here.
public class TenantSpecificAccessTokenModel : PageModel {
private readonly IUserTokenStore _userTokenStore;
public TenantSpecificAccessTokenModel(IUserTokenStore userTokenStore) {
_userTokenStore = userTokenStore;
}
public async Task OnGetAsync() {
Guid tenantId = Guid.Parse(HttpContext.Request.Query["tenantid"]);
await DoSignInForTenant(tenantId);
}
public async Task DoSignInForTenant(Guid tenantId) {
HttpClient client = new HttpClient();
Dictionary<String, String> parameters = new Dictionary<string, string>();
parameters.Add("AccessToken", await HttpContext.GetUserAccessTokenAsync());
parameters.Add("TenantIdRequested", tenantId.ToString());
TokenRequest tokenRequest = new TokenRequest() {
Address = IdentityProviderConfiguration.Authority + "connect/token",
ClientId = "tenant.client",
ClientSecret = "xxx",
GrantType = "Tenant",
Parameters = parameters
};
TokenResponse tokenResponse = await client.RequestTokenAsync(tokenRequest).ConfigureAwait(false);
if (!tokenResponse.IsError) {
await _userTokenStore.StoreTokenAsync(HttpContext.User, tokenResponse.AccessToken, tokenResponse.ExpiresIn, tokenResponse.RefreshToken);
Response.Redirect(Url.Content("~/").ToString());
}
}
}

Cannot get the list of subscriptions from Azure AD using Microsoft Graph API

I am trying to get the list of notification subscriptions from Azure AD using Microsoft graph api.
I am trying it in a webjob application.
The funtions class in the Webjob project has a code like
public static void CreateAndUpdateSubcriptions()
{
var config= new ConfigurationSettings
{
AppId = "*****",
AppSecret = "***",
Ngrok = "****",
TenantId = "*****"
};
var graphServiceClient = GetGraphClient(config);//creates the graph client using client credential flow
var subscriptions = graphServiceClient.Subscriptions
.Request()
.GetAsync().Result;
List<Subscription> subscription = new List<Subscription>();
foreach (var subscrip in subscriptions)
{
if (subscrip.NotificationUrl == config.Ngrok + "/api/notifications")
{
subscription.Add(subscrip);
}
}
if (subscription.Count == 0)
{
var s = new Subscription
{
ChangeType = "updated",
NotificationUrl = config.Ngrok + "/api/notifications",
Resource = "groups",
ExpirationDateTime = DateTime.UtcNow.AddMinutes(10),
ClientState = "****"
};
s = graphServiceClient
.Subscriptions
.Request()
.AddAsync(sub).Result;
}
else
{
CheckSubscriptions(subscription, config);
}
}
I have given the application the delegated permission(Subscription.Read.All).
But I am not getting any subscriptions back from the section of the code when I am running the code again(so there is a subscription which exists),
var subscriptions = graphServiceClient.Subscriptions
.Request()
.GetAsync().Result;
I am not sure what I am doing wrong.
Any Help?
You should give the application the application permission instead of delegated permission.
Refer to https://learn.microsoft.com/en-us/azure/active-directory/develop/v1-permissions-and-consent#types-of-permissions
Also remember to click grant admin consent button.
Updates: refer to this document.

Getting Forbidden/Access Denied error from Azure AD Graph Education API

I am using Graph education API, want all information about the user
profile.
Getting below error in response/json objects
Forbidden
AccessDenied
Required claim values are not provided.
public async Task<ActionResult> GetUserDetails()
{
List<User> listUser = new List<User>();
List<UserRole> userRole = new List<UserRole>();
string clientId = configuration.GetValue<string>("AzureAd:ClientId");
string clientSecret = configuration.GetValue<string>("AzureAd:ClientSecret");
//var email = User.Identity.Name;
//AuthenticationContext authContext = new AuthenticationContext("https://login.windows.net/LPExamDev.onmicrosoft.com/oauth2/token");
AuthenticationContext authContext = new AuthenticationContext("https://login.windows.net/LPExamStaging.onmicrosoft.com/oauth2/token");
ClientCredential creds = new ClientCredential(clientId, clientSecret);
AuthenticationResult authResult = await authContext.AcquireTokenAsync("https://graph.microsoft.com/", creds);
HttpClient http = new HttpClient();
string url = $"https://graph.microsoft.com/v1.0/education/users"; // Microsoft Education Graph
//string url = $"https://graph.microsoft.com/v1.0/users"; // Microsoft Graph // Working fine.
////string url = "https://graph.windows.net/LPExamStaging.onmicrosoft.com/users?api-version=1.6";
// Append the access token for the Graph API to the Authorization header of the request by using the Bearer scheme.
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
HttpResponseMessage response = await http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
var jsonResponse = response.ToString();
bool responseCode = response.IsSuccessStatusCode;
//ViewBag.userData = json;
//SaveAPIData(json);
if (responseCode)
{
SaveAPIData(json);
}
}
You need to grant your application EduRoster.Read.All permission and click grant admin consent button.
Login azure portal->click Azure Active Directory->click App registrations(preview)->click your application->click API permissions->add a permission->choose Application permissions
Then click Grant admin consent button.
You can decoded your access token by using https://jwt.io/ to check if you have already got that permission.

Microsoft graph API: getting 403 while trying to read user groups

I am trying to get user's group information who log-Ins into the application.
Using below code, when I am hitting https://graph.microsoft.com/v1.0/users/{user}, then I am able to see that user is exist (200), but when trying to hit https://graph.microsoft.com/v1.0/users/{user}/memberOf, then I am getting 403.
private static async Task Test()
{
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "TOKEN HERE");
var user = "testuser#onmicrosoft.com";
var userExist = await DoesUserExistsAsync(client, user);
Console.WriteLine($"Does user exists? {userExist}");
if (userExist)
{
var groups = await GetUserGroupsAsync(client, user);
foreach (var g in groups)
{
Console.WriteLine($"Group: {g}");
}
}
}
}
private static async Task<bool> DoesUserExistsAsync(HttpClient client, string user)
{
var payload = await client.GetStringAsync($"https://graph.microsoft.com/v1.0/users/{user}");
return true;
}
private static async Task<string[]> GetUserGroupsAsync(HttpClient client, string user)
{
var payload = await client.GetStringAsync($"https://graph.microsoft.com/v1.0/users/{user}/memberOf");
var obj = JsonConvert.DeserializeObject<JObject>(payload);
var groupDescription = from g in obj["value"]
select g["displayName"].Value<string>();
return groupDescription.ToArray();
}
Is this something related to permission issue, my token has below scope now,
Note - Over here I am not trying to access other user/group information, only who log-ins. Thanks!
Calling /v1.0/users/[a user]/memberOf requires your access token to have either Directory.Read.All, Directory.ReadWrite.All or Directory.AccessAsUser.All and this is
documented at https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_list_memberof.
A great way to test this API call before implementing it in code is to use the Microsoft Graph explorer where you can change which permissions your token has by using the "modify permissions" dialog.

Resources