How to handle OIDC login as part of existing solution?

Viewed 204

We have existing ASP.NET Core 5 WebAPI project that is not using Identity. When a user sends login credentials we issue JWT token and return it as response body. It works well.

Now we should add an ability to login using OIDC (for example Google).

I managed to send user to Identity provider and redirect them back to our backend. Here I have all information needed about that user. I would use that data to check if user is already registred in our database and if not, I would create new user. Next step would be creation of our JWT and returning it to frontend.

This part I don't know how to solve. I think I need some info from frontend to know where to send new token. Any sugestions on how to return new token to frontend app?

Controller

/// <summary>
/// The controller for handling external user related request.
/// </summary>
/// <seealso cref="Microsoft.AspNetCore.Mvc.ControllerBase" />
[ApiController]
[Route("[controller]")]
public class ExternalUserController : ControllerBase
{
    private readonly IUserService _userService;

    /// <summary>
    /// Initializes a new instance of the <see cref="ExternalUserController"/> class.
    /// </summary>
    /// <param name="userService">The user service.</param>
    public ExternalUserController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet]
    public async Task Login(string returnUrl = "/ExternalUser/token")
    {
        await HttpContext.ChallengeAsync("oidc", new AuthenticationProperties
        {
            RedirectUri = returnUrl
        });
    }
    
    [Authorize(AuthenticationSchemes= "oidc")]
    [HttpGet("token")]
    public async Task<ActionResult> GetToken()
    {
        if (User.Identity.IsAuthenticated)
        {                
            var nameIdentifier = User.FindFirst(ClaimTypes.NameIdentifier);
            var name = User.FindFirst(ClaimTypes.Name);
            var givenName = User.FindFirst(ClaimTypes.GivenName);
            var surname = User.FindFirst(ClaimTypes.Surname);
            var email = User.FindFirst(ClaimTypes.Email);
            var mobilePhone = User.FindFirst(ClaimTypes.MobilePhone);
            var authenticationMethod = User.FindFirst(ClaimTypes.AuthenticationMethod);
            var emails = User.FindFirst("emails");

            // Register user if not present and issue new JWT.
            // How to return this to specific frontend URL?
            return Ok("NEW JWT");
        }
        return Ok();
        
    }
}

In ConfigureServices

.AddOpenIdConnect("oidc", options =>
                {
                    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

                    options.Authority = "https://accounts.google.com";
                    options.RequireHttpsMetadata = false;

                    options.ClientId = "MY CLIENT ID";
                    options.ClientSecret = "MY SECRET";
                    options.ResponseType = $"{OpenIdConnectParameterNames.Code} {OpenIdConnectParameterNames.IdToken}";

                    options.SaveTokens = true;
                    options.GetClaimsFromUserInfoEndpoint = true;

                    options.Scope.Add("openid");
                    options.Scope.Add("profile");
                    options.Scope.Add("email");

                    options.Events = new OpenIdConnectEvents()
                    {
                        OnTokenValidated = async y =>
                        {
                            await Task.FromResult(0);
                        }
                    };
                })
                .AddCookie();
2 Answers

If your frontend does not need the access token directly you can tie the JWT to a session and then use it in any subsequent request.

If you need the token on the frontend, then why you just can't send the JWT in response to the request to your /token endpoint? It should be your frontend who is calling the /token endpoint anyway (if that happens on frontend, if it happens on backend, then what I wrote in the first sentence is true).

I solved it like this:

In Startup.cs > ConfigureServices

