using DodoSSH.Contracts;
using DodoSSH.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Authorization;
///
/// Requires that the caller has published an identity key.
///
///
/// Not a confidentiality boundary — vault access is decided by ownership and grants, and an
/// unenrolled user holds neither. What this prevents is a client with no key material writing
/// ciphertext nobody can ever decrypt, and reading vault contents it has no way to open. Both
/// present to a user as data corruption, so the server refuses early and says why.
///
internal sealed class EnrolledRequirement : IAuthorizationRequirement;
/// Checks enrollment state against the database.
///
/// Scoped, not singleton, because it uses the request's . Registering it
/// with the default singleton lifetime would capture one context for the process.
///
internal sealed class EnrolledHandler(
ICurrentUserContext currentUser,
DodoDbContext database,
IHttpContextAccessor accessor)
: AuthorizationHandler
{
///
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
EnrolledRequirement requirement)
{
ArgumentNullException.ThrowIfNull(context);
// Anonymous callers are already refused by the policy's authentication requirement, which
// produces a 401. Leaving this one unmet as well would turn that into a 403.
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var enrolled = await database.UserKeys
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
if (enrolled)
{
context.Succeed(requirement);
}
}
}
///
/// Turns an unmet into a ProblemDetails response carrying
/// .
///
///
/// A bare 403 has an empty body, so a client cannot tell "you have not enrolled" — which it can
/// fix on its own — from "you may not touch this vault", which it cannot. Every other failure falls
/// through to the framework's default handling unchanged.
///
internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
///
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(authorizeResult);
var enrollmentMissing = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } failure
&& failure.FailedRequirements.OfType().Any();
if (!enrollmentMissing)
{
await defaultHandler.HandleAsync(next, context, policy, authorizeResult).ConfigureAwait(false);
return;
}
await TypedResults.Problem(
detail: "Publish an identity key at POST /api/v1/me/enrollment before using vaults.",
statusCode: StatusCodes.Status403Forbidden,
type: ProblemCodes.TypeBaseUri + ProblemCodes.EnrollmentRequired,
extensions: new Dictionary(StringComparer.Ordinal)
{
["code"] = ProblemCodes.EnrollmentRequired,
})
.ExecuteAsync(context)
.ConfigureAwait(false);
}
}