Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
96 lines
4.6 KiB
C#
96 lines
4.6 KiB
C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace DodoSSH.Api.Setup;
|
|
|
|
/// <summary>Authentication and authorization wiring.</summary>
|
|
/// <remarks>
|
|
/// The API validates bearer access tokens only. It never runs a browser flow itself: the desktop
|
|
/// app is a public client using Authorization Code with PKCE and a loopback redirect, and it talks
|
|
/// to the identity provider directly.
|
|
/// </remarks>
|
|
internal static class Auth
|
|
{
|
|
/// <summary>Policy requiring an authenticated caller.</summary>
|
|
internal const string AuthenticatedPolicy = "Authenticated";
|
|
|
|
/// <summary>Policy requiring a caller who has completed key enrollment.</summary>
|
|
internal const string EnrolledPolicy = "Enrolled";
|
|
|
|
internal static IServiceCollection AddDodoAuthentication(this IServiceCollection services)
|
|
{
|
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
// Bound late so options validation has already run and the values are known good.
|
|
var oidc = services.BuildServiceProvider().GetRequiredService<IOptions<OidcOptions>>().Value;
|
|
|
|
options.Authority = oidc.Authority;
|
|
options.Audience = oidc.Audience;
|
|
options.RequireHttpsMetadata = oidc.RequireHttpsMetadata;
|
|
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
RequireSignedTokens = true,
|
|
RequireExpirationTime = true,
|
|
|
|
// 30 seconds, not the 5-minute default. A five-minute grace period on a
|
|
// credential that grants vault ciphertext access is far more slack than any
|
|
// sane clock needs.
|
|
ClockSkew = TimeSpan.FromSeconds(30),
|
|
};
|
|
|
|
// Tokens are the one thing that must never reach a log or a trace.
|
|
options.IncludeErrorDetails = false;
|
|
|
|
// Keep claim names as the provider issued them. The default mapping rewrites
|
|
// "sub" to a long WS-Federation URI, which makes provider-agnostic claim
|
|
// configuration confusing and silently breaks when a provider is swapped.
|
|
options.MapInboundClaims = false;
|
|
});
|
|
|
|
services.AddAuthorization(AddDodoPolicies);
|
|
|
|
return services;
|
|
}
|
|
|
|
private static void AddDodoPolicies(AuthorizationOptions options)
|
|
{
|
|
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
|
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
|
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
|
options.AddPolicy(
|
|
AuthenticatedPolicy,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
|
|
|
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
|
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
|
// "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
|
|
// tell a client whether the problem is theirs to fix.
|
|
options.AddPolicy(
|
|
EnrolledPolicy,
|
|
policy => policy
|
|
.RequireAuthenticatedUser()
|
|
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
|
|
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
|
|
|
// The same policy again, as the default rather than the fallback. FastEndpoints attaches
|
|
// authorization metadata to every endpoint that is not AllowAnonymous, and an endpoint that
|
|
// carries metadata is covered by the default policy rather than the fallback — so the
|
|
// fallback is no longer what secures the API. Setting both to the same policy keeps them
|
|
// from drifting, because tightening one and not the other would protect half the surface
|
|
// and look like it had protected all of it.
|
|
options.DefaultPolicy = options.GetPolicy(AuthenticatedPolicy)!;
|
|
}
|
|
}
|