// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using Microsoft.Extensions.Options; namespace Progmasoft.OAuth; /// Keeps Progmasoft OAuth and the Progmasoft Console on their own hosts, and the account API off them. /// /// /// The account API, the OAuth provider and the console run in one process. OpenIddict answers its endpoints on any /// host, so this filter, placed before authentication, answers 404 for a path on the wrong host: tokens are only ever /// issued under the issuer's own name, and applications are only managed in the console. /// /// /// The issuer host serves the discovery documents, the key set and the endpoints clients call directly. The authorize /// host serves the authorization and end session endpoints, sign-in, consent and the allowed applications. The /// console host serves the applications and the account of the Progmasoft account session. All three serve /// /health and nothing of the account API, whose cookies belong to the account site. Nginx routes the hosts /// the same way; this keeps the rule when a request reaches the process directly. /// /// /// The next middleware. /// The settings that name the hosts. internal sealed class OAuthHostFilter(RequestDelegate next, IOptions options) { /// Paths only the authorize host serves; a path is matched with its sub-paths. private static readonly string[] AuthorizePaths = [ "/connect/authorize", "/connect/logout", "/api/v1/oauth/consent", "/api/v1/oauth/allowed", "/api/v1/oauth/signin", "/api/v1/oauth/signout" ]; /// Paths only the issuer host serves, the authorize paths under /connect excepted. private static readonly string[] IssuerPaths = [ "/connect", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/jwks" ]; /// Paths only the console host serves. private static readonly string[] ConsolePaths = ["/api/v1/oauth/applications"]; /// /// Paths the authorize and console hosts serve. The authorize host answers with its own sign-in, the console /// with the Progmasoft account session; the console has no sign-in of its own. /// private static readonly string[] SharedPaths = ["/api/v1/oauth/session"]; /// Answers 404 for a request on the wrong host, and passes the others on. /// The request. /// A task that completes when the request has been answered. public Task InvokeAsync(HttpContext context) { PathString path = context.Request.Path; string host = context.Request.Host.Host; OAuthOptions settings = options.Value; bool isAuthorizePath = Matches(path, AuthorizePaths); bool isIssuerPath = !isAuthorizePath && Matches(path, IssuerPaths); bool isConsolePath = Matches(path, ConsolePaths); bool isSharedPath = Matches(path, SharedPaths); bool isHealth = path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase); bool allowed = Is(host, settings.Host) ? isIssuerPath || isHealth : Is(host, settings.AuthorizeHost) ? isAuthorizePath || isSharedPath || isHealth : Is(host, settings.ConsoleHost) ? isConsolePath || isSharedPath || isHealth : !(isAuthorizePath || isIssuerPath || isConsolePath || isSharedPath); if (allowed) { return next(context); } context.Response.StatusCode = StatusCodes.Status404NotFound; return Task.CompletedTask; } /// Whether a host name is the given one. private static bool Is(string host, string expected) => string.Equals(host, expected, StringComparison.OrdinalIgnoreCase); /// Whether a path is one of the given paths or below one. private static bool Matches(PathString path, string[] prefixes) => prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); }