Search code examples
c#asp.net-coreasp.net-identitymicrosoft-graph-apiazure-ad-msal

Register external users using MSAL with Identity on ASP.NET Core


Problem: In my ASP.NET Core 3.1 MVC Web app I Use the services.AddSignIn(Configuration); in my startup.cs (provided by the Microsoft.Identity.Web 0.1.5-preview) and I want to register a User upon logging in, the callback method ExternalLoginInfo info = await _signInManager.GetExternalLoginInfoAsync(); always returns null.

Background:

  • I have an application which uses ASP.NET Identity to register and store users. - all the users use their Microsoft accounts
  • I wanted to enhance the app to use MS Graph API (to reach a Sharepoint site for some files), so I've modified the app to use the MSAL for authentication against the Graph API.
  • I've used these MSAL tutorials to try the features out and the sign in went well, I could connect the graph API as well.

Details: My appsettings.json:

"AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "something.onmicrosoft.com",
    "TenantId": "GUID",
    "ClientId": "GUID",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath ": "/signout-callback-oidc",
    "ClientSecret": "GUID"
  }

Startup cs:

services.AddDbContext<UserDbContext>(options =>
            options.UseSqlServer(
                Configuration.GetConnectionString("DefaultConnection"), u => u.MigrationsAssembly("DatabaseLayer.Core")));
        services.AddIdentity<AppUser, IdentityRole>(options =>
        {
            options.User.RequireUniqueEmail = true;
        })
            .AddEntityFrameworkStores<UserDbContext>()
            .AddDefaultTokenProviders();

        services.Configure<CookiePolicyOptions>(options =>
        {
            // This lambda determines whether user consent for non-essential cookies is needed for a given request.
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
            // Handling SameSite cookie according to https://learn.microsoft.com/en-us/aspnet/core/security/samesite?view=aspnetcore-3.1
            options.HandleSameSiteCookieCompatibility();
        });

        services.AddOptions();


        services.AddSignIn(Configuration); 

Question: What do I need to configure to get any externallogins returned in order to register the users?


Solution

  • It is possible by saving the uid and utid claims from the MSAL access token in the local user claims store. These claims are being looked up by the MSAL library when keying into their token store.

    Code example in a Blazor 8 Web App template, in the file Components\Account\Pages\ExternalLogin.razor in function OnValidSubmitAsync:

    private async Task OnValidSubmitAsync()
    {
            ... 
        var claimsStore = GetClaimsStore();
    
        string? OptClaim(List<string> claimTypes) => 
            claimTypes.Select(s => externalLoginInfo.Principal.FindFirstValue(s))
                .SingleOrDefault(s => s is not null);
    
        const string tenantIdClaimType = "utid";
        const string tenantIdClaimTypeAlt = 
            "http://schemas.microsoft.com/identity/claims/tenantid";
        const string objectIdClaimType = "uid";
        const string objectIdClaimTypeAlt = 
            "http://schemas.microsoft.com/identity/claims/objectidentifier";
    
        var tenantId = OptClaim([tenantIdClaimType, tenantIdClaimTypeAlt]);
        var objectId = OptClaim([objectIdClaimType, objectIdClaimTypeAlt]);
    
        if (tenantId is not null) {
            await claimsStore.AddClaimsAsync(
                user, 
                [new(tenantIdClaimType, tenantId)], 
                default);
        }
    
        if (objectId is not null) {
            await claimsStore.AddClaimsAsync(
                user, 
                [new(objectIdClaimType, objectId)], 
                default);
        }
    

    EDIT: and here's the definition for GetClaimsStore for completeness:

        private IUserClaimStore<ApplicationUser> GetClaimsStore() {
            if (!UserManager.SupportsUserClaim) {
                throw new NotSupportedException(
                    "The default UI requires a user store with claims support.");
            }
            return (IUserClaimStore<ApplicationUser>)UserStore;
        }
    

    EDIT #2: It is also necessary to obtain the external login info from the MSAL-created Cookies scheme, since it doesn't use Identity.External, here is the modification to OnInitializeAsync:

        // This method is a slightly modified version of
        //    SignInManager.GetExternalLoginInfoAsync
        private async Task<ExternalLoginInfo?> GetMsalInfo() {
            var auth = await HttpContext.AuthenticateAsync("Cookies");
            var items = auth?.Properties?.Items;
            if (auth?.Principal == null || items == null 
                || !items.TryGetValue("LoginProvider", out var provider))
            {
                return null;
            }
    
            var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier) 
                ?? auth.Principal.FindFirstValue("sub");
            if (providerKey == null || provider == null)
            {
                return null;
            }
    
            var providerDisplayName = 
                (await SignInManager.GetExternalAuthenticationSchemesAsync())
                .FirstOrDefault(p => p.Name == provider)
                ?.DisplayName ?? provider;
            return new ExternalLoginInfo(
                auth.Principal, 
                provider, 
                providerKey, 
                providerDisplayName)
                {
                    AuthenticationTokens = auth.Properties?.GetTokens(),
                    AuthenticationProperties = auth.Properties,
                };
        }
    
        protected override async Task OnInitializedAsync()
        {
                ...
    
            var info = await GetMsalInfo();
            info ??= await SignInManager.GetExternalLoginInfoAsync();
    
                ...