Ryan Langton
Ryan Langton

Reputation: 6160

How use an id_token returned from google auth to access server API's

I have a mobile & server side (web w/ API) application that uses Identity to authenticate the user. The mobile app opens a WebView on the server, once the user successfully logs in, the mobile app is re-opened with an access token sent back from the server. It can then utilize API's on the server side that require Bearer token authentication.

This pattern is explained here.

enter image description here

When using the mobile app, the Bearer token works if the user logged in via user/pass. If the user logged in via google, the API returns a 401 (access denied) error. Here is my auth setup code:

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
    options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ApplicationScheme;
})
// I tried .AddGoogle here previously, but this is the only way I've been able to return an id_token. The access_token is for google API's, not internal API's
.AddOpenIdConnect(
    GoogleDefaults.AuthenticationScheme,
    GoogleDefaults.DisplayName, o =>
{
    var googleAuthNSection = configuration.GetSection("Authentication:Google");
    o.SignInScheme = IdentityConstants.ExternalScheme;
    o.Authority = "https://accounts.google.com";
    o.ClientId = googleAuthNSection["ClientId"]!;
    o.ClientSecret = googleAuthNSection["ClientSecret"]!;
    o.ResponseType = OpenIdConnectResponseType.IdToken;
    o.CallbackPath = "/signin-google";
    o.SaveTokens = true;
    o.Scope.Add("email");
})
.AddCookie(IdentityConstants.ApplicationScheme, o =>
{
    o.LoginPath = new PathString("/Account/Login");
    o.Events = new CookieAuthenticationEvents
    {
        OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
    };
})
.AddBearerToken(IdentityConstants.BearerScheme, o =>
{
    o.BearerTokenExpiration = TimeSpan.FromHours(5);
})
.AddExternalCookie();

Here is the code that acquires the bearer token if the user logs in with a user/pass. Notice I post to the /login endpoint to get the bearer token with the current users email/password since I have it accessible here. When logging on with google I won't have a email/password so cannot re-authenticate to an identity endpoint to get a bearer token.

    var body = new { email, password };
    var content = JsonSerializer.Serialize(body);
    var buffer = Encoding.UTF8.GetBytes(content);
    var byteContent = new ByteArrayContent(buffer);
    byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
    var client = httpClientFactory.CreateClient(MealPlanApi.ApiClient);
    var loginResponse = await client.PostAsync("/login", byteContent);
    var contents = await loginResponse.Content.ReadAsStringAsync();
    var doc = JsonSerializer.Deserialize<JsonElement>(contents);
    var accessToken = doc.GetProperty("accessToken").GetString()!;
    await RedirectToMobileAuthenticatedAsync(context, user, accessToken, returnUrl);

Here is the code that logs in 3rd party auth and sends the token back to the mobile app. The user is obviously authenticated, but the access token acquired here does not work for accessing the APIs.

[Route("mobileauth")]
[ApiController]
public class AuthController : ControllerBase
{
    const string callbackScheme = "MyCallbackScheme";

    [HttpGet("{scheme}")] // eg: Microsoft, Facebook, Apple, etc
    public async Task Get([FromRoute] string scheme)
    {
        var auth = await Request.HttpContext.AuthenticateAsync(scheme);

        if (!auth.Succeeded
            || auth?.Principal == null
            || !auth.Principal.Identities.Any(id => id.IsAuthenticated)
            || string.IsNullOrEmpty(auth.Properties.GetTokenValue("access_token")))
        {
            // Not authenticated, challenge
            await Request.HttpContext.ChallengeAsync(scheme);
        }
        else
        {
            var claims = auth.Principal.Identities.FirstOrDefault()?.Claims;
            var email = string.Empty;
            email = claims?.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;

            // Get parameters to send back to the callback
            var qs = new Dictionary<string, string>
            {
                { "access_token", auth.Properties.GetTokenValue("access_token") },
                { "refresh_token", auth.Properties.GetTokenValue("refresh_token") ?? string.Empty },
                { "expires", (auth.Properties.ExpiresUtc?.ToUnixTimeSeconds() ?? -1).ToString() },
                { "email", email }
            };

            // Build the result url
            var url = callbackScheme + "://#" + string.Join(
                "&",
                qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value) && kvp.Value != "-1")
                .Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));

            // Redirect to final url
            Request.HttpContext.Response.Redirect(url);
        }
    }
}

Is this perhaps a problem with how I'm securing and using the endpoints?

In Program Main

app.MapControllers();

Controller

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Identity.Bearer,Identity.External,Google")]
[ApiController]
public class MyController() : ControllerBase

Here is my response from HttpContext.AuthenticateAsync("Google")

enter image description here

Notice I am authenticated. The current user can access resources on the web. However, if I pass this access token or the ID token to the mobile app, it is not useable for the API.

Upvotes: 1

Views: 171

Answers (1)

Ryan Langton
Ryan Langton

Reputation: 6160

.AddOpenIdConnect is needed for the web (to authenticate)

.AddJwtBearer is needed for the API (to give access to the APIs)

If these are two separate applications these would go in the two separate startups. In my case, the web app and the API are running in the same service, so these go together.

.AddOpenIdConnect(
    GoogleDefaults.AuthenticationScheme,
    GoogleDefaults.DisplayName, o =>
{
    var googleAuthNSection = configuration.GetSection("Authentication:Google");
    o.SignInScheme = IdentityConstants.ExternalScheme;
    o.Authority = "https://accounts.google.com";
    o.ClientId = googleAuthNSection["ClientId"]!;
    o.ClientSecret = googleAuthNSection["ClientSecret"]!;
    o.ResponseType = OpenIdConnectResponseType.CodeToken;
    o.CallbackPath = "/signin-google";
    o.SaveTokens = true;
    o.Scope.Add("email");
})
.AddJwtBearer(o =>
{
    var googleAuthNSection = configuration.GetSection("Authentication:Google");
    var googleIssuer = "https://accounts.google.com";
    var googleClientId = googleAuthNSection["ClientId"]!;
    o.Authority = googleIssuer;
    o.Audience = googleClientId;
    o.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = false,
        ValidateIssuerSigningKey = false,
        ValidIssuer = googleIssuer,
        ValidAudience = googleClientId,
    };
})

The API is then secured with the "Bearer" scheme

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
[ApiController]
public class MyController : ControllerBase

The access token is the id_token returned from OAuth with the callback

private async Task OnLoginCallbackAsync()
{
    // Sign in the user with this external login provider if the user already has a login.
    var result = await SignInManager.ExternalLoginSignInAsync(
        externalLoginInfo.LoginProvider,
        externalLoginInfo.ProviderKey,
        isPersistent: true,
        bypassTwoFactor: true);

    var email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
    if (result.Succeeded)
    {
        Logger.LogInformation(
            "{Name} logged in with {LoginProvider} provider.",
            externalLoginInfo.Principal.Identity?.Name,
            externalLoginInfo.LoginProvider);

        var auth = await HttpContext.AuthenticateAsync(externalLoginInfo.LoginProvider);
        var token = auth.Properties.GetTokenValue("id_token");
        // token can be passed back to the mobile app

Upvotes: 1

Related Questions