Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 0 additions & 102 deletions src/Orbit.Api/Controllers/OAuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Orbit.Api.Extensions;
using Orbit.Api.OAuth;
using Orbit.Api.RateLimiting;
using Orbit.Application.Auth.Commands;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;
Expand All @@ -25,9 +22,7 @@ public partial class OAuthController(
IMediator mediator,
OAuthAuthorizationStore authStore,
IGenericRepository<ApiKey> apiKeyRepository,
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork,
IHttpClientFactory httpClientFactory,
IOptions<GoogleSettings> googleSettings,
IConfiguration configuration,
ILogger<OAuthController> logger) : ControllerBase
Expand Down Expand Up @@ -309,103 +304,6 @@ private string BuildGoogleRedirectUri()
}
#pragma warning restore S6932

public record GoogleAuthRequest(
string Credential,
string State, string CodeChallenge, string RedirectUri, string ClientId,
string? Nonce = null);

[HttpPost("/oauth/google")]
[Obsolete]
[DistributedRateLimit("auth")]
public async Task<IActionResult> GoogleAuth([FromBody] GoogleAuthRequest request, CancellationToken ct)
{
if (!IsRedirectUriAllowed(request.RedirectUri))
return BadRequest(new { error = InvalidRedirectUriError });

if (string.IsNullOrEmpty(request.State))
return BadRequest(new { error = MissingStateError });

var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(
$"https://oauth2.googleapis.com/tokeninfo?id_token={Uri.EscapeDataString(request.Credential)}", ct);

if (!response.IsSuccessStatusCode)
return BadRequest(ErrorMessages.InvalidGoogleToken.ToErrorBody());

var json = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;

var rawEmail = root.TryGetProperty("email", out var emailProp) ? emailProp.GetString() : null;
if (string.IsNullOrEmpty(rawEmail))
return BadRequest(ErrorMessages.GoogleEmailUnavailable.ToErrorBody());

var email = rawEmail.Trim().ToLowerInvariant();

var tokenAud = root.TryGetProperty("aud", out var audProp) ? audProp.GetString() : null;
var expectedClientId = googleSettings.Value.ClientId;
if (!string.IsNullOrEmpty(expectedClientId) && tokenAud != expectedClientId)
return BadRequest(ErrorMessages.GoogleTokenAudienceMismatch.ToErrorBody());

var name = root.TryGetProperty("name", out var nameProp) && nameProp.GetString() is string n
? n : email.Split('@')[0];

var userResult = await FindOrCreateGoogleUserAsync(email, name, ct);
if (userResult.IsFailure)
return userResult.ToErrorResult();

var user = userResult.Value;

if (user.IsDeactivated)
{
await ConcurrencyRetry.SaveWithRetryAsync(
unitOfWork,
async c =>
{
var tracked = await userRepository.FindOneTrackedIgnoringFiltersAsync(u => u.Id == user.Id, c);
tracked?.CancelDeactivation();
},
ct);
}

var authCode = authStore.CreateCode(
user.Id, request.CodeChallenge, request.RedirectUri, request.ClientId, request.Nonce);

var separator = request.RedirectUri.Contains('?') ? "&" : "?";
var redirectUrl = $"{request.RedirectUri}{separator}code={Uri.EscapeDataString(authCode)}&state={Uri.EscapeDataString(request.State)}";

return Ok(new { redirectUrl });
}

private async Task<Result<Domain.Entities.User>> FindOrCreateGoogleUserAsync(string email, string name, CancellationToken ct)
{
var existing = await userRepository.FindOneTrackedIgnoringFiltersAsync(u => u.Email == email, ct);
if (existing is not null)
return Result.Success(existing);

var createResult = Domain.Entities.User.Create(name, email);
if (createResult.IsFailure)
return createResult;

var user = createResult.Value;
await userRepository.AddAsync(user, ct);

try
{
await unitOfWork.SaveChangesAsync(ct);
}
catch (DbUpdateException exception) when (DbUniqueViolation.IsUniqueViolation(exception))
{
var raced = await userRepository.FindOneTrackedIgnoringFiltersAsync(u => u.Email == email, ct);
if (raced is null)
throw;

return Result.Success(raced);
}

return Result.Success(user);
}

