From 19a4da93827c7325ef13c65eaf8a5aa67bf96731 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 28 Mar 2026 14:24:10 +0100 Subject: [PATCH] Auto-link verified Google sign-ins by email --- .../AuthAndSystemControllerTests.cs | 89 +++++++++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 12 +++ .../Services/GoogleTokenValidator.cs | 10 ++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 29b3ef2..b2e3e61 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -4,10 +4,13 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; +using System.Collections; +using System.Linq.Expressions; using Xunit; namespace JobTrackerApi.Tests; @@ -65,6 +68,41 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("Email delivery unavailable", details.Title); } + [Fact] + public async Task Exchange_google_token_auto_links_single_verified_email_match() + { + var user = new ApplicationUser + { + Id = "user-1", + Email = "dj@cesnimda.co.uk", + UserName = "dj@cesnimda.co.uk", + }; + + var userManager = CreateUserManager(); + userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); + userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + + var googleValidator = new Mock(); + googleValidator + .Setup(x => x.ValidateAsync("google-token", It.IsAny())) + .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); + + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, NullLogger.Instance); + + var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.Equal("app-token", payload.AccessToken); + Assert.Equal("google-subject", user.GoogleSubject); + Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail); + Assert.NotNull(user.GoogleLinkedAt); + } + [Fact] public void Me_result_includes_google_link_details_for_local_users() { @@ -132,6 +170,57 @@ public sealed class AuthAndSystemControllerTests ); } + private sealed class TestAsyncQueryProvider : IAsyncQueryProvider + { + private readonly IQueryProvider _inner; + + public TestAsyncQueryProvider(IQueryProvider inner) + { + _inner = inner; + } + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + public object? Execute(Expression expression) => _inner.Execute(expression); + public TResult Execute(Expression expression) => _inner.Execute(expression); + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + => (TResult)typeof(Task) + .GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(typeof(TResult).GetGenericArguments()[0]) + .Invoke(null, new[] { Execute(expression) })!; + } + + private sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable + { + public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } + public TestAsyncEnumerable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); + } + + private sealed class TestAsyncEnumerator : IAsyncEnumerator + { + private readonly IEnumerator _inner; + + public TestAsyncEnumerator(IEnumerator inner) + { + _inner = inner; + } + + public T Current => _inner.Current; + + public ValueTask DisposeAsync() + { + _inner.Dispose(); + return ValueTask.CompletedTask; + } + + public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); + } + private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment { public string EnvironmentName { get; set; } = "Test"; diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index e429f32..11f6cb6 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -135,6 +135,15 @@ public sealed class AuthController : ControllerBase x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken); + if (user is null && google.EmailVerified && !string.IsNullOrWhiteSpace(google.Email)) + { + user = await _users.FindByEmailAsync(google.Email); + if (user is not null) + { + _logger.LogInformation("Auto-linking Google sign-in for existing local account {Email}", google.Email); + } + } + if (user is null) { return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); @@ -145,6 +154,9 @@ public sealed class AuthController : ControllerBase user.GoogleSubject = google.Subject; user.GoogleEmail = google.Email; user.GoogleLinkedAt ??= DateTimeOffset.UtcNow; + user.DisplayName ??= TrimOrNull(google.Name); + user.FirstName ??= TrimOrNull(google.GivenName); + user.LastName ??= TrimOrNull(google.FamilyName); await _users.UpdateAsync(user); } diff --git a/JobTrackerApi/Services/GoogleTokenValidator.cs b/JobTrackerApi/Services/GoogleTokenValidator.cs index 07cfd6c..538f64f 100644 --- a/JobTrackerApi/Services/GoogleTokenValidator.cs +++ b/JobTrackerApi/Services/GoogleTokenValidator.cs @@ -5,7 +5,7 @@ using Microsoft.IdentityModel.Tokens; namespace JobTrackerApi.Services; -public sealed record GoogleTokenPrincipal(string Subject, string? Email, string? GivenName, string? FamilyName, string? Name); +public sealed record GoogleTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name); public interface IGoogleTokenValidator { @@ -56,9 +56,17 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator return new GoogleTokenPrincipal( Subject: subject, Email: principal.FindFirst("email")?.Value?.Trim(), + EmailVerified: IsEmailVerified(principal), GivenName: principal.FindFirst("given_name")?.Value?.Trim(), FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), Name: principal.FindFirst("name")?.Value?.Trim() ); } + + private static bool IsEmailVerified(System.Security.Claims.ClaimsPrincipal principal) + { + var raw = principal.FindFirst("email_verified")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(raw)) return false; + return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1"; + } }