External Authentication
Integrate Microsoft Entra ID (formerly Azure AD) and other enterprise identity providers.
Overview
External authentication delegates user verification to a trusted provider:
User Clicks "Sign in with Entra ID"
↓
Browser Redirected to Microsoft Login
↓
User Authenticates with Entra/O365 Credentials
↓
Microsoft Redirects Back with Authorization Code
↓
PearDrop Exchanges Code for Tokens
↓
PearDrop Creates/Updates User in System
↓
User Signed In
Setup Prerequisites
- Microsoft Azure Account - With active subscription
- Azure App Registration - Created in your tenant
- Admin Consent - For graph API permissions
- Redirect URI - Configured in Azure portal
Azure App Registration Setup
Step 1: Register Application
- Go to Azure Portal
- Search for App registrations
- Click New registration
- Enter details:
- Name:
PearDrop MyApp Dev(or production name) - Supported account types: Choose based on users
- "Accounts in this organizational directory only" (single tenant)
- "Accounts in any organizational directory + personal accounts" (multi-tenant)
- Redirect URI: Select "Web" and enter
https://myapp.example.com/auth/callback
- Name:
Step 2: Get Credentials
-
From app registration, copy:
- Application (client) ID - Used in configuration
- Directory (tenant) ID - Used in configuration
-
Create client secret:
- Go to Certificates & secrets
- Click New client secret
- Set expiration (6 months recommended)
- Copy the secret value (can't retrieve later!)
Step 3: Configure Graph API Permissions
-
Go to API permissions
-
Click Add a permission
-
Select Microsoft Graph
-
Choose Delegated permissions
-
Search and add:
User.Read- Read user profileUser.Read.All- Read user directoryDirectory.Read.All- Read directory structure (optional)Mail.Send- Send emails from app (optional)
-
Click Grant admin consent for [organization]
Step 4: Add Redirect URIs
- Go to Authentication
- Under Redirect URIs, add all your app URLs:
https://myapp.example.com/auth/callback
https://myapp-dev.example.com/auth/callback
https://localhost:5000/auth/callback - Check Tokens section:
- ✓ ID tokens (for sign-in)
- ✓ Access tokens (for API calls)
PearDrop Configuration
CLI Setup
Easiest method using CLI:
dotnet tool run peardrop auth add-entra
Interactive prompts:
- Provider Instance ID: Leave blank for auto-generated GUID
- Application (client) ID: Paste from Azure portal
- Client secret: Paste from Azure portal
- Tenant ID: Paste from Azure portal
- Button text: "Sign in with Entra ID" (customize as needed)
- Icon URL:
https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Microsoft_logo.svg/512px-Microsoft_logo.svg.png - Internal auth URL:
/auth/callback
Manual Configuration
Edit appsettings.json:
{
"PearDrop": {
"modules": {
"authentication": {
"appAddress": "https://myapp.example.com",
"useInternal": true,
"providers": {
"external": {
"entra": {
"550e8400-e29b-41d4-a716-446655440000": {
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"clientSecret": "client_secret_value_here",
"tenantId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"buttonText": "Sign in with Entra ID",
"iconUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Microsoft_logo.svg/512px-Microsoft_logo.svg.png",
"internalAuthUrl": "/auth/callback"
}
}
}
}
}
}
},
"ConnectionStrings": {
"PearDrop-Auth": "Server=localhost,1433;Database=MyApp;User Id=sa;..."
}
}
Multi-Tenant Setup
Add multiple Entra ID providers for different organizations:
{
"entra": {
"customer-a-id": {
"clientId": "...",
"clientSecret": "...",
"tenantId": "...",
"buttonText": "Sign in with Customer A"
},
"customer-b-id": {
"clientId": "...",
"clientSecret": "...",
"tenantId": "...",
"buttonText": "Sign in with Customer B"
}
}
}
User Provisioning
Automatic Provisioning
First sign-in with Entra ID automatically creates user:
// User signs in with Entra ID
// PearDrop catches authorization code
// Exchanges for access token
// Queries Microsoft Graph for user info
// Creates user if doesn't exist:
var command = new CreateExternalUserCommand(
externalUserId: "entra-user-object-id",
externalProviderType: "Entra",
emailAddress: user.mail,
firstName: user.givenName,
lastName: user.surname);
var result = await commandRunner.ExecuteAsync(command);
// User automatically signed in
Just-In-Time Provisioning
Create user on first sign-in (default):
- Minimal setup
- Works for public apps
- Info auto-populated from Entra ID
Pre-Provisioned
Create users before they sign in:
// Admin bulk imports users from Entra ID
var externalUsers = await graphService.GetUsersFromEntraAsync();
foreach (var externalUser in externalUsers)
{
var command = new CreateExternalUserCommand(
externalUserId: externalUser.Id,
externalProviderType: "Entra",
emailAddress: externalUser.Mail,
firstName: externalUser.GivenName,
lastName: externalUser.Surname);
await commandRunner.ExecuteAsync(command);
}
// Users can now sign in immediately
Custom Provisioning
Implement IExternalUserLookupProvider:
public class CustomExternalUserLookupProvider : IExternalUserLookupProvider
{
private readonly IGraphServiceClient graphClient;
private readonly IApplicationDbContext dbContext;
public async Task<ExternalUser?> LookupUserAsync(
string externalUserId,
string providerType,
CancellationToken cancellationToken)
{
// Query custom mapping table
var mapping = await dbContext.ExternalUserMappings
.FirstOrDefaultAsync(m => m.ExternalId == externalUserId);
if (mapping == null)
return null;
// Get latest info from Graph
var graphUser = await graphClient.Users[externalUserId].GetAsync();
return new ExternalUser
{
ExternalUserId = externalUserId,
Email = graphUser.Mail,
FirstName = graphUser.GivenName,
LastName = graphUser.Surname,
CustomId = mapping.InternalId
};
}
}
// Register in Program.cs
builder.Services.AddScoped<IExternalUserLookupProvider, CustomExternalUserLookupProvider>();
Graph API Integration
IGraphServiceProvider
Access Microsoft Graph API:
public interface IGraphServiceProvider
{
IGraphServiceClient GetGraphServiceClient();
}
Query User Information
var graphService = provider.GetRequiredService<IGraphServiceClient>();
// Get current user
var me = await graphService.Me.GetAsync();
Console.WriteLine($"{me.DisplayName} ({me.Mail})");
// Get specific user
var user = await graphService.Users["user-id"].GetAsync();
// Search users
var users = await graphService.Users
.GetAsync((requestConfiguration) =>
{
requestConfiguration.QueryParameters.Filter = "startsWith(displayName, 'John')";
});
Query Groups
// Get user's groups
var groups = await graphService.Me.MemberOf.GetAsync();
// Get group members
var groupMembers = await graphService.Groups["group-id"]
.Members.GetAsync();
Use in Authorization
Map Entra groups to roles:
public class EntraGroupAuthorizationHandler : AuthorizationHandler<EntraGroupRequirement>
{
private readonly IGraphServiceClient graphService;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
EntraGroupRequirement requirement)
{
var graphClient = graphService.GetGraphServiceClient();
var userMemberships = await graphClient.Me.MemberOf.GetAsync();
var userGroups = userMemberships.Value
.OfType<Group>()
.Select(g => g.Id)
.ToList();
if (userGroups.Contains(requirement.RequiredGroupId))
{
context.Succeed(requirement);
}
}
}
// Usage
[Authorize(Policy = "AdminGroup")]
public class AdminController : ControllerBase { }
User Profile Sync
On Sign-In
Automatically sync profile from Entra ID:
public class EntraSignInHandler
{
public async Task SyncUserFromEntra(ClaimsPrincipal principal, IGraphServiceClient graphService)
{
var graphUser = await graphService.Me.GetAsync();
var command = new UpdateUserCoreDetailsCommand(
userId: currentUserId,
emailAddress: graphUser.Mail,
firstName: graphUser.GivenName,
lastName: graphUser.Surname);
await commandRunner.ExecuteAsync(command);
}
}
Periodic Sync
Background job to sync all external users:
public class EntraUserSyncService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await SyncAllEntraUsersAsync(stoppingToken);
// Sync every 6 hours
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error syncing Entra users");
}
}
}
private async Task SyncAllEntraUsersAsync(CancellationToken cancellationToken)
{
var entraUsers = await graphService.Users.GetAsync();
foreach (var entraUser in entraUsers.Value)
{
var existingUser = await readModels.Users
.Where(u => u.ExternalUserId == entraUser.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existingUser != null)
{
var command = new UpdateUserCoreDetailsCommand(
userId: existingUser.Id,
emailAddress: entraUser.Mail,
firstName: entraUser.GivenName,
lastName: entraUser.Surname);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
}
}
}
// Register in Program.cs
builder.Services.AddHostedService<EntraUserSyncService>();
Troubleshooting
"Invalid client secret"
- Verify secret hasn't expired (check Azure portal)
- Ensure entire secret value is pasted (no extra spaces)
- Secret can only be viewed once after creation
- Generate new secret if lost
"Redirect URI not registered"
- Verify exact URL matches Azure App Registration
- Check protocol (http vs https)
- Include port number if serving on non-standard port
- Add
localhost:5000for debugging
"User consent not granted for permissions"
- Grant admin consent in Azure portal
- API permissions → Grant admin consent for [org]
- Or enable "Require admin consent" prompt on first use
"External user not created on first sign-in"
- Check
useRegistrationis enabled in configuration - Verify
IExternalUserLookupProviderisn't rejecting user - Check user's email/profile fields aren't empty
- Enable debug logging to see provisioning errors
"Users can't sign out of Entra ID"
- Clearing app cookie doesn't sign out of Microsoft session
- User stays authenticated at microsoft.com
- This is expected behavior
- Redirect to Microsoft logout after app logout if desired
Azure AD (Legacy)
For older Azure AD configuration:
dotnet tool run peardrop auth add-aad
Same configuration as Entra ID, just different provider name.
Recommendation: Use Entra ID for new projects. Migrate legacy Azure AD to Entra ID.
Multiple External Providers
Support Entra ID + Google + custom provider:
{
"providers": {
"external": {
"entra": {
"provider-1": { ... }
},
"google": {
"provider-2": { ... }
},
"custom": {
"provider-3": { ... }
}
}
}
}
Each provider shown as separate sign-in button.
Security Best Practices
-
Secrets Management
- Store client secrets in Azure Key Vault
- Never commit to version control
- Rotate every 6 months
- Use system-managed identities when available
-
Tokens
- Validate token signature
- Check token expiration
- Verify issuer matches your tenant
- Use ID tokens for authentication, access tokens for APIs
-
Scopes
- Request minimum scopes needed
User.Readfor profile- Avoid
.Allscopes unless necessary - Review permission changes during upgrades
-
Audit
- Log all sign-ins
- Track failed authentication attempts
- Monitor account lockouts
- Review admin consent grants
Next Steps
- User Management - Manage users programmatically
- MFA Setup - Require additional verification
- Authentication Settings - Fine-tune behavior