From 49564c7e7e9eeded3ae5a2f10cf698fa319e5afe Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 05:58:47 +0200 Subject: [PATCH] feat: add presence-gated session leases (#7) Closes #7 --- docs/api/rendezvous-v1.json | 261 +++++++++- .../0004-atomic-ephemeral-state.md | 5 +- .../0005-session-lease-lifecycle.md | 91 ++++ docs/architecture/README.md | 1 + .../ContractValidation.cs | 6 +- .../Http/SessionContracts.cs | 9 + .../Http/ContractEndpoints.cs | 203 +++++++- .../Http/RendezvousExceptionHandler.cs | 37 ++ src/FinalFactory.Rendezvous.Server/Program.cs | 61 ++- .../Sessions/EphemeralCapabilityIssuer.cs | 158 ++++++ .../Sessions/SessionLeaseService.cs | 452 ++++++++++++++++++ .../State/EphemeralStateContracts.cs | 54 ++- .../State/InMemoryEphemeralRendezvousStore.cs | 70 ++- .../Transport/UdpMediatorService.cs | 73 ++- .../Contracts/ContractLimitTests.cs | 9 + .../Contracts/ContractSerializationTests.cs | 1 + .../Contracts/OpenApiCompatibilityTests.cs | 21 + .../Server/UdpMediatorServiceTests.cs | 45 +- .../Sessions/SessionHttpEndpointTests.cs | 145 ++++++ .../Sessions/SessionLeaseServiceTests.cs | 294 ++++++++++++ .../Sessions/SessionLeaseTestData.cs | 93 ++++ .../State/EphemeralStateTestData.cs | 6 +- .../InMemoryEphemeralRendezvousStoreTests.cs | 16 +- .../Contracts/v1/contracts-public-api.txt | 3 + .../v1/register-session-response.json | 1 + 25 files changed, 2069 insertions(+), 46 deletions(-) create mode 100644 docs/architecture/0005-session-lease-lifecycle.md create mode 100644 src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs create mode 100644 src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs create mode 100644 src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session-response.json diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index 93b870a..90965bf 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -75,8 +75,68 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -85,7 +145,12 @@ } } } - } + }, + "security": [ + { + "PublisherBearer": [ ] + } + ] }, "get": { "tags": [ @@ -211,8 +276,68 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -221,7 +346,12 @@ } } } - } + }, + "security": [ + { + "PublisherBearer": [ ] + } + ] } }, "/v1/sessions/{listingId}": { @@ -254,8 +384,48 @@ "204": { "description": "No Content" }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -264,7 +434,12 @@ } } } - } + }, + "security": [ + { + "PublisherBearer": [ ] + } + ] }, "delete": { "tags": [ @@ -295,8 +470,38 @@ "204": { "description": "No Content" }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -305,7 +510,12 @@ } } } - } + }, + "security": [ + { + "PublisherBearer": [ ] + } + ] }, "get": { "tags": [ @@ -875,7 +1085,9 @@ "leaseToken", "hostPresenceHandle", "hostPresenceCapability", - "expiresAt" + "expiresAt", + "leaseRenewAfterSeconds", + "hostPresenceRefreshAfterSeconds" ], "type": "object", "properties": { @@ -901,6 +1113,14 @@ "expiresAt": { "type": "string", "format": "date-time" + }, + "leaseRenewAfterSeconds": { + "type": "integer", + "format": "int32" + }, + "hostPresenceRefreshAfterSeconds": { + "type": "integer", + "format": "int32" } } }, @@ -942,7 +1162,8 @@ "RenewLeaseResponse": { "required": [ "contractVersion", - "expiresAt" + "expiresAt", + "renewAfterSeconds" ], "type": "object", "properties": { @@ -953,6 +1174,10 @@ "expiresAt": { "type": "string", "format": "date-time" + }, + "renewAfterSeconds": { + "type": "integer", + "format": "int32" } } }, @@ -1115,6 +1340,14 @@ } } } + }, + "securitySchemes": { + "PublisherBearer": { + "type": "http", + "description": "Tenant-scoped publisher credential issued during game provisioning.", + "scheme": "bearer", + "bearerFormat": "rv1 publisher credential" + } } }, "tags": [ diff --git a/docs/architecture/0004-atomic-ephemeral-state.md b/docs/architecture/0004-atomic-ephemeral-state.md index b382370..f69969a 100644 --- a/docs/architecture/0004-atomic-ephemeral-state.md +++ b/docs/architecture/0004-atomic-ephemeral-state.md @@ -41,11 +41,12 @@ observe the store. ### Concurrency and idempotency -- Listing registration and join-attempt creation use an owner-scoped idempotency +- Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency key plus a canonical request fingerprint. An exact duplicate returns the original live result; reuse with different input returns `Conflict`; replay after the resource has expired returns `Expired` until the bounded idempotency - record itself expires. + record itself expires. Configuration requires idempotency retention to cover + every listing and attempt lifetime, preventing a live duplicate after eviction. - Lease renewal is compare-and-swap by version. A stale renewal returns the latest version as `Conflict`. Renew/delete races are serialized: renewal either commits before deletion or observes the listing as absent. diff --git a/docs/architecture/0005-session-lease-lifecycle.md b/docs/architecture/0005-session-lease-lifecycle.md new file mode 100644 index 0000000..5d98813 --- /dev/null +++ b/docs/architecture/0005-session-lease-lifecycle.md @@ -0,0 +1,91 @@ +# ADR 0005: authenticated session lease and presence lifecycle + +- Status: Accepted +- Date: 2026-07-16 +- Tracking: #7 + +## Context + +A host needs to publish a player-facing session without letting an HTTP request +claim a public endpoint or remain visible after the gameplay socket disappears. +Registration retries must be safe, credentials must remain opaque, and policy or +ownership checks cannot race state mutation. + +## Decision + +The four host HTTP operations require `Authorization: Bearer `. +The signed principal supplies the authoritative game, environment, publisher trust +mode, subject, and allowed regions. Request fields never widen that scope. Creation +and update apply the enabled `GamePolicy` to exact protocol, region, visibility, +bounded display/build/capacity values, and the allowlisted metadata schema. + +Capacity reported by a host is advisory directory information. Rendezvous bounds +and publishes it but never treats it as final admission authority; the game host +still decides identity, bans, reserved slots, and whether a connection may join. + +```mermaid +stateDiagram-v2 + [*] --> AwaitingPresence: authorized register + AwaitingPresence --> Listed: valid host UDP presence + Listed --> AwaitingPresence: presence deadline passes + AwaitingPresence --> AwaitingPresence: lease renew or data update + Listed --> Listed: lease renew, data update, or presence refresh + AwaitingPresence --> Removed: lease expiry or delete + Listed --> Removed: lease expiry or delete + Removed --> [*] +``` + +Registration returns a listing ID, lease ID/token, host-presence handle/capability, +lease expiry, a 30-second renewal suggestion, and a 10-second presence-refresh +suggestion. The authoritative ceilings remain 60 seconds for the lease and 20 +seconds for presence. Timing suggestions are server-controlled, not client-selected. + +The lease token and presence capability are 256-bit opaque values derived with +HMAC-SHA256 from an in-memory per-process secret, a purpose label, the publisher +subject, the idempotency key, a canonical request fingerprint, and a random +per-registration derivation salt. Opaque IDs use separate purpose labels. Exact +retries read the retained non-secret salt and therefore reproduce the original +response without retaining plaintext credentials. Once the bounded idempotency +record expires, a new salt rotates IDs and capabilities so an old token cannot +regain authority. Metadata order is canonicalized before fingerprinting. The store +retains the salt and only a second keyed fingerprint of each token. Restart rotates +the derivation secret while the matching ephemeral state disappears. + +Renew, update, and delete require both the same publisher subject and the lease +capability. Cross-owner or wrong-capability access returns the same not-found shape. +Update may change display name, build label, advisory capacity, and metadata only; +game, environment, region, protocol, visibility, trust mode, and opaque IDs remain +canonical. Delete is idempotent and does not reveal whether another publisher owns +the supplied ID. + +### UDP presence + +Only a structurally valid `HostPresence` datagram with the issued capability can +refresh presence. The public endpoint is the UDP packet's observed source on the +host's gameplay socket; the HTTP API never accepts one. The bounded local candidate +comes from the authenticated datagram. Invalid, unknown, or client-presence packets +receive no response. Presence expiry demotes public visibility but keeps the lease, +so the same handle can restore visibility without changing session identity. + +Public listing responses contain bounded listing data only. They never contain +public/local endpoints, lease tokens, presence capabilities, fingerprints, store +keys, or canonical player identity. + +## Failure semantics + +- malformed or policy-invalid fields return a stable typed `InvalidRequest`; +- an unsupported gameplay protocol returns `IncompatibleProtocol`; +- missing/invalid publisher authentication returns `AuthenticationRequired`; +- cross-scope authorization returns `Forbidden` without resource disclosure; +- wrong owner/capability or expired state returns the tenant-hidden `NotFound`; +- idempotency reuse with changed input returns `Conflict`; +- publisher/global exhaustion returns `CapacityExceeded`; and +- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join. + +## Consequences + +- HTTP registration alone can never make a public session browseable. +- Plaintext session capabilities are returned to the intended host but are not + retained, logged, included in public listing DTOs, or exported as metrics. +- Re-registration after restart is the recovery path; there is no durable session + identity or gameplay state in Rendezvous. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index cce10b0..f008ab2 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -7,6 +7,7 @@ decision requires a superseding ADR and corresponding contract/test updates. - [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md) - [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md) - [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md) +- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md) - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.md) - [Versioned HTTP and UDP contracts](../contracts/README.md) diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs index 3e8d345..3dcb126 100644 --- a/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs +++ b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs @@ -46,10 +46,12 @@ public static class ContractValidation value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters); public static bool IsBuildVersionValid(string? value) => - IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); + !string.IsNullOrWhiteSpace(value) + && IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); public static bool IsDisplayNameValid(string? value) => - IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes); + !string.IsNullOrWhiteSpace(value) + && IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes); public static bool IsOpaqueHttpCredentialValid(string? value) => value is not null diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs index 3a5cf2a..ef08673 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs @@ -99,6 +99,12 @@ public sealed class RegisterSessionResponse [JsonRequired] public DateTimeOffset ExpiresAt { get; set; } + + [JsonRequired] + public int LeaseRenewAfterSeconds { get; set; } + + [JsonRequired] + public int HostPresenceRefreshAfterSeconds { get; set; } } public sealed class RenewLeaseRequest @@ -117,6 +123,9 @@ public sealed class RenewLeaseResponse [JsonRequired] public DateTimeOffset ExpiresAt { get; set; } + + [JsonRequired] + public int RenewAfterSeconds { get; set; } } public sealed class UpdateSessionRequest diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs index a7d6eb8..06bd39d 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs @@ -1,4 +1,7 @@ using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; using Microsoft.AspNetCore.Mvc; namespace FinalFactory.Rendezvous.Server.Http; @@ -14,22 +17,41 @@ internal static class ContractEndpoints sessions.MapPost("/", RegisterSession) .Accepts("application/json") .Produces(StatusCodes.Status201Created) - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status410Gone) + .Produces(StatusCodes.Status429TooManyRequests) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RegisterSession"); sessions.MapPost("/{listingId}/renew", RenewLease) .Accepts("application/json") .Produces() - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status410Gone) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RenewSessionLease"); sessions.MapPut("/{listingId}", UpdateSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("UpdateSession"); sessions.MapDelete("/{listingId}", DeleteSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("DeleteSession"); sessions.MapGet("/", BrowseSessions) .Produces() @@ -61,20 +83,115 @@ internal static class ContractEndpoints return endpoints; } - private static IResult RegisterSession([FromBody] RegisterSessionRequest request) => - NotImplemented(); + private static IResult RegisterSession( + [FromBody] RegisterSessionRequest request, + [FromHeader(Name = "Authorization")] string? authorizationHeader, + [FromServices] PrincipalCredentialService credentials, + [FromServices] SessionLeaseService sessions, + [FromServices] IWallClock clock, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (!TryAuthenticatePublisher( + authorizationHeader, + credentials, + clock, + out AuthenticatedPrincipal? principal)) + { + return AuthenticationRequired(httpContext); + } + + SessionServiceResult result = sessions.Register( + principal!, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value) + : Error(result.Error); + } private static IResult RenewLease( SessionListingId listingId, - [FromBody] RenewLeaseRequest request) => NotImplemented(); + [FromBody] RenewLeaseRequest request, + [FromHeader(Name = "Authorization")] string? authorizationHeader, + [FromServices] PrincipalCredentialService credentials, + [FromServices] SessionLeaseService sessions, + [FromServices] IWallClock clock, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (!TryAuthenticatePublisher( + authorizationHeader, + credentials, + clock, + out AuthenticatedPrincipal? principal)) + { + return AuthenticationRequired(httpContext); + } + + SessionServiceResult result = sessions.Renew( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } private static IResult UpdateSession( SessionListingId listingId, - [FromBody] UpdateSessionRequest request) => NotImplemented(); + [FromBody] UpdateSessionRequest request, + [FromHeader(Name = "Authorization")] string? authorizationHeader, + [FromServices] PrincipalCredentialService credentials, + [FromServices] SessionLeaseService sessions, + [FromServices] IWallClock clock, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (!TryAuthenticatePublisher( + authorizationHeader, + credentials, + clock, + out AuthenticatedPrincipal? principal)) + { + return AuthenticationRequired(httpContext); + } + + SessionServiceResult result = sessions.Update( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } private static IResult DeleteSession( SessionListingId listingId, - [FromBody] DeleteSessionRequest request) => NotImplemented(); + [FromBody] DeleteSessionRequest request, + [FromHeader(Name = "Authorization")] string? authorizationHeader, + [FromServices] PrincipalCredentialService credentials, + [FromServices] SessionLeaseService sessions, + [FromServices] IWallClock clock, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (!TryAuthenticatePublisher( + authorizationHeader, + credentials, + clock, + out AuthenticatedPrincipal? principal)) + { + return AuthenticationRequired(httpContext); + } + + SessionServiceResult result = sessions.Delete( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } private static IResult BrowseSessions( [FromQuery] int contractVersion, @@ -109,4 +226,72 @@ internal static class ContractEndpoints }, ContractJson.Options, statusCode: NotImplementedStatus); + + private static bool TryAuthenticatePublisher( + string? authorizationHeader, + PrincipalCredentialService credentials, + IWallClock clock, + out AuthenticatedPrincipal? principal) + { + principal = null; + const string bearerPrefix = "Bearer "; + if (authorizationHeader is null + || !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string token = authorizationHeader[bearerPrefix.Length..]; + CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow); + if (!validation.IsValid || validation.Principal is not IPublisherPrincipal) + { + return false; + } + + principal = validation.Principal; + return true; + } + + private static IResult Error(RendezvousErrorCode code) => Results.Json( + new ApiError + { + Code = code, + Message = ErrorMessage(code), + }, + ContractJson.Options, + statusCode: ErrorStatus(code)); + + private static IResult AuthenticationRequired(HttpContext context) + { + context.Response.Headers.WWWAuthenticate = "Bearer"; + return Error(RendezvousErrorCode.AuthenticationRequired); + } + + private static int ErrorStatus(RendezvousErrorCode code) => code switch + { + RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, + RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden, + RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound, + RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, + RendezvousErrorCode.Expired => StatusCodes.Status410Gone, + RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded => + StatusCodes.Status429TooManyRequests, + RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError, + _ => StatusCodes.Status400BadRequest, + }; + + private static string ErrorMessage(RendezvousErrorCode code) => code switch + { + RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.", + RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.", + RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.", + RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.", + RendezvousErrorCode.Expired => "The session lease has expired.", + RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", + RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.", + RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", + RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.", + _ => "The session request is invalid.", + }; } diff --git a/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs new file mode 100644 index 0000000..9ee41e4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using Microsoft.AspNetCore.Diagnostics; + +namespace FinalFactory.Rendezvous.Server.Http; + +internal sealed class RendezvousExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (httpContext.Response.HasStarted) + { + return false; + } + + bool invalidRequest = exception is BadHttpRequestException or JsonException; + httpContext.Response.StatusCode = invalidRequest + ? StatusCodes.Status400BadRequest + : StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsJsonAsync( + new ApiError + { + Code = invalidRequest + ? RendezvousErrorCode.InvalidRequest + : RendezvousErrorCode.InternalError, + Message = invalidRequest + ? "The request body, route, or query value is invalid." + : "The service could not complete the request.", + }, + ContractJson.Options, + cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index ff55247..d335395 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -2,6 +2,7 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.Transport; using Microsoft.OpenApi; @@ -13,6 +14,7 @@ bool isOpenApiGeneration = string.Equals( StringComparison.Ordinal); builder.Services.AddOpenApi("v1", static options => +{ options.AddSchemaTransformer(static (schema, context, cancellationToken) => { Type type = context.JsonTypeInfo.Type; @@ -32,16 +34,65 @@ builder.Services.AddOpenApi("v1", static options => } return Task.CompletedTask; - })); + }); + options.AddDocumentTransformer(static (document, context, cancellationToken) => + { + const string schemeName = "PublisherBearer"; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= + new Dictionary(StringComparer.Ordinal); + document.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "rv1 publisher credential", + Description = "Tenant-scoped publisher credential issued during game provisioning.", + }; + + HashSet securedOperations = new(StringComparer.Ordinal) + { + "RegisterSession", + "RenewSessionLease", + "UpdateSession", + "DeleteSession", + }; + OpenApiSecuritySchemeReference reference = new(schemeName, document, null); + foreach (OpenApiPathItem path in document.Paths.Values) + { + if (path.Operations is null) + { + continue; + } + + foreach (OpenApiOperation operation in path.Operations.Values.Where( + operation => securedOperations.Contains(operation.OperationId ?? string.Empty))) + { + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [reference] = [], + }); + } + } + + return Task.CompletedTask; + }); +}); builder.Services.ConfigureHttpJsonOptions(static options => ContractJson.Configure(options.SerializerOptions)); +builder.Services.Configure(static options => + options.ThrowOnBadRequest = true); +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); SystemRendezvousClock rendezvousClock = new(); +EphemeralStoreOptions stateOptions = new(); InMemoryEphemeralRendezvousStore stateStore = new( - new EphemeralStoreOptions(), + stateOptions, rendezvousClock, rendezvousClock); builder.Services.AddSingleton(stateStore); +builder.Services.AddSingleton(rendezvousClock); if (isOpenApiGeneration) { @@ -63,6 +114,11 @@ else builder.Services.AddSingleton(provisioning.Policies); builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.PublisherAuthorization); + EphemeralCapabilityIssuer sessionCapabilities = new(); + builder.Services.AddSingleton(sessionCapabilities); + builder.Services.AddSingleton(sessionCapabilities); + builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); + builder.Services.AddSingleton(); builder.Services.AddSingleton(new ProvisioningReadiness(true)); } @@ -84,6 +140,7 @@ if (!isOpenApiGeneration) WebApplication app = builder.Build(); app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain()); +app.UseExceptionHandler(); app.MapOpenApi(); app.MapRendezvousContractEndpoints(); app.MapGet( diff --git a/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs b/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs new file mode 100644 index 0000000..2f6783d --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Sessions/EphemeralCapabilityIssuer.cs @@ -0,0 +1,158 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Server.Sessions; + +internal interface ISessionCapabilityService +{ + string CreateDerivationSalt(); + string DeriveCapability( + string purpose, + string ownerSubject, + string idempotencyKey, + string requestFingerprint, + string derivationSalt); + Guid DeriveGuid( + string purpose, + string ownerSubject, + string idempotencyKey, + string requestFingerprint, + string derivationSalt); + bool TryFingerprint(string? capability, out SecretFingerprint fingerprint); +} + +internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable +{ + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + private bool _disposed; + + public string CreateDerivationSalt() + { + ObjectDisposedException.ThrowIf(_disposed, this); + byte[] salt = RandomNumberGenerator.GetBytes(32); + try + { + return Encode(salt); + } + finally + { + CryptographicOperations.ZeroMemory(salt); + } + } + + public string DeriveCapability( + string purpose, + string ownerSubject, + string idempotencyKey, + string requestFingerprint, + string derivationSalt) + { + byte[] digest = Derive( + purpose, + ownerSubject, + idempotencyKey, + requestFingerprint, + derivationSalt); + try + { + return Encode(digest); + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } + + public Guid DeriveGuid( + string purpose, + string ownerSubject, + string idempotencyKey, + string requestFingerprint, + string derivationSalt) + { + byte[] digest = Derive( + purpose, + ownerSubject, + idempotencyKey, + requestFingerprint, + derivationSalt); + try + { + Span guidBytes = digest.AsSpan(0, 16); + guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80); + guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80); + return new Guid(guidBytes); + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } + + public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint) + { + fingerprint = default; + if (_disposed + || capability is null + || capability.Length != 43 + || capability.Any(static character => + character is not (>= 'A' and <= 'Z') + and not (>= 'a' and <= 'z') + and not (>= '0' and <= '9') + and not '-' + and not '_')) + { + return false; + } + + byte[] digest = Derive("fingerprint", capability); + try + { + fingerprint = new SecretFingerprint(Encode(digest)); + return true; + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + CryptographicOperations.ZeroMemory(_key); + } + + public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]"; + + private byte[] Derive(params string[] segments) + { + ObjectDisposedException.ThrowIf(_disposed, this); + using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key); + Span length = stackalloc byte[sizeof(int)]; + foreach (string segment in segments) + { + ArgumentException.ThrowIfNullOrEmpty(segment); + byte[] encoded = Encoding.UTF8.GetBytes(segment); + BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length); + hmac.AppendData(length); + hmac.AppendData(encoded); + CryptographicOperations.ZeroMemory(encoded); + } + + return hmac.GetHashAndReset(); + } + + private static string Encode(ReadOnlySpan bytes) => Convert + .ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +} diff --git a/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs new file mode 100644 index 0000000..979f456 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs @@ -0,0 +1,452 @@ +using System.Security.Cryptography; +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Server.Sessions; + +internal sealed record SessionLeaseTiming( + int LeaseRenewAfterSeconds, + int HostPresenceRefreshAfterSeconds) +{ + public static SessionLeaseTiming From(EphemeralStoreOptions options) => new( + Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)), + Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2))); +} + +internal sealed record SessionServiceResult(RendezvousErrorCode Error, T? Value = default) +{ + public bool Succeeded => Error == RendezvousErrorCode.None; +} + +internal sealed class SessionLeaseService( + PublisherAuthorizationService authorization, + IEphemeralRendezvousStore store, + ISessionCapabilityService capabilities, + SessionLeaseTiming timing, + IWallClock clock) +{ + public SessionServiceResult Register( + AuthenticatedPrincipal principal, + RegisterSessionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(principal); + ArgumentNullException.ThrowIfNull(request); + RendezvousErrorCode validation = ValidateRegistration(request); + if (validation != RendezvousErrorCode.None) + { + return new(validation); + } + + PublisherAuthorizationResult authorized = authorization.Authorize( + principal, + request.GameId, + request.EnvironmentId, + request.RegionId, + request.ProtocolVersion, + request.Visibility, + request.Metadata, + clock.UtcNow); + if (!authorized.IsAllowed || authorized.Context is null) + { + return new(MapAuthorization(authorized.Error)); + } + + AuthorizedPublisherContext context = authorized.Context; + string requestFingerprint = ComputeRegistrationFingerprint(request); + string derivationSalt = capabilities.CreateDerivationSalt(); + string leaseToken = capabilities.DeriveCapability( + "lease-token", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt); + string presenceCapability = capabilities.DeriveCapability( + "host-presence", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt); + if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint) + || !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint)) + { + throw new InvalidOperationException("Derived session capabilities could not be fingerprinted."); + } + + SessionListingId listingId = new(capabilities.DeriveGuid( + "listing-id", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt)); + LeaseId leaseId = new(capabilities.DeriveGuid( + "lease-id", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt)); + MediationHandle presenceHandle = new(capabilities.DeriveGuid( + "presence-handle", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt)); + int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted + ? context.Policy.MaxAnonymousListingsPerAddress + : context.Policy.MaxListingsPerPrincipal; + if (ownerLimit <= 0) + { + return new(RendezvousErrorCode.CapacityExceeded); + } + + StoreResult created = store.CreateListing(new( + request.IdempotencyKey, + requestFingerprint, + new ListingDefinition + { + ListingId = listingId, + LeaseId = leaseId, + Scope = new(context.GameId, context.EnvironmentId), + OwnerSubject = context.Subject, + RegionId = context.RegionId, + ProtocolVersion = context.ProtocolVersion, + BuildVersion = request.BuildVersion, + DisplayName = request.DisplayName, + Visibility = context.Visibility, + TrustMode = context.TrustMode, + CurrentPlayers = request.Capacity.CurrentPlayers, + MaximumPlayers = request.Capacity.MaximumPlayers, + Metadata = request.Metadata, + LeaseFingerprint = leaseFingerprint, + HostPresenceHandle = presenceHandle, + HostPresenceFingerprint = presenceFingerprint, + CapabilityDerivationSalt = derivationSalt, + }, + ownerLimit), cancellationToken); + if (!created.Succeeded || created.Value is null) + { + return new(MapStore(created.Code)); + } + + ListingDefinition persisted = created.Value.Definition; + leaseToken = capabilities.DeriveCapability( + "lease-token", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + persisted.CapabilityDerivationSalt); + presenceCapability = capabilities.DeriveCapability( + "host-presence", + context.Subject, + request.IdempotencyKey, + requestFingerprint, + persisted.CapabilityDerivationSalt); + + return new(RendezvousErrorCode.None, new RegisterSessionResponse + { + ListingId = persisted.ListingId, + LeaseId = persisted.LeaseId, + LeaseToken = leaseToken, + HostPresenceHandle = persisted.HostPresenceHandle, + HostPresenceCapability = presenceCapability, + ExpiresAt = created.Value.LeaseExpiresAt, + LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds, + HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds, + }); + } + + public SessionServiceResult Renew( + AuthenticatedPrincipal principal, + SessionListingId listingId, + RenewLeaseRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(principal); + ArgumentNullException.ThrowIfNull(request); + RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); + if (validation != RendezvousErrorCode.None) + { + return new(validation); + } + + RendezvousErrorCode lookup = GetAuthorizedListing( + principal, + listingId, + request.LeaseToken, + cancellationToken, + out StoredListing? listing); + if (lookup != RendezvousErrorCode.None) + { + return new(lookup); + } + + StoredListing ownedListing = listing!; + PublisherAuthorizationResult authorized = AuthorizeExisting( + principal, + ownedListing, + ownedListing.Definition.Metadata); + if (!authorized.IsAllowed) + { + return new(MapAuthorization(authorized.Error)); + } + + capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); + StoreResult renewed = store.RenewLease(new( + listingId, + ownedListing.Definition.LeaseId, + fingerprint, + ownedListing.Definition.OwnerSubject, + ownedListing.Version), cancellationToken); + return renewed.Succeeded && renewed.Value is not null + ? new(RendezvousErrorCode.None, new RenewLeaseResponse + { + ExpiresAt = renewed.Value.LeaseExpiresAt, + RenewAfterSeconds = timing.LeaseRenewAfterSeconds, + }) + : new(MapStore(renewed.Code)); + } + + public SessionServiceResult Update( + AuthenticatedPrincipal principal, + SessionListingId listingId, + UpdateSessionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(principal); + ArgumentNullException.ThrowIfNull(request); + RendezvousErrorCode validation = ValidateUpdate(request); + if (validation != RendezvousErrorCode.None) + { + return new(validation); + } + + RendezvousErrorCode lookup = GetAuthorizedListing( + principal, + listingId, + request.LeaseToken, + cancellationToken, + out StoredListing? listing); + if (lookup != RendezvousErrorCode.None) + { + return new(lookup); + } + + StoredListing ownedListing = listing!; + PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); + if (!authorized.IsAllowed) + { + return new(MapAuthorization(authorized.Error)); + } + + capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); + StoreResult updated = store.UpdateListing(new( + listingId, + ownedListing.Definition.LeaseId, + fingerprint, + ownedListing.Definition.OwnerSubject, + request.BuildVersion, + request.DisplayName, + request.Capacity.CurrentPlayers, + request.Capacity.MaximumPlayers, + request.Metadata), cancellationToken); + return updated.Succeeded + ? new(RendezvousErrorCode.None, true) + : new(MapStore(updated.Code)); + } + + public SessionServiceResult Delete( + AuthenticatedPrincipal principal, + SessionListingId listingId, + DeleteSessionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(principal); + ArgumentNullException.ThrowIfNull(request); + RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); + if (validation != RendezvousErrorCode.None) + { + return new(validation); + } + + if (principal is not IPublisherPrincipal publisher + || !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint)) + { + return new(RendezvousErrorCode.Forbidden); + } + + StoreResult found = store.GetListing(listingId, false, cancellationToken); + if (!found.Succeeded || found.Value is null) + { + return found.Code == StoreResultCode.ServiceUnavailable + ? new(RendezvousErrorCode.ServiceUnavailable) + : new(RendezvousErrorCode.None, true); + } + + StoreResult deleted = store.DeleteListing(new( + listingId, + found.Value.Definition.LeaseId, + fingerprint, + publisher.Subject), cancellationToken); + return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound + ? new(RendezvousErrorCode.None, true) + : new(MapStore(deleted.Code)); + } + + private RendezvousErrorCode GetAuthorizedListing( + AuthenticatedPrincipal principal, + SessionListingId listingId, + string leaseToken, + CancellationToken cancellationToken, + out StoredListing? listing) + { + listing = null; + if (principal is not IPublisherPrincipal publisher) + { + return RendezvousErrorCode.Forbidden; + } + + if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint)) + { + return RendezvousErrorCode.NotFound; + } + + StoreResult found = store.GetListing(listingId, false, cancellationToken); + if (!found.Succeeded || found.Value is null) + { + return MapStore(found.Code); + } + + if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal) + || found.Value.Definition.LeaseFingerprint != fingerprint) + { + return RendezvousErrorCode.NotFound; + } + + listing = found.Value; + return RendezvousErrorCode.None; + } + + private PublisherAuthorizationResult AuthorizeExisting( + AuthenticatedPrincipal principal, + StoredListing listing, + IReadOnlyDictionary metadata) => authorization.Authorize( + principal, + listing.Definition.Scope.GameId, + listing.Definition.Scope.EnvironmentId, + listing.Definition.RegionId, + listing.Definition.ProtocolVersion, + listing.Definition.Visibility, + metadata, + clock.UtcNow); + + private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request) + { + RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); + if (version != RendezvousErrorCode.None) + { + return version; + } + + return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey) + || string.IsNullOrEmpty(request.GameId.Value) + || string.IsNullOrEmpty(request.EnvironmentId.Value) + || string.IsNullOrEmpty(request.RegionId.Value) + || request.ProtocolVersion == 0 + || !ContractValidation.IsBuildVersionValid(request.BuildVersion) + || !ContractValidation.IsDisplayNameValid(request.DisplayName) + || !Enum.IsDefined(request.Visibility) + || !ContractValidation.IsCapacityValid(request.Capacity) + || !ContractValidation.IsMetadataValid(request.Metadata) + ? RendezvousErrorCode.InvalidRequest + : RendezvousErrorCode.None; + } + + private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request) + { + RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken); + if (lease != RendezvousErrorCode.None) + { + return lease; + } + + return !ContractValidation.IsBuildVersionValid(request.BuildVersion) + || !ContractValidation.IsDisplayNameValid(request.DisplayName) + || !ContractValidation.IsCapacityValid(request.Capacity) + || !ContractValidation.IsMetadataValid(request.Metadata) + ? RendezvousErrorCode.InvalidRequest + : RendezvousErrorCode.None; + } + + private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken) + { + RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion); + if (version != RendezvousErrorCode.None) + { + return version; + } + + return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken) + ? RendezvousErrorCode.None + : RendezvousErrorCode.InvalidRequest; + } + + private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch + { + PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired, + PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol, + PublisherAuthorizationError.RegionNotAllowed + or PublisherAuthorizationError.VisibilityNotAllowed + or PublisherAuthorizationError.AnonymousMustBeUnlisted + or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest, + _ => RendezvousErrorCode.Forbidden, + }; + + private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch + { + StoreResultCode.NotFound => RendezvousErrorCode.NotFound, + StoreResultCode.Expired => RendezvousErrorCode.Expired, + StoreResultCode.Revoked => RendezvousErrorCode.Forbidden, + StoreResultCode.Conflict => RendezvousErrorCode.Conflict, + StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded, + StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected, + StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable, + _ => RendezvousErrorCode.InternalError, + }; + + private static string ComputeRegistrationFingerprint(RegisterSessionRequest request) + { + RegisterSessionRequest canonical = new() + { + ContractVersion = request.ContractVersion, + IdempotencyKey = request.IdempotencyKey, + GameId = request.GameId, + EnvironmentId = request.EnvironmentId, + RegionId = request.RegionId, + ProtocolVersion = request.ProtocolVersion, + BuildVersion = request.BuildVersion, + DisplayName = request.DisplayName, + Visibility = request.Visibility, + Capacity = new SessionCapacity + { + CurrentPlayers = request.Capacity.CurrentPlayers, + MaximumPlayers = request.Capacity.MaximumPlayers, + }, + Metadata = request.Metadata + .OrderBy(static item => item.Key, StringComparer.Ordinal) + .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal), + }; + byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options); + byte[] digest = SHA256.HashData(encoded); + CryptographicOperations.ZeroMemory(encoded); + try + { + return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + finally + { + CryptographicOperations.ZeroMemory(digest); + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs index 645733e..411c22a 100644 --- a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs +++ b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs @@ -53,6 +53,12 @@ internal sealed record EphemeralStoreOptions RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime)); RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime)); RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime)); + if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime) + { + throw new ArgumentOutOfRangeException( + nameof(IdempotencyLifetime), + "Idempotency retention must cover every idempotent resource lifetime."); + } } private static void RequirePositive(int value, string name) @@ -74,8 +80,10 @@ internal sealed record EphemeralStoreOptions internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId); -internal readonly record struct SecretFingerprint +internal readonly struct SecretFingerprint : IEquatable { + private readonly string? _value; + public SecretFingerprint(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > 128) @@ -83,12 +91,33 @@ internal readonly record struct SecretFingerprint throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value)); } - Value = value; + _value = value; } - public string Value { get; } - public bool IsValid => !string.IsNullOrWhiteSpace(Value) && Value.Length <= 128; + public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128; + public bool Equals(SecretFingerprint other) + { + ReadOnlySpan left = _value.AsSpan(); + ReadOnlySpan right = other._value.AsSpan(); + if (left.Length != right.Length) + { + return false; + } + + int difference = 0; + for (int index = 0; index < left.Length; index++) + { + difference |= left[index] ^ right[index]; + } + + return difference == 0; + } + + public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty); public override string ToString() => "[REDACTED]"; + public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right); + public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right); } internal readonly record struct ObservedEndpoint @@ -138,6 +167,7 @@ internal sealed record ListingDefinition public required SecretFingerprint LeaseFingerprint { get; init; } public required MediationHandle HostPresenceHandle { get; init; } public required SecretFingerprint HostPresenceFingerprint { get; init; } + public required string CapabilityDerivationSalt { get; init; } } internal sealed record StoredListing @@ -163,12 +193,25 @@ internal sealed record RenewLeaseCommand( SessionListingId ListingId, LeaseId LeaseId, SecretFingerprint LeaseFingerprint, + string OwnerSubject, long ExpectedVersion); +internal sealed record UpdateListingCommand( + SessionListingId ListingId, + LeaseId LeaseId, + SecretFingerprint LeaseFingerprint, + string OwnerSubject, + string BuildVersion, + string DisplayName, + int CurrentPlayers, + int MaximumPlayers, + IReadOnlyDictionary Metadata); + internal sealed record DeleteListingCommand( SessionListingId ListingId, LeaseId LeaseId, - SecretFingerprint LeaseFingerprint); + SecretFingerprint LeaseFingerprint, + string OwnerSubject); internal sealed record BindHostPresenceCommand( MediationHandle Handle, @@ -265,6 +308,7 @@ internal interface IEphemeralRendezvousStore StoreResult CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default); StoreResult RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default); + StoreResult UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default); StoreResult DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default); StoreResult GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default); StoreResult> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default); diff --git a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs index f558118..b67ab6e 100644 --- a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs +++ b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs @@ -80,7 +80,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return admission; } - string idempotencyKey = $"listing:{command.Listing.OwnerSubject}:{command.IdempotencyKey}"; + string idempotencyKey = $"listing:{command.Listing.Scope.GameId}:{command.Listing.Scope.EnvironmentId}:{command.Listing.OwnerSubject}:{command.IdempotencyKey}"; if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous)) { if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal)) @@ -151,7 +151,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto } if (entry.Definition.LeaseId != command.LeaseId - || entry.Definition.LeaseFingerprint != command.LeaseFingerprint) + || entry.Definition.LeaseFingerprint != command.LeaseFingerprint + || !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal)) { return new(StoreResultCode.NotFound); } @@ -167,6 +168,52 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return new(StoreResultCode.Success, Snapshot(entry)); }, cancellationToken); + public StoreResult UpdateListing( + UpdateListingCommand command, + CancellationToken cancellationToken = default) => Atomic(_ => + { + ArgumentNullException.ThrowIfNull(command); + ValidateSubject(command.OwnerSubject, nameof(command.OwnerSubject)); + if (!ContractValidation.IsBuildVersionValid(command.BuildVersion) + || !ContractValidation.IsDisplayNameValid(command.DisplayName) + || command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers + || command.CurrentPlayers < 0 + || command.CurrentPlayers > command.MaximumPlayers + || !ContractValidation.IsMetadataValid(command.Metadata)) + { + throw new ArgumentException("Listing update invariants are invalid.", nameof(command)); + } + + if (!_available) + { + return new(StoreResultCode.ServiceUnavailable); + } + + if (_drainDeadline.HasValue) + { + return new(StoreResultCode.Draining); + } + + if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry) + || entry.Definition.LeaseId != command.LeaseId + || entry.Definition.LeaseFingerprint != command.LeaseFingerprint + || !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal)) + { + return new(StoreResultCode.NotFound); + } + + entry.Definition = StoredListing.Freeze(entry.Definition with + { + BuildVersion = command.BuildVersion, + DisplayName = command.DisplayName, + CurrentPlayers = command.CurrentPlayers, + MaximumPlayers = command.MaximumPlayers, + Metadata = command.Metadata, + }); + entry.Version++; + return new(StoreResultCode.Success, Snapshot(entry)); + }, cancellationToken); + public StoreResult DeleteListing( DeleteListingCommand command, CancellationToken cancellationToken = default) => Atomic(_ => @@ -174,7 +221,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto ArgumentNullException.ThrowIfNull(command); if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry) || entry.Definition.LeaseId != command.LeaseId - || entry.Definition.LeaseFingerprint != command.LeaseFingerprint) + || entry.Definition.LeaseFingerprint != command.LeaseFingerprint + || !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal)) { return new(StoreResultCode.NotFound); } @@ -287,7 +335,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return admission; } - string idempotencyKey = $"attempt:{command.IdempotencyOwner}:{command.IdempotencyKey}"; + string idempotencyKey = $"attempt:{command.Scope.GameId}:{command.Scope.EnvironmentId}:{command.IdempotencyOwner}:{command.IdempotencyKey}"; if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous)) { if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal)) @@ -699,7 +747,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || listing.CurrentPlayers > listing.MaximumPlayers || !ContractValidation.IsMetadataValid(listing.Metadata) || !listing.LeaseFingerprint.IsValid - || !listing.HostPresenceFingerprint.IsValid) + || !listing.HostPresenceFingerprint.IsValid + || !IsDerivationSaltValid(listing.CapabilityDerivationSalt)) { throw new ArgumentException("Listing invariants are invalid.", nameof(listing)); } @@ -753,6 +802,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto private static bool IsScopeValid(TenantScope scope) => !string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value); + private static bool IsDerivationSaltValid(string? value) => value is not null + && value.Length == 43 + && value.All(static character => + character is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-' + or '_'); + private static void ValidateSubject(string subject, string parameterName) { if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256) @@ -767,7 +825,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto DateTimeOffset wallExpiresAt, long version) { - public ListingDefinition Definition { get; } = definition; + public ListingDefinition Definition { get; set; } = definition; public TimeSpan LeaseDeadline { get; set; } = leaseDeadline; public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt; public long Version { get; set; } = version; diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs index 11232df..5a5422f 100644 --- a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs +++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs @@ -1,5 +1,8 @@ using System.Net; using System.Net.Sockets; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Server.Transport; @@ -7,10 +10,12 @@ namespace FinalFactory.Rendezvous.Server.Transport; /// /// Owns the cancellable UDP socket used by the future NAT mediator. /// -public sealed partial class UdpMediatorService : BackgroundService +internal sealed partial class UdpMediatorService : BackgroundService { private readonly ILogger _logger; private readonly UdpMediatorOptions _options; + private readonly IEphemeralRendezvousStore _store; + private readonly ISessionCapabilityService _capabilities; private UdpClient? _udpClient; /// @@ -18,10 +23,14 @@ public sealed partial class UdpMediatorService : BackgroundService /// public UdpMediatorService( IOptions options, - ILogger logger) + ILogger logger, + IEphemeralRendezvousStore store, + ISessionCapabilityService capabilities) { _options = options.Value; _logger = logger; + _store = store; + _capabilities = capabilities; } /// @@ -81,7 +90,10 @@ public sealed partial class UdpMediatorService : BackgroundService { while (!stoppingToken.IsCancellationRequested) { - _ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false); + UdpReceiveResult received = await udpClient + .ReceiveAsync(stoppingToken) + .ConfigureAwait(false); + ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken); // Bootstrap deliberately emits no UDP response. Protocol handling lands in #11. } } @@ -99,6 +111,53 @@ public sealed partial class UdpMediatorService : BackgroundService } } + internal UdpPresenceProcessingResult ProcessDatagram( + ReadOnlySpan encoded, + IPEndPoint observedSource, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(observedSource); + if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _) + || datagram is null + || !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint)) + { + return UdpPresenceProcessingResult.Dropped; + } + + if (datagram.MessageType != UdpPresenceMessageType.HostPresence) + { + return UdpPresenceProcessingResult.ClientPresenceDeferred; + } + + AddressFamilyKind publicFamily = observedSource.AddressFamily switch + { + AddressFamily.InterNetwork => AddressFamilyKind.Ipv4, + AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6, + _ => 0, + }; + if (publicFamily == 0) + { + return UdpPresenceProcessingResult.Dropped; + } + + ObservedEndpoint publicEndpoint = new( + publicFamily, + observedSource.Address.ToString(), + observedSource.Port); + ObservedEndpoint localEndpoint = new( + datagram.AddressFamily, + datagram.LocalAddress, + datagram.LocalPort); + StoreResult bound = _store.BindHostPresence(new( + datagram.MediationHandle, + fingerprint, + publicEndpoint, + localEndpoint), cancellationToken); + return bound.Succeeded + ? UdpPresenceProcessingResult.HostPresenceAccepted + : UdpPresenceProcessingResult.HostPresenceRejected; + } + [LoggerMessage( EventId = 1, Level = LogLevel.Information, @@ -114,3 +173,11 @@ public sealed partial class UdpMediatorService : BackgroundService Message = "UDP mediator stopped")] private static partial void LogMediatorStopped(ILogger logger); } + +internal enum UdpPresenceProcessingResult +{ + Dropped = 0, + HostPresenceAccepted = 1, + HostPresenceRejected = 2, + ClientPresenceDeferred = 3, +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs index ce8fa70..e0325a5 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs @@ -4,6 +4,15 @@ namespace FinalFactory.Rendezvous.Tests.Contracts; public sealed class ContractLimitTests { + [Fact] + public void RequiredPlayerFacingTextRejectsEmptyOrWhitespaceValues() + { + Assert.False(ContractValidation.IsBuildVersionValid(string.Empty)); + Assert.False(ContractValidation.IsBuildVersionValid(" ")); + Assert.False(ContractValidation.IsDisplayNameValid(string.Empty)); + Assert.False(ContractValidation.IsDisplayNameValid(" ")); + } + [Fact] public void ByteAndCollectionLimitsAcceptTheBoundaryOnly() { diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs index ef92215..957dc25 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs @@ -8,6 +8,7 @@ public sealed class ContractSerializationTests public static TheoryData GoldenJsonVectors => new() { { "register-session.json", typeof(RegisterSessionRequest) }, + { "register-session-response.json", typeof(RegisterSessionResponse) }, { "browse-sessions.json", typeof(BrowseSessionsResponse) }, { "create-join-response.json", typeof(CreateJoinAttemptResponse) }, { "api-error.json", typeof(ApiError) }, diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs index 55ac51b..0c09eb1 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs @@ -64,5 +64,26 @@ public sealed class OpenApiCompatibilityTests property.Contains("token", StringComparison.OrdinalIgnoreCase) || property.Contains("endpoint", StringComparison.OrdinalIgnoreCase) || property.Contains("playerId", StringComparison.OrdinalIgnoreCase)); + + JsonElement publisherBearer = root.GetProperty("components") + .GetProperty("securitySchemes") + .GetProperty("PublisherBearer"); + Assert.Equal("http", publisherBearer.GetProperty("type").GetString()); + Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString()); + (string Path, string Method)[] publisherOperations = + [ + ("/v1/sessions", "post"), + ("/v1/sessions/{listingId}", "put"), + ("/v1/sessions/{listingId}", "delete"), + ("/v1/sessions/{listingId}/renew", "post"), + ]; + foreach ((string operationPath, string method) in publisherOperations) + { + JsonElement security = root.GetProperty("paths") + .GetProperty(operationPath) + .GetProperty(method) + .GetProperty("security"); + Assert.True(security[0].TryGetProperty("PublisherBearer", out _)); + } } } diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs index 87030f8..28ad77c 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs @@ -1,5 +1,10 @@ using System.Net; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.Transport; +using FinalFactory.Rendezvous.Tests.Sessions; +using FinalFactory.Rendezvous.Tests.State; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -7,6 +12,39 @@ namespace FinalFactory.Rendezvous.Tests.Server; public sealed class UdpMediatorServiceTests { + [Fact] + public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + using UdpMediatorService service = new( + Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }), + NullLogger.Instance, + fixture.Store, + fixture.Capabilities); + PresenceDatagram presence = new() + { + MessageType = UdpPresenceMessageType.HostPresence, + MediationHandle = registration.HostPresenceHandle, + AddressFamily = AddressFamilyKind.Ipv4, + LocalAddress = "192.168.1.50", + LocalPort = 40_000, + Capability = registration.HostPresenceCapability, + }; + IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234); + presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + Assert.Equal( + UdpPresenceProcessingResult.HostPresenceRejected, + service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket)); + Assert.Empty(fixture.Browse()); + + presence.Capability = registration.HostPresenceCapability; + Assert.Equal( + UdpPresenceProcessingResult.HostPresenceAccepted, + service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket)); + Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId); + } + [Fact] public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly() { @@ -16,9 +54,14 @@ public sealed class UdpMediatorServiceTests ListenAddress = IPAddress.Loopback.ToString(), Port = 0, }; + ManualRendezvousClock clock = new(); + InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock); + using EphemeralCapabilityIssuer capabilities = new(); using UdpMediatorService service = new( Options.Create(options), - NullLogger.Instance); + NullLogger.Instance, + store, + capabilities); await service.StartAsync(timeout.Token); diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs new file mode 100644 index 0000000..caaef87 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Http; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; +using FinalFactory.Rendezvous.Tests.State; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace FinalFactory.Rendezvous.Tests.Sessions; + +public sealed class SessionHttpEndpointTests +{ + [Fact] + public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses() + { + ManualRendezvousClock clock = new(ProvisioningTestData.Now); + EphemeralStoreOptions stateOptions = new(); + InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); + EphemeralCapabilityIssuer capabilities = new(); + ProvisioningRuntime provisioning = ProvisioningRuntime.Create( + ProvisioningTestData.CreateOptions(), + ProvisioningTestData.CreateSecrets("secret-1"), + clock.UtcNow); + DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(); + string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(static options => + ContractJson.Configure(options.SerializerOptions)); + builder.Services.Configure(static options => + options.ThrowOnBadRequest = true); + builder.Services.AddProblemDetails(); + builder.Services.AddExceptionHandler(); + builder.Services.AddSingleton(provisioning); + builder.Services.AddSingleton(provisioning.Credentials); + builder.Services.AddSingleton(provisioning.PublisherAuthorization); + builder.Services.AddSingleton(store); + builder.Services.AddSingleton(clock); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); + builder.Services.AddSingleton(); + await using WebApplication app = builder.Build(); + app.UseExceptionHandler(); + app.MapRendezvousContractEndpoints(); + await app.StartAsync(); + IServer server = app.Services.GetRequiredService(); + string address = Assert.Single(server.Features.Get()!.Addresses); + using HttpClient client = new() { BaseAddress = new Uri(address) }; + RegisterSessionRequest registration = new() + { + IdempotencyKey = "http-register-1", + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.4.2", + DisplayName = "HTTP host", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "co-op", + }, + }; + + HttpResponseMessage unauthenticated = await client.PostAsJsonAsync( + "/v1/sessions", + registration, + ContractJson.Options); + Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode); + Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme); + ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync( + ContractJson.Options); + Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + publisherCredential); + string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options) + .Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal); + HttpResponseMessage invalid = await client.PostAsync( + "/v1/sessions", + new StringContent(invalidJson, Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + ApiError? invalidError = await invalid.Content.ReadFromJsonAsync(ContractJson.Options); + Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code); + + HttpResponseMessage created = await client.PostAsJsonAsync( + "/v1/sessions", + registration, + ContractJson.Options); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync( + ContractJson.Options); + Assert.NotNull(session); + Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString); + + HttpResponseMessage renewed = await client.PostAsJsonAsync( + $"/v1/sessions/{session.ListingId}/renew", + new RenewLeaseRequest { LeaseToken = session.LeaseToken }, + ContractJson.Options); + Assert.Equal(HttpStatusCode.OK, renewed.StatusCode); + Assert.NotNull(await renewed.Content.ReadFromJsonAsync(ContractJson.Options)); + + HttpResponseMessage updated = await client.PutAsJsonAsync( + $"/v1/sessions/{session.ListingId}", + new UpdateSessionRequest + { + LeaseToken = session.LeaseToken, + BuildVersion = "1.4.3", + DisplayName = "HTTP host updated", + Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, + Metadata = new Dictionary { ["mode"] = "co-op" }, + }, + ContractJson.Options); + Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode); + + using HttpRequestMessage deleteRequest = new( + HttpMethod.Delete, + $"/v1/sessions/{session.ListingId}") + { + Content = JsonContent.Create( + new DeleteSessionRequest { LeaseToken = session.LeaseToken }, + options: ContractJson.Options), + }; + HttpResponseMessage deleted = await client.SendAsync(deleteRequest); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code); + + await app.StopAsync(); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs new file mode 100644 index 0000000..65c6e2f --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs @@ -0,0 +1,294 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Tests.Sessions; + +public sealed class SessionLeaseServiceTests +{ + [Fact] + public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence() + { + using SessionLeaseFixture fixture = new(); + + RegisterSessionResponse response = fixture.Register(); + + Assert.Equal(30, response.LeaseRenewAfterSeconds); + Assert.Equal(10, response.HostPresenceRefreshAfterSeconds); + Assert.Equal(43, response.LeaseToken.Length); + Assert.Equal(43, response.HostPresenceCapability.Length); + Assert.Empty(fixture.Browse()); + string json = JsonSerializer.Serialize(response, ContractJson.Options); + Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionRequest request = fixture.Request("same-key"); + + RegisterSessionResponse first = fixture.Register(request); + RegisterSessionRequest reordered = fixture.Request("same-key"); + reordered.Metadata = new Dictionary(StringComparer.Ordinal) + { + ["map"] = "europa", + ["mode"] = "co-op", + }; + RegisterSessionResponse duplicate = fixture.Register(reordered); + RegisterSessionRequest changedRequest = fixture.Request("same-key"); + changedRequest.DisplayName = "Changed"; + SessionServiceResult changed = fixture.Service.Register( + fixture.Principal, + changedRequest); + + Assert.Equal(first.ListingId, duplicate.ListingId); + Assert.Equal(first.LeaseId, duplicate.LeaseId); + Assert.Equal(first.LeaseToken, duplicate.LeaseToken); + Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle); + Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability); + Assert.Equal(RendezvousErrorCode.Conflict, changed.Error); + } + + [Fact] + public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities() + { + EphemeralStoreOptions options = new() + { + LeaseLifetime = TimeSpan.FromSeconds(5), + JoinAttemptLifetime = TimeSpan.FromSeconds(5), + IdempotencyLifetime = TimeSpan.FromSeconds(6), + }; + using SessionLeaseFixture fixture = new(options); + RegisterSessionRequest request = fixture.Request("reused-after-expiry"); + RegisterSessionResponse first = fixture.Register(request); + + fixture.Clock.Advance(options.IdempotencyLifetime); + RegisterSessionResponse second = fixture.Register(request); + + Assert.NotEqual(first.ListingId, second.ListingId); + Assert.NotEqual(first.LeaseId, second.LeaseId); + Assert.NotEqual(first.LeaseToken, second.LeaseToken); + Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability); + } + + [Fact] + public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + + Assert.Empty(fixture.Browse()); + Assert.True(fixture.BindPresence(registration).Succeeded); + Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId); + + fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime); + Assert.Empty(fixture.Browse()); + Assert.True(fixture.BindPresence(registration).Succeeded); + Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId); + } + + [Fact] + public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + fixture.Clock.Advance(TimeSpan.FromSeconds(1)); + + SessionServiceResult renewed = fixture.Service.Renew( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }); + SessionServiceResult updated = fixture.Service.Update( + fixture.Principal, + registration.ListingId, + new() + { + LeaseToken = registration.LeaseToken, + BuildVersion = "1.4.3", + DisplayName = "Europa Updated", + Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "co-op", + ["map"] = "europa", + }, + }); + StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!; + + Assert.True(renewed.Succeeded); + Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt); + Assert.True(updated.Succeeded); + Assert.Equal(registration.ListingId, stored.Definition.ListingId); + Assert.Equal(fixture.Scope, stored.Definition.Scope); + Assert.Equal(8, stored.Definition.CurrentPlayers); + Assert.Equal(8, stored.Definition.MaximumPlayers); + Assert.True(fixture.Service.Delete( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Succeeded); + Assert.True(fixture.Service.Delete( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Succeeded); + Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); + } + + [Fact] + public void AnotherPublisherCannotRenewUpdateOrDeleteListing() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2"); + + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew( + other, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Error); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update( + other, + registration.ListingId, + new() + { + LeaseToken = registration.LeaseToken, + BuildVersion = "1.4.3", + DisplayName = "Hijacked", + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 }, + Metadata = new Dictionary { ["mode"] = "co-op" }, + }).Error); + Assert.True(fixture.Service.Delete( + other, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Succeeded); + Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded); + } + + [Fact] + public async Task ConcurrentRenewDeleteCannotResurrectListing() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + using ManualResetEventSlim start = new(false); + Task> renew = Task.Run(() => + { + start.Wait(); + return fixture.Service.Renew( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }); + }); + Task> delete = Task.Run(() => + { + start.Wait(); + return fixture.Service.Delete( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }); + }); + + start.Set(); + await Task.WhenAll(renew, delete); + SessionServiceResult renewResult = await renew; + SessionServiceResult deleteResult = await delete; + + Assert.True(deleteResult.Succeeded); + Assert.Contains(renewResult.Error, new[] + { + RendezvousErrorCode.None, + RendezvousErrorCode.NotFound, + RendezvousErrorCode.Conflict, + }); + Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); + } + + [Fact] + public void AbandonedRegistrationExpiresAndFreesBoundedCapacity() + { + EphemeralStoreOptions options = new() + { + MaxListings = 1, + LeaseLifetime = TimeSpan.FromSeconds(5), + }; + using SessionLeaseFixture fixture = new(options); + fixture.Register(fixture.Request("first")); + Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register( + fixture.Principal, + fixture.Request("second")).Error); + + fixture.Clock.Advance(options.LeaseLifetime); + + Assert.True(fixture.Service.Register( + fixture.Principal, + fixture.Request("second")).Succeeded); + } + + [Fact] + public void LeaseExpiryRemovesMutationAndPresencePaths() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + fixture.BindPresence(registration); + + fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime); + + Assert.Empty(fixture.Browse()); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Error); + Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code); + } + + [Fact] + public void LossOfAtomicStateFailsLeaseMutationClosed() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionResponse registration = fixture.Register(); + fixture.Store.MarkUnavailable(); + + Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew( + fixture.Principal, + registration.ListingId, + new() { LeaseToken = registration.LeaseToken }).Error); + } + + [Fact] + public void InvalidPolicyBoundInputsReturnStableTypedErrors() + { + using SessionLeaseFixture fixture = new(); + RegisterSessionRequest capacity = fixture.Request("bad-capacity"); + capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 }; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( + fixture.Principal, + capacity).Error); + RegisterSessionRequest protocol = fixture.Request("bad-protocol"); + protocol.ProtocolVersion = 8; + Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register( + fixture.Principal, + protocol).Error); + RegisterSessionRequest region = fixture.Request("bad-region"); + region.RegionId = new("us-east"); + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( + fixture.Principal, + region).Error); + RegisterSessionRequest visibility = fixture.Request("bad-visibility"); + visibility.Visibility = (ListingVisibility)99; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( + fixture.Principal, + visibility).Error); + RegisterSessionRequest metadata = fixture.Request("bad-metadata"); + metadata.Metadata = new Dictionary { ["unknown"] = "value" }; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( + fixture.Principal, + metadata).Error); + RegisterSessionRequest build = fixture.Request("bad-build"); + build.BuildVersion = " "; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( + fixture.Principal, + build).Error); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs new file mode 100644 index 0000000..5483dc5 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs @@ -0,0 +1,93 @@ +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; +using FinalFactory.Rendezvous.Tests.State; + +namespace FinalFactory.Rendezvous.Tests.Sessions; + +internal sealed class SessionLeaseFixture : IDisposable +{ + private int _sequence; + + public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null) + { + StoreOptions = storeOptions ?? new EphemeralStoreOptions(); + Clock = new(); + Store = new(StoreOptions, Clock, Clock); + Capabilities = new(); + GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); + Service = new( + new PublisherAuthorizationService(policies), + Store, + Capabilities, + SessionLeaseTiming.From(StoreOptions), + Clock); + Principal = Publisher("publisher-1"); + } + + public EphemeralStoreOptions StoreOptions { get; } + public ManualRendezvousClock Clock { get; } + public InMemoryEphemeralRendezvousStore Store { get; } + public EphemeralCapabilityIssuer Capabilities { get; } + public SessionLeaseService Service { get; } + public DedicatedPublisherPrincipal Principal { get; } + public TenantScope Scope { get; } = new(new("space-game"), new("production")); + + public DedicatedPublisherPrincipal Publisher(string subject) => new( + subject, + Clock.UtcNow.AddMinutes(10), + Scope.GameId, + Scope.EnvironmentId, + new HashSet { new("eu-central") }); + + public RegisterSessionRequest Request(string? idempotencyKey = null) => new() + { + IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}", + GameId = Scope.GameId, + EnvironmentId = Scope.EnvironmentId, + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.4.2", + DisplayName = "Europa Relay", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "co-op", + ["map"] = "europa", + }, + }; + + public RegisterSessionResponse Register( + RegisterSessionRequest? request = null, + DedicatedPublisherPrincipal? principal = null) + { + SessionServiceResult result = Service.Register( + principal ?? Principal, + request ?? Request()); + Assert.True(result.Succeeded); + Assert.NotNull(result.Value); + return result.Value; + } + + public StoreResult BindPresence(RegisterSessionResponse registration) + { + Assert.True(Capabilities.TryFingerprint( + registration.HostPresenceCapability, + out SecretFingerprint fingerprint)); + return Store.BindHostPresence(new( + registration.HostPresenceHandle, + fingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000), + new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000))); + } + + public IReadOnlyList Browse() => Store.BrowseVisibleListings(new( + Scope, + 7, + new RegionId("eu-central"))).Value!; + + public void Dispose() => Capabilities.Dispose(); +} diff --git a/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs b/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs index e13a805..7a9a44e 100644 --- a/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs +++ b/tests/FinalFactory.Rendezvous.Tests/State/EphemeralStateTestData.cs @@ -5,7 +5,10 @@ namespace FinalFactory.Rendezvous.Tests.State; internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock { - public DateTimeOffset UtcNow { get; private set; } = new(2026, 7, 16, 0, 0, 0, TimeSpan.Zero); + public ManualRendezvousClock(DateTimeOffset? utcNow = null) => + UtcNow = utcNow ?? new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero); + + public DateTimeOffset UtcNow { get; private set; } public TimeSpan Elapsed { get; private set; } public void Advance(TimeSpan duration) @@ -58,6 +61,7 @@ internal sealed class EphemeralStateFixture LeaseFingerprint = Fingerprint($"lease-{sequence}"), HostPresenceHandle = NewHandle(), HostPresenceFingerprint = Fingerprint($"presence-{sequence}"), + CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", }); } diff --git a/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs b/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs index aa4754a..e54391d 100644 --- a/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs @@ -5,6 +5,16 @@ namespace FinalFactory.Rendezvous.Tests.State; public sealed class InMemoryEphemeralRendezvousStoreTests { + [Fact] + public void IdempotencyRetentionMustCoverResourceLifetimes() + { + ManualRendezvousClock clock = new(); + Assert.Throws(() => new InMemoryEphemeralRendezvousStore( + new EphemeralStoreOptions { IdempotencyLifetime = TimeSpan.FromSeconds(5) }, + clock, + clock)); + } + [Fact] public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts() { @@ -49,6 +59,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests listing.Definition.ListingId, listing.Definition.LeaseId, listing.Definition.LeaseFingerprint, + listing.Definition.OwnerSubject, listing.Version)); Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt); fixture.Clock.MoveWall(TimeSpan.FromDays(-60)); @@ -72,6 +83,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests listing.Definition.ListingId, listing.Definition.LeaseId, listing.Definition.LeaseFingerprint, + listing.Definition.OwnerSubject, listing.Version)); }); Task> delete = Task.Run(() => @@ -80,7 +92,8 @@ public sealed class InMemoryEphemeralRendezvousStoreTests return fixture.Store.DeleteListing(new( command.Listing.ListingId, command.Listing.LeaseId, - command.Listing.LeaseFingerprint)); + command.Listing.LeaseFingerprint, + command.Listing.OwnerSubject)); }); start.Set(); @@ -102,6 +115,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests listing.Definition.ListingId, listing.Definition.LeaseId, listing.Definition.LeaseFingerprint, + listing.Definition.OwnerSubject, listing.Version); StoreResult first = fixture.Store.RenewLease(command); diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt index cec8d9b..e20fad4 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt @@ -217,7 +217,9 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse PROP System.DateTimeOffset ExpiresAt {get;set;} PROP System.String HostPresenceCapability {get;set;} PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;} + PROP System.Int32 HostPresenceRefreshAfterSeconds {get;set;} PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;} + PROP System.Int32 LeaseRenewAfterSeconds {get;set;} PROP System.String LeaseToken {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;} TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode @@ -250,6 +252,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse CTOR () PROP System.Int32 ContractVersion {get;set;} PROP System.DateTimeOffset ExpiresAt {get;set;} + PROP System.Int32 RenewAfterSeconds {get;set;} TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest CTOR () PROP System.Int32 ContractVersion {get;set;} diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session-response.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session-response.json new file mode 100644 index 0000000..09ab8bb --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session-response.json @@ -0,0 +1 @@ +{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","leaseId":"11112233-4455-6677-8899-aabbccddeeff","leaseToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","hostPresenceHandle":"22222233-4455-6677-8899-aabbccddeeff","hostPresenceCapability":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","expiresAt":"2026-07-16T12:01:00+00:00","leaseRenewAfterSeconds":30,"hostPresenceRefreshAfterSeconds":10}