// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using System.Text.Json; using Microsoft.EntityFrameworkCore; using OpenIddict.EntityFrameworkCore.Models; using Progmasoft.OAuth.Developers; using Progmasoft.OAuth.Storage; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Progmasoft.OAuth.Api; /// Lets single-page applications call the token endpoints from the browser. /// /// /// A single-page application has no server, so it exchanges its code and refreshes its tokens from the browser, on /// another origin. The token, revocation and user information endpoints answer an origin only when a single-page /// application registered a redirection address on it; every other origin gets no CORS header and the browser keeps /// the response from the page. /// /// /// The discovery documents and the signing keys are public and answer every origin. No response allows credentials: /// the endpoints authenticate with PKCE and bearer tokens, never with a cookie. /// /// /// The next middleware. internal sealed class OAuthCors(RequestDelegate next) { /// The endpoints a single-page application calls from the browser. private static readonly string[] TokenPaths = ["/connect/token", "/connect/revoke", "/connect/userinfo"]; /// The public documents of the server. private static readonly string[] PublicPaths = ["/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/jwks"]; /// Adds the CORS headers a request may have, and answers its preflight. /// The request. /// The OAuth tables. /// A task that completes when the request has been answered. public async Task InvokeAsync(HttpContext context, OAuthDbContext database) { string? origin = context.Request.Headers.Origin; PathString path = context.Request.Path; bool isPublic = PublicPaths.Any(entry => path.Equals(entry, StringComparison.OrdinalIgnoreCase)); bool isToken = TokenPaths.Any(entry => path.Equals(entry, StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrEmpty(origin) || !(isPublic || isToken)) { await next(context); return; } IHeaderDictionary headers = context.Response.Headers; headers.Vary = "Origin"; bool allowed = isPublic || await IsSinglePageOriginAsync(database, origin, context.RequestAborted); if (allowed) { headers.AccessControlAllowOrigin = isPublic ? "*" : origin; } if (HttpMethods.IsOptions(context.Request.Method) && context.Request.Headers.ContainsKey("Access-Control-Request-Method")) { if (allowed) { headers.AccessControlAllowMethods = "GET, POST"; headers.AccessControlAllowHeaders = "Authorization, Content-Type"; headers.AccessControlMaxAge = "600"; } context.Response.StatusCode = StatusCodes.Status204NoContent; return; } await next(context); } /// Whether a single-page application registered a redirection address on an origin. /// The OAuth tables. /// The Origin header of the request. /// Cancels the operation. /// when the origin belongs to a single-page application. internal static async Task IsSinglePageOriginAsync( OAuthDbContext database, string origin, CancellationToken cancellationToken) { // Only an origin that is exactly scheme, host and port is looked up; anything else is no origin of a page. if (!Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) || uri.Scheme is not ("https" or "http") || !string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.OrdinalIgnoreCase)) { return false; } // The stored addresses are a JSON array of strings, so every address on the origin contains it followed by // a slash. The candidates are then checked exactly. string prefix = $"\"{uri.GetLeftPart(UriPartial.Authority)}/"; List candidates = await database .Set() .Where(application => application.ClientType == ClientTypes.Public && application.RedirectUris != null && application.RedirectUris.Contains(prefix)) .Take(50) .ToListAsync(cancellationToken); return candidates.Any(application => IsSinglePage(application) && RedirectsTo(application, uri)); } /// Whether a stored application is a single-page application. private static bool IsSinglePage(OpenIddictEntityFrameworkCoreApplication application) { if (string.IsNullOrEmpty(application.Properties)) { return false; } using JsonDocument properties = JsonDocument.Parse(application.Properties); return properties.RootElement.TryGetProperty(ApplicationRegistry.KindProperty, out JsonElement kind) && kind.ValueKind is JsonValueKind.String && kind.GetString() == ApplicationKind.SinglePage.Slug(); } /// Whether a stored application registered a redirection address on an origin. private static bool RedirectsTo(OpenIddictEntityFrameworkCoreApplication application, Uri origin) { string[] addresses = JsonSerializer.Deserialize(application.RedirectUris!) ?? []; return addresses.Any(address => Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) && Uri.Compare(uri, origin, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0); } }