// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using System.Security.Claims; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using Progmasoft.Progmaweb.Api.Accounts; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Progmasoft.OAuth.Pages; /// The account signed in to Progmasoft OAuth. internal static class OAuthAccount { /// Finds the account of the sign-in cookie. /// An account that no longer exists ends the sign-in. /// The request. /// The account, or when nobody is signed in. public static async Task CurrentAsync(HttpContext 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; } /// Sends a visitor to the sign-in page and back to where they were. /// The request. /// The redirection. public static IResult SignInFirst(HttpContext context) => Results.Redirect( "/signin?ReturnUrl=" + Uri.EscapeDataString(context.Request.Path + context.Request.QueryString)); /// Whether a form carries the token this host served with it. /// The request. /// when the form is genuine. public static Task IsGenuineFormAsync(HttpContext context) => context.RequestServices.GetRequiredService().IsRequestValidAsync(context); /// A return address that stays on this host. /// The requested address. /// The address, or / when it would leave the host. public static string LocalReturnUrl(string? returnUrl) => !string.IsNullOrEmpty(returnUrl) && returnUrl[0] == '/' && (returnUrl.Length == 1 || (returnUrl[1] != '/' && returnUrl[1] != '\\')) ? returnUrl : "/"; } /// The home page, the sign-in page and signing out. internal static class SignInEndpoints { /// Maps the pages. /// The application. public static void MapSignInPages(this WebApplication app) { app.MapGet("/", (Delegate)HomeAsync); app.MapGet("/signin", SignInPage); app.MapPost("/signin", SignInAsync).RequireRateLimiting("account-auth"); app.MapPost("/signout", (Delegate)SignOutAsync); } /// Shows the home page of the OAuth host. private static async Task HomeAsync(HttpContext context) { AccountRecord? account = await OAuthAccount.CurrentAsync(context); string body = "

Progmasoft OAuth

" + "

Sign in with Progmasoft: an OAuth 2.1 and OpenID Connect provider for Progmasoft accounts. " + "Any developer can register an application.

"; if (account is null) { body += "

Sign in to register applications or to review the applications you " + "have allowed.

"; } else { body += $"

Signed in as {HtmlPage.Encode(account.AccountName)}.

" + "" + HtmlPage.Form(context, "/signout", ""); } body += "

Discovery document: " + "/.well-known/openid-configuration

"; return HtmlPage.Render(context, "Progmasoft OAuth", body); } /// Shows the sign-in form. private static IResult SignInPage(HttpContext context, IOptions options, string? returnUrl) => RenderSignIn(context, options.Value, OAuthAccount.LocalReturnUrl(returnUrl), null, null); /// Checks the submitted email address and password and signs the account in. private static async Task SignInAsync( HttpContext context, AccountService accounts, IOptions options) { if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } IFormCollection form = await context.Request.ReadFormAsync(context.RequestAborted); string returnUrl = OAuthAccount.LocalReturnUrl(form["returnUrl"]); string? email = form["email"]; AccountRecord? account = await accounts.VerifyPasswordAsync(email, form["password"], context.RequestAborted); if (account is null) { // One message for every failure, as the account API gives: it does not tell registered addresses apart. return RenderSignIn(context, options.Value, returnUrl, email, "The email address or the password is not right.", StatusCodes.Status401Unauthorized); } ClaimsIdentity identity = new(OAuthRegistration.CookieScheme, Claims.Name, Claims.Role); identity.AddClaim(new Claim(Claims.Subject, account.Id.ToString())); identity.AddClaim(new Claim(Claims.Name, account.AccountName)); await context.SignInAsync(OAuthRegistration.CookieScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = false }); return Results.Redirect(returnUrl); } /// Ends the sign-in. private static async Task SignOutAsync(HttpContext context) { if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } await context.SignOutAsync(OAuthRegistration.CookieScheme); return Results.Redirect("/"); } /// Shows the sign-in form with an optional message. private static IResult RenderSignIn( HttpContext context, OAuthOptions options, string returnUrl, string? email, string? error, int statusCode = StatusCodes.Status200OK) { string fields = HtmlPage.Hidden("returnUrl", returnUrl) + "" + $"" + "" + "" + ""; string body = "

Sign in to Progmasoft

" + HtmlPage.Error(error) + HtmlPage.Form(context, "/signin", fields) + $"

No account yet? " + "Create a Progmasoft account.

"; return HtmlPage.Render(context, "Sign in", body, statusCode); } }