services
.AddAuthentication(configuration =>
{
    configuration.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    configuration.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(configuration =>
{
    configuration.Events = new JwtBearerEvents
    {
        OnTokenValidated = context =>
        {
            var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();
            var userId = context.Principal?.Identity?.Name;
            if (userId == null)
            {
                context.Fail("Unauthorized");
                return Task.CompletedTask;
            }

            var user = db.Users.AsNoTracking().Include(x => x.Role)
                .FirstOrDefault(x => x.Id == Guid.Parse(userId));

            if (user == null)
            {
                context.Fail("Unauthorized");
                return Task.CompletedTask;
            }

            if (user.RoleId != null)
            {
                var identity = context.Principal?.Identity as ClaimsIdentity;
                identity?.AddClaim(new Claim(ClaimTypes.Role, user.Role.Name));
            }

            return Task.CompletedTask;
        }
    };
    configuration.RequireHttpsMetadata = false;
    configuration.SaveToken = true;
    configuration.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = false,
        ValidateAudience = false
    };
})
.AddOpenIdConnect("oidc-google", options =>
{
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Authority = "https://accounts.google.com";
    options.RequireHttpsMetadata = false;
    options.ClientId = _appSettings.OidcGoogleClientId;
    options.ClientSecret = _appSettings.OidcGoogleClientSecret;
    options.ResponseType = $"{OpenIdConnectParameterNames.Code} {OpenIdConnectParameterNames.IdToken}";
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
})
.AddCookie();

In controller

/// <summary>
/// Authenticates the user with Google account. Must be called from web browser!
/// </summary>
/// <returns></returns>
[HttpGet("loginWithGoogle")]
public async Task LoginWithGoogle()
{
    await HttpContext.ChallengeAsync("oidc-google", new AuthenticationProperties
    {
        RedirectUri = "/ExternalUser/loginWithGoogleToken"
    });
}

/// <summary>
/// Returns new authentication token if user is successfully authenticated with Google.
/// </summary>
/// <returns>The authentication token.</returns>
[Authorize(AuthenticationSchemes = "oidc-google")]
[HttpGet("loginWithGoogleToken")]
public async Task<ActionResult<DtoAuth.ResponseDto>> LoginWithGoogleToken()
{
    if (!User.Identity.IsAuthenticated) return BadRequest();

    var nameIdentifier = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var givenName = User.FindFirst(ClaimTypes.GivenName)?.Value;
    var familyName = User.FindFirst(ClaimTypes.Surname)?.Value;
    var email = User.FindFirst(ClaimTypes.Email)?.Value;
    var dto = await _userService.AuthenticateExternalAsync("Google", nameIdentifier, email, givenName,
        familyName);
    return Ok(dto);
}

In UserService

/// <inheritdoc />
public async Task<Dto.AuthenticateAsync.ResponseDto> AuthenticateExternalAsync(string externalIdentityProvider,
    string externalId, string email, string givenName, string familyName)
{
    var user = await _db.Users.SingleOrDefaultAsync(x =>
        x.ExternalIdentityProvider == externalIdentityProvider && x.ExternalId == externalId);
    var now = DateTime.UtcNow;

    // Create a user if doesn't exist.
    if (user == null)
    {
        user = new User
        {
            Id = Guid.NewGuid(),
            ExternalId = externalId,
            ExternalIdentityProvider = externalIdentityProvider,
            Username = $"{externalId}|{externalIdentityProvider}",
            CreatedAt = now,
            IsActive = true
        };
        await _db.Users.AddAsync(user);
    }

    user.GivenName = givenName;
    user.FamilyName = familyName;
    user.Email = email;
    user.UpdatedAt = now;

    // Authentication successful.
    user.LoginFailedCount = 0;
    user.LoginFailedAt = null;
    user.LastLoginAt = now;
    await _db.SaveChangesAsync();

    return new Dto.AuthenticateAsync.ResponseDto
    {
        Id = user.Id,
        Username = user.Username,
        GivenName = user.GivenName,
        FamilyName = user.FamilyName,
        Email = user.Email,
        Token = CreateToken(user.Id.ToString())
    };
}
  • User gets redirected in web browser to Google
  • User continues with login on Google domain
  • After login user is redirected back to our backend
  • We check if user is already in our db (if not we add him)
  • We create our own token and send it to user
Related