Updating claims with Azure B2C / .NET Core - azure-active-directory

I've spent some time getting my MVC 6 .NET Core website working with Azure B2C and everything seems to be working great. However, there are a few questions surrounding claims that I can't seem to figure out the correct strategy.
Say a user signs up on my site with email, firstname, lastname. Once the registration is complete, I would like to add a record into a UserProfile table in my database that references this user.
Question 1:
Should I create a "UserProfileId" claim in Azure B2C? Or should I create an "ObjectId" field in my database table that references the AD user? What would make more sense?
Question 2:
Once a user registers, where and how would I update an AD User claim? Would I do it in one of these events? Or somewhere else? I see there is a "User is new" claim that I could check for?
OnAuthenticationValidated
OnAuthorizationCodeReceived
OnRedirectToAuthenticationEndpoint
Question 3:
To update the claims, would I use: Microsoft.Azure.ActiveDirectory.GraphClient? Does anyone have any sample code for how to update a custom claim? I've tried this but it doesn't seem to persist:
var identity = context.AuthenticationTicket.Principal.Identity as ClaimsIdentity;
identity?.AddClaim(new Claim("EmployeeId", "33"));
Here is my authentication configuration. Thanks!!!!!
public void ConfigureAuth(IApplicationBuilder app, IOptions<PolicySettings> policySettings, AuthenticationHelper authHelper)
{
app.UseCookieAuthentication(options =>
{
options.AutomaticAuthenticate = true;
options.AutomaticChallenge = true;
options.AccessDeniedPath = "/Home/Forbidden";
options.CookieSecure = CookieSecureOption.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(1);
options.SlidingExpiration = true;
});
app.UseOpenIdConnectAuthentication(options =>
{
options.PostLogoutRedirectUri = policySettings.Value.PostLogoutRedirectUri;
options.AutomaticAuthenticate = true;
options.AutomaticChallenge = true;
options.ClientId = policySettings.Value.ClientId;
options.CallbackPath = new PathString("/signin-mysite");
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.ResponseType = OpenIdConnectResponseTypes.IdToken;
options.Authority = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", policySettings.Value.AadInstance, policySettings.Value.Tenant);
options.Events = new OpenIdConnectEvents {
OnAuthenticationValidated = OnAuthenticationValidated,
OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
OnAuthenticationFailed = OnAuthenticationFailed,
OnRedirectToAuthenticationEndpoint = OnRedirectToAuthenticationEndpoint
};
options.ConfigurationManager = new PolicyConfigurationManager(
String.Format(CultureInfo.InvariantCulture, "{0}/{1}/{2}/{3}", policySettings.Value.AadInstance, policySettings.Value.Tenant, "v2.0", OpenIdProviderMetadataNames.Discovery),
new string[] { policySettings.Value.SignUpInPolicyId, policySettings.Value.ProfilePolicyId, policySettings.Value.PasswordPolicyId });
});
}

