Skip to content

Commit d275e32

Browse files
committed
Remove RequireHost from account API endpoints so back-office host serves them post-split
1 parent 99b8f9c commit d275e32

12 files changed

Lines changed: 34 additions & 44 deletions

application/account/Api/Endpoints/AuthenticationEndpoints.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public sealed class AuthenticationEndpoints : IEndpoints
1313

1414
public void MapEndpoints(IEndpointRouteBuilder routes)
1515
{
16-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
17-
18-
var group = routes.MapGroup(RoutesPrefix).WithTags("Authentication").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
16+
var group = routes.MapGroup(RoutesPrefix).WithTags("Authentication").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1917

2018
group.MapPost("/logout", async Task<ApiResult> (IMediator mediator)
2119
=> await mediator.Send(new LogoutCommand())
@@ -34,8 +32,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
3432
);
3533

3634
// Note: This endpoint must be called with the refresh token as Bearer token in the Authorization header.
37-
// Internal-only endpoint (BlockInternalApiTransform rejects external callers); skips RequireHost so
38-
// backend-to-backend callers using the cluster's localhost address still reach it.
35+
// Internal-only endpoint reachable backend-to-backend via the cluster's localhost address.
36+
// BlockInternalApiTransform in AppGateway rejects external callers.
3937
routes.MapPost("/internal-api/account/authentication/refresh-authentication-tokens", async Task<ApiResult> (IMediator mediator)
4038
=> await mediator.Send(new RefreshAuthenticationTokensCommand())
4139
).WithGroupName(OpenApiDocumentNames.Account).DisableAntiforgery();

application/account/Api/Endpoints/BillingEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ public sealed class BillingEndpoints : IEndpoints
1212

1313
public void MapEndpoints(IEndpointRouteBuilder routes)
1414
{
15-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
16-
17-
var group = routes.MapGroup(RoutesPrefix).WithTags("Billing").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
15+
var group = routes.MapGroup(RoutesPrefix).WithTags("Billing").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1816

1917
group.MapGet("/payment-history", async Task<ApiResult<PaymentHistoryResponse>> ([AsParameters] GetPaymentHistoryQuery query, IMediator mediator)
2018
=> await mediator.Send(query)

application/account/Api/Endpoints/EmailAuthenticationEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ public sealed class EmailAuthenticationEndpoints : IEndpoints
1212

1313
public void MapEndpoints(IEndpointRouteBuilder routes)
1414
{
15-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
16-
17-
var group = routes.MapGroup(RoutesPrefix).WithTags("EmailAuthentication").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
15+
var group = routes.MapGroup(RoutesPrefix).WithTags("EmailAuthentication").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1816

1917
group.MapPost("/login/start", async Task<ApiResult<StartEmailLoginResponse>> (StartEmailLoginCommand command, IMediator mediator)
2018
=> await mediator.Send(command)

application/account/Api/Endpoints/ExternalAuthenticationEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public sealed class ExternalAuthenticationEndpoints : IEndpoints
1313

1414
public void MapEndpoints(IEndpointRouteBuilder routes)
1515
{
16-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
17-
18-
var group = routes.MapGroup(RoutesPrefix).WithTags("ExternalAuthentication").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
16+
var group = routes.MapGroup(RoutesPrefix).WithTags("ExternalAuthentication").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1917

2018
group.MapGet("/{provider}/login/start", async Task<ApiResult<string>> (ExternalProviderType provider, [AsParameters] StartExternalLoginCommand command, IMediator mediator)
2119
=> await mediator.Send(command with { ProviderType = provider })

application/account/Api/Endpoints/StripeWebhookEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public sealed class StripeWebhookEndpoints : IEndpoints
1313

1414
public void MapEndpoints(IEndpointRouteBuilder routes)
1515
{
16-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
17-
18-
var group = routes.MapGroup(RoutesPrefix).WithTags("StripeWebhook").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
16+
var group = routes.MapGroup(RoutesPrefix).WithTags("StripeWebhook").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1917

2018
// Two-phase webhook processing with pessimistic locking requires inline logic beyond 3-line convention
2119
group.MapPost("/", async Task<ApiResult> (HttpRequest request, IMediator mediator, ProcessPendingStripeEvents processPendingStripeEvents) =>

application/account/Api/Endpoints/SubscriptionEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ public sealed class SubscriptionEndpoints : IEndpoints
1212

1313
public void MapEndpoints(IEndpointRouteBuilder routes)
1414
{
15-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
16-
17-
var group = routes.MapGroup(RoutesPrefix).WithTags("Subscriptions").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
15+
var group = routes.MapGroup(RoutesPrefix).WithTags("Subscriptions").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1816

1917
group.MapGet("/pricing-catalog", async Task<ApiResult<PricingCatalogResponse>> ([AsParameters] GetPricingCatalogQuery query, IMediator mediator)
2018
=> await mediator.Send(query)

application/account/Api/Endpoints/TenantEndpoints.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public sealed class TenantEndpoints : IEndpoints
1313

1414
public void MapEndpoints(IEndpointRouteBuilder routes)
1515
{
16-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
17-
18-
var group = routes.MapGroup(RoutesPrefix).WithTags("Tenants").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
16+
var group = routes.MapGroup(RoutesPrefix).WithTags("Tenants").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1917

2018
group.MapGet("/current", async Task<ApiResult<TenantResponse>> (IMediator mediator)
2119
=> await mediator.Send(new GetCurrentTenantQuery())
@@ -37,8 +35,8 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
3735
=> await mediator.Send(new RemoveTenantLogoCommand())
3836
);
3937

40-
// Internal-only endpoint (BlockInternalApiTransform rejects external callers); skips RequireHost so
41-
// backend-to-backend callers using the cluster's localhost address still reach it.
38+
// Internal-only endpoint reachable backend-to-backend via the cluster's localhost address.
39+
// BlockInternalApiTransform in AppGateway rejects external callers.
4240
routes.MapDelete("/internal-api/account/tenants/{id}", async Task<ApiResult> (TenantId id, IMediator mediator)
4341
=> await mediator.Send(new DeleteTenantCommand(id))
4442
).WithGroupName(OpenApiDocumentNames.Account);

application/account/Api/Endpoints/UserEndpoints.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public sealed class UserEndpoints : IEndpoints
1313

1414
public void MapEndpoints(IEndpointRouteBuilder routes)
1515
{
16-
var appHost = routes.ServiceProvider.GetRequiredService<IConfiguration>()["Hostnames:App"]!;
17-
18-
var group = routes.MapGroup(RoutesPrefix).WithTags("Users").WithGroupName(OpenApiDocumentNames.Account).RequireHost(appHost).RequireAuthorization().ProducesValidationProblem();
16+
var group = routes.MapGroup(RoutesPrefix).WithTags("Users").WithGroupName(OpenApiDocumentNames.Account).RequireAuthorization().ProducesValidationProblem();
1917

2018
group.MapGet("/", async Task<ApiResult<UsersResponse>> ([AsParameters] GetUsersQuery query, IMediator mediator)
2119
=> await mediator.Send(query)

application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ namespace Account.Tests.ArchitectureTests;
1111

1212
// Walks the actual registered endpoints in Account.Api and enforces:
1313
// - Every endpoint under /api/back-office/* must declare RequireHost on the back-office host.
14-
// - Every public endpoint under /api/account/* must declare RequireHost on the user-facing host.
15-
// (/internal-api/* is only callable backend-to-backend, so it skips RequireHost; BlockInternalApiTransform in AppGateway rejects external callers.)
14+
// Account-api hosts both the user-facing and back-office SPAs in one process; back-office endpoints
15+
// stay host-scoped so they cannot be reached via the user-facing host.
16+
// - Public /api/account/* endpoints intentionally do NOT declare RequireHost. Internal-only ACA apps
17+
// sit behind AppGateway and ACA's internal ingress; the host header from upstream proxies cannot
18+
// be trusted as a security boundary, and AppGateway is the public trust boundary.
1619
// - Every endpoint under /api/account/* and /internal-api/account/* must declare WithGroupName("account").
1720
// - Every endpoint under /api/back-office/* must declare WithGroupName("back-office").
1821
public sealed class EndpointMetadataTests : IDisposable
@@ -65,7 +68,7 @@ public void BackOfficeEndpoints_ShouldAllRequireHost()
6568
}
6669

6770
[Fact]
68-
public void PublicAccountEndpoints_ShouldAllRequireHost()
71+
public void PublicAccountEndpoints_ShouldNotDeclareRequireHost()
6972
{
7073
// Arrange
7174
var routeEndpoints = GetRouteEndpoints();
@@ -75,11 +78,11 @@ public void PublicAccountEndpoints_ShouldAllRequireHost()
7578
publicAccountEndpoints.Should().NotBeEmpty();
7679

7780
// Assert
78-
var endpointsMissingHost = publicAccountEndpoints
79-
.Where(endpoint => endpoint.Metadata.GetMetadata<IHostMetadata>() is null || !endpoint.Metadata.GetMetadata<IHostMetadata>()!.Hosts.Contains(AppHost))
81+
var endpointsWithHost = publicAccountEndpoints
82+
.Where(endpoint => endpoint.Metadata.GetMetadata<IHostMetadata>() is not null)
8083
.Select(endpoint => endpoint.RoutePattern.RawText)
8184
.ToList();
82-
endpointsMissingHost.Should().BeEmpty($"public account endpoints must declare RequireHost('{AppHost}') so they cannot be reached via the back-office host");
85+
endpointsWithHost.Should().BeEmpty("public account endpoints must NOT declare RequireHost. AppGateway and ACA internal ingress are the trust boundaries; X-Forwarded-Host from upstream proxies is not a reliable security signal.");
8386
}
8487

8588
[Fact]

application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ protected BackOfficeEndpointBaseTest()
6262
// configuring it here lets BackOfficeAdminAuthorizationHandler and GetMe.IsAdmin
6363
// resolve admin status the same way they do in dev.
6464
["BackOffice:AdminsGroupId"] = MockEasyAuthIdentities.MockAdminsGroupId,
65-
// Account endpoints declare RequireHost(Hostnames:App). Tests that target the
66-
// user-facing host use app.test.localhost.
65+
// The user-facing SPA shell is scoped to Hostnames:App via UseHostScopedSinglePageAppFallback.
66+
// Tests that target the user-facing host use app.test.localhost.
6767
["Hostnames:App"] = "app.test.localhost"
6868
};
6969

0 commit comments

Comments
 (0)