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
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Exceptionless.Core.Jobs.WorkItemHandlers;
using Exceptionless.Web.Hubs;
using Exceptionless.Web.Mapping;
using Exceptionless.Web.Security;
using Foundatio.Extensions.Hosting.Startup;
using Foundatio.Jobs;
using Foundatio.Messaging;
Expand All @@ -16,6 +17,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton<MessageBusBroker>();

services.AddSingleton<ApiMapper>();
services.AddSingleton<IOAuthProviderClient, OAuthProviderClient>();

Core.Bootstrapper.RegisterServices(services, appOptions);
Insulation.Bootstrapper.RegisterServices(services, appOptions, appOptions.RunJobsInProcess);
Expand Down
44 changes: 10 additions & 34 deletions src/Exceptionless.Web/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,13 @@
using Exceptionless.DateTimeExtensions;
using Exceptionless.Web.Extensions;
using Exceptionless.Web.Models;
using Exceptionless.Web.Security;
using Foundatio.Caching;
using Foundatio.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.IdentityModel.Tokens;
using OAuth2.Client;
using OAuth2.Client.Impl;
using OAuth2.Configuration;
using OAuth2.Infrastructure;
using OAuth2.Models;

namespace Exceptionless.Web.Controllers;
Expand All @@ -36,6 +33,7 @@ public class AuthController : ExceptionlessApiController
private readonly IUserRepository _userRepository;
private readonly ITokenRepository _tokenRepository;
private readonly IOAuthTokenRepository _oauthTokenRepository;
private readonly IOAuthProviderClient _oauthProviderClient;
private readonly ScopedCacheClient _cache;
private readonly IMailer _mailer;
private readonly ILogger _logger;
Expand All @@ -44,7 +42,7 @@ public class AuthController : ExceptionlessApiController
private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60);

public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions, IOrganizationRepository organizationRepository, IUserRepository userRepository,
ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider,
ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient oauthProviderClient, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider,
TimeProvider timeProvider, ILogger<AuthController> logger) : base(timeProvider)
{
_authOptions = authOptions;
Expand All @@ -54,6 +52,7 @@ public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions,
_userRepository = userRepository;
_tokenRepository = tokenRepository;
_oauthTokenRepository = oauthTokenRepository;
_oauthProviderClient = oauthProviderClient;
_cache = new ScopedCacheClient(cacheClient, "Auth");
_mailer = mailer;
_logger = logger;
Expand Down Expand Up @@ -335,11 +334,7 @@ public Task<ActionResult<TokenResult>> GitHubAsync(ExternalAuthInfo value)
return ExternalLoginAsync(value,
_authOptions.GitHubId,
_authOptions.GitHubSecret,
(f, c) =>
{
c.Scope = "user:email";
return new GitHubClient(f, c);
}
_oauthProviderClient.GetGitHubUserInfoAsync
);
}

Expand All @@ -357,11 +352,7 @@ public Task<ActionResult<TokenResult>> GoogleAsync(ExternalAuthInfo value)
return ExternalLoginAsync(value,
_authOptions.GoogleId,
_authOptions.GoogleSecret,
(f, c) =>
{
c.Scope = "profile email";
return new GoogleClient(f, c);
}
_oauthProviderClient.GetGoogleUserInfoAsync
);
}

Expand All @@ -379,11 +370,7 @@ public Task<ActionResult<TokenResult>> FacebookAsync(ExternalAuthInfo value)
return ExternalLoginAsync(value,
_authOptions.FacebookId,
_authOptions.FacebookSecret,
(f, c) =>
{
c.Scope = "email";
return new FacebookClient(f, c);
}
_oauthProviderClient.GetFacebookUserInfoAsync
);
}

Expand All @@ -401,11 +388,7 @@ public Task<ActionResult<TokenResult>> LiveAsync(ExternalAuthInfo value)
return ExternalLoginAsync(value,
_authOptions.MicrosoftId,
_authOptions.MicrosoftSecret,
(f, c) =>
{
c.Scope = "wl.emails";
return new WindowsLiveClient(f, c);
}
_oauthProviderClient.GetMicrosoftUserInfoAsync
);
}

