openiddict / openiddict-core

Flexible and versatile OAuth 2.0/OpenID Connect stack for .NET
https://openiddict.com/
Apache License 2.0
4.43k stars 520 forks source link

User authentication with token by external site #1734

Closed paolo8417 closed 1 year ago

paolo8417 commented 1 year ago

Confirm you've already contributed to this project or that you sponsor it

Version

4.x

Question

Hi,

I've an enterprise .Net 6 solution which consists of:

The MVC Frontend use the authorization code grant to authenticate the users by cookie.

On MVC Frontend there is a page where an authenticated user can see a list of personal resources. (e.g. /Account/Resources).

I need to integrate the MVC Frontend in an external site.

In particular, I need to authorize the external site to embed the MVC Frontend page on iframe and view a user resources page without user manual authentication (digit username e password). This because the user is already auhtenticated on the external site by another identity provider, but is logically the same user of our Identity server.

The external site (before to open the iframe) get a special token from Identity server (with client credentials grant).

This token is stored on the Identity Server and it is related to user.

At this point, the external site load an iframe calling a special anonymous MVC Frontend controller containing this token (e.g. /Account/Token/{token}).

As your opinion, what is the best solution to achieve this?

kevinchalet commented 1 year ago

Hey,

Thanks for sponsoring the project πŸ˜ƒ

At this point, the external site load an iframe calling a special anonymous MVC Frontend controller containing this token (e.g. /Account/Token/{token}). As your opinion, what is the best solution to achieve this?

With browser vendors banning "third-party cookies", anything involving cross-domains iframes and cookies is not going to work well, so if the external site is on a different domain, flowing tokens manually is pretty much your best option.

That said, instead of appending it to the request path, I'd use the standard access_token query string parameter (and assuming you're using the OpenIddict validation handler with the native ASP.NET Core integration, it's extracted for you without any custom code).

Alternatively, depending on the scenario, you could also provide your partner an HTTP API they could integrate with instead of relying on an iframe.

I wish I had a better option to offer, but anything involving iframes drastically reduces the options these days πŸ˜„

paolo8417 commented 1 year ago

Hi,

thank you for your reply.

The token I'm talking about, it is not the OpendIddict _accesstoken, but a custom token that the Identity Server create to permit the external site to open the MVC Frontend user Resources page with a direct link.

The desired flow is as follows:

  1. External site (by backchannel) get _accesstoken from Identity Server by _clientcredentials grant.
  2. External site (by backchannel) call an authenticated Identity Server API (authenticated with previous _accesstoken) and request a special token for a user. Identity Server API create a record of special token associated with user and returns it to the external site.
  3. External site frontend call an MVC Frontend controller passing this token (e.g. /Account/Token/{token}) to view the user Resources page.

The point of my request is to understand if there is, as your opinion, a way to get an MVC Frontend authentication cookie (with _authorizationcode grant), bypassing the user login but using the special token.

I mean, for example:

  1. MVC Frontend from the /Account/Token/{token} controller pass the token on Challenge authorize query parameters with redirect uri Resources page.
  2. Identity Server, from the AuthorizationController /connect/authorize, if it detects this query parameter, returns SignInResult for the user retrieved by the token instead the Challenge (this token is stored on Identity Server and it is associated with user).
  3. MVC Frontend open user Resources page (from redirect uri) with authentication cookie.

I hope to be more precise and clear.

Thank you

kevinchalet commented 1 year ago

Thanks for the additional details.

The point of my request is to understand if there is, as your opinion, a way to get an MVC Frontend authentication cookie (with _authorizationcode grant), bypassing the user login but using the special token.

The option you have in mind is technically possible: after all, OpenIddict doesn't care about how your users are authenticated (all it needs is a ClaimsPrincipal), so you could definitely create an ASP.NET Core IAuthenticationHandler (or just some custom logic directly in your authorization controller) that would extract your custom token from the request path and create a ClaimsPrincipal based on it.

But as I said, many browser vendors now block cross-site cookies for "privacy protection", so unless your partner is willing to force their users to disable this option in their browser settings (which is a terrible user experience), your page loaded via the iframe won't see any existing authentication cookie, which would require doing this unusual code flow dance with your special custom token in the authorization request parameters every time the iframe would be loaded from a different domain.

Would I recommend doing that? No. The proper way here would consist in inviting your partner to act as a classical code flow client to request a one-time authorization from your server and get back a refresh token bound to the user who allowed the demand in return, a token that they could use to get new access tokens (via grant_type=refresh_token) that could be attached to the query string of the page loaded via the iframe (which would basically be considered a resource like a more classical API).