Question 1: Should I create a "UserProfileId" claim in Azure B2C? Or should I create an "ObjectId" field in my database table that references the AD user? What would make more sense?
1a - I didn't add anything to the B2C tenant.
1b - I take the object id from B2C and store it in my table as an alternate key. My table has a unique id of it's own. Should I ever wish to have additional identity providers, this will be necessary.
I only use the object id from B2C to look up users and get my own id.
Question 2: Once a user registers, where and how would I update an AD User claim? Would I do it in one of these events? Or somewhere else? I see there is a "User is new" claim that I could check for?
When you say "update the claim" do you mean update permanently in the B2C tenant, or do you mean add it to the other claims and use it temporarily during the life of this particular token?
There's no connection back to B2C without using the graph client.
The userIsNew claim comes from B2C one time and only at the end of the signup process. You use that to determine if you have a new user trying to access your system. I hook that to create new entries in my tables from the claims that B2C gives me and from then on, the claims all come from the information in my tables.
Question 3: To update the claims, would I use: Microsoft.Azure.ActiveDirectory.GraphClient? Does anyone have any sample code for how to update a custom claim? I've tried this but it doesn't seem to persist:
I have to ask the "update" question again.
What you may be looking for is to "transform" the claims. That's usually done during a TicketReceived event for cookies. That occurs when they have authenticated for the first time. (Not to be confused with signing up.)
I'm not all that bright, but I'll tell you that I spent waaaay too much time on this trying to get it right. Mostly it's because there are a vast number of options and no one can tell you all the right ones for your project. So you just see reams of information that you have go through to find the bits you want.
I found this book (and it's author) incredibly helpful. It's current and he's a Microsoft guy who writes really well.
HTH

Regarding question 1: I did the same as nhwilly: I store the additional information in my database.
Regarding question 2: you can add claims in the OnsigningIn event:
app.UseCookieAuthentication(new Microsoft.AspNetCore.Builder.CookieAuthenticationOptions()
{
Events = new CookieAuthenticationEvents()
{
OnSigningIn = (context) =>
{
ClaimsIdentity identity = (ClaimsIdentity)context.Principal.Identity;
identity.AddClaim(new Claim("sb:tID", "555"));
return Task.FromResult(0);
}
}
});
I got the information from Transforming Open Id Connect claims in ASP.Net Core.
Regarding question 3: I haven't done it myself, but this link should get you kickstarted: https://learn.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-devquickstarts-graph-dotnet
Hope that helps!

Related

How to mimic `Get-AzureADUser` in C#

I am working on an Azure AD B2C application and the B2C policy stores the MFA secret-key in the extension_mfaTotpSecretKey property of the user. This works and when I run Get-AzureADUser -ObjectId '<object-id>' | ConvertTo-Json, then it shows:
{
"ExtensionProperty": {
"odata.metadata": "https://graph.windows.net/<tenant-id>/$metadata#directoryObjects/#Element",
"odata.type": "Microsoft.DirectoryServices.User",
"createdDateTime": "2/4/2022 2:13:22 PM",
"employeeId": null,
"onPremisesDistinguishedName": null,
"userIdentities": "[]",
"extension_7eb927869ae04818b3aa16db92645c09_mfaTotpSecretKey": "32YZJFPXXOMHT237M64IVW63645GXQLV"
},
"DeletionTimestamp": null,
...
}
During the migration process from the old directory to the new Azure B2C directory, I also want to transfer the existing TOTP key so users don't need to reregister their TOTP key. I have spent all day to get this to work, but no luck and I really don't know what's left.
I have created an app registration in the tenant with Directory.ReadWrite.All rights, but when I read the user, then the extension is empty:
var creds = new ClientSecretCredential("<tenant-id>", "<client-id>", "<client-secret>", new TokenCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud });
var graphClient = new GraphServiceClient(creds, new[] { "https://graph.microsoft.com/.default" });
var user = await graphClient.Users["<object-id>"].Request().Select(u => new {u.Id, u.Extensions}).Expand(u => new { u.Extensions}).GetAsync();
If I can't read the value, then I probably can't write it. I tried using OpenTypeExtension, but I am under the impression that this is a completely different property.
I can't find any documentation that tells me how I can run Get-AzureADUser using Graph API v2 in C#.
It seems that there three possible ways to extend properties in Azure AD for an object:
AzureAD Graph extension attributes
Azure AD Open extensions
Azure AD Schema extensions
Azure B2C uses AzureAD Graph extension attributes and these should be fetched directly on the user object like this:
var graphClient = new GraphServiceClient(...);
var user = await graphClient.Users["<object-id>"].Request().Select("extension_7eb927869ae04818b3aa16db92645c09_mfaTotpSecretKey").GetAsync();
var mfaTotpSecretKey = user.AdditionalData["extension_7eb927869ae04818b3aa16db92645c09_mfaTotpSecretKey"]?.ToString();
When the user is created, then these properties can be added to the AdditionalData property of the user.
Note that Azure B2C uses the persistent claim name extension_mfaTotpSecretKey, but this is translated to extension_<client-id-without-hyphens>_mfaTotpSecretKey, where <client-id> is the client-id of the B2C extensions app (with all hyphens removed).
Extension attributes are not included by default if you use the v1 endpoint of the Microsoft Graph. You must explicitly ask for them via a $select, as per #Ramon answer. When you use a $select statement, you'll get back only the specified attributes plus the id, so pay attention and specify all the fields you need. Moreover, the SDK is misleading since you'll find the extension attributes under the AdditionalData field, not in the Extensions field.
When you are going to migrate the users to a new tenant, keep in mind that the extension attribute name will change since the middle part is the b2c-extensions appId without the dashes.
i.e.
on Tenant 1: extension_xxx_myAttribute
on Tenant 2: extension_yyy_myAttribute
When you'll try to write the extension attribute on Tenant 2 via Microsoft Graph it must already exist. If you never run your custom policies on the new tenant you can create the attribute via Microsoft Graph as well with a simple POST operation:
POST https://graph.microsoft.com/v1.0/applications/<b2c-extensions-app-objectId/extensionProperties
{
"name": "attributeName",
"dataType":"string/int/etc.",
"targetObjects": ["User"]
}
You'll get the full extension attribute name in the response (i.e. extension_xxx_attributeName)
HTH, F.
https://learn.microsoft.com/en-us/graph/api/application-list-extensionproperty?view=graph-rest-1.0&tabs=csharp
GraphServiceClient graphClient = new GraphServiceClient( authProvider );
var extensionProperties = await graphClient.Applications["{application-id}"].ExtensionProperties
.Request()
.GetAsync();