Expand Down Expand Up @@ -672,23 +655,16 @@ private async Task AddGlobalAdminRoleIfFirstUserAsync(User user)
_isFirstUserChecked = true;
}

private async Task<ActionResult<TokenResult>> ExternalLoginAsync<TClient>(ExternalAuthInfo authInfo, string? appId, string? appSecret, Func<IRequestFactory, IClientConfiguration, TClient> createClient) where TClient : OAuth2Client
private async Task<ActionResult<TokenResult>> ExternalLoginAsync(ExternalAuthInfo authInfo, string? appId, string? appSecret, Func<ExternalAuthInfo, string, string, Task<UserInfo>> getUserInfoAsync)
{
using var _ = _logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(HttpContext));
if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret))
throw new ConfigurationErrorsException("Missing Configuration for OAuth provider");

var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration
{
ClientId = appId,
ClientSecret = appSecret,
RedirectUri = authInfo.RedirectUri
});

UserInfo userInfo;
try
{
userInfo = await client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri);
userInfo = await getUserInfoAsync(authInfo, appId, appSecret);
}
catch (Exception ex)
{
Expand Down
6 changes: 5 additions & 1 deletion src/Exceptionless.Web/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Exceptionless.Web.Extensions;
using Exceptionless.Web.Mapping;
using Exceptionless.Web.Models;
using Exceptionless.Web.Security;
using Exceptionless.Web.Utility;
using Foundatio.Jobs;
using Foundatio.Queues;
Expand All @@ -35,6 +36,7 @@ public class ProjectController : RepositoryApiController<IProjectRepository, Pro
private readonly IQueue<WorkItemData> _workItemQueue;
private readonly BillingManager _billingManager;
private readonly SlackService _slackService;
private readonly IOAuthProviderClient _oauthProviderClient;
private readonly ITextSerializer _serializer;
private readonly AppOptions _options;
private readonly UsageService _usageService;
Expand All @@ -49,6 +51,7 @@ public ProjectController(
IQueue<WorkItemData> workItemQueue,
BillingManager billingManager,
SlackService slackService,
IOAuthProviderClient oauthProviderClient,
SampleDataService sampleDataService,
ApiMapper mapper,
IAppQueryValidator validator,
Expand All @@ -67,6 +70,7 @@ ILoggerFactory loggerFactory
_workItemQueue = workItemQueue;
_billingManager = billingManager;
_slackService = slackService;
_oauthProviderClient = oauthProviderClient;
_serializer = serializer;
_sampleDataService = sampleDataService;
_options = options;
Expand Down Expand Up @@ -679,7 +683,7 @@ public async Task<IActionResult> AddSlackAsync(string id, string code)
SlackToken? token;
try
{
token = await _slackService.GetAccessTokenAsync(code);
token = await _oauthProviderClient.GetSlackAccessTokenAsync(code);
}
catch (Exception ex)
{
Expand Down
81 changes: 81 additions & 0 deletions src/Exceptionless.Web/Security/OAuthProviderClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Services;
using Exceptionless.Web.Extensions;
using Exceptionless.Web.Models;
using OAuth2.Client;
using OAuth2.Client.Impl;
using OAuth2.Configuration;
using OAuth2.Infrastructure;
using OAuth2.Models;

namespace Exceptionless.Web.Security;

public interface IOAuthProviderClient
{
Task<UserInfo> GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret);
Task<UserInfo> GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret);
Task<UserInfo> GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret);
Task<UserInfo> GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret);
Task<SlackToken?> GetSlackAccessTokenAsync(string code);
}

public sealed class OAuthProviderClient(SlackService slackService) : IOAuthProviderClient
{
public Task<UserInfo> GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret)
{
return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) =>
{
configuration.Scope = "email";
return new FacebookClient(factory, configuration);
});
}

public Task<UserInfo> GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret)
{
return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) =>
{
configuration.Scope = "user:email";
return new GitHubClient(factory, configuration);
});
}

public Task<UserInfo> GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret)
{
return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) =>
{
configuration.Scope = "profile email";
return new GoogleClient(factory, configuration);
});
}

