diff --git a/TickAPI/TickAPI.Tests/Common/Auth/Services/GoogleAuthServiceTests.cs b/TickAPI/TickAPI.Tests/Common/Auth/Services/GoogleAuthServiceTests.cs index c9c3824..70835f9 100644 --- a/TickAPI/TickAPI.Tests/Common/Auth/Services/GoogleAuthServiceTests.cs +++ b/TickAPI/TickAPI.Tests/Common/Auth/Services/GoogleAuthServiceTests.cs @@ -1,44 +1,93 @@ -using Google.Apis.Auth; +using System.Net; using Microsoft.AspNetCore.Http; using Moq; +using System.Text.Json; using TickAPI.Common.Auth.Abstractions; +using TickAPI.Common.Auth.Responses; using TickAPI.Common.Auth.Services; -using TickAPI.Common.Result; namespace TickAPI.Tests.Common.Auth.Services; public class GoogleAuthServiceTests { + private readonly Mock _googleDataFetcherMock; + + public GoogleAuthServiceTests() + { + var validMessage = new HttpResponseMessage(HttpStatusCode.OK); + validMessage.Content = new StringContent(JsonSerializer.Serialize(new GoogleUserData("example@test.com", "Name", "Surname"))); + + var unauthorizedMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized); + + var wrongContentMessage = new HttpResponseMessage(HttpStatusCode.OK); + wrongContentMessage.Content = new StringContent("null"); + + _googleDataFetcherMock = new Mock(); + _googleDataFetcherMock + .Setup(m => m.FetchUserDataAsync("validToken")) + .ReturnsAsync(validMessage); + _googleDataFetcherMock + .Setup(m => m.FetchUserDataAsync("invalidToken")) + .ReturnsAsync(unauthorizedMessage); + _googleDataFetcherMock + .Setup(m => m.FetchUserDataAsync("nullToken")) + .ReturnsAsync(wrongContentMessage); + _googleDataFetcherMock + .Setup(m => m.FetchUserDataAsync("throwToken")) + .ThrowsAsync(new Exception("An exception occured")); + } + [Fact] - public async Task GetUserDataFromToken_WhenTokenValidatorReturnsPayload_ShouldReturnEmailFromPayload() + public async Task GetUserDataFromAccessToken_WhenDataFetcherReturnsValidResponse_ShouldReturnUserDataFromResponse() { - var googleTokenValidatorMock = new Mock(); - googleTokenValidatorMock - .Setup(m => m.ValidateAsync("validToken")) - .ReturnsAsync(new GoogleJsonWebSignature.Payload { Email = "example@test.com", GivenName = "First", FamilyName = "Last"}); - var sut = new GoogleAuthService(googleTokenValidatorMock.Object); + var sut = new GoogleAuthService(_googleDataFetcherMock.Object); - var result = await sut.GetUserDataFromToken("validToken"); + var result = await sut.GetUserDataFromAccessToken("validToken"); + Assert.NotNull(result); Assert.True(result.IsSuccess); - Assert.Equal("example@test.com", result.Value?.Email); - Assert.Equal("First", result.Value?.FirstName); - Assert.Equal("Last", result.Value?.LastName); + Assert.Equal("example@test.com", result.Value!.Email); + Assert.Equal("Name", result.Value!.GivenName); + Assert.Equal("Surname", result.Value!.FamilyName); } [Fact] - public async Task GetUserDataFromToken_WhenTokenValidatorThrowsException_ShouldReturnFailure() + public async Task + GetUserDataFromAccessToken_WhenDataFetcherReturnsResponseWithErrorStatusCode_ShouldReturnFailure() { - var googleTokenValidatorMock = new Mock(); - googleTokenValidatorMock - .Setup(m => m.ValidateAsync("invalidToken")) - .Throws(new InvalidJwtException("Invalid Google ID token")); - var sut = new GoogleAuthService(googleTokenValidatorMock.Object); + var sut = new GoogleAuthService(_googleDataFetcherMock.Object); - var result = await sut.GetUserDataFromToken("invalidToken"); + var result = await sut.GetUserDataFromAccessToken("invalidToken"); + Assert.NotNull(result); Assert.True(result.IsError); Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode); - Assert.Equal("Invalid Google ID token", result.ErrorMsg); + Assert.Equal("Invalid Google access token", result.ErrorMsg); + } + + [Fact] + public async Task GetUserDataFromAccessToken_WhenDataFetcherReturnsNullResponse_ShouldReturnFailure() + { + var sut = new GoogleAuthService(_googleDataFetcherMock.Object); + + var result = await sut.GetUserDataFromAccessToken("nullToken"); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal("Failed to parse Google user info", result.ErrorMsg); + } + + [Fact] + public async Task GetUserDataFromAccessToken_WhenDataFetcherThrowsAnException_ShouldReturnFailure() + { + var sut = new GoogleAuthService(_googleDataFetcherMock.Object); + + var result = await sut.GetUserDataFromAccessToken("throwToken"); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal($"Error fetching user data: An exception occured", result.ErrorMsg); } } \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs b/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs index 9c8079b..6be9918 100644 --- a/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs +++ b/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs @@ -20,11 +20,11 @@ public async Task GoogleLogin_WhenAuthSuccessAndCustomerExists_ShouldReturnToken { // Arrange const string email = "existing@test.com"; - const string idToken = "valid-google-token"; + const string accessToken = "valid-google-token"; const string jwtToken = "valid-jwt-token"; var googleAuthServiceMock = new Mock(); - googleAuthServiceMock.Setup(m => m.GetUserDataFromToken(idToken)) + googleAuthServiceMock.Setup(m => m.GetUserDataFromAccessToken(accessToken)) .ReturnsAsync(Result.Success(new GoogleUserData(email, "First", "Last"))); var customerServiceMock = new Mock(); @@ -41,7 +41,7 @@ public async Task GoogleLogin_WhenAuthSuccessAndCustomerExists_ShouldReturnToken customerServiceMock.Object); // Act - var actionResult = await sut.GoogleLogin(new GoogleLoginDto(idToken)); + var actionResult = await sut.GoogleLogin(new GoogleLoginDto(accessToken)); // Assert Assert.Equal(jwtToken, actionResult.Value?.Token); @@ -52,13 +52,13 @@ public async Task GoogleLogin_WhenAuthSuccessAndCustomerDoesNotExist_ShouldCreat { // Arrange const string email = "new@test.com"; - const string idToken = "valid-google-token"; + const string accessToken = "valid-google-token"; const string firstName = "First"; const string lastName = "Last"; const string jwtToken = "valid-jwt-token"; var googleAuthServiceMock = new Mock(); - googleAuthServiceMock.Setup(m => m.GetUserDataFromToken(idToken)) + googleAuthServiceMock.Setup(m => m.GetUserDataFromAccessToken(accessToken)) .ReturnsAsync(Result.Success(new GoogleUserData(email, "First", "Last"))); var customerServiceMock = new Mock(); @@ -83,7 +83,7 @@ public async Task GoogleLogin_WhenAuthSuccessAndCustomerDoesNotExist_ShouldCreat customerServiceMock.Object); // Act - var result = await sut.GoogleLogin(new GoogleLoginDto( idToken )); + var result = await sut.GoogleLogin(new GoogleLoginDto( accessToken )); // Assert Assert.Equal(jwtToken, result.Value?.Token); diff --git a/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleAuthService.cs b/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleAuthService.cs index 039e75c..3a3c5f5 100644 --- a/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleAuthService.cs +++ b/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleAuthService.cs @@ -5,5 +5,5 @@ namespace TickAPI.Common.Auth.Abstractions; public interface IGoogleAuthService { - Task> GetUserDataFromToken(string token); + Task> GetUserDataFromAccessToken(string accessToken); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleDataFetcher.cs b/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleDataFetcher.cs new file mode 100644 index 0000000..79086c1 --- /dev/null +++ b/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleDataFetcher.cs @@ -0,0 +1,9 @@ +using Google.Apis.Auth; +using TickAPI.Common.Result; + +namespace TickAPI.Common.Auth.Abstractions; + +public interface IGoogleDataFetcher +{ + Task FetchUserDataAsync(string accessToken); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleTokenValidator.cs b/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleTokenValidator.cs deleted file mode 100644 index f944e24..0000000 --- a/TickAPI/TickAPI/Common/Auth/Abstractions/IGoogleTokenValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Google.Apis.Auth; -using TickAPI.Common.Result; - -namespace TickAPI.Common.Auth.Abstractions; - -public interface IGoogleTokenValidator -{ - Task ValidateAsync(string idToken); -} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Controllers/AuthController.cs b/TickAPI/TickAPI/Common/Auth/Controllers/AuthController.cs deleted file mode 100644 index 6483535..0000000 --- a/TickAPI/TickAPI/Common/Auth/Controllers/AuthController.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using TickAPI.Common.Auth.Abstractions; -using TickAPI.Common.Auth.Enums; - -namespace TickAPI.Common.Auth.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class AuthController : ControllerBase -{ - private readonly IGoogleAuthService _googleAuthService; - private readonly IJwtService _jwtService; - - public AuthController(IGoogleAuthService googleAuthService, IJwtService jwtService) - { - _googleAuthService = googleAuthService; - _jwtService = jwtService; - } - - // TODO: this is a placeholder method that shows off the general structure of how logging in through Google works - // in the application. It should be replaced with appropriate login/register endpoints. - [HttpPost("google-login")] - public async Task GoogleLogin([FromBody] GoogleLoginRequest request) - { - var userDataResult = await _googleAuthService.GetUserDataFromToken(request.IdToken); - - if(userDataResult.IsError) - return StatusCode(userDataResult.StatusCode, userDataResult.ErrorMsg); - - var jwtTokenResult = _jwtService.GenerateJwtToken(userDataResult.Value?.Email, UserRole.Customer); - - if(jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); - - return Ok(new { token = jwtTokenResult.Value }); - } - - public class GoogleLoginRequest - { - public string IdToken { get; set; } - } -} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Responses/GoogleUserData.cs b/TickAPI/TickAPI/Common/Auth/Responses/GoogleUserData.cs index d317e07..9512e88 100644 --- a/TickAPI/TickAPI/Common/Auth/Responses/GoogleUserData.cs +++ b/TickAPI/TickAPI/Common/Auth/Responses/GoogleUserData.cs @@ -1,7 +1,9 @@ -namespace TickAPI.Common.Auth.Responses; +using System.Text.Json.Serialization; + +namespace TickAPI.Common.Auth.Responses; public record GoogleUserData( - string Email, - string FirstName, - string LastName + [property :JsonPropertyName("email")] string Email, + [property :JsonPropertyName("given_name")] string GivenName, + [property :JsonPropertyName("family_name")] string FamilyName ); \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Services/GoogleAuthService.cs b/TickAPI/TickAPI/Common/Auth/Services/GoogleAuthService.cs index b8fc876..a0f9602 100644 --- a/TickAPI/TickAPI/Common/Auth/Services/GoogleAuthService.cs +++ b/TickAPI/TickAPI/Common/Auth/Services/GoogleAuthService.cs @@ -1,4 +1,5 @@ -using TickAPI.Common.Auth.Abstractions; +using System.Text.Json; +using TickAPI.Common.Auth.Abstractions; using TickAPI.Common.Auth.Responses; using TickAPI.Common.Result; @@ -6,24 +7,37 @@ namespace TickAPI.Common.Auth.Services; public class GoogleAuthService : IGoogleAuthService { - private readonly IGoogleTokenValidator _googleTokenValidator; + private IGoogleDataFetcher _googleDataFetcher; - public GoogleAuthService(IGoogleTokenValidator googleTokenValidator) + public GoogleAuthService(IGoogleDataFetcher googleDataFetcher) { - _googleTokenValidator = googleTokenValidator; + _googleDataFetcher = googleDataFetcher; } - public async Task> GetUserDataFromToken(string idToken) + public async Task> GetUserDataFromAccessToken(string accessToken) { try { - var payload = await _googleTokenValidator.ValidateAsync(idToken); - var userData = new GoogleUserData(payload.Email, payload.GivenName, payload.FamilyName); - return Result.Success(userData); + var response = await _googleDataFetcher.FetchUserDataAsync(accessToken); + + if (!response.IsSuccessStatusCode) + { + return Result.Failure(StatusCodes.Status401Unauthorized, "Invalid Google access token"); + } + + var jsonResponse = await response.Content.ReadAsStringAsync(); + var userInfo = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions()); + + if (userInfo == null) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "Failed to parse Google user info"); + } + + return Result.Success(userInfo); } - catch (Exception) + catch (Exception ex) { - return Result.Failure(StatusCodes.Status401Unauthorized, "Invalid Google ID token"); + return Result.Failure(StatusCodes.Status500InternalServerError, $"Error fetching user data: {ex.Message}"); } } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Services/GoogleDataFetcher.cs b/TickAPI/TickAPI/Common/Auth/Services/GoogleDataFetcher.cs new file mode 100644 index 0000000..8e6ece3 --- /dev/null +++ b/TickAPI/TickAPI/Common/Auth/Services/GoogleDataFetcher.cs @@ -0,0 +1,27 @@ +using Google.Apis.Auth; +using TickAPI.Common.Auth.Abstractions; +using TickAPI.Common.Result; + +namespace TickAPI.Common.Auth.Services; + +public class GoogleDataFetcher : IGoogleDataFetcher +{ + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + + public GoogleDataFetcher(IConfiguration configuration, IHttpClientFactory httpClientFactory) + { + _configuration = configuration; + _httpClientFactory = httpClientFactory; + } + + public async Task FetchUserDataAsync(string accessToken) + { + var client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + + var response = await client.GetAsync(_configuration["Authentication:Google:UserInfoEndpoint"]); + + return response; + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Services/GoogleTokenValidator.cs b/TickAPI/TickAPI/Common/Auth/Services/GoogleTokenValidator.cs deleted file mode 100644 index a6bd4d4..0000000 --- a/TickAPI/TickAPI/Common/Auth/Services/GoogleTokenValidator.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Google.Apis.Auth; -using TickAPI.Common.Auth.Abstractions; - -namespace TickAPI.Common.Auth.Services; - -public class GoogleTokenValidator : IGoogleTokenValidator -{ - private readonly IConfiguration _configuration; - - public GoogleTokenValidator(IConfiguration configuration) - { - _configuration = configuration; - } - - public async Task ValidateAsync(string idToken) - { - return await GoogleJsonWebSignature.ValidateAsync(idToken, new GoogleJsonWebSignature.ValidationSettings - { - Audience = [_configuration["Authentication:Google:ClientId"]] - }); - } -} \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs b/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs index ea02b83..2a4d48a 100644 --- a/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs +++ b/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs @@ -27,7 +27,7 @@ public CustomerController(IGoogleAuthService googleAuthService, IJwtService jwtS [HttpPost("google-login")] public async Task> GoogleLogin([FromBody] GoogleLoginDto request) { - var userDataResult = await _googleAuthService.GetUserDataFromToken(request.IdToken); + var userDataResult = await _googleAuthService.GetUserDataFromAccessToken(request.AccessToken); if(userDataResult.IsError) return StatusCode(userDataResult.StatusCode, userDataResult.ErrorMsg); @@ -36,7 +36,7 @@ public async Task> GoogleLogin([FromBody] G var existingCustomerResult = await _customerService.GetCustomerByEmailAsync(userData.Email); if (existingCustomerResult.IsError) { - var newCustomerResult = await _customerService.CreateNewCustomerAsync(userData.Email, userData.FirstName, userData.LastName); + var newCustomerResult = await _customerService.CreateNewCustomerAsync(userData.Email, userData.GivenName, userData.FamilyName); if (newCustomerResult.IsError) return StatusCode(newCustomerResult.StatusCode, newCustomerResult.ErrorMsg); } diff --git a/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs index 043b7ce..4d9561e 100644 --- a/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs +++ b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs @@ -1,5 +1,5 @@ namespace TickAPI.Customers.DTOs.Request; public record GoogleLoginDto( - string IdToken + string AccessToken ); \ No newline at end of file diff --git a/TickAPI/TickAPI/Program.cs b/TickAPI/TickAPI/Program.cs index 0faecf6..36d62a5 100644 --- a/TickAPI/TickAPI/Program.cs +++ b/TickAPI/TickAPI/Program.cs @@ -94,7 +94,7 @@ // Add common services. builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -148,6 +148,9 @@ // Setup healtcheck builder.Services.AddHealthChecks().AddSqlServer(connectionString: builder.Configuration.GetConnectionString("ResellioDatabase") ?? ""); +// Add http client +builder.Services.AddHttpClient(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/TickAPI/TickAPI/appsettings.example.json b/TickAPI/TickAPI/appsettings.example.json index d24bd13..9070fd3 100644 --- a/TickAPI/TickAPI/appsettings.example.json +++ b/TickAPI/TickAPI/appsettings.example.json @@ -16,7 +16,8 @@ "Authentication": { "Google": { "ClientId": "your-google-client-id-here", - "ClientSecret": "your-google-client-secret-here" + "ClientSecret": "your-google-client-secret-here", + "UserInfoEndpoint" : "https://www.googleapis.com/oauth2/v3/userinfo" }, "Jwt": { "Issuer": "your-api-issuer-here",