Skip to content
13 changes: 13 additions & 0 deletions src/Exceptionless.Core/Configuration/OAuthServerOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Exceptionless.Core.Services;
using Microsoft.Extensions.Configuration;

namespace Exceptionless.Core.Configuration;
Expand All @@ -7,8 +8,11 @@ public class OAuthServerOptions
public TimeSpan AuthorizationCodeLifetime { get; internal set; } = TimeSpan.FromMinutes(5);
public TimeSpan AccessTokenLifetime { get; internal set; } = TimeSpan.FromHours(1);
public TimeSpan RefreshTokenLifetime { get; internal set; } = TimeSpan.FromDays(30);
public TimeSpan DeviceCodeLifetime { get; internal set; } = TimeSpan.FromMinutes(15);
public TimeSpan DeviceCodePollingInterval { get; internal set; } = TimeSpan.FromSeconds(5);
public bool EnableClientIdMetadataDocuments { get; internal set; } = true;
public int DynamicClientRegistrationIpLimit { get; internal set; } = 20;
public int DeviceAuthorizationIpLimit { get; internal set; } = 120;
public TimeSpan ClientMetadataDocumentCacheLifetime { get; internal set; } = TimeSpan.FromHours(1);
public TimeSpan ClientMetadataDocumentRequestTimeout { get; internal set; } = TimeSpan.FromSeconds(5);
public int ClientMetadataDocumentMaxBytes { get; internal set; } = 32 * 1024;
Expand All @@ -19,8 +23,11 @@ public static OAuthServerOptions ReadFromConfiguration(IConfiguration config)
options.AuthorizationCodeLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AuthorizationCodeLifetimeMinutes", 5));
options.AccessTokenLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AccessTokenLifetimeMinutes", 60));
options.RefreshTokenLifetime = TimeSpan.FromDays(config.GetValue("OAuthServer:RefreshTokenLifetimeDays", 30));
options.DeviceCodeLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:DeviceCodeLifetimeMinutes", 15));
options.DeviceCodePollingInterval = TimeSpan.FromSeconds(config.GetValue("OAuthServer:DeviceCodePollingIntervalSeconds", 5));
options.EnableClientIdMetadataDocuments = config.GetValue("OAuthServer:EnableClientIdMetadataDocuments", true);
options.DynamicClientRegistrationIpLimit = config.GetValue("OAuthServer:DynamicClientRegistrationIpLimit", 20);
options.DeviceAuthorizationIpLimit = config.GetValue("OAuthServer:DeviceAuthorizationIpLimit", 120);
options.ClientMetadataDocumentCacheLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:ClientMetadataDocumentCacheLifetimeMinutes", 60));
options.ClientMetadataDocumentRequestTimeout = TimeSpan.FromSeconds(config.GetValue("OAuthServer:ClientMetadataDocumentRequestTimeoutSeconds", 5));
options.ClientMetadataDocumentMaxBytes = config.GetValue("OAuthServer:ClientMetadataDocumentMaxBytes", 32 * 1024);
Expand All @@ -34,6 +41,7 @@ public record OAuthClientOptions
public required string Name { get; init; }
public IReadOnlyCollection<string> RedirectUris { get; init; } = [];
public IReadOnlyCollection<string> Scopes { get; init; } = [];
public IReadOnlyCollection<string> GrantTypes { get; init; } = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken];
public bool IsDisabled { get; init; }

internal OAuthClientOptions Normalize()
Expand All @@ -49,6 +57,11 @@ internal OAuthClientOptions Normalize()
.Where(s => !String.IsNullOrWhiteSpace(s))
.Select(s => s.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToArray(),
GrantTypes = GrantTypes
.Where(g => !String.IsNullOrWhiteSpace(g))
.Select(g => g.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray()
};
}
Expand Down
30 changes: 29 additions & 1 deletion src/Exceptionless.Core/Models/OAuthApplication.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Authorization;
using Exceptionless.Core.Services;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;
Expand All @@ -21,13 +22,17 @@ public class OAuthApplication : IIdentity, IHaveDates, IValidatableObject
public string Name { get; set; } = null!;

[Required]
[Length(1, 20)]
[Length(0, 20)]
public string[] RedirectUris { get; set; } = [];

[Required]
[Length(1, 20)]
public string[] Scopes { get; set; } = [];

[Required]
[Length(1, 3)]
public string[] GrantTypes { get; set; } = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken];

[MaxLength(1000)]
public string? Notes { get; set; }

Expand All @@ -44,6 +49,29 @@ public class OAuthApplication : IIdentity, IHaveDates, IValidatableObject

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
foreach (string _ in GrantTypes.Where(String.IsNullOrWhiteSpace))
yield return new ValidationResult("Grant type cannot be empty.", [nameof(GrantTypes)]);

foreach (string grantType in GrantTypes.Where(g => !String.IsNullOrWhiteSpace(g)))
{
if (!OAuthGrantTypes.SupportedGrantTypes.Contains(grantType, StringComparer.Ordinal))
yield return new ValidationResult($"'{grantType}' is not a supported OAuth grant type.", [nameof(GrantTypes)]);
}

bool supportsAuthorizationCode = GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal);
bool supportsDeviceCode = GrantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal);
if (!supportsAuthorizationCode && !supportsDeviceCode)
yield return new ValidationResult("OAuth applications must support authorization_code or device_code.", [nameof(GrantTypes)]);

if (GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal) && !supportsAuthorizationCode && !supportsDeviceCode)
yield return new ValidationResult("The refresh_token grant type requires authorization_code or device_code.", [nameof(GrantTypes)]);

if (Scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal))
yield return new ValidationResult("The offline_access scope requires the refresh_token grant type.", [nameof(Scopes)]);

if (supportsAuthorizationCode && RedirectUris.Length == 0)
yield return new ValidationResult("Redirect URIs are required for authorization_code clients.", [nameof(RedirectUris)]);

foreach (string _ in RedirectUris.Where(String.IsNullOrWhiteSpace))
yield return new ValidationResult("Redirect URI cannot be empty.", [nameof(RedirectUris)]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor<OAuthApplicatio
.Keyword(e => e.ClientId)
.Keyword(e => e.RedirectUris)
.Keyword(e => e.Scopes)
.Keyword(e => e.GrantTypes)
.Keyword(e => e.CreatedByUserId)
.Keyword(e => e.UpdatedByUserId)
.Boolean(e => e.IsDisabled));
Expand Down
Loading
Loading