dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.48k stars 10.04k forks source link

Authentication: Support for OAuth 2.0 for Native Apps Flow in ASP.NET Core #57838

Closed kagudimov closed 1 month ago

kagudimov commented 2 months ago

Is there an existing issue for this?

Is your feature request related to a problem? Please describe the problem.

I am trying to provide mobile backend endpoints to my mobile app so that it can authenticate users via Google. Upon successful authentication, I would like to generate and provide the app with access and refresh tokens using the standard "BearerToken" scheme.

The application uses a plugin for React Native for authentication. The plugin uses native system components and user interfaces for authentication, if they are available (it has a more user-friendly UI, which is important for users). If not available, the browser is used.

For authentication, the plugin does not involve the mobile backend; everything happens on the application side. The scheme used is similar to the one described in OAuth for Native Apps and in RFC 8252: OAuth 2.0 for Native Apps.

In order for the mobile backend to authenticate the user too, a special code received from Google is sent to it from the application. Then this code is exchanged for a token on the backend, the token gets validated, and with its help, the backend receives information about the user from Google. After that, the backend generates and returns its access tokens to the application.

Access and refresh tokens on the backend are generated and validated using the standard authentication handler BearerTokenHandler.

I would like to reuse the OAuth authentication handler Microsoft.AspNetCore.Authentication.Google.GoogleHandler (use .AddGoogle(...)). On callback, it already does what I need: exchanges the code for information about the user. I could have just sent the code to the callback URI and signed in the user on the options.Events.OnTicketReceived event. But it does not work this way, because in addition to the "code", the handler requires "state" to be present in the query string, which can not be provided by the app.

From OAuthHandler<TOptions> class:

    protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
    {
        var query = Request.Query;

        var state = query["state"];  // <--------------------------- State is required
        var properties = Options.StateDataFormat.Unprotect(state);

        if (properties == null)
        {
            return HandleRequestResults.InvalidState;
        }

So the problem is that I could not reuse Microsoft.AspNetCore.Authentication.Google.GoogleHandler for a mobile app that uses native components to authenticate users with Google.

Describe the solution you'd like

I would like to register GoogleHandler in Program.cs:

builder.Services.AddAuthentication(BearerTokenDefaults.AuthenticationScheme)
    .AddBearerToken()
    .AddGoogle(options =>
    {
        options.ClientId = "...";
        options.ClientSecret = "...";
        options.CallbackPath = "...";
    });

And then do something like this in my controller:

[Route("api/v1/account")]
[ApiController]
public class AccountController : ControllerBase
{
    // ...

    [HttpPost("login")]
    public ActionResult Login(LoginRequestDto loginRequest)
    {
        // Need some way to pass the code to the handler...
        HttpContext.Items["GoogleAuthenticationCode"] = loginRequest.Code;

        // Authenticate: do what OAuthHandler<TOptions>.HandleRemoteAuthenticateAsync would do on the callback with the code
        var authenticationResult = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
        if (!authenticationResult.Succeeded)
            throw new InvalidOperationException("Google authentication failed: " + authenticationResult.Failure?.Message, authenticationResult.Failure);

        // Principal from Google:
        var externalPrincipal = authenticationResult.Principal;

        // Login or register the user, create local principal object
        // var principal = ...

        // Sign in with BearerToken scheme (generate access and refresh tokens for the app)
        return new SignInResult(BearerTokenDefaults.AuthenticationScheme, principal);
    }
}

Alternative solution

To make the above code work, I tried the following. However I am not sure if this is the best approach. I do not like that I had to disable PKCE and duplicate some parts of the code from OAuthHandler<TOptions>.HandleRemoteAuthenticateAsync.

I derived a new handler from GoogleHandler and overrode one method. This allowed me to use it for both authenticating on the website and providing a login method for my mobile app.

public class NativeAppGoogleHandler : GoogleHandler
{
    public NativeAppGoogleHandler(IOptionsMonitor<GoogleOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
    { }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Override the method so that it works with native applications.
        // You can call it from the controller using HttpContext.AuthenticateAsync().
        // The implementation is based on the OAuthHandler<TOptions>.HandleRemoteAuthenticateAsync method.
        // The difference is that the "state" (and optional "code_verifier") is not provided by native applications, while in the base implementation it is required.

        var code = Context.Items["GoogleAuthenticationCode"] as string;
        if (code != null)
        {
            var properties = new AuthenticationProperties();
            var codeExchangeContext = new OAuthCodeExchangeContext(properties, code.ToString(), BuildRedirectUri(Options.CallbackPath));
            using var tokens = await ExchangeCodeAsync(codeExchangeContext);

            if (tokens.Error != null)
            {
                return HandleRequestResult.Fail(tokens.Error, properties);
            }

            if (string.IsNullOrEmpty(tokens.AccessToken))
            {
                return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
            }

            var identity = new ClaimsIdentity(ClaimsIssuer);
            var ticket = await CreateTicketAsync(identity, properties, tokens);

            if (ticket != null)
            {
                return HandleRequestResult.Success(ticket);
            }
            else
            {
                return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
            }
        }

        return await base.HandleAuthenticateAsync();
    }
}

Then I registered it in Program.cs:

builder.Services
    .AddAuthentication(BearerTokenDefaults.AuthenticationScheme)
    .AddBearerToken()
    .AddOAuth<GoogleOptions, NativeAppGoogleHandler>(GoogleDefaults.AuthenticationScheme, GoogleDefaults.DisplayName, options =>
    {
        options.ClientId = "...";
        options.ClientSecret = "...";
        options.CallbackPath = "...";
        options.UsePkce = false; // Disable PKCE because native apps do not provide "code_virifier" parameter
    });

It this a good enough approach, or are there better ones?

Additional context

Native UI for Sign in with Google:

mkArtakMSFT commented 2 months ago

Thanks for contacting us.

The GoogleAuth handler in ASP.NET Core is intended for handling authentication from the server, not from the client.

Is there an issue if you don't add the Google handler, and just use AddBearerToken, and have your native client try to get the auth token directly from Google? Here is what Google recommends about how to handle this: https://developers.google.com/identity/protocols/oauth2/native-app

kagudimov commented 2 months ago

In this case, the native client gets the auth token from Google and then retrieves the user ID and email from Google.

Next, it has to log in the user on the backend to make other requests on their behalf.

The client cannot send the plain user ID and email to log in the user with a BearerToken, because a modified client application could send an arbitrary user ID to the backend to impersonate a user.

Therefore, the native client must send the verifiable auth token to the backend. The backend then has to use the Google API to verify the token and obtain the user ID and email from Google on its side.

Actually, for this purpose, the mentioned plugin provides the auth code, which can be sent to the backend instead of the auth token.

With the auth code, the backend can securely exchange it for tokens and request user information. To do this the backend can use the Google API, but it would have to reimplement almost the exact same algorithm that is already implemented in GoogleHandler and OAuthHandler<TOptions>.

brockallen commented 1 month ago

Have you had a look at: IdentityModel.OidcClient?

mkArtakMSFT commented 1 month ago

Thanks @kagudimov. Our OAuthHandler implementation is not designed for your scenario.

Therefore, the native client must send the verifiable auth token to the backend. The backend then has to use the Google API to verify the token and obtain the user ID and email from Google on its side.

We would recommend to use JwtBearerHandler to validate the token.

kagudimov commented 1 month ago

@brockallen,

Interesting. The mentioned library can be used to provide a custom or browser-based UI. However, we already use Google SDKs for both Android and iOS to show native UI for authentication, which must feel more natural to users than a custom UI

kagudimov commented 1 month ago

Thanks for your comments.