How to build a guest user self-service registration for Azure AD with Azure PaaS services
Last updated on October 18, 2021
In some extranet scenarios, you want to limit external sharing only to the employees of specific organizations. In Azure AD, you can do that by configuring a list of allowed domains for guest invites. But what if one or more of your stakeholder organizations have blocked access to other tenants via tenant restrictions?
In that case, you need to allow the employees of those organizations to access your tenant using some other email account, such as outlook.com or gmail.com. But at this point, the configured list of allowed domains prevents them from doing that, and adding those third-party email domains to the list would allow just anyone to get invited. So how can you allow only the employees of those specific organizations to get invited as guests using their third-party email addresses?
First of all, we need to scratch the list of allowed domains; we are going to restrict external sharing another way:
- We need to limit the tenant external sharing settings to existing external users only, and
- invite guests first and only after that share content with them.
The problem with this though is that inviting a ton of users manually is a lot of work. You’d first need to ask them for their email addresses and then send the invitations manually (or with a script), and repeat the process every single time new users need access to your tenant.
Or, alternatively, you could set up a self-service registration, so the employees of those specific stakeholder organizations can invite themselves on demand. And in this blog post, I’ll show you how.
Table of Contents
- Sharing to existing external users only
- The registration form and allowed domains list
- The queue message content and format
- Validating the third-party email address
- Sending the invitation
- Changing the user primary email address
Sharing to Existing External Users Only
As mentioned earlier, we first need to limit the external sharing settings. For SharePoint and OneDrive, it is very straightforward. Just go to the OneDrive admin center, and you can adjust the level for both.
For Office 365 groups, however, achieving the “existing external users only” setting requires us to tweak settings in two places: the Office 365 admin center and Azure AD portal.
- In Azure AD (Users → User settings → Manage external collaboration settings), you need to set Members can invite setting to No.
- In Office 365 Groups settings (under Settings → Services & add-ins), you need to set Let group owners add people outside the organization to groups setting to Yes.
Only through this combination, we can ensure that only the existing guest users can be added to Office 365 groups.
The Registration Form and Allowed Domains List
Now that the external sharing settings have been configured, let’s get to the actual self-service registration process. Naturally, we need a registration form. This can be any type of form that is accessible anonymously, can be used for collecting the information we need for setting up the guest user account and can do the required validations securely. I personally coded a simple ASP.NET MVC application for this purpose and hosted it in Azure.
Another thing we need for the solution is our own “allowed domains” list. This list should be easily configurable in case of changes, accessible only by the appropriate people, and reachable from our form code. A SharePoint list satisfies all of these requirements and is easy to use by all kinds of administrators.
In the list, we have three columns: the stakeholder organization name (not required by the code, but makes it easier for humans to spot the correct row for configurations), the allowed email domain, and a yes/no switch to indicate whether the stakeholder organization has tenant restrictions in place or not. You can ask, for example, the IT department of the stakeholder organization for this information. And if they have tenant restrictions in place, it’s always worth asking if they are willing to whitelist your tenant for access. But it’s ok if they don’t want to do that; our self-service registration allows the employees of that organization to easily gain access to our tenant anyway.
The Form Logic
After the user fills in their name and organization email address and submits the registration form, we check on the server-side if the domain of the email address can be found on the SharePoint list. If registration is allowed for the domain, we further check if it has been marked as having tenant restrictions.
If the domain is allowed and has no tenant restrictions, we tell the user that they will receive an invitation in a few minutes, and add their information to an Azure storage queue to wait to be processed. If the email domain is not on our list (the domain is not allowed for registering), we display the same message but don’t add the message to the queue. There is a security-related reason why we display the same message in both situations: we want to prevent people from using the form to figure out which domains are allowed to register and which aren’t.
If the domain has tenant restrictions, we show another view to the user, explaining to them that their organization has decided not to allow logins to other tenants using their Azure AD credentials. The user now needs to provide another email address they want to use for login.
After the user submits the form, we’ll again validate the previously stored organization email address against our allowed domains list in case of tampering. If everything is ok, we’ll send the user information to an Azure logic app which will handle our third-party email address validation process by sending an approval request to the user’s work email address.
The diagram above illustrates the form logic. There are some other little things too, like client-side validation for ensuring the form has been filled properly before submitting, checking if the user already exists, and error handling. But in the end, the amount of code that is required is relatively small. The most important thing is that you do the email address validations properly on server-side to avoid unauthorized access.
In my solution, I utilized the following NuGet packages:
- SharePointPnPCoreOnline for authenticating to SharePoint and reading the SharePoint list. Here are instructions on how to set up app authentication using a certificate. You should store the certificate in Azure and fetch it in code when needed with the certificate thumbprint.
- WindowsAzure.Storage for adding items to the Azure storage queue
- Office UI Fabric for styling the form
- Microsoft.Azure.KeyVault and Microsoft.Azure.Services.AppAuthentication for fetching the logic app SAS token from Azure key vault using the application’s managed service identity (MSI)
The queue message content and format
The collected user information is sent to the logic app or the Azure storage queue in the following format.
{ "invitedUserDisplayName": "Catherine White", "invitedUserEmailAddress": "kitty.white@outlook.com", "sendInvitationMessage": true, "inviteRedirectUrl": "https://catloversunited.sharepoint.com/sites/extranet", "invitedUserMessageInfo": { "messageLanguage": "en-US", "ccRecipients": [ { "emailAddress": { "name": null, "address": "catherine.white@pawsitiveimpact.com" } } ], "customizedMessageBody": "Welcome to the Cat Lovers United extranet site! Click the link below and sign in. If you encounter problems, contact support@catloversunited.com." }
You might be wondering, why this JSON format? It is the format accepted by the invitation manager of Microsoft Graph which we are going to use later on for sending the actual invitation to our guest. By formatting the message early on, we don’t need to worry about it later, no matter where the message is sent to. The invitedUserEmailAddress is used for creating the guest account and the address is normally there just for sending the invitation to another address. However, we are going to use that property value for some other purposes, too.
In case of tenant restrictions, the invitedUserEmailAddress is the third-party email address that we are going to use for creating the guest user account, and which is later used for login. The address is the user’s work email address where we will send the approval email, and which will in the future receive all email messages.
If there are no tenant restrictions in place, the message is similar, except that the invitedUserEmailAddress contains the work email address and the address is empty because it is not needed. Also, the message is directly added to the Azure storage queue because there’s no third-party email address that should be approved.
Validating the Third-Party Email Address
Implementing an approval workflow with Azure logic apps is super fast. There is an action called Send approval email that sends an email with configurable approval options to the recipient. Unlike the Start an approval action of Microsoft Power Automate, this action accepts approvals also from people outside of your organization.
We start the logic app by making a POST request to it from our registration form web app. You can see how to fetch the logic app SAS token from Azure key vault and make the request in my other blog post How to securely call a logic app from an Azure function + benefits.
On the logic app side, we have the When an HTTP request is received trigger and immediately after that a Parse JSON action to make the information in the request body available to the rest of our logic app actions.
The JSON schema matches the message sent by our web app. You can easily generate the schema by clicking the Use sample payload to generate schema link and pasting the example message from earlier.
After parsing the request body, we’ll send the approval email to our user’s work email address (the one with the allowed domain) using the Send approval email (Office 365) action. For the action, you need to create a connection using the account that you want to appear as the email sender. This is most likely a service account with a descriptive and nice looking display name and email address.
The work email address is contained in the address property. Referencing it causes a For each action to be added to our workflow automatically because there can be many ccRecipients even though in our case there is always just one.
For the rest of the configurations, it is really up to you what you want the message to look like. You can even change the approval options to be whatever you like.
You can set the time the approval action waits for a reply in the action settings behind the three dots. In this case, we’ll wait for a maximum of 7 days.
What we need next is a Condition action within our For each loop to check for the approval outcome and to act accordingly. The approval outcome is accessible via the SelectedOption dynamic content. If the user has processed the approval in their work email inbox and confirmed that the third party email address really belongs to them, we’ll finally add the registration information to the Azure storage queue; the same place where we add messages also when there are no tenant restrictions. On the other hand, if they have denied owning that email address or have not responded at all, the process ends.
Sending the Invitation
I chose to implement the actual invitation logic with an Azure logic app, but you can do it in other ways too, e.g., with an Azure function. I chose logic apps because the implementation is really fast and hence saves money — even though it costs a tiny bit to run for each invited user.
Also, we need to wait for quite a long time before we are able to change the email address in our guest’s “mail user” object. If I wanted to use an Azure function, which is limited to 5-10 minutes of runtime, I’d need to set up another queue where I’d send the message and a separate function that’d poll it on a schedule. Using a logic app makes things more straight-forward.
Polling the Azure storage queue
As mentioned earlier, the accepted registration information gets added to an Azure storage queue to wait to be processed. You might be wondering, why are we using an Azure storage queue, can’t we just send the information directly to the processing component?
The Azure storage queue helps us ensure the redundancy and resiliency of our application. If our processing component goes down or an error occurs while processing the information, the message is returned to the queue and retried later on. We don’t want the registration requests of our stakeholders to go missing or be left unprocessed. It’d be quite embarrassing to ask them to resubmit their information. Of course, if the Azure storage queue itself goes down, then we are out of luck. Also, using the Azure storage queue makes our overall solution more modular.
I am polling the Azure storage queue only every few minutes to save a bit of money. Every time you poll the queue, it costs one standard connector execution (see logic apps pricing), so if you poll often, it can really add up.
If you want to poll more frequently or require the invitation to happen almost instantaneously, you might want to poll the queue with an Azure function instead and then call the logic app only when there is data to process. This method is described in my other blog article, How to securely call a logic app from an Azure function + benefits.
If you think using the queue is overkill, you can use the When an HTTP request is received trigger. In that case, you’ll post the information directly to the logic app instead of the Azure storage queue.
Processing the queue message
After picking up a new message from the queue, we immediately send the invitation. Because we already formatted the user information in the JSON format that is accepted by the operation before we added the message into the queue, we can simply use the Message Text as the request body.
Now that the invitation has been sent successfully, we need to delete the message from the queue. If we didn’t delete the message at this point and one of the following actions failed, the message would be returned to the queue after 30 seconds and tried again, which would lead to our guest receiving multiple invitations in their email.
After we’ve deleted the message, let’s parse the response we got from Microsoft Graph when we invited the user.
Here is an example of the response body. You can use it to generate the schema for the Parse JSON action.
{ "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#invitations/$entity", "id": "0fe1c0db-3f3b-4df6-a844-ca6769ec207c", "inviteRedeemUrl": "https://invitations.microsoft.com/redeem/?tenant=...&user=...&ticket=...&ver=2.0", "invitedUserDisplayName": "Catherine White", "invitedUserType": "Guest", "invitedUserEmailAddress": "kitty.white@outlook.com", "sendInvitationMessage": true, "inviteRedirectUrl": "https://catloversunited.sharepoint.com/sites/extranet", "status": "PendingAcceptance", "invitedUserMessageInfo": { "messageLanguage": "en-US", "customizedMessageBody": "Welcome to the Cat Lovers United extranet site! Click the link below and sign in. If you encounter problems, contact support@catloversunited.com.", "ccRecipients": [ { "emailAddress": { "name": null, "address": "catherine.white@pawsitiveimpact.com" } } ] }, "invitedUser": { "id": "36fc072c-e6a0-4efa-a638-a89080d61070" } }
As you can see from the example above, we’ll receive the newly added guest user ID in the response. We can use the ID to add the user to an Office 365 group that is used for granting all guests read access to our extranet site.
Changing the user primary email address
Now that our guest has been invited and granted access to SharePoint, we need to do one last thing: check if we need to change the email address that is used for receiving messages to the user’s work email address. Even if the guest signs in using their third-party email address, we want possible messages sent from our extranet to go into their work email, keeping all of their work-related emails in one place, and not cluttering their off-work email inbox.
For changing the email address, we call an Azure function that does the job. However, before we can change the email address, we need to wait for the mail user object to be generated for the new guest user. Only after that, we can change their email address. In my experience, it usually takes approximately 6-7 minutes for the mail user object to show up, so we wait a bit before we make a call to the Azure function.
Sometimes even 7 minutes is not enough; occasionally it can take close to half an hour for the mail user object to be generated. Because of this, we have retry configurations in place for the ChangeUserPrimaryEmailAddress action. If the Azure function fails, we wait for another 7 minutes before we try again.
The Azure function
Here’s the PowerShell script ran by the Azure function. First, we’ll authenticate to Exchange Online, and then make the required change to the mail user object.
$requestBody = Get-Content $req -Raw | ConvertFrom-Json $securePassword = ConvertTo-SecureString $password -AsPlainText -Force $credentials = New-Object System.Management.Automation.PSCredential ($userName, $securePassword) $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $credentials -Authentication Basic -AllowRedirection Import-PSSession $session Set-MailUser -Identity $requestBody.userId -EmailAddresses SMTP:$requestBody.email Remove-PSSession $session
If you wish to fetch the $password from an Azure key vault (recommended), you can do it with this script. Here we’ll first authenticate to the Azure key vault by using the Azure function managed service identity (MSI), and then make the actual request for fetching the secret (password) from the vault.
$authUri = $env:MSI_ENDPOINT + "?resource=https://vault.azure.net&api-version=2017-09-01" $authResponse = Invoke-RestMethod -Method Get -Headers @{"Secret"="$env:MSI_SECRET"} -Uri $authUri $accessToken = $authResponse.access_token $queryUri = "https://$vaultName.vault.azure.net/secrets/" + $secretName + "?api-version=2016-10-01" $secretResponse = Invoke-RestMethod -Method GET -Uri $queryUri -Headers @{"Authorization"="Bearer $accessToken"} $password = $secretResponse.Value
- Add a reference to the secret stored in an Azure key vault to the resource configuration application settings in the following format:
@Microsoft.KeyVault(SecretUri=https://<vault-name>.vault.azure.net/secrets/<secret-name>/<secret-version-id>)
- Get the application setting value in your code the same way as you would normally get any other app setting value.
Note that the key vault reference always requires the secret version ID. If you regularly update the secret in the vault (the action creates a new version with a different ID), using this method requires you to update the application setting as well. However, using the code above does not have this issue: it always gets the latest version of the secret automatically.
Afterword
In the solution we leveraged the following resources:
- Azure web app
- Azure storage queue
- Azure logic apps
- Azure functions
- Azure key vault
- SharePoint Online list
The most important things to keep in mind when building this kind of a solution are security and reliability. Can you think of better ways to implement this process, or do you perhaps have some recommendations on how it could be improved? If so, let me know in the comments; I’d love to learn from your experiences! And if you enjoyed reading this article and would like to consume more content like it in the future, follow me on Twitter so you won’t miss it when I publish new articles.
Thank you for reading, and until next time!
Laura
Congratulations!! great work! great ideas and explanation of all the steps! great guide.
ps: just a question … what did you use for the flow diagrams … they are so clean 🙂
Hi Helder,
Great to hear you enjoyed this blog post! I used Microsoft Visio for drawing the diagrams.
Laura
Hi Laura,
I am working on a Guest User Registration PoC in Azure, where we don’t want to whitelist gmail domain but want to allow some users having gmail id. Is there any way to achieve the same.
BR,
Vikash
Hi Vikash,
Yes, it is possible. All the answers you need are in this blog post. 🙂
Laura
Hi Laura! This solution looks great! We are trying to implement it on our SharePoint tenant. Just curious do you know if this method is considered B2C?
Thanks,
Nick
Hi Nick,
This specific method is not for B2C.
Laura
Hi Laura
I want to think you for this amazing article, i find realy many important ideas that i can use to implement a collaboration portal for my prospects.
I will follow your steps and i wish i can do something useful for my company.
Thanks
Chakib
Hi Chakib, sounds like a solid plan! 🙂
Laura
Fantastic article. I am implementing a similar custom invitation process and doing some similar steps, you’ve given me some additional ideas.
Just as a thought, my current client doesn’t have an Azure Subscription (yet) and I suspect that there are a growing number of clients who will adopt Office 365 but just won’t procure an Azure Subscription.
This leaves us in the position of trying to work out where we automatically execute the code to send the custom invitation, and to host the self-registration portal. A “quick” solution is to use an anonymous Microsoft Form that has a Flow behind it to do all the extra work of handling the custom invitation (with the flow containing some approval steps for checking we’re not get spammed).
I can’t use the Form to do anything other than gather the details (no way to give the type of feedback you have for allowed domains etc.) but at least we have a way to let external users initiate the self-registration.
Hi Colin,
I’m glad the article was able to give you some new ideas. 🙂 And yes, you need to work with the tools you’ve been given. But hey, at least that keeps you creative!
Laura