Auto-link verified Google sign-ins by email

This commit is contained in:
2026-03-28 14:24:10 +01:00
parent 9f949ee9df
commit 19a4da9382
3 changed files with 110 additions and 1 deletions
@@ -4,10 +4,13 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
using System.Collections;
using System.Linq.Expressions;
using Xunit; using Xunit;
namespace JobTrackerApi.Tests; namespace JobTrackerApi.Tests;
@@ -65,6 +68,41 @@ public sealed class AuthAndSystemControllerTests
Assert.Equal("Email delivery unavailable", details.Title); 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<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.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<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance);
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<AuthController.AuthResult>(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] [Fact]
public void Me_result_includes_google_link_details_for_local_users() public void Me_result_includes_google_link_details_for_local_users()
{ {
@@ -132,6 +170,57 @@ public sealed class AuthAndSystemControllerTests
); );
} }
private sealed class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
{
private readonly IQueryProvider _inner;
public TestAsyncQueryProvider(IQueryProvider inner)
{
_inner = inner;
}
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new TestAsyncEnumerable<TElement>(expression);
public object? Execute(Expression expression) => _inner.Execute(expression);
public TResult Execute<TResult>(Expression expression) => _inner.Execute<TResult>(expression);
public TResult ExecuteAsync<TResult>(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<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
{
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
public TestAsyncEnumerable(Expression expression) : base(expression) { }
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
=> new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
}
private sealed class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
{
private readonly IEnumerator<T> _inner;
public TestAsyncEnumerator(IEnumerator<T> inner)
{
_inner = inner;
}
public T Current => _inner.Current;
public ValueTask DisposeAsync()
{
_inner.Dispose();
return ValueTask.CompletedTask;
}
public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext());
}
private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment
{ {
public string EnvironmentName { get; set; } = "Test"; public string EnvironmentName { get; set; } = "Test";
@@ -135,6 +135,15 @@ public sealed class AuthController : ControllerBase
x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email),
cancellationToken); 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) if (user is null)
{ {
return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); 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.GoogleSubject = google.Subject;
user.GoogleEmail = google.Email; user.GoogleEmail = google.Email;
user.GoogleLinkedAt ??= DateTimeOffset.UtcNow; user.GoogleLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(google.Name);
user.FirstName ??= TrimOrNull(google.GivenName);
user.LastName ??= TrimOrNull(google.FamilyName);
await _users.UpdateAsync(user); await _users.UpdateAsync(user);
} }
@@ -5,7 +5,7 @@ using Microsoft.IdentityModel.Tokens;
namespace JobTrackerApi.Services; 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 public interface IGoogleTokenValidator
{ {
@@ -56,9 +56,17 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator
return new GoogleTokenPrincipal( return new GoogleTokenPrincipal(
Subject: subject, Subject: subject,
Email: principal.FindFirst("email")?.Value?.Trim(), Email: principal.FindFirst("email")?.Value?.Trim(),
EmailVerified: IsEmailVerified(principal),
GivenName: principal.FindFirst("given_name")?.Value?.Trim(), GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
Name: principal.FindFirst("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";
}
} }