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.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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user