Skip to main content

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

  1. Microsoft Azure Account - With active subscription
  2. Azure App Registration - Created in your tenant
  3. Admin Consent - For graph API permissions
  4. Redirect URI - Configured in Azure portal

Azure App Registration Setup

Step 1: Register Application

  1. Go to Azure Portal
  2. Search for App registrations
  3. Click New registration
  4. 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

Step 2: Get Credentials

  1. From app registration, copy:

    • Application (client) ID - Used in configuration
    • Directory (tenant) ID - Used in configuration
  2. 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

  1. Go to API permissions

  2. Click Add a permission

  3. Select Microsoft Graph

  4. Choose Delegated permissions

  5. Search and add:

    • User.Read - Read user profile
    • User.Read.All - Read user directory
    • Directory.Read.All - Read directory structure (optional)
    • Mail.Send - Send emails from app (optional)
  6. Click Grant admin consent for [organization]

Step 4: Add Redirect URIs

  1. Go to Authentication
  2. 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
  3. 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:

  1. Provider Instance ID: Leave blank for auto-generated GUID
  2. Application (client) ID: Paste from Azure portal
  3. Client secret: Paste from Azure portal
  4. Tenant ID: Paste from Azure portal
  5. Button text: "Sign in with Entra ID" (customize as needed)
  6. Icon URL: https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Microsoft_logo.svg/512px-Microsoft_logo.svg.png
  7. 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:5000 for debugging
  • Grant admin consent in Azure portal
  • API permissionsGrant admin consent for [org]
  • Or enable "Require admin consent" prompt on first use

"External user not created on first sign-in"

  • Check useRegistration is enabled in configuration
  • Verify IExternalUserLookupProvider isn'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

  1. Secrets Management

    • Store client secrets in Azure Key Vault
    • Never commit to version control
    • Rotate every 6 months
    • Use system-managed identities when available
  2. Tokens

    • Validate token signature
    • Check token expiration
    • Verify issuer matches your tenant
    • Use ID tokens for authentication, access tokens for APIs
  3. Scopes

    • Request minimum scopes needed
    • User.Read for profile
    • Avoid .All scopes unless necessary
    • Review permission changes during upgrades
  4. Audit

    • Log all sign-ins
    • Track failed authentication attempts
    • Monitor account lockouts
    • Review admin consent grants

Next Steps