Search code examples
asp.net-core-2.0identityserver4

Re-authorize persistent login (MVC client) without triggering login UI


I am trying to figure out how a server-side client (MVC / ASP.NET Core 2) can query IdentityServer4 to retrieve various claims scopes for a persistent login created in some previous session without prompting for login if the persistent login is invalid (user inactive, cookie expired, etc).

We're using Implicit flow with third-party auth (Google, FB, etc) but we changed the session-duration on the cookie to a more user-friendly 30 day expiration in IdentityServer's ExternalLoginCallback.

Accessing claims on HttpContext.User (we are not using ASP.NET Identity) works great during the session that establishes login. On some later session, navigating to a client resource with an [Authorize] attribute also works: if the user had logged in previously, they transparently gain access to the resource, claims are populated, etc. If not, they're prompted for login, which is ok in response to a user-initiated action.

However, we have a requirement for the client landing page to alter the content depending on whether the user is anonymous or authenticated. A simple example would be "Register" and "Log in" links for anonymous users, but "Account" and "Log out" links for authenticated users.

Hence the reason to retrieve claims and jump-start the persistent login if it's valid, but do nothing (no login prompt) if invalid: we don't want the landing page to force every anonymous user to a login screen.

Nothing special to say about our setups on either end of the pipeline. Client:

services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")

.AddOpenIdConnect("oidc", options =>
{
    options.SignInScheme = "Cookies";
    options.Authority = "https://localhost:5000";
    options.RequireHttpsMetadata = true;
    options.ClientId = "example.com.webserver";
    options.ClientSecret = "examplesecret";
    options.ResponseType = "id_token";
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.Scope.Add("example.com.identity");
});

IdentityServer client resource definition:

new Client
{
    ClientId = "example.com.webserver",
    ClientName = "example.com",
    ClientUri = "https://localhost:5002",
    AllowedGrantTypes = GrantTypes.Implicit,
    ClientSecrets = {new Secret("examplesecret".Sha256())},
    RequireConsent = false,
    AllowRememberConsent = true,
    AllowOfflineAccess = true,
    RedirectUris = { "https://localhost:5002/signin-oidc"},
    PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc"},
    AllowedScopes = new List<string>
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        IdentityServerConstants.StandardScopes.Email,
        IdentityServerConstants.StandardScopes.Phone,
        IdentityServerConstants.StandardScopes.Address,
        "example.com.identity"
    }
}

The client runs an intentional login (user clicks "Log in" link) like so:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task Login()
{
    await HttpContext.SignOutAsync("oidc");
    await HttpContext.ChallengeAsync("oidc", 
        new AuthenticationProperties() {
            RedirectUri = Url.Action("LoginCallback")
        });
}

Solution

  • The solution is to add a second OIDC auth flow in Setup, intercept the redirect to change the Prompt option to none so that no login prompt is shown, intercept the resulting login_required error message, and trigger that flow in the landing page's PageModel OnGet handler (the client app uses RazorPages).

    One caveat is the handler must set flags so that this is only attempted once, and so that it can detect whether the page is being hit for the first time, or as the return-trip from the login attempt. This is achieved by just dropping a value into the Razor TempData which is just a cookie-based bucket of name-value pairs.

    Add to Setup.cs

    .AddOpenIdConnect("persistent", options =>
    {
        options.CallbackPath = "/signin-persistent";
        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = context =>
            {
                context.ProtocolMessage.Prompt = "none";
                return Task.FromResult<object>(null);
            },
    
            OnMessageReceived = context => {
                if(string.Equals(context.ProtocolMessage.Error, "login_required", StringComparison.Ordinal))
                {
                    context.HandleResponse();
                    context.Response.Redirect("/");
                }
                return Task.FromResult<object>(null);
            }
        };
    
        options.SignInScheme = "Cookies";
        options.Authority = "https://localhost:5000";
        options.RequireHttpsMetadata = true;
        options.ClientId = "example.com.webserver";
        options.ClientSecret = "examplesecret";
        options.ResponseType = "code";
        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.Scope.Add("example.com.identity");
    })
    

    Landing page (Index.cshtml.cs)

    public class IndexModel : PageModel
    {
        private bool PersistentLoginAttempted = false;
        private const string PersistentLoginFlag = "persistent_login_attempt";
    
        public IActionResult OnGet()
        {
            // Always clean up an existing flag.
            bool FlagFound = false;
            if(!String.IsNullOrEmpty(TempData[PersistentLoginFlag] as string))
            {
                FlagFound = true;
                TempData.Remove(PersistentLoginFlag);
            }
    
            // Try to refresh a persistent login the first time an anonymous user hits the index page in this session
            if(!User.Identity.IsAuthenticated && !PersistentLoginAttempted)
            {
                PersistentLoginAttempted = true;
                // If there was a flag, this is the return-trip from a failed persistent login attempt.
                if(!FlagFound)
                {
                    // No flag was found. Create it, then begin the OIDC challenge flow.
                    TempData[PersistentLoginFlag] = PersistentLoginFlag;
                    return Challenge("persistent");
                }
            }
            return Page();
        }
    }