Auto-link verified Google sign-ins by email
This commit is contained in:
@@ -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<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]
|
||||
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
|
||||
{
|
||||
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),
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user