[HttpPost("/oauth/token")]
[DistributedRateLimit("auth")]
[Consumes("application/x-www-form-urlencoded")]
Expand Down
33 changes: 0 additions & 33 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9507,39 +9507,6 @@
}
}
},
"/oauth/google": {
"post": {
"tags": [
"OAuth"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GoogleAuthRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/GoogleAuthRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/GoogleAuthRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK"
}
},
"deprecated": true
}
},
"/oauth/token": {
"post": {
"tags": [
Expand Down
1 change: 0 additions & 1 deletion src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ public static class ErrorCodes
public const string PlayNotificationVerificationFailed = "PLAY_NOTIFICATION_VERIFICATION_FAILED";
public const string CodeRequestCooldown = "CODE_REQUEST_COOLDOWN";
public const string GoogleEmailUnavailable = "GOOGLE_EMAIL_UNAVAILABLE";
public const string GoogleTokenAudienceMismatch = "GOOGLE_TOKEN_AUDIENCE_MISMATCH";
public const string TemplateNotFound = "TEMPLATE_NOT_FOUND";
public const string SuggestionNotFound = "SUGGESTION_NOT_FOUND";
public const string PushEndpointInvalid = "PUSH_ENDPOINT_INVALID";
Expand Down
1 change: 0 additions & 1 deletion src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ public static class ErrorMessages
public static readonly AppError GoogleRedirectUriNotAllowed = new(ErrorCodes.GoogleRedirectUriNotAllowed, "Google sign-in redirect URI is not allowed");
public static readonly AppError GoogleCodeExchangeFailed = new(ErrorCodes.GoogleCodeExchangeFailed, "Could not exchange Google sign-in code");
public static readonly AppError GoogleEmailUnavailable = new(ErrorCodes.GoogleEmailUnavailable, "Could not retrieve email from Google account");
public static readonly AppError GoogleTokenAudienceMismatch = new(ErrorCodes.GoogleTokenAudienceMismatch, "Google token was not issued for this application");
public static readonly AppError TemplateNotFound = new(ErrorCodes.TemplateNotFound, "Template not found.");
public static readonly AppError SuggestionNotFound = new(ErrorCodes.SuggestionNotFound, "Suggestion not found.");
public static readonly AppError PushEndpointInvalid = new(ErrorCodes.PushEndpointInvalid, "Push subscription endpoint must be an absolute https:// URL.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,6 @@ private static AgentCapability[] AccountAndAuthCapabilities()
"OAuthController.Authorize",
"OAuthController.SendCode",
"OAuthController.VerifyCode",
"OAuthController.GoogleAuth",
"OAuthController.GoogleStart",
"OAuthController.GoogleCallback",
"OAuthController.Token",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
Expand All @@ -31,9 +32,7 @@ public class OAuthControllerTests : IDisposable
private readonly MutableTimeProvider _timeProvider = new();
private readonly OAuthAuthorizationStore _authStore;
private readonly IGenericRepository<ApiKey> _apiKeyRepo = Substitute.For<IGenericRepository<ApiKey>>();
private readonly IGenericRepository<User> _userRepo = Substitute.For<IGenericRepository<User>>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
private readonly ILogger<OAuthController> _logger = Substitute.For<ILogger<OAuthController>>();
private readonly OAuthController _controller;

Expand All @@ -56,8 +55,7 @@ public OAuthControllerTests()
.Build();

_controller = new OAuthController(
_mediator, _authStore, _apiKeyRepo, _userRepo, _unitOfWork, _httpClientFactory,
googleSettings, config, _logger);
_mediator, _authStore, _apiKeyRepo, _unitOfWork, googleSettings, config, _logger);

var httpContext = new DefaultHttpContext();
httpContext.Request.Scheme = "https";
Expand Down Expand Up @@ -313,7 +311,7 @@ public void GoogleStart_MissingConfigurationReturnsUnavailable(string clientId,
AllowedRedirectUris = callbackAllowed ? ["https://api.useorbit.org/oauth/google/callback"] : []
});
var controller = new OAuthController(_mediator, _authStore, _apiKeyRepo,
_userRepo, _unitOfWork, _httpClientFactory, settings, new ConfigurationBuilder().Build(), _logger)
_unitOfWork, settings, new ConfigurationBuilder().Build(), _logger)
{
ControllerContext = _controller.ControllerContext
};
Expand Down Expand Up @@ -360,14 +358,18 @@ public async Task GoogleCallback_RejectsStoredRedirectUriThatIsNoLongerAllowed()
}

[Fact]
public void GoogleAuth_RouteRemainsAvailableAndDeprecated()
public void GoogleAuth_OneTapRoute_IsGoneSoAPostTo_OauthGoogle_Returns404()
{
var route = typeof(OAuthController).GetMethods()
.SingleOrDefault(method => method.GetCustomAttributes(typeof(HttpPostAttribute), false)
.Cast<HttpPostAttribute>().Any(attribute => attribute.Template == "/oauth/google"));
var templates = typeof(OAuthController).Assembly.GetTypes()
.Where(type => type.IsClass && !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
.SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public))
.SelectMany(method => method.GetCustomAttributes(typeof(HttpMethodAttribute), false)
.Cast<HttpMethodAttribute>())
.Select(attribute => attribute.Template)
.ToList();

route.Should().NotBeNull();
route!.GetCustomAttributes(typeof(ObsoleteAttribute), false).Should().ContainSingle();
templates.Should().NotContain("/oauth/google");
templates.Should().Contain("/oauth/google/start").And.Contain("/oauth/google/callback");
}

[Fact]
Expand Down
Loading