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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CodeBeam.UltimateAuth.Core.Options;

namespace CodeBeam.UltimateAuth.Core.Abstractions;

public interface ICredentialSessionPolicy
{
CredentialSessionBehavior Resolve(CredentialChangeReason reason);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CodeBeam.UltimateAuth.Core.Options;

public enum CredentialChangeReason
{
SelfChange = 0,
AdminChange = 10,
Recovery = 20
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CodeBeam.UltimateAuth.Core.Options;

public enum CredentialSessionBehavior
{
PreserveAll = 0,
PreserveCurrent = 10,
RevokeAll = 20
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ private static void Validate(UAuthResponseOptions response)

private static void ValidateDelivery(CredentialResponseOptions delivery)
{
// TODO:
if (delivery.Mode == TokenResponseMode.Body)
throw new NotSupportedException($"Body credential delivery is not supported for '{delivery.Kind}'.");

if (delivery.Mode == TokenResponseMode.Cookie && delivery.Cookie is null)
{
throw new InvalidOperationException($"Credential '{delivery.Kind}' is configured as Cookie but no cookie options were bound.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol

services.TryAddScoped<IAuthFlow, AuthFlow>();
services.TryAddScoped<IRefreshResponsePolicy, RefreshResponsePolicy>();
services.TryAddScoped<ICredentialSessionPolicy, UAuthCredentialSessionPolicy>();
services.TryAddSingleton<IAuthStore, InMemoryAuthStore>();
services.TryAddScoped<ICurrentUser, HttpContextCurrentUser>();
services.TryAddSingleton<IIdentifierNormalizer, IdentifierNormalizer>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.Extensions.Options;

namespace CodeBeam.UltimateAuth.Server.Infrastructure;

internal sealed class UAuthCredentialSessionPolicy : ICredentialSessionPolicy
{
private readonly UAuthCredentialSessionPolicyOptions _options;

public UAuthCredentialSessionPolicy(IOptions<UAuthServerOptions> options)
{
_options = options.Value.CredentialSessionPolicy;
}

public CredentialSessionBehavior Resolve(CredentialChangeReason reason)
{
return reason switch
{
CredentialChangeReason.SelfChange => _options.OnSelfChange,

CredentialChangeReason.AdminChange => _options.OnAdminChange,

CredentialChangeReason.Recovery => _options.OnRecovery,

_ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null)
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using CodeBeam.UltimateAuth.Core.Options;

namespace CodeBeam.UltimateAuth.Server.Options;

public sealed class UAuthCredentialSessionPolicyOptions
{
/// <summary>
/// Session behavior when a user changes their own credential.
/// By default, the current session chain is preserved.
/// </summary>
public CredentialSessionBehavior OnSelfChange { get; set; } = CredentialSessionBehavior.PreserveCurrent;

/// <summary>
/// Session behavior when an administrator changes a user's credential.
/// By default, all session chains are revoked.
/// </summary>
public CredentialSessionBehavior OnAdminChange { get; set; } = CredentialSessionBehavior.RevokeAll;

/// <summary>
/// Session behavior after successful credential recovery.
/// By default, all session chains are revoked.
/// <br /> <br />
/// Preserving the current session don't work well with recovery flows, because the current session is usually the one that was lost and is being recovered.
/// In this case, RevokeAll option is using instead of PreserveCurrent automatically.
/// </summary>
public CredentialSessionBehavior OnRecovery { get; set; } = CredentialSessionBehavior.RevokeAll;

internal UAuthCredentialSessionPolicyOptions Clone()
{
return new UAuthCredentialSessionPolicyOptions
{
OnSelfChange = OnSelfChange,
OnAdminChange = OnAdminChange,
OnRecovery = OnRecovery
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ public sealed class UAuthServerOptions

public UAuthResetOptions ResetCredential { get; init; } = new();

/// <summary>
/// Controls session invalidation after credential changes and recovery.
/// </summary>
public UAuthCredentialSessionPolicyOptions CredentialSessionPolicy { get; init; } = new();

public UAuthHubOptions Hub { get; set; } = new();

/// <summary>
Expand Down Expand Up @@ -151,6 +156,7 @@ internal UAuthServerOptions Clone()
PrimaryCredential = PrimaryCredential.Clone(),
AuthResponse = AuthResponse.Clone(),
ResetCredential = ResetCredential.Clone(),
CredentialSessionPolicy = CredentialSessionPolicy.Clone(),
Hub = Hub.Clone(),
SessionResolution = SessionResolution.Clone(),
Identifiers = Identifiers.Clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,14 @@ public async Task<IResult> CreateRoleAsync(HttpContext ctx)

var role = await _roles.CreateAsync(accessContext, req.Name, req.Permissions, ctx.RequestAborted);

return Results.Ok(role);
return Results.Ok(new RoleInfo
{
Id = role.Id,
Name = role.Name,
Permissions = role.Permissions.ToArray(),
CreatedAt = role.CreatedAt,
UpdatedAt = role.UpdatedAt
});
}

public async Task<IResult> RenameRoleAsync(RoleId roleId, HttpContext ctx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public async Task<UAuthResult<CredentialActionResult>> CompleteResetUserAsync(Us

public async Task<UAuthResult> DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request)
{
var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"));
var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"), request);
return UAuthResultMapper.From(raw);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ public async Task<UAuthResult<RevokeResult>> LogoutMyDeviceAsync(LogoutDeviceReq
{
var result = UAuthResultMapper.FromJson<RevokeResult>(raw);

if (result.Value?.CurrentChain == true)
if (result.IsSuccess && result.Value?.CurrentChain == true)
{
await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public async Task<UAuthResult<RevokeResult>> RevokeMyChainAsync(SessionChainId c
var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId.Value}/revoke"));
var result = UAuthResultMapper.FromJson<RevokeResult>(raw);

if (result.Value?.CurrentChain == true)
if (result.IsSuccess && result.Value?.CurrentChain == true)
{
await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ public async Task<UAuthResult> DeleteMeAsync()

if (result.IsSuccess)
{
await _events.PublishAsync(
new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch));
await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch));
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,29 @@ public async Task<IResult> ChangeSecretAsync(HttpContext ctx)
return Results.Ok(result);
}


public async Task<IResult> RevokeAsync(HttpContext ctx)
{
if (!TryGetSelf(out var flow, out var error))
return error!;

var request = await ctx.ReadJsonAsync<RevokeCredentialRequest>(ctx.RequestAborted);
var request = await ctx.ReadJsonAsync<RevokeCredentialRequest>(
ctx.RequestAborted);

var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Credentials.RevokeSelf,
resource: "credentials",
resourceId: flow.UserKey!.Value);

await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted);
var result = await _credentials.RevokeAsync(
accessContext,
request,
ctx.RequestAborted);

if (!result.IsSuccess)
return MapCredentialActionFailure(result);

return Results.NoContent();
}

Expand Down Expand Up @@ -173,76 +182,100 @@ public async Task<IResult> ChangeSecretAdminAsync(UserKey userKey, HttpContext c
return Results.Ok(result);
}


public async Task<IResult> RevokeAdminAsync(UserKey userKey, HttpContext ctx)
{
var flow = _authFlow.Current;

if (!flow.IsAuthenticated)
return Results.Unauthorized();

var request = await ctx.ReadJsonAsync<RevokeCredentialRequest>(ctx.RequestAborted);
var request = await ctx.ReadJsonAsync<RevokeCredentialRequest>(
ctx.RequestAborted);

var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Credentials.RevokeAdmin,
resource: "credentials",
resourceId: userKey.Value);

await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted);
var result = await _credentials.RevokeAsync(
accessContext,
request,
ctx.RequestAborted);

if (!result.IsSuccess)
return MapCredentialActionFailure(result);

return Results.NoContent();
}


public async Task<IResult> DeleteAdminAsync(UserKey userKey, HttpContext ctx)
{
var flow = _authFlow.Current;

if (!flow.IsAuthenticated)
return Results.Unauthorized();

var request = await ctx.ReadJsonAsync<DeleteCredentialRequest>(ctx.RequestAborted);
var request = await ctx.ReadJsonAsync<DeleteCredentialRequest>(
ctx.RequestAborted);

var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Credentials.DeleteAdmin,
resource: "credentials",
resourceId: userKey.Value);

await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted);
var result = await _credentials.DeleteAsync(
accessContext,
request,
ctx.RequestAborted);

if (!result.IsSuccess)
return MapCredentialActionFailure(result);

return Results.NoContent();
}


public async Task<IResult> BeginResetAdminAsync(UserKey userKey, HttpContext ctx)
{
if (!TryGetSelf(out var flow, out var error))
return error!;

var request = await ctx.ReadJsonAsync<BeginResetCredentialRequest>(ctx.RequestAborted);
var request = await ctx.ReadJsonAsync<BeginResetCredentialRequest>(
ctx.RequestAborted);

var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Credentials.BeginResetAdmin,
resource: "credentials",
resourceId: userKey.Value);

await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted);
return Results.NoContent();
var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted);

return Results.Ok(result);
}


public async Task<IResult> CompleteResetAdminAsync(UserKey userKey, HttpContext ctx)
{
if (!TryGetSelf(out var flow, out var error))
return error!;

var request = await ctx.ReadJsonAsync<CompleteResetCredentialRequest>(ctx.RequestAborted);
var request = await ctx.ReadJsonAsync<CompleteResetCredentialRequest>(
ctx.RequestAborted);

var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Credentials.CompleteResetAdmin,
resource: "credentials",
resourceId: userKey.Value);

await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted);
return Results.NoContent();
var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted);

return Results.Ok(result);
}

private bool TryGetSelf(out AuthFlowContext flow, out IResult? error)
Expand All @@ -257,4 +290,23 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error)
error = null;
return true;
}


private static IResult MapCredentialActionFailure(CredentialActionResult result)
{
var reason = result.Error ?? "credential_action_failed";

return reason switch
{
"credential_not_found" => Results.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Credential not found",
detail: reason),

_ => Results.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Credential action failed",
detail: reason)
};
}
}
Loading
Loading