Exception in Site.createExternalUser in Apex RESTclass: Site.ExternalUserCreateException: [That operation is only allowed from within an active site.]

I have a Non-Salesforce Auth System which holds usernames and passwords for a few thousand users. I am willing to migrate these users to Salesforce and give access to these users to my Experience Cloud site. I am developing an apex REST Resource which will take username and password as arguments and create a user with that username and password with a community profile. I am planning to call this API from my Non-Salesforce system and migrate all these users. I am using Site.createExternalUser method in this API. I am getting the exception
Site.ExternalUserCreateException: [That operation is only allowed from within an active site.]
The reason I am using Site.createExternalUser is because I don't want to send the welcome email/reset password email to my users since they already have signed up successfully long ago.
I am open to any alternatives for achiving this.
Below is my code:
#RestResource(urlMapping='/createUser/*')
global with sharing class createUserRestResource {
#HttpPost
global static String doPost(){
Contact con=new Contact();
con.Firstname="First";
con.LastName= "Last";
con.Email="first.last#example.com";
con.AccountId='/Add an account Id here./';
insert con;
usr.Username= "usernameFromRequest#example.com";
usr.Alias= "alias123";
usr.Email= "first.last#example.com";
usr.FirstName= "First";
usr.IsActive= true;
usr.LastName= "Last";
usr.ProfileId='/Community User Profile Id/';
usr.EmailEncodingKey= 'ISO-8859-1';
usr.TimeZoneSidKey= 'America/Los_Angeles';
usr.LocaleSidKey= 'en_US';
usr.LanguageLocaleKey= 'en_US';
usr.ContactId = con.Id;
String userId = Site.createExternalUser(usr, con.AccountId, 'Password#1234', false);
return userId;
}
}
You can suppress sending emails out in whole org (Setup -> Deliverability) or in the Community config there will be way to not send welcome emails (your community -> Workspaces -> Administration -> Emails).
Without running on actual Site I don't think you can pull it off in one go. In theory it's simple, insert contact, then insert user. In practice depends which fields you set on the user. If it's Partner community you might be setting UserRoleId too and that's forbidden. See MIXED DML error. In Customer community you might be safe... until you decide to assign them some permission sets too.
You might need 2 separate endpoints, 1 to create contact, 1 to make user out of it. Or save the contact and then offload user creation to #future/Queueable/something else like that.

Laravel 8 Fortify User UUID Login Problem