Hope it's clear πŸ˜ƒ

paolo8417 commented 1 year ago

Would I recommend doing that? No. The proper way here would consist in inviting your partner to act as a classical code flow client to request a one-time authorization from your server and get back a refresh token bound to the user who allowed the demand in return, a token that they could use to get new access tokens (via grant_type=refresh_token) that could be attached to the query string of the page loaded via the iframe (which would basically be considered a resource like a more classical API).

If I understood correctly, you suggest this flow:

  1. External site user get authenticated with classic _authorizationcode (e.g. typing username and password) getting _accesstoken and _refreshtoken from Identity Server.
  2. External site attach the _accesstoken (if expired, retrieved with the _refreshtoken by grant_type=refresh_token) to the query string of the page loaded on the MVC Frontend iframe.

By this way, the MVC Frontend receive the access token on the query string. Now, how can I create the authentication cookie with this access token?

kevinchalet commented 1 year ago

If I understood correctly, you suggest this flow:

Yup!

Now, how can I create the authentication cookie with this access token?

Why would you want to do that?

paolo8417 commented 1 year ago

Why would you want to do that?

To view the MVC Frontend user cookie authenticated page on the iframe.

The ultimate goal is to allow the users to enjoy the entire MVC website as if they had logged in with a username and password.

kevinchalet commented 1 year ago

The ultimate goal is to allow the users to enjoy the entire MVC website as if they had logged in with a username and password.

Your initial post seemed to imply it was only a single page, not an entire site πŸ˜…

On MVC Frontend there is a page where an authenticated user can see a list of personal resources. (e.g. /Account/Resources).

I wouldn't recommend at all using iframes to do that as you'll hit many security blockers that will require reducing the security of your application drastically (e.g you'll need to use SameSite=None for all your cookies and if you have any form, you'll also need to disable all frame-busting mechanisms, including the X-Frame-Options header added for you by the ASP.NET Core antiforgery stack).

If you're absolutely sure you don't have any better option, I can think of two ways of achieving what you want:

(IMHO, iframes offer a terrible user experience in 2023: they should never be used, and many websites will use frame-busting techniques to deliberately block that)

paolo8417 commented 1 year ago

Your initial post seemed to imply it was only a single page, not an entire site πŸ˜…

I apologize for not being precise.

As your opinion, If the external site attach the user _refreshtoken to the query string, can I obtain the authentication cookie in the MVC Frontend using in some way the _refreshtoken grant to Identity Server?

kevinchalet commented 1 year ago

I apologize for not being precise.

No worries πŸ˜„

As your opinion, If the external site attach the user refresh_token to the query string, can I obtain the authentication cookie in the MVC Frontend using in some way the refresh_token grant to Identity Server?

Nah, at least not without adding terrible hacks (flowing the access token in the query string across multiple pages is bad, but doing that with a long-term token like a refresh token is a total abomination πŸ˜„).

If you really want a cookie-based approach, the option you suggested here is pretty much the only practical way to implement that:

I mean, for example:

1. _MVC Frontend_  from the  _/Account/Token/{**token**}_ controller pass the **token** on _Challenge_ authorize query parameters with redirect uri _Resources_ page.

2. Identity Server, from the _AuthorizationController /connect/authorize_, if it detects this query parameter, returns _SignInResult_ for the user retrieved by the **token** instead the _Challenge_ (this **token** is stored on _Identity Server_ and it is associated with user).

3. _MVC Frontend_ open user _Resources_ page (from redirect uri) with authentication cookie.

If you wanted to remove the custom token from the equation, you could adopt a variant of this approach by using the standard id_token_hint authorization request parameter:

  1. The partner app would implement the classical code flow and would get back an id_token in the token response (grant_type=refresh_token can be used too).
  2. The partner app would attach the identity token to the iframe URI, the same way it's done in your existing approach.
  3. Your MVC frontend would trigger a challenge to redirect to the identity provider with the id_token_hint attached. If you use the OpenIddict client, it should be as easy as:
