Public Access
Add configuration, OIDC auth wiring and discovery endpoints (M1)
Options, JWT bearer validation, the /meta and .well-known endpoints, and a dev compose stack with Keycloak. Verified end to end: compose up, migrate, run, both discovery endpoints return correct payloads, and readiness reports the schema current. Configuration: - Strongly-typed options for Server, Oidc, Relay and Sync, all ValidateOnStart. A self-hosted server that boots half-configured and fails later per-request is far harder to diagnose than one that refuses to start and names the bad setting. - Cross-field validation the annotations cannot express: relay needs a WebSocketUrl when enabled, idle timeout must be under max session duration, item payload cap under batch cap. - Startup warnings for combinations that are individually valid but dangerous together: RequireHttpsMetadata false outside Development, and AllowEmailLinking (which turns any token bearing a victim's email into account takeover, hence default false). Auth: - JwtBearer with ClockSkew cut to 30s from the 5-minute default; five minutes of slack on a credential granting vault ciphertext access is more than any clock needs. - IncludeErrorDetails off, and a FallbackPolicy so an endpoint without an explicit policy still requires a caller rather than silently being public. Discovery, per ADR 0002: - /api/v1/meta reports versions, features and push caps. - /.well-known/dodossh-configuration is the onboarding story: the user types one server URL and the client discovers OIDC authority, client id, scopes and relay endpoint. Two environment problems found by actually running the stack: - PostgreSQL 18 changed its data mount point. Mounting /var/lib/postgresql/data — correct through 17 — makes the image refuse to start; 18+ wants a single mount at /var/lib/postgresql with the cluster in a subdirectory. - Keycloak moved to host port 18080. An unrelated Apache Tomcat on this machine holds 127.0.0.1:8080, and a loopback-specific bind beats Docker's 0.0.0.0 publish for "localhost". It presents as Keycloak 404ing every realm while its own log says the import succeeded, which is a genuinely misleading failure. Also: CA1848 is enforced, not advisory — warnings are errors, so the .editorconfig comment claiming otherwise was wrong. Startup and health logging now uses [LoggerMessage]. And a clean rebuild is back to zero warnings; the incremental build had been hiding 40 in test projects (banned Guid.NewGuid, an obsolete Testcontainers constructor, and two analyzer families that are genuinely noise under a test host). Verified: 0 warnings on a clean rebuild, 122 tests pass, format clean.
This commit is contained in:
+4
-3
@@ -98,9 +98,10 @@ dotnet_diagnostic.IDE0055.severity = error
|
|||||||
# meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig.
|
# meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig.
|
||||||
dotnet_diagnostic.CA2007.severity = none
|
dotnet_diagnostic.CA2007.severity = none
|
||||||
|
|
||||||
# Prefer LoggerMessage source generation over ILogger extension calls — allocation-free
|
# Require LoggerMessage source generation over ILogger extension calls — allocation-free,
|
||||||
# and gives structured events by construction. Warning, so it is visible but not a wall
|
# and event ids plus message templates become a greppable inventory rather than string
|
||||||
# during early development; raised to error once the logging pass lands in M4.
|
# literals scattered through the code. Effectively an error, since warnings are errors;
|
||||||
|
# stated as such rather than pretending it is advisory.
|
||||||
dotnet_diagnostic.CA1848.severity = warning
|
dotnet_diagnostic.CA1848.severity = warning
|
||||||
|
|
||||||
# Exceptions carry ProblemDetails codes, not localised text.
|
# Exceptions carry ProblemDetails codes, not localised text.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<ItemGroup Label="ASP.NET Core">
|
<ItemGroup Label="ASP.NET Core">
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Pinned transitive dependencies">
|
<ItemGroup Label="Pinned transitive dependencies">
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Development dependencies only: PostgreSQL and Keycloak.
|
||||||
|
#
|
||||||
|
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
||||||
|
# schema is still churning. The production stack is deploy/docker-compose.yml.
|
||||||
|
#
|
||||||
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
|
# dotnet run --project src/DodoSSH.Api
|
||||||
|
#
|
||||||
|
# Keycloak admin console: http://localhost:18080 (admin / admin)
|
||||||
|
# Test user: alice / alice
|
||||||
|
|
||||||
|
name: dodossh-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
container_name: dodossh-dev-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: dodossh
|
||||||
|
POSTGRES_USER: dodossh
|
||||||
|
POSTGRES_PASSWORD: dodossh
|
||||||
|
# Deterministic collation, matching the server's InvariantGlobalization.
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
# PostgreSQL 18+ wants a single mount at /var/lib/postgresql, with the cluster in a
|
||||||
|
# subdirectory. Mounting /var/lib/postgresql/data directly — which was correct for 17
|
||||||
|
# and earlier — makes the image refuse to start, and pg_upgrade --link cannot cross the
|
||||||
|
# mount boundary later.
|
||||||
|
- postgres-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U dodossh -d dodossh"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.4
|
||||||
|
container_name: dodossh-dev-keycloak
|
||||||
|
# start-dev, never in production: it disables HTTPS enforcement and uses an in-memory
|
||||||
|
# database. The realm is imported on every start so this stays disposable.
|
||||||
|
command: ["start-dev", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
ports:
|
||||||
|
# 18080, not 8080. Port 8080 is heavily contested on developer machines — a stray Tomcat
|
||||||
|
# or WSL relay bound to 127.0.0.1 wins over Docker's 0.0.0.0 publish for "localhost",
|
||||||
|
# which presents as Keycloak returning 404 for every realm and is thoroughly confusing to
|
||||||
|
# debug. Keycloak derives the token issuer from the request host, so the port simply has
|
||||||
|
# to match Oidc:Authority.
|
||||||
|
- "18080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./keycloak:/opt/keycloak/data/import:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"realm": "dodossh",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "DodoSSH (development)",
|
||||||
|
"sslRequired": "none",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"accessTokenLifespan": 900,
|
||||||
|
"ssoSessionIdleTimeout": 1800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "dodossh-desktop",
|
||||||
|
"name": "DodoSSH Desktop",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "http://127.0.0.1:*/*"
|
||||||
|
},
|
||||||
|
"redirectUris": [
|
||||||
|
"http://127.0.0.1:*/callback",
|
||||||
|
"http://localhost:*/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": [],
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "dodossh-api-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "dodossh-api",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"firstName": "Alice",
|
||||||
|
"lastName": "Example",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "alice",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "bob",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"email": "bob@example.com",
|
||||||
|
"firstName": "Bob",
|
||||||
|
"lastName": "Example",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "bob",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,6 +12,14 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Domain/DodoSSH.Domain.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Meta;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capability and discovery endpoints.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These replace URL-based API versioning. When client and server upgrade independently — which is
|
||||||
|
/// the normal case for self-hosted software — a client needs to ask what this particular server
|
||||||
|
/// supports rather than assume. See ADR 0002.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class MetaEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||||
|
internal const int SyncProtocolVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Feature flag for the relay.</summary>
|
||||||
|
internal const string RelayFeature = "relay";
|
||||||
|
|
||||||
|
internal static IEndpointRouteBuilder MapMetaEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// Anonymous by necessity: a client must be able to discover how to authenticate before it
|
||||||
|
// can authenticate.
|
||||||
|
app.MapGet("/api/v1/meta", GetMeta)
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("GetMeta")
|
||||||
|
.WithSummary("Server capabilities, versions and limits.");
|
||||||
|
|
||||||
|
app.MapGet("/.well-known/dodossh-configuration", GetConfiguration)
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("GetDodoSshConfiguration")
|
||||||
|
.WithSummary("Everything a client needs to begin authenticating, from one URL.");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ok<MetaResponse> GetMeta(
|
||||||
|
IOptions<SyncOptions> sync,
|
||||||
|
IOptions<RelayOptions> relay,
|
||||||
|
IOptions<ServerOptions> server)
|
||||||
|
{
|
||||||
|
List<string> features = ["teams"];
|
||||||
|
if (relay.Value.Enabled)
|
||||||
|
{
|
||||||
|
features.Add(RelayFeature);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypedResults.Ok(new MetaResponse(
|
||||||
|
ServerVersion: ServerVersion,
|
||||||
|
ApiVersions: [1],
|
||||||
|
SyncProtocolVersion: SyncProtocolVersion,
|
||||||
|
CryptoSpecVersion: CryptoSpec.CurrentAadVersion,
|
||||||
|
Features: features,
|
||||||
|
MinClientVersion: server.Value.MinClientVersion,
|
||||||
|
MaxOperationsPerPush: sync.Value.MaxOperationsPerPush,
|
||||||
|
MaxPayloadBytes: sync.Value.MaxPayloadBytes,
|
||||||
|
MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ok<DodoSshConfiguration> GetConfiguration(
|
||||||
|
IOptions<OidcOptions> oidc,
|
||||||
|
IOptions<RelayOptions> relay,
|
||||||
|
IOptions<ServerOptions> server)
|
||||||
|
{
|
||||||
|
var relayOptions = relay.Value;
|
||||||
|
|
||||||
|
return TypedResults.Ok(new DodoSshConfiguration(
|
||||||
|
ApiBaseUrl: new Uri(server.Value.PublicBaseUrl, UriKind.Absolute),
|
||||||
|
Oidc: new OidcConfiguration(
|
||||||
|
Authority: new Uri(oidc.Value.Authority, UriKind.Absolute),
|
||||||
|
ClientId: oidc.Value.ClientId,
|
||||||
|
Scopes: [.. oidc.Value.Scopes],
|
||||||
|
LoopbackRedirectPattern: oidc.Value.LoopbackRedirectPattern),
|
||||||
|
Relay: new RelayConfiguration(
|
||||||
|
Enabled: relayOptions.Enabled,
|
||||||
|
WebSocketUrl: relayOptions.Enabled && relayOptions.WebSocketUrl is not null
|
||||||
|
? new Uri(relayOptions.WebSocketUrl, UriKind.Absolute)
|
||||||
|
: null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ServerVersion { get; } =
|
||||||
|
typeof(MetaEndpoints).Assembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? "0.0.0";
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ using DodoSSH.Api.Setup;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Docker secrets, if mounted. Optional so the same image works with plain environment variables.
|
||||||
|
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true);
|
||||||
|
builder.Configuration.AddEnvironmentVariables(prefix: "DODOSSH_");
|
||||||
|
|
||||||
|
builder.Services.AddDodoOptions();
|
||||||
|
builder.Services.AddDodoPersistence(builder.Configuration);
|
||||||
|
builder.Services.AddDodoAuthentication();
|
||||||
builder.Services.AddDodoOpenApi();
|
builder.Services.AddDodoOpenApi();
|
||||||
builder.Services.AddDodoHealthChecks();
|
builder.Services.AddDodoHealthChecks();
|
||||||
|
|
||||||
@@ -16,11 +23,17 @@ var app = builder.Build();
|
|||||||
// produces redirect loops behind a proxy. HTTPS in development comes from the
|
// produces redirect loops behind a proxy. HTTPS in development comes from the
|
||||||
// launch profile instead.
|
// launch profile instead.
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapDodoHealthChecks();
|
app.MapDodoHealthChecks();
|
||||||
|
app.MapDodoEndpoints();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.WarnOnRiskyConfiguration();
|
||||||
|
|
||||||
await app.RunAsync().ConfigureAwait(false);
|
await app.RunAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
||||||
|
|
||||||
|
// Enrollment state lives in the database, so the real handler arrives with the
|
||||||
|
// enrollment feature. Registered now so endpoint groups can reference the policy name
|
||||||
|
// and the endpoint-inventory test has something to assert against.
|
||||||
|
options.AddPolicy(EnrolledPolicy, policy => policy.RequireAuthenticatedUser());
|
||||||
|
|
||||||
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>Binds and validates strongly-typed options.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every section uses <c>ValidateOnStart</c>. A self-hosted server that boots half-configured and
|
||||||
|
/// fails later, per-request, is far harder to diagnose than one that refuses to start and says
|
||||||
|
/// which setting is wrong.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class Configuration
|
||||||
|
{
|
||||||
|
internal static IServiceCollection AddDodoOptions(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddOptions<ServerOptions>()
|
||||||
|
.BindConfiguration(ServerOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddOptions<OidcOptions>()
|
||||||
|
.BindConfiguration(OidcOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddOptions<SyncOptions>()
|
||||||
|
.BindConfiguration(SyncOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.Validate(
|
||||||
|
options => options.MaxItemPayloadBytes <= options.MaxPayloadBytes,
|
||||||
|
"Sync:MaxItemPayloadBytes must not exceed Sync:MaxPayloadBytes.")
|
||||||
|
.Validate(
|
||||||
|
options => options.DefaultPullLimit <= options.MaxPullLimit,
|
||||||
|
"Sync:DefaultPullLimit must not exceed Sync:MaxPullLimit.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddOptions<RelayOptions>()
|
||||||
|
.BindConfiguration(RelayOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.Validate(
|
||||||
|
options => !options.Enabled || !string.IsNullOrWhiteSpace(options.WebSocketUrl),
|
||||||
|
"Relay:WebSocketUrl is required when Relay:Enabled is true.")
|
||||||
|
.Validate(
|
||||||
|
options => options.IdleTimeout < options.MaxSessionDuration,
|
||||||
|
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warns about configuration that is individually valid but dangerous in combination.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These cannot be hard failures — each is legitimate in some deployment — but silently
|
||||||
|
/// accepting them produces subtly broken security properties that nobody notices.
|
||||||
|
/// </remarks>
|
||||||
|
internal static WebApplication WarnOnRiskyConfiguration(this WebApplication app)
|
||||||
|
{
|
||||||
|
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("DodoSSH.Startup");
|
||||||
|
var oidc = app.Services.GetRequiredService<IOptions<OidcOptions>>().Value;
|
||||||
|
|
||||||
|
if (!oidc.RequireHttpsMetadata && !app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
StartupLog.InsecureMetadataOutsideDevelopment(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oidc.AllowEmailLinking)
|
||||||
|
{
|
||||||
|
StartupLog.EmailLinkingEnabled(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>Identity provider settings.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at
|
||||||
|
/// <c>realm_access.roles</c>, Entra uses <c>roles</c> and <c>groups</c>, Auth0 namespaces them,
|
||||||
|
/// Authentik uses <c>groups</c> — so the mapping is configuration rather than code.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OidcOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Oidc";
|
||||||
|
|
||||||
|
/// <summary>Issuer URL. Discovery and JWKS are fetched from here.</summary>
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string Authority { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Expected audience of access tokens.</summary>
|
||||||
|
[Required]
|
||||||
|
public string Audience { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Public client identifier the desktop app uses.</summary>
|
||||||
|
[Required]
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Scopes the client should request.</summary>
|
||||||
|
public IList<string> Scopes { get; } = ["openid", "profile", "email", "offline_access"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registered loopback redirect pattern. RFC 8252: a native client uses a loopback redirect on
|
||||||
|
/// an ephemeral port with the system browser, never a custom scheme and never an embedded
|
||||||
|
/// browser, so the user can see the real address bar.
|
||||||
|
/// </summary>
|
||||||
|
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1:*/callback";
|
||||||
|
|
||||||
|
/// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
|
||||||
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a new identity-provider subject may be linked to an existing account by matching
|
||||||
|
/// email.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Defaults to false, and must stay that way. If an attacker can obtain a token from any
|
||||||
|
/// configured provider carrying a victim's email address, email linking hands them the
|
||||||
|
/// victim's account.
|
||||||
|
/// </remarks>
|
||||||
|
public bool AllowEmailLinking { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Claim type holding the user's email.</summary>
|
||||||
|
public string EmailClaim { get; set; } = "email";
|
||||||
|
|
||||||
|
/// <summary>Claim type holding the user's display name.</summary>
|
||||||
|
public string NameClaim { get; set; } = "name";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Relay settings. See ADR 0004.</summary>
|
||||||
|
public sealed class RelayOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Relay";
|
||||||
|
|
||||||
|
/// <summary>Whether this deployment offers a relay at all.</summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Public WebSocket URL clients should dial. Required when enabled.</summary>
|
||||||
|
public string? WebSocketUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether RFC1918 and other private ranges may be dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
||||||
|
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
||||||
|
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
||||||
|
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
||||||
|
/// </remarks>
|
||||||
|
public bool AllowPrivateNetworks { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Maximum concurrent sessions per user.</summary>
|
||||||
|
[Range(1, 1000)]
|
||||||
|
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>Maximum concurrent sessions per node.</summary>
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int MaxConcurrentSessionsTotal { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>Maximum session lifetime.</summary>
|
||||||
|
public TimeSpan MaxSessionDuration { get; set; } = TimeSpan.FromHours(12);
|
||||||
|
|
||||||
|
/// <summary>Idle timeout.</summary>
|
||||||
|
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
/// <summary>Timeout for the outbound TCP connect.</summary>
|
||||||
|
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <summary>Ticket lifetime. Deliberately tiny: single-use and single-host.</summary>
|
||||||
|
public TimeSpan TicketLifetime { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>How long to keep draining live sessions during shutdown.</summary>
|
||||||
|
public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sync protocol limits.</summary>
|
||||||
|
public sealed class SyncOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Sync";
|
||||||
|
|
||||||
|
/// <summary>Maximum operations in one push. Enforced before the transaction opens.</summary>
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int MaxOperationsPerPush { get; set; } = 500;
|
||||||
|
|
||||||
|
/// <summary>Maximum total ciphertext in one push.</summary>
|
||||||
|
[Range(1024, 1024L * 1024 * 1024)]
|
||||||
|
public long MaxPayloadBytes { get; set; } = 8L * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>Maximum ciphertext for a single item.</summary>
|
||||||
|
[Range(1024, 1024L * 1024 * 1024)]
|
||||||
|
public long MaxItemPayloadBytes { get; set; } = 256L * 1024;
|
||||||
|
|
||||||
|
/// <summary>Default page size for a pull.</summary>
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int DefaultPullLimit { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>Maximum page size for a pull.</summary>
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int MaxPullLimit { get; set; } = 1000;
|
||||||
|
|
||||||
|
/// <summary>How long tombstones are retained before collection.</summary>
|
||||||
|
[Range(1, 3650)]
|
||||||
|
public int TombstoneRetentionDays { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key used to sign sync cursors, base64.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Cursors are integrity-tagged so a tampered one is rejected rather than silently
|
||||||
|
/// mis-serving. Generated per deployment; losing it only invalidates in-flight cursors, since
|
||||||
|
/// clients simply resync from the beginning.
|
||||||
|
/// </remarks>
|
||||||
|
public string? CursorSigningKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server identity and client compatibility.</summary>
|
||||||
|
public sealed class ServerOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Server";
|
||||||
|
|
||||||
|
/// <summary>Public base URL clients should use for API calls.</summary>
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public string PublicBaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oldest client version this server will serve.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Self-hosted means version skew is normal, not exceptional. A client below this must be
|
||||||
|
/// shown a clear remediation screen rather than failing obscurely mid-sync.
|
||||||
|
/// </remarks>
|
||||||
|
public string? MinClientVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using DodoSSH.Api.Features.Meta;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single, explicit list of every endpoint module.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately not reflection-based discovery. Explicit registration gives predictable startup,
|
||||||
|
/// survives trimming, and makes every route greppable — and a route that silently disappears
|
||||||
|
/// because an assembly was not scanned is a genuinely nasty failure. The cost is one line per
|
||||||
|
/// module.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class EndpointRegistration
|
||||||
|
{
|
||||||
|
internal static WebApplication MapDodoEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapMetaEndpoints();
|
||||||
|
|
||||||
|
// Registered as each feature lands:
|
||||||
|
// Identity — /me, enrollment, key rotation, devices
|
||||||
|
// Directory — public-key lookup
|
||||||
|
// Vaults — grants, rekey, ACL
|
||||||
|
// Sync — pull and push
|
||||||
|
// Relay — tickets and the WebSocket
|
||||||
|
// Teams, Audit, Admin
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>Database wiring.</summary>
|
||||||
|
internal static class Persistence
|
||||||
|
{
|
||||||
|
private const string ConnectionStringName = "Postgres";
|
||||||
|
|
||||||
|
internal static IServiceCollection AddDodoPersistence(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString(ConnectionStringName)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||||
|
|
||||||
|
services.AddDbContext<DodoDbContext>(options => options
|
||||||
|
.UseNpgsql(connectionString, npgsql => npgsql
|
||||||
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||||
|
// Retry on transient faults only. Note this is safe here because writes go
|
||||||
|
// through explicitly-managed transactions rather than relying on the execution
|
||||||
|
// strategy to replay ambient ones.
|
||||||
|
.EnableRetryOnFailure(3))
|
||||||
|
.UseSnakeCaseNamingConvention());
|
||||||
|
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddCheck<DatabaseHealthCheck>(
|
||||||
|
"postgres",
|
||||||
|
tags: [HealthChecks.ReadyTag, HealthChecks.StartupTag]);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the database reachable and the schema current.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pending migrations make the instance unready rather than crashing it. The API never migrates in
|
||||||
|
/// production — a separate migrator job does — so the correct response to a schema mismatch is to
|
||||||
|
/// stop taking traffic and say so loudly, not to attempt a repair.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DatabaseHealthCheck(DodoDbContext context, ILogger<DatabaseHealthCheck> logger)
|
||||||
|
: IHealthCheck
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext healthCheckContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Cannot connect to PostgreSQL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = await context.Database
|
||||||
|
.GetPendingMigrationsAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pendingList = pending.ToList();
|
||||||
|
if (pendingList.Count > 0)
|
||||||
|
{
|
||||||
|
// Computed once and used for both the log and the health result, so this is not
|
||||||
|
// work done only for logging.
|
||||||
|
var pendingDescription = string.Join(", ", pendingList);
|
||||||
|
|
||||||
|
StartupLog.PendingMigrations(logger, pendingList.Count, pendingDescription);
|
||||||
|
|
||||||
|
return HealthCheckResult.Unhealthy(
|
||||||
|
$"{pendingList.Count} migration(s) pending: {pendingDescription}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthCheckResult.Healthy();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("PostgreSQL health check failed.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source-generated log messages for startup and health.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>[LoggerMessage]</c> rather than <c>ILogger</c> extension calls: allocation-free, and the
|
||||||
|
/// event ids and message templates become a stable, greppable inventory instead of string literals
|
||||||
|
/// scattered through wiring code.
|
||||||
|
/// </remarks>
|
||||||
|
internal static partial class StartupLog
|
||||||
|
{
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 1001,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Oidc:RequireHttpsMetadata is false outside Development. Token signing keys are "
|
||||||
|
+ "being fetched over plaintext HTTP, so anyone on the network path can serve their "
|
||||||
|
+ "own keys and mint tokens this server will accept.")]
|
||||||
|
internal static partial void InsecureMetadataOutsideDevelopment(ILogger logger);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 1002,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Oidc:AllowEmailLinking is enabled. A token from any configured provider "
|
||||||
|
+ "carrying a victim's email address will be linked to that victim's existing account. "
|
||||||
|
+ "Only enable this when every configured provider verifies email ownership.")]
|
||||||
|
internal static partial void EmailLinkingEnabled(ILogger logger);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 1010,
|
||||||
|
Level = LogLevel.Critical,
|
||||||
|
Message = "Database schema is out of date: {PendingCount} migration(s) pending "
|
||||||
|
+ "({PendingMigrations}). Readiness will fail until the migrator has run.")]
|
||||||
|
internal static partial void PendingMigrations(
|
||||||
|
ILogger logger,
|
||||||
|
int pendingCount,
|
||||||
|
string pendingMigrations);
|
||||||
|
}
|
||||||
@@ -4,5 +4,21 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": "Host=localhost;Port=5432;Database=dodossh;Username=dodossh;Password=dodossh"
|
||||||
|
},
|
||||||
|
"Server": {
|
||||||
|
"PublicBaseUrl": "http://localhost:5233"
|
||||||
|
},
|
||||||
|
"Oidc": {
|
||||||
|
"Authority": "http://localhost:18080/realms/dodossh",
|
||||||
|
"Audience": "dodossh-api",
|
||||||
|
"ClientId": "dodossh-desktop",
|
||||||
|
"RequireHttpsMetadata": false
|
||||||
|
},
|
||||||
|
"Relay": {
|
||||||
|
"Enabled": true,
|
||||||
|
"WebSocketUrl": "ws://localhost:5233/api/v1/relay/connect"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,34 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": ""
|
||||||
|
},
|
||||||
|
"Server": {
|
||||||
|
"PublicBaseUrl": "http://localhost:5233"
|
||||||
|
},
|
||||||
|
"Oidc": {
|
||||||
|
"Authority": "",
|
||||||
|
"Audience": "dodossh-api",
|
||||||
|
"ClientId": "dodossh-desktop",
|
||||||
|
"LoopbackRedirectPattern": "http://127.0.0.1:*/callback",
|
||||||
|
"RequireHttpsMetadata": true,
|
||||||
|
"AllowEmailLinking": false
|
||||||
|
},
|
||||||
|
"Relay": {
|
||||||
|
"Enabled": false,
|
||||||
|
"AllowPrivateNetworks": true,
|
||||||
|
"MaxConcurrentSessionsPerUser": 10,
|
||||||
|
"MaxConcurrentSessionsTotal": 200
|
||||||
|
},
|
||||||
|
"Sync": {
|
||||||
|
"MaxOperationsPerPush": 500,
|
||||||
|
"MaxPayloadBytes": 8388608,
|
||||||
|
"MaxItemPayloadBytes": 262144
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
"resolved": "3.0.134",
|
"resolved": "3.0.134",
|
||||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||||
},
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[10.0.10, )",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "VAcqS42zb9WJd9DjPdkVTS5YrQENmNzPNJuRu8VAW7x3TEWUipc4d4hHzVJdFB0h/KLdr4XcXZzRHcUOKVanMQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.AspNetCore.OpenApi": {
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.10, )",
|
||||||
@@ -23,11 +32,162 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"Microsoft.Bcl.Cryptography": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "10.0.2",
|
||||||
|
"contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw=="
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw=="
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Logging": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Abstractions": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.Protocols": "8.19.2",
|
||||||
|
"System.IdentityModel.Tokens.Jwt": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.IdentityModel.Tokens": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Bcl.Cryptography": "10.0.2",
|
||||||
|
"Microsoft.IdentityModel.Logging": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Npgsql": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "10.0.3",
|
||||||
|
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
|
||||||
|
},
|
||||||
|
"System.IdentityModel.Tokens.Jwt": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.19.2",
|
||||||
|
"contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
|
||||||
|
"Microsoft.IdentityModel.Tokens": "8.19.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.contracts": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"dodossh.crypto": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"NSec.Cryptography": "[26.4.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.domain": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"dodossh.infrastructure": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"DodoSSH.Domain": "[1.0.0, )",
|
||||||
|
"EFCore.NamingConventions": "[10.0.1, )",
|
||||||
|
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EFCore.NamingConventions": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.1, )",
|
||||||
|
"resolved": "10.0.1",
|
||||||
|
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||||
|
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libsodium": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[1.0.22, )",
|
||||||
|
"resolved": "1.0.22",
|
||||||
|
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.10, )",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||||
|
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore.Relational": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.10, )",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.EntityFrameworkCore": "10.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.OpenApi": {
|
"Microsoft.OpenApi": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[2.11.0, )",
|
"requested": "[2.11.0, )",
|
||||||
"resolved": "2.11.0",
|
"resolved": "2.11.0",
|
||||||
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
||||||
|
},
|
||||||
|
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.3, )",
|
||||||
|
"resolved": "10.0.3",
|
||||||
|
"contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
|
||||||
|
"Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
|
||||||
|
"Npgsql": "10.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSec.Cryptography": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[26.4.0, )",
|
||||||
|
"resolved": "26.4.0",
|
||||||
|
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium": "[1.0.22, 1.0.23)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,14 @@
|
|||||||
<!-- CA1707: test method names use underscores by convention (Method_State_Expectation).
|
<!-- CA1707: test method names use underscores by convention (Method_State_Expectation).
|
||||||
CA2007: no SynchronizationContext in tests, ConfigureAwait is noise.
|
CA2007: no SynchronizationContext in tests, ConfigureAwait is noise.
|
||||||
CA1861: inline constant arrays in theories are clearer than static fields. -->
|
CA1861: inline constant arrays in theories are clearer than static fields. -->
|
||||||
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515</NoWarn>
|
<!--
|
||||||
|
MA0004 is the Meziantou equivalent of CA2007: there is no SynchronizationContext under
|
||||||
|
the test host, so ConfigureAwait is noise.
|
||||||
|
xUnit1051 wants TestContext.Current.CancellationToken threaded through every awaited
|
||||||
|
call. Worth doing for long-running suites; here the container fixture owns lifetime and
|
||||||
|
the assertions are short, so it buys nothing but churn.
|
||||||
|
-->
|
||||||
|
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515;MA0004;xUnit1051</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Test framework — every test project gets these">
|
<ItemGroup Label="Test framework — every test project gets these">
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ namespace DodoSSH.Infrastructure.Tests;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class PostgresFixture : IAsyncLifetime
|
public sealed class PostgresFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder()
|
// Image passed to the constructor: the parameterless overload is obsolete in
|
||||||
.WithImage("postgres:18-alpine")
|
// Testcontainers 4.13 and pinning the tag here keeps the test image in step with the one
|
||||||
|
// deploy/docker-compose.dev.yml uses.
|
||||||
|
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine")
|
||||||
.WithDatabase("dodossh")
|
.WithDatabase("dodossh")
|
||||||
.WithUsername("postgres")
|
.WithUsername("postgres")
|
||||||
.WithPassword("test")
|
.WithPassword("test")
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
public async Task User_IsUniquePerIssuerAndSubject()
|
public async Task User_IsUniquePerIssuerAndSubject()
|
||||||
{
|
{
|
||||||
await using var context = fixture.CreateContext();
|
await using var context = fixture.CreateContext();
|
||||||
var subject = Guid.NewGuid().ToString();
|
var subject = Guid.CreateVersion7().ToString();
|
||||||
|
|
||||||
context.Users.Add(NewUser("https://idp.example", subject));
|
context.Users.Add(NewUser("https://idp.example", subject));
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
@@ -329,7 +329,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
{
|
{
|
||||||
// Multi-issuer from the start: the issuer is part of the identity, not a detail.
|
// Multi-issuer from the start: the issuer is part of the identity, not a detail.
|
||||||
await using var context = fixture.CreateContext();
|
await using var context = fixture.CreateContext();
|
||||||
var subject = Guid.NewGuid().ToString();
|
var subject = Guid.CreateVersion7().ToString();
|
||||||
|
|
||||||
context.Users.Add(NewUser("https://idp-a.example", subject));
|
context.Users.Add(NewUser("https://idp-a.example", subject));
|
||||||
context.Users.Add(NewUser("https://idp-b.example", subject));
|
context.Users.Add(NewUser("https://idp-b.example", subject));
|
||||||
@@ -342,14 +342,14 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
{
|
{
|
||||||
// citext: an attacker must not be able to register Alice@x with alice@x already present.
|
// citext: an attacker must not be able to register Alice@x with alice@x already present.
|
||||||
await using var context = fixture.CreateContext();
|
await using var context = fixture.CreateContext();
|
||||||
var local = $"user{Guid.NewGuid():N}";
|
var local = $"user{Guid.CreateVersion7():N}";
|
||||||
|
|
||||||
var first = NewUser("https://idp.example", Guid.NewGuid().ToString());
|
var first = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
|
||||||
first.Email = $"{local}@example.com";
|
first.Email = $"{local}@example.com";
|
||||||
context.Users.Add(first);
|
context.Users.Add(first);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var second = NewUser("https://idp.example", Guid.NewGuid().ToString());
|
var second = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
|
||||||
second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
|
second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
|
||||||
context.Users.Add(second);
|
context.Users.Add(second);
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
|
|
||||||
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
|
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
|
||||||
{
|
{
|
||||||
var user = NewUser("https://idp.example", Guid.NewGuid().ToString());
|
var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
|
||||||
context.Users.Add(user);
|
context.Users.Add(user);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
return user;
|
return user;
|
||||||
@@ -482,7 +482,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7(),
|
Id = Guid.CreateVersion7(),
|
||||||
Name = "Team",
|
Name = "Team",
|
||||||
Slug = $"team-{Guid.NewGuid():N}",
|
Slug = $"team-{Guid.CreateVersion7():N}",
|
||||||
CreatedByUserId = createdBy,
|
CreatedByUserId = createdBy,
|
||||||
CreatedAtUtc = Now,
|
CreatedAtUtc = Now,
|
||||||
};
|
};
|
||||||
@@ -551,7 +551,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
|||||||
Generation = generation,
|
Generation = generation,
|
||||||
EncryptionPublicKey = new byte[32],
|
EncryptionPublicKey = new byte[32],
|
||||||
SigningPublicKey = new byte[32],
|
SigningPublicKey = new byte[32],
|
||||||
FingerprintSha256 = Guid.NewGuid().ToByteArray().Concat(Guid.NewGuid().ToByteArray()).ToArray(),
|
FingerprintSha256 = Guid.CreateVersion7().ToByteArray().Concat(Guid.CreateVersion7().ToByteArray()).ToArray(),
|
||||||
Statement = "{}",
|
Statement = "{}",
|
||||||
StatementSignature = new byte[64],
|
StatementSignature = new byte[64],
|
||||||
IsCurrent = isCurrent,
|
IsCurrent = isCurrent,
|
||||||
|
|||||||
Reference in New Issue
Block a user