public Task<UserInfo> GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret)
{
return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) =>
{
configuration.Scope = "wl.emails";
return new WindowsLiveClient(factory, configuration);
});
}

public Task<SlackToken?> GetSlackAccessTokenAsync(string code)
{
return slackService.GetAccessTokenAsync(code);
}

private static Task<UserInfo> GetUserInfoAsync<TClient>(
ExternalAuthInfo authInfo,
string appId,
string appSecret,
Func<IRequestFactory, IClientConfiguration, TClient> createClient
) where TClient : OAuth2Client
{
var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration
{
ClientId = appId,
ClientSecret = appSecret,
RedirectUri = authInfo.RedirectUri
});

return client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri);
}
}
87 changes: 87 additions & 0 deletions tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Exceptionless.Core.Billing;
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories;
Expand Down Expand Up @@ -46,6 +47,24 @@ protected override async Task ResetDataAsync()
await service.CreateDataAsync();
}

[Fact]
public async Task RequeueAsync_WithoutPath_ReturnsEmptyEnqueuedCount()
{
// Arrange
const int expectedEnqueuedCount = 0;

// Act
var response = await SendRequestAsAsync<RequeueResult>(r => r
.AsGlobalAdminUser()
.AppendPaths("admin", "requeue")
.StatusCodeShouldBeOk()
);

// Assert
Assert.NotNull(response);
Assert.Equal(expectedEnqueuedCount, response.Enqueued);
}

[Fact]
public async Task RunJobAsync_WhenFixStackStatsWithExplicitUtcWindow_ShouldRepairStatsEndToEnd()
{
Expand Down Expand Up @@ -631,6 +650,72 @@ public Task EchoRequest_WithoutAuth_ReturnsUnauthorized()
.StatusCodeShouldBeUnauthorized());
}

[Fact]
public async Task GenerateSampleEventsAsync_WithInvalidDaysBack_ReturnsValidationError()
{
// Arrange
const int eventCount = 25;
const int daysBack = 0;

// Act
var response = await SendRequestAsync(r => r
.AsGlobalAdminUser()
.Post()
.AppendPaths("admin", "generate-sample-events")
.QueryString("eventCount", eventCount)
.QueryString("daysBack", daysBack)
.StatusCodeShouldBeUnprocessableEntity());

// Assert
Assert.NotNull(response);
var stats = await _workItemQueue.GetQueueStatsAsync();
Assert.Equal(0, stats.Enqueued);
}

[Fact]
public async Task GenerateSampleEventsAsync_WithInvalidEventCount_ReturnsValidationError()
{
// Arrange
const int eventCount = 0;
const int daysBack = 7;

// Act
var response = await SendRequestAsync(r => r
.AsGlobalAdminUser()
.Post()
.AppendPaths("admin", "generate-sample-events")
.QueryString("eventCount", eventCount)
.QueryString("daysBack", daysBack)
.StatusCodeShouldBeUnprocessableEntity());

// Assert
Assert.NotNull(response);
var stats = await _workItemQueue.GetQueueStatsAsync();
Assert.Equal(0, stats.Enqueued);
}

[Fact]
public async Task GenerateSampleEventsAsync_WithValidArguments_QueuesGenerateSampleEventsWorkItem()
{
// Arrange
const int eventCount = 25;
const int daysBack = 14;

// Act
var response = await SendRequestAsync(r => r
.AsGlobalAdminUser()
.Post()
.AppendPaths("admin", "generate-sample-events")
.QueryString("eventCount", eventCount)
.QueryString("daysBack", daysBack)
.StatusCodeShouldBeOk());

// Assert
Assert.NotNull(response);
var stats = await _workItemQueue.GetQueueStatsAsync();
Assert.Equal(1, stats.Enqueued);
}

[Fact]
public async Task GetAssemblies_AsGlobalAdmin_ReturnsAssemblyList()
{
Expand Down Expand Up @@ -804,4 +889,6 @@ public Task SetBonusAsync_AsNonAdmin_ReturnsForbidden()
.QueryString("bonusEvents", 1000)
.StatusCodeShouldBeForbidden());
}

private sealed record RequeueResult([property: JsonPropertyName("enqueued")] int Enqueued);
}
Loading