// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using Progmasoft.Progmaweb.Api.Accounts; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Progmasoft.OAuth.Api; /// The account signed in to Progmasoft OAuth, and the origin check of its requests. internal static class OAuthAccount { /// Finds the signed-in account of a request. /// /// The Progmasoft Console has no sign-in of its own: it knows the account of the Progmasoft account session, whose /// cookie production shares with progmasoft.com. The authorize host keeps its own sign-in cookie, so that an /// authorization never depends on the account site. An account that no longer exists ends that sign-in. /// /// The request. /// The account, or when nobody is signed in. public static async Task CurrentAsync(HttpContext context) { OAuthOptions options = context.RequestServices.GetRequiredService>().Value; if (string.Equals(context.Request.Host.Host, options.ConsoleHost, StringComparison.OrdinalIgnoreCase)) { return await context.RequestServices.GetRequiredService().AuthenticateAsync(context); } AuthenticateResult result = await context.AuthenticateAsync(OAuthRegistration.CookieScheme); string? subject = result.Principal?.FindFirst(Claims.Subject)?.Value; if (!result.Succeeded || !Guid.TryParse(subject, out Guid id)) { return null; } AccountService accounts = context.RequestServices.GetRequiredService(); AccountRecord? account = await accounts.FindAsync(id, context.RequestAborted); if (account is null) { await context.SignOutAsync(OAuthRegistration.CookieScheme); } return account; } /// Whether a request that changes something comes from a page of this host. /// /// The pages of the OAuth host are served by the Progmasoft site on the same origin, so every request they make /// that changes state carries an Origin header naming this host. A request from another site is refused /// even before the SameSite=Lax cookie would keep it from acting as the account. /// /// The request. /// when the request may change state. public static bool IsSameOrigin(HttpContext context) { string? origin = context.Request.Headers.Origin; if (string.IsNullOrEmpty(origin)) { return context.Request.Headers["Sec-Fetch-Site"] == "same-origin"; } string expected = $"{context.Request.Scheme}://{context.Request.Host.Value}"; return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) && string.Equals(uri.GetLeftPart(UriPartial.Authority), expected, StringComparison.OrdinalIgnoreCase); } /// The answer for a request that needs a signed-in account. /// 401 with a problem that names the remedy. public static IResult SignInRequired() => Results.Problem( statusCode: StatusCodes.Status401Unauthorized, title: "Sign in to Progmasoft first."); /// The answer for a request from another origin. public static IResult ForeignOrigin() => Results.Problem( statusCode: StatusCodes.Status403Forbidden, title: "The request did not come from this site."); /// A validation problem from the messages of each refused field. /// The messages, keyed by field. /// The problem. public static IResult Refused(IReadOnlyDictionary errors) => Results.ValidationProblem( errors.ToDictionary(error => error.Key, error => new[] { error.Value })); }