diff --git a/Directory.Packages.props b/Directory.Packages.props index 2417274..15873dd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -12,6 +13,7 @@ + @@ -19,7 +21,10 @@ + + + @@ -29,6 +34,12 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + @@ -37,6 +48,8 @@ + + @@ -54,6 +67,7 @@ + diff --git a/src/Account/Business/UseCases/AccessAccountUseCase.cs b/src/Account/Business/UseCases/AccessAccountUseCase.cs new file mode 100644 index 0000000..10a4c3d --- /dev/null +++ b/src/Account/Business/UseCases/AccessAccountUseCase.cs @@ -0,0 +1,122 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Kairos.Account.Configuration; +using Kairos.Account.Domain; +using Kairos.Account.Infra; +using Kairos.Shared.Contracts; +using Kairos.Shared.Contracts.Account.AccessAccount; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Kairos.Account.Business.UseCases; + +internal sealed class AccessAccountUseCase( + IOptions config, + ILogger logger, + SignInManager identity, + AccountContext db +) : IRequestHandler> +{ + readonly JwtOptions _settings = config.Value.Jwt; + + public async Task> Handle( + AccessAccountCommand input, + CancellationToken cancellationToken) + { + var enrichers = new Dictionary + { + ["Identifier"] = input.Identifier.Value, + ["CorrelationId"] = input.CorrelationId + }; + + using (logger.BeginScope(enrichers)) + { + try + { + var id = input.Identifier; + + var account = await db.Investors.FirstOrDefaultAsync( + i => + (id.Type == AccountIdentifier.Document && i.Document == id.Value) || + (id.Type == AccountIdentifier.Email && i.Email == id.Value) || + (id.Type == AccountIdentifier.PhoneNumber && i.PhoneNumber == id.Value) || + (id.Type == AccountIdentifier.AccountNumber && i.Id.ToString() == id.Value), + cancellationToken); + + if (account is null) + { + logger.LogWarning("Sign-in failed. Account not found."); + return Output.PolicyViolation(["Identificador ou senha inválidos."]); + } + + var result = await identity.CheckPasswordSignInAsync( + account, + input.Password, + lockoutOnFailure: true); + + if (result.IsLockedOut) + { + logger.LogWarning("Sign-in failed. Account is locked out."); + return Output.PolicyViolation(["Esta conta está bloqueada. Tente novamente após 5 minutos."]); + } + + if (result.IsNotAllowed) + { + logger.LogWarning("Sign-in failed. Email not confirmed."); + return Output.PolicyViolation(["Confirme seu e-mail antes de acessar a conta."]); + } + + if (result.Succeeded is false) + { + logger.LogWarning("Sign-in failed. Invalid password."); + return Output.PolicyViolation(["Identificador ou senha inválidos."]); + } + + logger.LogInformation("Sign-in successful. Generating token."); + + var token = GenerateJwtToken(account); + + return Output.Ok(token, ["Autenticação realizada com sucesso!"]); + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred during sign-in."); + return Output.UnexpectedError([ex.Message]); + } + } + } + + string GenerateJwtToken(Investor account) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_settings.Secret); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, account.Id.ToString()), + new(JwtRegisteredClaimNames.Email, account.Email!), + new(JwtRegisteredClaimNames.Name, account.Name), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddMinutes(_settings.ExpiryMinutes), + Issuer = _settings.Issuer, + Audience = _settings.Audience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } +} diff --git a/src/Account/Business/UseCases/GetAccountInfoUseCase.cs b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs new file mode 100644 index 0000000..92a96b4 --- /dev/null +++ b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs @@ -0,0 +1,48 @@ +using Kairos.Account.Infra; +using Kairos.Shared.Contracts; +using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Contracts.Account.GetAccountInfo; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Kairos.Account.Business.UseCases; + +internal sealed class GetAccountInfoUseCase( + ILogger logger, + AccountContext db) : IRequestHandler> +{ + public async Task> Handle(GetAccountInfoQuery input, CancellationToken cancellationToken) + { + try + { + var account = await db.Investors.FindAsync(input.Id, cancellationToken); + + if (account is null) + { + logger.LogWarning( + "Attempted to access non-existent account with ID {AccountId}", + input.Id); + return Output.PolicyViolation(["Conta não encontrada."]); + } + + return Output.Ok(new AccountInfo( + account.Id, + account.Name, + account.Birthdate, + account.Gender, + account.PhoneNumber ?? string.Empty, + account.Document, + account.Email!, + Address: null, + ProfilePicUrl: null + )); + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred."); + return Output.UnexpectedError([ + "Um erro inesperado ocorreu...", + ex.Message]); + } + } +} diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 47a9c0e..24e980f 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -1,13 +1,18 @@ using System.Reflection; +using System.Text; +using Kairos.Account.Configuration; using Kairos.Account.Domain; using Kairos.Account.Infra; using Kairos.Account.Infra.Consumers; using Kairos.Shared.Contracts.Account; using MassTransit; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; namespace Kairos.Account; @@ -16,16 +21,21 @@ public static class DependencyInjection public static IServiceCollection AddAccount( this IServiceCollection services, IConfigurationManager config) + IConfigurationManager config) { + services.Configure(config); + return services .AddIdentity(config) .AddMediatR(cfg => { cfg.LicenseKey = config["Keys:MediatR"]; cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - }); + }) + .AddAuth(); } + public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x) public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x) { x.AddConsumers(Assembly.GetExecutingAssembly()); @@ -34,6 +44,11 @@ public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistra c.UseSqlServer(); c.UseBusOutbox(); }); + x.AddEntityFrameworkOutbox(c => + { + c.UseSqlServer(); + c.UseBusOutbox(); + }); return x; } @@ -81,8 +96,50 @@ static IServiceCollection AddIdentity( o.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() + .AddSignInManager() .AddDefaultTokenProviders(); return services; } + + static IServiceCollection AddAuth(this IServiceCollection services) + { + var jwt = services + .BuildServiceProvider() + .GetRequiredService>() + .Value.Jwt; + + services + .AddAuthentication(o => + { + o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwt.Issuer, + ValidAudience = jwt.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)) + }; + + o.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + context.Token = context.Request.Cookies[jwt.CookieName]; + return Task.CompletedTask; + } + }; + }); + + services.AddAuthorization(); + + return services; + } } diff --git a/src/Account/Domain/Investor.cs b/src/Account/Domain/Investor.cs index 759f5fd..8e49c71 100644 --- a/src/Account/Domain/Investor.cs +++ b/src/Account/Domain/Investor.cs @@ -12,7 +12,7 @@ internal sealed class Investor : KairosAccount public string Name { get; private set; } public string Document { get; private set; } public DateTime Birthdate { get; private set; } - public Gender Gender { get; private set; } + public Gender Gender { get; } public PersonType Type { get; private set; } Investor( diff --git a/src/Account/Infra/Configuration/JwtOptions.cs b/src/Account/Infra/Configuration/JwtOptions.cs new file mode 100644 index 0000000..15bf5dc --- /dev/null +++ b/src/Account/Infra/Configuration/JwtOptions.cs @@ -0,0 +1,10 @@ +namespace Kairos.Account.Configuration; + +public sealed class JwtOptions +{ + public required string CookieName { get; init; } + public required string Secret { get; init; } + public required string Issuer { get; init; } + public required string Audience { get; init; } + public required int ExpiryMinutes { get; init; } +} \ No newline at end of file diff --git a/src/Account/Infra/Configuration/Settings.cs b/src/Account/Infra/Configuration/Settings.cs new file mode 100644 index 0000000..f98e3a1 --- /dev/null +++ b/src/Account/Infra/Configuration/Settings.cs @@ -0,0 +1,6 @@ +namespace Kairos.Account.Configuration; + +internal partial class Settings +{ + public required JwtOptions Jwt { get; init; } +} \ No newline at end of file diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index d2ba90f..4cbda96 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -7,9 +7,11 @@ + + diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index ac6050e..95c16f4 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -83,6 +83,30 @@ static IServiceCollection AddSwagger(this IServiceCollection services) => .AddSwaggerGen(o => { o.SchemaFilter(); + o.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Cookie, + Description = "Please enter a valid JWT token", + Name = "kairos-token", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + + o.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); o.SwaggerDoc("v1", new OpenApiInfo { diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 76624ad..c3b2608 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -29,6 +29,7 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, OutputStatus.NotFound => StatusCodes.Status404NotFound, OutputStatus.PolicyViolation => StatusCodes.Status422UnprocessableEntity, + OutputStatus.CredentialsRequired => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status500InternalServerError, }; @@ -44,8 +45,10 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn catch (Exception ex) { logger.LogError(ex, "{Error}", ex.Message); + logger.LogError(ex, "{Error}", ex.Message); return Results.Json( + data: new Response(null, [ex.Message]), data: new Response(null, [ex.Message]), statusCode: StatusCodes.Status500InternalServerError ); diff --git a/src/Gateway/Modules/Account/AccountModule.cs b/src/Gateway/Modules/Account/AccountModule.cs index 874359d..f7e71fc 100644 --- a/src/Gateway/Modules/Account/AccountModule.cs +++ b/src/Gateway/Modules/Account/AccountModule.cs @@ -1,8 +1,14 @@ +using System.Security.Claims; using Carter; +using Kairos.Account.Configuration; +using Kairos.Gateway.Filters; using Kairos.Gateway.Modules.Account.Request; +using Kairos.Shared.Contracts; using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Contracts.Account.GetAccountInfo; using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Response = Kairos.Gateway.Filters.Response; namespace Kairos.Gateway.Modules; @@ -79,5 +85,87 @@ public override void AddRoutes(IEndpointRouteBuilder app) e.Responses["500"].Description = "An unexpected server error occurred."; return e; }); + + app.MapPost("/access", + async ( + [FromBody] AccessAccountCommand command, + HttpContext ctx, + IOptions accountSettings) => + { + Output output = await _mediator.Send(command); + + if (output.IsFailure) + { + return output; + } + + var jwt = accountSettings.Value.Jwt; + + ctx.Response.Cookies.Append(jwt.CookieName, output.Value!, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Expires = DateTime.UtcNow.AddMinutes(jwt.ExpiryMinutes) + }); + + return output; + }) + .WithSummary("Access an account") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "Authentication successful. The JWT is in the 'data' field."; + e.Responses["422"].Description = "Policy violation, e.g., invalid credentials, locked out, or unconfirmed e-mail."; + e.Responses["400"].Description = "Invalid input, such as a malformed e-mail."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); + + app.MapGet("/me", + async (HttpContext ctx) => + { + var accountIdValue = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (long.TryParse(accountIdValue, out var accountId) is false) + { + return Output.CredentialsRequired(["Acesse sua conta para visualizar os dados cadastrais."]); + } + + var output = await _mediator.Send(new GetAccountInfoQuery(accountId)); + + return output; + }) + .RequireAuthorization() + .WithSummary("Get the authenticated account's data") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "Returns the account details for the authenticated user."; + e.Responses["401"].Description = "Unauthorized if the auth cookie is missing or invalid."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); + + app.MapDelete("/exit", + async ( + HttpContext ctx, + IOptions accountSettings) => + { + ctx.Response.Cookies.Delete(accountSettings.Value.Jwt.CookieName); + + return Output.Empty; + }) + .WithSummary("Sign out") + .Produces(StatusCodes.Status204NoContent) + .WithOpenApi(e => + { + e.Responses["204"].Description = "Logged out successfully."; + return e; + }); } } \ No newline at end of file diff --git a/src/Gateway/Program.cs b/src/Gateway/Program.cs index 5f3ca4c..fe51118 100644 --- a/src/Gateway/Program.cs +++ b/src/Gateway/Program.cs @@ -45,6 +45,8 @@ app .UseRouting() + .UseAuthentication() + .UseAuthorization() .UseStaticFiles() .UseHealthChecks("/health/ready", new HealthCheckOptions { diff --git a/src/Gateway/appsettings.json b/src/Gateway/appsettings.json index f539ec8..bf20945 100644 --- a/src/Gateway/appsettings.json +++ b/src/Gateway/appsettings.json @@ -23,6 +23,13 @@ "ConnectionString": "Database:MarketData:ConnectionString" } }, + "Jwt": { + "CookieName": "kairos-token", + "Secret": "Jwt:Secret", + "Issuer": "kairos-broker-api", + "Audience": "kairos-broker-ui", + "ExpiryMinutes": 120 + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Infra/Configuration/BrapiHealthCheck.cs similarity index 100% rename from src/MarketData/Configuration/BrapiHealthCheck.cs rename to src/MarketData/Infra/Configuration/BrapiHealthCheck.cs diff --git a/src/MarketData/Configuration/Settings.cs b/src/MarketData/Infra/Configuration/Settings.cs similarity index 64% rename from src/MarketData/Configuration/Settings.cs rename to src/MarketData/Infra/Configuration/Settings.cs index 02614a2..0e574dc 100644 --- a/src/MarketData/Configuration/Settings.cs +++ b/src/MarketData/Infra/Configuration/Settings.cs @@ -2,7 +2,7 @@ namespace Kairos.MarketData.Configuration; -internal static partial class Settings +internal partial class Settings { public sealed partial class Api { @@ -13,4 +13,9 @@ public sealed partial class Database { public required DbOptions MarketData { get; init; } } + + public sealed partial class Database + { + public required DbOptions MarketData { get; init; } + } } \ No newline at end of file diff --git a/src/Shared/Abstractions/ICommand.cs b/src/Shared/Abstractions/ICommand.cs index 8ecc7de..4902473 100644 --- a/src/Shared/Abstractions/ICommand.cs +++ b/src/Shared/Abstractions/ICommand.cs @@ -14,4 +14,4 @@ public interface ICommand : IRequest /// /// Represents a command that returns a result /// -public interface ICommand : IRequest>, ICommand; +public interface ICommand : IRequest>; \ No newline at end of file diff --git a/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs b/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs new file mode 100644 index 0000000..adc7ae7 --- /dev/null +++ b/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs @@ -0,0 +1,14 @@ +using Kairos.Shared.Abstractions; +using Kairos.Shared.Contracts.Account.AccessAccount; + +namespace Kairos.Shared.Contracts; + +public sealed record AccessAccountCommand( + Identifier Identifier, + string Password, + Guid CorrelationId +) : ICommand; + +public sealed record Identifier( + string Value, + AccountIdentifier Type); \ No newline at end of file diff --git a/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs b/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs new file mode 100644 index 0000000..9110170 --- /dev/null +++ b/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs @@ -0,0 +1,9 @@ +namespace Kairos.Shared.Contracts.Account.AccessAccount; + +public enum AccountIdentifier +{ + AccountNumber = 1, + Document, + Email, + PhoneNumber, +} \ No newline at end of file diff --git a/src/Shared/Contracts/Account/Gender.cs b/src/Shared/Contracts/Account/Gender.cs new file mode 100644 index 0000000..ebb7633 --- /dev/null +++ b/src/Shared/Contracts/Account/Gender.cs @@ -0,0 +1,9 @@ +namespace Kairos.Account.Domain.Enum; + +public enum Gender +{ + Unspecified = 0, + Male, + Female, + Other +} diff --git a/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs new file mode 100644 index 0000000..e8389f2 --- /dev/null +++ b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs @@ -0,0 +1,15 @@ +using Kairos.Account.Domain.Enum; + +namespace Kairos.Shared.Contracts.Account.GetAccountInfo; + +public sealed record AccountInfo( + long Id, + string Name, + DateTime Birthdate, + Gender Gender, + string PhoneNumber, + string Document, + string Email, + string? Address, + Uri? ProfilePicUrl +); diff --git a/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs b/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs new file mode 100644 index 0000000..a789302 --- /dev/null +++ b/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs @@ -0,0 +1,11 @@ +using Kairos.Shared.Abstractions; +using Kairos.Shared.Contracts.Account.GetAccountInfo; + +namespace Kairos.Shared.Contracts.Account; + +public sealed record GetAccountInfoQuery( + long Id, + Guid CorrelationId) : IQuery +{ + public GetAccountInfoQuery(long id) : this(id, Guid.NewGuid()) { } +} \ No newline at end of file diff --git a/src/Shared/Contracts/Account/PersonType.cs b/src/Shared/Contracts/Account/PersonType.cs new file mode 100644 index 0000000..7d98ca5 --- /dev/null +++ b/src/Shared/Contracts/Account/PersonType.cs @@ -0,0 +1,14 @@ +namespace Kairos.Account.Domain.Enum; + +public enum PersonType +{ + /// + /// Individual + /// + Natural = 1, + + /// + /// Corporate + /// + Legal +} diff --git a/src/Shared/Contracts/Output.cs b/src/Shared/Contracts/Output.cs index c618106..77428ef 100644 --- a/src/Shared/Contracts/Output.cs +++ b/src/Shared/Contracts/Output.cs @@ -30,7 +30,8 @@ public Output(Output result) Messages = result.Messages; } - #region Success + #region Success + public static Output Ok(IEnumerable? messages = null) => new(OutputStatus.Ok, messages ?? []); @@ -44,6 +45,9 @@ public static Output Created(IEnumerable? messages = null) => public static Output UnexpectedError(IEnumerable messages) => new(OutputStatus.UnexpectedError, messages); + public static Output CredentialsRequired(IEnumerable messages) => + new(OutputStatus.CredentialsRequired, messages); + public static Output PolicyViolation(IEnumerable messages) => new(OutputStatus.PolicyViolation, messages); @@ -86,9 +90,13 @@ public static Output Created(TValue value, IEnumerable? messages public static Output InvalidInput(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.InvalidInput, messages); + public static Output NotFound(IEnumerable messages, TValue? value = default) => + new(value, OutputStatus.NotFound, messages); public static Output NotFound(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.NotFound, messages); + public static Output PolicyViolation(IEnumerable messages, TValue? value = default) => + new(value, OutputStatus.PolicyViolation, messages); public static Output PolicyViolation(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.PolicyViolation, messages); diff --git a/src/Shared/Contracts/OutputStatus.cs b/src/Shared/Contracts/OutputStatus.cs index 249a93f..e9ca343 100644 --- a/src/Shared/Contracts/OutputStatus.cs +++ b/src/Shared/Contracts/OutputStatus.cs @@ -39,5 +39,10 @@ public enum OutputStatus /// Unexpected internal error /// UnexpectedError, + + /// + /// Unauthorized + /// + CredentialsRequired, #endregion }