I am currently setting up a new project using Laravel 8. Out of the box, Laravel is configured to use auto-incrementing ID's for the user's ID. In the past I have overrode this by doing the following.
Updating the ID column in the user table creation migration to
$table->uuid('id');
$table->primary('id');
Adding the following trait
trait UsesUUID
{
protected static function bootUsesUUID()
{
static::creating(function ($model) {
$model->{$model->getKeyName()} = (string) Str::orderedUuid();
});
}
}
Adding the following to the user model file
use UsesUUID;
public $incrementing = false;
protected $primaryKey = 'id';
protected $keyType = 'uuid';
On this new project, I did the same as above. This seems to break the login functionality. When the email and password are entered and submitted, the form clears as though the page has been refreshed. Thing to note is there are no typical validation error messages returned as would be expected if the email and/or password is wrong.
To check that the right account is actually being found and the password is being checked properly, I added the following code to the FortifyServiceProvider boot method. The log file confirms that the user is found and the user object dump is correct too.
Fortify::authenticateUsing(function(Request $request) {
\Log::debug('running login flow...');
$user = User::where('email', $request->email)->first();
if ($user && Hash::check($request->password, $user->password)) {
\Log::debug('user found');
\Log::debug($user);
return $user;
}
\Log::debug('user not found');
return false;
});
Undoing the above changes to the user model fixes the login problem. However, it introduces a new problem that is the login will be successful but it wont be the right account that is logged in. For example, there are 3 accounts, I enter the credentials for the second or third account, but no matter what, the system will always login using the first account.
Anyone have any suggestions or ideas as to what I may be doing wrong, or if anyone has come across the same/similar issue and how you went about resolving it?
Thanks.
After digging around some more, I have found the solution.
Laravel 8 now stores sessions inside the sessions table in the database. The sessions table has got a user_id column that is a foreign key to the id column in the users table.
Looking at the migration file for the sessions table, I found that I had forgot to change the following the problem.
From
$table->foreignId('user_id')->nullable()->index();
To
$table->foreignUuid('user_id')->nullable()->index();
This is because Laravel 8 by default uses auto incrementing ID for user ID. Since I had modified the ID column to the users table to UUID, I had forgotten to update the reference in the sessions table too.

Azure Active Directory: Consent Framework has stopped granting consent

I have just written an app with Azure Active Directory Single Sign-On.
The consent framework is handled in the AccountController, in the ApplyForConsent action listed below. Until recently, everything has worked seamlessly. I could grant consent as an admin user of an external tenant, then sign out of the app, and sign in again as a non-admin user.
My Azure Active Directory app requires the following delegated permissions:
Read directory data
Enable sign-on and read users' profiles
Access your organisation's directory
Now, after I have gone through the consent framework (by POSTing from a form to ApplyForConsent as an admin user), signing in as a non-admin user fails with the error message AADSTS90093 (This operation can only be performed by an administrator). Unhelpfully, the error message doesn't say what "this operation" actually is, but I suspect it is the third one (Access your organisation's directory).
I stress again, this has only recently stopped working. Nothing has changed in this part of the code, although I grant it is possible that other changes elsewhere in the codebase may have had knock-on effects of which I remain blissfully ignorant.
Looking at the documentation, it seems that this use of the consent framework is already considered "Legacy", but I'm having a difficult time finding a more up-to-date implementation.
The requested permissions in the code sample below is the single string "DirectoryReaders".
I have three questions for helping me debug this code:
What is the difference between "Read directory data" and "Access your organisation's directory"? When would I need one rather than another?
Do I need to request more than just "DirectoryReaders"?
Is there now a better way to implement the Consent Framework?
This is the existing code:
private static readonly string ClientId = ConfigurationManager.AppSettings["ida:ClientID"];
[HttpPost]
public ActionResult ApplyForConsent()
{
string signupToken = Guid.NewGuid().ToString();
string replyUrl = Url.Action("ConsentCallback", "Account", new { signupToken }, Request.Url.Scheme);
DatabaseIssuerNameRegistry.CleanUpExpiredSignupTokens();
DatabaseIssuerNameRegistry.AddSignupToken(signupToken, DateTimeOffset.UtcNow.AddMinutes(5));
return new RedirectResult(CreateConsentUrl(ClientId, "DirectoryReaders", replyUrl));
}
[HttpGet]
public ActionResult ConsentCallback()
{
string tenantId = Request.QueryString["TenantId"];
string signupToken = Request.QueryString["signupToken"];
if (DatabaseIssuerNameRegistry.ContainsTenant(tenantId))
{
return RedirectToAction("Validate");
}
string consent = Request.QueryString["Consent"];
if (!String.IsNullOrEmpty(tenantId) && String.Equals(consent, "Granted", StringComparison.OrdinalIgnoreCase))
{
if (DatabaseIssuerNameRegistry.TryAddTenant(tenantId, signupToken))
{
return RedirectToAction("Validate");
}
}
const string error = "Consent could not be provided to your Active Directory. Please contact SharpCloud for assistance.";
var reply = Request.Url.GetLeftPart(UriPartial.Authority) + Url.Action("Consent", new { error });
var config = FederatedAuthentication.FederationConfiguration.WsFederationConfiguration;
var signoutMessage = new SignOutRequestMessage(new Uri(config.Issuer), reply);
signoutMessage.SetParameter("wtrealm", config.Realm);
FederatedAuthentication.SessionAuthenticationModule.SignOut();
return Redirect(signoutMessage.WriteQueryString());
}
private static string CreateConsentUrl(string clientId, string requestedPermissions, string consentReturnUrl)
{
string consentUrl = String.Format(CultureInfo.InvariantCulture, ConsentUrlFormat, HttpUtility.UrlEncode(clientId));
if (!String.IsNullOrEmpty(requestedPermissions))
{
consentUrl += "&RequestedPermissions=" + HttpUtility.UrlEncode(requestedPermissions);
}
if (!String.IsNullOrEmpty(consentReturnUrl))
{
consentUrl += "&ConsentReturnURL=" + HttpUtility.UrlEncode(consentReturnUrl);
}
return consentUrl;
}
I think this link addresses your issue:
http://blogs.msdn.com/b/aadgraphteam/archive/2015/03/19/update-to-graph-api-consent-permissions.aspx
The quick summary is that now only admins can grant consent to a web app for '•Access your organisation's directory'.
This change was made back in March. Native apps are not affected by the change.
My suspicion was correct. I was using legacy tech in the question. By moving to Owin and Identity 2.0, all issues were solved.
The new approach is summarised by https://github.com/AzureADSamples/WebApp-GroupClaims-DotNet