var properties = new AuthenticationProperties(new Dictionary<string, string>
{
    // Note: when only one client is registered in the client options,
    // setting the issuer property is not required and can be omitted.
    [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = issuer,

    [OpenIddictClientAspNetCoreConstants.Properties.IdentityTokenHint] = "the identity token resolved from the URI"
})
{
    // Only allow local return URLs to prevent open redirect attacks.
    RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"
};

// Ask the OpenIddict client middleware to redirect the user agent to the identity provider.
return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
  1. Your authorization endpoint action (in your identity provider project) would resolve the identity contained in id_token_hint using var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) and would build an identity from it without needing an authentication cookie. I'd strongly encourage you to check the result.Properties.IssuedUtc/result.Properties.ExpiresUtc properties to manually validate that the identity token was generated very very very recently (since there's no authentication cookie, the user can't control the lifetime of the session, so it's critical to ensure the user logged in quite recently).

  2. The rest of the flow would be identical to a classical code flow.

It's important to note that both your approach and the variant I described are prone to session fixation attacks: a malicious user could extract a custom token/identity token created for his session and use it to build an iframe URI containing this token: if a victim visited a website containing this iframe URI, he would be transparently logged under the attacker's account without necessarily realizing it. If there's sensitive data that can submitted on any page of your embedded site, the victim may be lured into sending sensitive information that would be attached to the attacker's account. Depending on your scenario, this may or may not be a problem.

(again, consider avoiding iframes πŸ˜…)

paolo8417 commented 1 year ago

Hi, just to avoid misunderstandings, here is a sample code of my option:

         // MVC Frontend AccountController

        [AllowAnonymous]
        [HttpGet("[controller]/Token/{token}")]        
        public IActionResult Token([FromRoute] string token)
        {                                 
            // This is the endpoint called by external site
            var properties = new AuthenticationProperties 
            { 
                RedirectUri = string.Format("/Account/Token/{0}/Validate",token)
            };              
            properties.Parameters["token"] = token;       

            // Force logout
            return SignOut(properties,CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme);          
        }

        [AllowAnonymous]
        [HttpGet("[controller]/Token/{token}/Validate")]
        public IActionResult ValidateToken([FromRoute] string token)
        {           

            // Make sure that only unauthenticated users can go ahead                     

            if ( User.Identity.IsAuthenticated )
            {           
                // Error or signout
                return SignOut(CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme); 
            }

            var properties = new AuthenticationProperties 
            { 
                RedirectUri = "/Resources"

            };            
            properties.Parameters["token"] = token;            
            return Challenge(properties,OpenIdConnectDefaults.AuthenticationScheme);       
        }     
        // Identity Server AuthorizationController

        [HttpGet("~/connect/authorize")]
        [HttpPost("~/connect/authorize")]
        [IgnoreAntiforgeryToken]
        public async Task<IActionResult> Authorize()
        {
            var request = HttpContext.GetOpenIddictServerRequest() ??
                throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            var token = (string) request["token"];

            ApplicationUser user = null;

            // Retrieve the user principal stored in the authentication cookie.
            // If it can't be extracted, redirect the user to the login page.
            var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);

            // The only difference between the standard flow and my option when I retrive the user by custom token

            if ( !string.IsNullOrWhiteSpace(token))
            {
                if ( result is not null && result.Succeeded )
                {
                    return Forbid(
                        authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
                        properties: new AuthenticationProperties(new Dictionary<string, string>
                        {
                            [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
                            [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Access denied."
                        }));             
                }                

                // Retrieve the profile of the logged in user, like normal code
                user = await RetrieveUserFromTokenAsync(token) ??
                    throw new InvalidOperationException("The user details cannot be retrieved.");  

            }
            else
            {   
                 // openiddict code

                 .....

                // Retrieve the profile of the logged in user.
                user = await _userManager.GetUserAsync(result.Principal) ??
                    throw new InvalidOperationException("The user details cannot be retrieved.");   
            }                   

            // Retrieve the application details from the database.
            var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
                throw new InvalidOperationException("Details concerning the calling client application cannot be found.");

           ....

It's important to note that both your approach and the variant I described are prone to session fixation attacks: a malicious user could extract a custom token/identity token created for his session and use it to build an iframe URI containing this token: if a victim visited a website containing this iframe URI, he would be transparently logged under the attacker's account without necessarily realizing it. If there's sensitive data that can submitted on any page of your embedded site, the victim may be lured into sending sensitive information that would be attached to the attacker's account. Depending on your scenario, this may or may not be a problem.

Thank you for your advice. There are no areas where the user can enter sensitive data, but as a form of protection I will set a life time for the custom token.

kevinchalet commented 1 year ago

Looks good πŸ‘πŸ» (as you said, except the custom token part, it's indeed pretty standard πŸ˜ƒ)

kevinchalet commented 1 year ago

Doing some housecleaning, but feel free to reopen if you need additional details πŸ˜ƒ