Has anyone created a DNN 7 user with the services framework?

I'm trying to create a user with the DNN 7 services framework. I've taken my working code from my custom registration module and modified to work within a DNN webapi function.
When I get to the UserController.CreateUser call in the code below I receive a
"\"There was an error generating the XML document.\""
exception. My user makes it into the aspnet_Users table and the DNN users table but does not make it into the DNN userportals table. Any ideas would be appreciated.
private void CreateUser()
{
//Update DisplayName to conform to Format
UpdateDisplayName();
User.Membership.Approved = PortalSettings.UserRegistration == (int)Globals.PortalRegistrationType.PublicRegistration;
var user = User;
CreateStatus = UserController.CreateUser(ref user);
I finally found the issue. I was not setting the portal ID for my new users and DNN was excepting out when it was adding them to a portal. All it took was User.PortalId = 0 before the CreateUser call.
I have found by trial and error that the minimum needed to create a viable DNN user is:
UserInfo uiNewUser = new UserInfo();
uiNewUser.Username = "<myUsername>";
uiNewUser.Displayname = "<myDisplayname>";
uiNewUser.Email = "<myUserEmail>";
UserMembership newMembership = new UserMembership(uiNewUser);
newMembership.Password = "<myUserPassword>";
uiNewUser.Membership = newMembership;
uiNewUser.PortalID = <myPortalID>;
DotNetNuke.Security.Membership.UserCreateStatus uStatus;
uStatus = DotNetNuke.Security.Membership.MembershipProvider.Instance().CreateUser(ref uiNewUser);
RoleInfo newRole = RoleController.Instance.GetRoleByName(uiNewUser.PortalID, "Registered Users");
RoleController.Instance.AddUserRole(uiNewUser.PortalID, uiNewUser.UserID, newRole.RoleID, (RoleStatus)1, false, DateTime.MinValue, DateTime.MaxValue);
If any of these are missed out, parts of the user are created in the database, but the user may not be visible in the Admin list of users, or an Exception may be generated. Other details can be added later.

Resources