Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf112026-10-23 11:02:39 +0300
committerLeitwolf112026-10-23 11:02:39 +0300
commit9099e9fbb50d27b622362b0c2f0dda5085d1fa03 (patch)
tree7d4905828cf0ff34c446025529f3adafa797a86f /apps/api
parent3481b5fe411fb3aa5898b0965e42cc852f3d690c (diff)
downloadprogmaweb-9099e9fbb50d27b622362b0c2f0dda5085d1fa03.tar.gz
Move the OAuth pages to Next.js and split the authorize host and the console
Progmasoft OAuth now runs on three hosts: - credentials.progmasoft.com stays the issuer: discovery documents, key set, token, user information, revocation and introspection. - authorize.progmasoft.com serves the pages people see: the authorization and end session endpoints (registered with OpenIddict as absolute URIs on that host), sign-in, the consent screen and the applications an account has allowed. - console.progmasoft.com is the Progmasoft Console. Its overview links the areas; OAuth 2.1 tokens live under /credentials, with New token, then the application type, then its form. The console has no sign-in of its own and never redirects. It uses the Progmasoft account session, so production now shares that cookie with progmasoft.com (__Secure-ProgmasoftSession, Domain=progmasoft.com, HttpOnly, Secure, SameSite=Lax). Without AccountSessions:CookieDomain the cookie stays host-only with the __Host- name, as in development and tests. Application types replace the confidential/public switch: web, single-page, desktop, mobile and service. Each type has its redirection rules (RFC 8252), its secret or none, and its grants; only a service uses client credentials and it registers no address or scope. The type is kept as an application property and cannot change. The HTML pages of the API are gone. The pages are Next.js pages in four languages (en-US, de-DE, ru-RU, he-IL) that call JSON endpoints under /api/v1/oauth. Requests that change state must come from the page's own origin; antiforgery forms are no longer needed. The token, revocation and user information endpoints answer CORS requests from the origins of single-page applications, without credentials; the discovery documents and the key set answer every origin. The host filter keeps every path on its host. Nginx gains the authorize and console virtual hosts, and the docs describe the hosts, the types and the shared session.
Diffstat (limited to 'apps/api')
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs10
-rw-r--r--apps/api/Accounts/SessionService.cs31
-rw-r--r--apps/api/OAuth/Api/AllowedApplicationEndpoints.cs99
-rw-r--r--apps/api/OAuth/Api/ApplicationEndpoints.cs240
-rw-r--r--apps/api/OAuth/Api/ConsentEndpoints.cs108
-rw-r--r--apps/api/OAuth/Api/OAuthAccount.cs82
-rw-r--r--apps/api/OAuth/Api/OAuthCors.cs128
-rw-r--r--apps/api/OAuth/Api/SessionEndpoints.cs78
-rw-r--r--apps/api/OAuth/Developers/ApplicationKind.cs104
-rw-r--r--apps/api/OAuth/Developers/ApplicationPolicy.cs96
-rw-r--r--apps/api/OAuth/Developers/ApplicationRegistry.cs80
-rw-r--r--apps/api/OAuth/Developers/DeveloperEndpoints.cs300
-rw-r--r--apps/api/OAuth/Endpoints/AuthorizationEndpoints.cs55
-rw-r--r--apps/api/OAuth/OAuthEndpoints.cs19
-rw-r--r--apps/api/OAuth/OAuthHostFilter.cs76
-rw-r--r--apps/api/OAuth/OAuthOptions.cs17
-rw-r--r--apps/api/OAuth/OAuthRegistration.cs15
-rw-r--r--apps/api/OAuth/Pages/AllowedApplicationsEndpoints.cs95
-rw-r--r--apps/api/OAuth/Pages/HtmlPage.cs95
-rw-r--r--apps/api/OAuth/Pages/SignInEndpoints.cs167
-rw-r--r--apps/api/Program.cs2
-rw-r--r--apps/api/appsettings.Development.json12
-rw-r--r--apps/api/appsettings.json5
23 files changed, 1095 insertions, 819 deletions
diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs
index c5be51c..f15b1be 100644
--- a/apps/api/Accounts/AccountEndpoints.cs
+++ b/apps/api/Accounts/AccountEndpoints.cs
@@ -192,13 +192,7 @@ internal static class AccountEndpoints
{
context.Request.Cookies.TryGetValue(sessions.CookieName, out string? token);
await sessions.RevokeAsync(token, cancellationToken);
- context.Response.Cookies.Delete(sessions.CookieName, new CookieOptions
- {
- HttpOnly = true,
- Path = "/",
- SameSite = SameSiteMode.Lax,
- Secure = true
- });
+ context.Response.Cookies.Delete(sessions.CookieName, sessions.CreateCookieOptions(null));
return Results.NoContent();
}
@@ -228,7 +222,7 @@ internal static class AccountEndpoints
context.Response.Cookies.Append(
sessions.CookieName,
authentication.SessionToken,
- SessionService.CreateCookieOptions(authentication.ExpiresAt));
+ sessions.CreateCookieOptions(authentication.ExpiresAt));
/// <summary>Projects a stored account to its public view.</summary>
/// <param name="account">The stored account.</param>
diff --git a/apps/api/Accounts/SessionService.cs b/apps/api/Accounts/SessionService.cs
index 24a81d0..3c20755 100644
--- a/apps/api/Accounts/SessionService.cs
+++ b/apps/api/Accounts/SessionService.cs
@@ -18,8 +18,18 @@ internal sealed class AccountSessionOptions
/// <see cref="MaximumLifetimeHours"/> are clamped to that range.
/// </summary>
public int LifetimeHours { get; init; } = MaximumLifetimeHours;
- /// <summary>Name of the session cookie. The <c>__Host-</c> prefix binds it to this host over HTTPS.</summary>
+ /// <summary>
+ /// Name of the session cookie. The <c>__Host-</c> prefix binds it to this host over HTTPS; a cookie shared with
+ /// <see cref="CookieDomain"/> takes the <c>__Secure-</c> prefix instead, which browsers allow with a domain.
+ /// </summary>
public string CookieName { get; init; } = "__Host-ProgmasoftSession";
+
+ /// <summary>
+ /// The domain the session cookie is shared with, such as <c>progmasoft.com</c>, or <see langword="null"/> for a
+ /// cookie of the account host alone. Production shares it so that the Progmasoft Console on
+ /// <c>console.progmasoft.com</c> knows the signed-in account without a sign-in of its own.
+ /// </summary>
+ public string? CookieDomain { get; init; }
}
/// <summary>Opens, checks and revokes account sessions.</summary>
@@ -99,10 +109,14 @@ internal sealed class SessionService(
}
/// <summary>Creates the attributes of the session cookie.</summary>
- /// <param name="expiresAt">Moment the session expires; the cookie expires with it.</param>
- /// <returns>Options for a secure, HTTP-only, same-site cookie on the root path.</returns>
- public static CookieOptions CreateCookieOptions(DateTimeOffset expiresAt) => new()
+ /// <param name="expiresAt">
+ /// Moment the session expires; the cookie expires with it. <see langword="null"/> describes the cookie for its
+ /// deletion.
+ /// </param>
+ /// <returns>Options for a secure, HTTP-only, same-site cookie on the root path of the configured domain.</returns>
+ public CookieOptions CreateCookieOptions(DateTimeOffset? expiresAt) => new()
{
+ Domain = string.IsNullOrEmpty(settings.CookieDomain) ? null : settings.CookieDomain,
Expires = expiresAt,
HttpOnly = true,
IsEssential = true,
@@ -111,6 +125,15 @@ internal sealed class SessionService(
Secure = true
};
+ /// <summary>Finds the account of the session cookie of a request.</summary>
+ /// <param name="context">The request.</param>
+ /// <returns>The account, or <see langword="null"/> without a valid session.</returns>
+ public ValueTask<AccountRecord?> AuthenticateAsync(HttpContext context)
+ {
+ context.Request.Cookies.TryGetValue(CookieName, out string? token);
+ return AuthenticateAsync(token, context.RequestAborted);
+ }
+
/// <summary>Name of the session cookie.</summary>
public string CookieName => settings.CookieName;
diff --git a/apps/api/OAuth/Api/AllowedApplicationEndpoints.cs b/apps/api/OAuth/Api/AllowedApplicationEndpoints.cs
new file mode 100644
index 0000000..3939c9a
--- /dev/null
+++ b/apps/api/OAuth/Api/AllowedApplicationEndpoints.cs
@@ -0,0 +1,99 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Collections.Immutable;
+using OpenIddict.Abstractions;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace Progmasoft.OAuth.Api;
+
+/// <summary>An application the account has allowed.</summary>
+/// <param name="AuthorizationId">The identifier of the allowance, used to withdraw it.</param>
+/// <param name="DisplayName">The name of the application, or <see langword="null"/> when it was deleted.</param>
+/// <param name="Scopes">The scopes the account allowed.</param>
+/// <param name="CreatedAt">When the account allowed it.</param>
+internal sealed record AllowedApplicationView(
+ string AuthorizationId,
+ string? DisplayName,
+ IReadOnlyList<string> Scopes,
+ DateTimeOffset? CreatedAt);
+
+/// <summary>The applications an account has allowed, and withdrawing an allowance.</summary>
+internal static class AllowedApplicationEndpoints
+{
+ /// <summary>Maps the endpoints.</summary>
+ /// <param name="api">The <c>/api/v1/oauth</c> group.</param>
+ public static void MapAllowedApplicationEndpoints(this RouteGroupBuilder api)
+ {
+ api.MapGet("/allowed", ListAsync);
+ api.MapDelete("/allowed/{authorizationId}", RevokeAsync).RequireRateLimiting("oauth");
+ }
+
+ /// <summary>Lists the applications the signed-in account has allowed.</summary>
+ private static async Task<IResult> ListAsync(
+ HttpContext context,
+ IOpenIddictAuthorizationManager authorizations,
+ IOpenIddictApplicationManager applications)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ List<AllowedApplicationView> allowed = [];
+ await foreach (object authorization in authorizations.FindBySubjectAsync(account.Id.ToString(),
+ context.RequestAborted))
+ {
+ if (!await authorizations.HasStatusAsync(authorization, Statuses.Valid, context.RequestAborted)
+ || !await authorizations.HasTypeAsync(authorization, AuthorizationTypes.Permanent, context.RequestAborted))
+ {
+ continue;
+ }
+
+ string? applicationId = await authorizations.GetApplicationIdAsync(authorization, context.RequestAborted);
+ object? application = applicationId is null
+ ? null
+ : await applications.FindByIdAsync(applicationId, context.RequestAborted);
+ ImmutableArray<string> scopes = await authorizations.GetScopesAsync(authorization, context.RequestAborted);
+ allowed.Add(new AllowedApplicationView(
+ (await authorizations.GetIdAsync(authorization, context.RequestAborted))!,
+ application is null
+ ? null
+ : await applications.GetDisplayNameAsync(application, context.RequestAborted),
+ scopes,
+ await authorizations.GetCreationDateAsync(authorization, context.RequestAborted)));
+ }
+
+ return Results.Ok(allowed.OrderByDescending(entry => entry.CreatedAt).ToList());
+ }
+
+ /// <summary>Withdraws an allowance and revokes the tokens issued under it.</summary>
+ private static async Task<IResult> RevokeAsync(
+ HttpContext context,
+ IOpenIddictAuthorizationManager authorizations,
+ IOpenIddictTokenManager tokens,
+ string authorizationId)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ if (!OAuthAccount.IsSameOrigin(context))
+ {
+ return OAuthAccount.ForeignOrigin();
+ }
+
+ object? authorization = await authorizations.FindByIdAsync(authorizationId, context.RequestAborted);
+ // Another account's authorization is treated as absent.
+ if (authorization is null
+ || await authorizations.GetSubjectAsync(authorization, context.RequestAborted) != account.Id.ToString())
+ {
+ return Results.NotFound();
+ }
+
+ await authorizations.TryRevokeAsync(authorization, context.RequestAborted);
+ await tokens.RevokeByAuthorizationIdAsync(authorizationId, context.RequestAborted);
+ return Results.NoContent();
+ }
+}
diff --git a/apps/api/OAuth/Api/ApplicationEndpoints.cs b/apps/api/OAuth/Api/ApplicationEndpoints.cs
new file mode 100644
index 0000000..e917554
--- /dev/null
+++ b/apps/api/OAuth/Api/ApplicationEndpoints.cs
@@ -0,0 +1,240 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Microsoft.Extensions.Options;
+using Progmasoft.OAuth.Developers;
+
+namespace Progmasoft.OAuth.Api;
+
+/// <summary>What the developer portal submits to make or change a token.</summary>
+/// <param name="Kind">The type, as <see cref="ApplicationKinds.Slug"/> writes it; read only when a token is made.</param>
+/// <param name="DisplayName">The name the consent page shows.</param>
+/// <param name="Homepage">The address of the application's site, or empty.</param>
+/// <param name="RedirectUris">The redirection addresses.</param>
+/// <param name="PostLogoutRedirectUris">The addresses after sign-out.</param>
+/// <param name="Scopes">The scopes the application may request.</param>
+internal sealed record ApplicationRequest(
+ string? Kind,
+ string? DisplayName,
+ string? Homepage,
+ string[]? RedirectUris,
+ string[]? PostLogoutRedirectUris,
+ string[]? Scopes)
+{
+ /// <summary>The submission of an application of a type.</summary>
+ /// <param name="kind">The type.</param>
+ /// <returns>The submission, with blank and repeated addresses and scopes left out.</returns>
+ public ApplicationInput ToInput(ApplicationKind kind) => new(
+ DisplayName ?? string.Empty,
+ (Homepage ?? string.Empty).Trim(),
+ kind,
+ Clean(RedirectUris),
+ Clean(PostLogoutRedirectUris),
+ Clean(Scopes));
+
+ /// <summary>Each non-blank entry once, trimmed, in the submitted order.</summary>
+ private static List<string> Clean(string[]? values) => (values ?? [])
+ .Select(value => value?.Trim() ?? string.Empty)
+ .Where(value => value.Length != 0)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+}
+
+/// <summary>An application as the developer portal shows it.</summary>
+/// <param name="ClientId">The client identifier.</param>
+/// <param name="DisplayName">The name the consent page shows.</param>
+/// <param name="Homepage">The address of the application's site, or empty.</param>
+/// <param name="Kind">The type, as <see cref="ApplicationKinds.Slug"/> writes it.</param>
+/// <param name="IsPublic">Whether the application has no secret.</param>
+/// <param name="RedirectUris">The redirection addresses.</param>
+/// <param name="PostLogoutRedirectUris">The addresses after sign-out.</param>
+/// <param name="Scopes">The scopes the application may request.</param>
+/// <param name="CreatedAt">When the token was made.</param>
+internal sealed record ApplicationView(
+ string ClientId,
+ string DisplayName,
+ string Homepage,
+ string Kind,
+ bool IsPublic,
+ IReadOnlyList<string> RedirectUris,
+ IReadOnlyList<string> PostLogoutRedirectUris,
+ IReadOnlyList<string> Scopes,
+ DateTimeOffset CreatedAt)
+{
+ /// <summary>The view of a registered application.</summary>
+ public static ApplicationView Of(RegisteredApplication application) => new(
+ application.ClientId,
+ application.DisplayName,
+ application.Homepage,
+ application.Kind.Slug(),
+ application.IsPublic,
+ application.RedirectUris,
+ application.PostLogoutRedirectUris,
+ application.Scopes,
+ application.CreatedAt);
+}
+
+/// <summary>The applications of an account, with how many it may have.</summary>
+/// <param name="Applications">The applications, newest first.</param>
+/// <param name="Maximum">How many applications an account may have.</param>
+internal sealed record ApplicationList(IReadOnlyList<ApplicationView> Applications, int Maximum);
+
+/// <summary>A token that was just made, or a secret that was just replaced.</summary>
+/// <param name="Application">The application.</param>
+/// <param name="ClientSecret">The secret, shown this once; <see langword="null"/> for a type without one.</param>
+internal sealed record ApplicationCredentials(ApplicationView Application, string? ClientSecret);
+
+/// <summary>
+/// The Progmasoft Cloud Console: any account makes and manages the tokens of its own applications there.
+/// </summary>
+/// <remarks>
+/// Every endpoint acts only on applications the signed-in account owns. An application of another account answers
+/// 404, so that client identifiers cannot be probed.
+/// </remarks>
+internal static class ApplicationEndpoints
+{
+ /// <summary>Maps the endpoints.</summary>
+ /// <param name="api">The <c>/api/v1/oauth</c> group.</param>
+ public static void MapApplicationEndpoints(this RouteGroupBuilder api)
+ {
+ api.MapGet("/applications", ListAsync);
+ api.MapPost("/applications", CreateAsync).RequireRateLimiting("oauth");
+ api.MapGet("/applications/{clientId}", ShowAsync);
+ api.MapPut("/applications/{clientId}", UpdateAsync).RequireRateLimiting("oauth");
+ api.MapPost("/applications/{clientId}/secret", RotateAsync).RequireRateLimiting("oauth");
+ api.MapDelete("/applications/{clientId}", DeleteAsync).RequireRateLimiting("oauth");
+ }
+
+ /// <summary>Lists the applications of the signed-in account.</summary>
+ private static async Task<IResult> ListAsync(
+ HttpContext context,
+ ApplicationRegistry registry,
+ IOptions<OAuthOptions> options)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ IReadOnlyList<RegisteredApplication> owned = await registry.ListAsync(account.Id, context.RequestAborted);
+ return Results.Ok(new ApplicationList(owned.Select(ApplicationView.Of).ToList(),
+ options.Value.MaximumApplicationsPerAccount));
+ }
+
+ /// <summary>Makes a token of the submitted type.</summary>
+ private static async Task<IResult> CreateAsync(
+ HttpContext context,
+ ApplicationRequest request,
+ ApplicationRegistry registry)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ if (!OAuthAccount.IsSameOrigin(context))
+ {
+ return OAuthAccount.ForeignOrigin();
+ }
+
+ if (ApplicationKinds.Parse(request.Kind) is not { } kind)
+ {
+ return OAuthAccount.Refused(new Dictionary<string, string> { ["kind"] = "Choose an application type." });
+ }
+
+ RegistrationResult result = await registry.CreateAsync(account.Id, request.ToInput(kind), context.RequestAborted);
+ return result.Application is null
+ ? OAuthAccount.Refused(result.Errors)
+ : Results.Created($"/api/v1/oauth/applications/{Uri.EscapeDataString(result.Application.ClientId)}",
+ new ApplicationCredentials(ApplicationView.Of(result.Application), result.ClientSecret));
+ }
+
+ /// <summary>Shows an application of the signed-in account.</summary>
+ private static async Task<IResult> ShowAsync(HttpContext context, ApplicationRegistry registry, string clientId)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ return await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted) is { } application
+ ? Results.Ok(ApplicationView.Of(application))
+ : Results.NotFound();
+ }
+
+ /// <summary>Saves the settings of an application. Its type, identifier and secret stay.</summary>
+ private static async Task<IResult> UpdateAsync(
+ HttpContext context,
+ ApplicationRequest request,
+ ApplicationRegistry registry,
+ string clientId)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ if (!OAuthAccount.IsSameOrigin(context))
+ {
+ return OAuthAccount.ForeignOrigin();
+ }
+
+ if (await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted) is not { } current)
+ {
+ return Results.NotFound();
+ }
+
+ IReadOnlyDictionary<string, string>? errors = await registry.UpdateAsync(account.Id, clientId,
+ request.ToInput(current.Kind), context.RequestAborted);
+ if (errors is null)
+ {
+ return Results.NotFound();
+ }
+
+ if (errors.Count != 0)
+ {
+ return OAuthAccount.Refused(errors);
+ }
+
+ RegisteredApplication? saved = await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted);
+ return saved is null ? Results.NotFound() : Results.Ok(ApplicationView.Of(saved));
+ }
+
+ /// <summary>Replaces the secret of an application and answers the new one once.</summary>
+ private static async Task<IResult> RotateAsync(HttpContext context, ApplicationRegistry registry, string clientId)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ if (!OAuthAccount.IsSameOrigin(context))
+ {
+ return OAuthAccount.ForeignOrigin();
+ }
+
+ string? secret = await registry.RotateSecretAsync(account.Id, clientId, context.RequestAborted);
+ RegisteredApplication? application = await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted);
+ return secret is null || application is null
+ ? Results.NotFound()
+ : Results.Ok(new ApplicationCredentials(ApplicationView.Of(application), secret));
+ }
+
+ /// <summary>Deletes an application with its authorizations and tokens.</summary>
+ private static async Task<IResult> DeleteAsync(HttpContext context, ApplicationRegistry registry, string clientId)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ if (!OAuthAccount.IsSameOrigin(context))
+ {
+ return OAuthAccount.ForeignOrigin();
+ }
+
+ return await registry.DeleteAsync(account.Id, clientId, context.RequestAborted)
+ ? Results.NoContent()
+ : Results.NotFound();
+ }
+}
diff --git a/apps/api/OAuth/Api/ConsentEndpoints.cs b/apps/api/OAuth/Api/ConsentEndpoints.cs
new file mode 100644
index 0000000..cc3bfb4
--- /dev/null
+++ b/apps/api/OAuth/Api/ConsentEndpoints.cs
@@ -0,0 +1,108 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using OpenIddict.Abstractions;
+using Progmasoft.OAuth.Developers;
+using Progmasoft.Progmaweb.Api.Accounts;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace Progmasoft.OAuth.Api;
+
+/// <summary>What the consent page shows about an authorization request.</summary>
+/// <param name="ClientId">The client identifier.</param>
+/// <param name="DisplayName">The name of the application.</param>
+/// <param name="Homepage">The address of the application's site, or empty.</param>
+/// <param name="Owner">The account that registered the application, or <see langword="null"/>.</param>
+/// <param name="ReturnTo">The origin, or for a native application the scheme, that the account holder returns to.</param>
+/// <param name="Scopes">The requested scopes the consent page lists, <c>openid</c> left out.</param>
+/// <param name="AccountName">The account that is signed in.</param>
+internal sealed record ConsentView(
+ string ClientId,
+ string DisplayName,
+ string Homepage,
+ string? Owner,
+ string ReturnTo,
+ IReadOnlyList<string> Scopes,
+ string AccountName);
+
+/// <summary>The data of the consent page.</summary>
+/// <remarks>
+/// <c>/connect/authorize</c> sends an account holder who has not allowed the request yet to the consent page with
+/// the parameters of the request. The page reads what to show here and posts the decision back to
+/// <c>/connect/authorize</c>, where OpenIddict checks the whole request again. The checks here only keep the page
+/// from showing an application, an address or a scope that the request could not use.
+/// </remarks>
+internal static class ConsentEndpoints
+{
+ /// <summary>Maps the endpoint.</summary>
+ /// <param name="api">The <c>/api/v1/oauth</c> group.</param>
+ public static void MapConsentEndpoints(this RouteGroupBuilder api) => api.MapGet("/consent", DescribeAsync);
+
+ /// <summary>Describes an authorization request for the consent page.</summary>
+ private static async Task<IResult> DescribeAsync(
+ HttpContext context,
+ IOpenIddictApplicationManager applications,
+ ApplicationRegistry registry,
+ AccountService accounts,
+ string? client_id,
+ string? redirect_uri,
+ string? scope)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is not { } account)
+ {
+ return OAuthAccount.SignInRequired();
+ }
+
+ object? application = string.IsNullOrEmpty(client_id)
+ ? null
+ : await applications.FindByClientIdAsync(client_id, context.RequestAborted);
+ if (application is null
+ || !await applications.HasPermissionAsync(application, Permissions.Endpoints.Authorization,
+ context.RequestAborted)
+ || string.IsNullOrEmpty(redirect_uri)
+ || !Uri.TryCreate(redirect_uri, UriKind.Absolute, out Uri? redirect)
+ || !await applications.ValidateRedirectUriAsync(application, redirect_uri, context.RequestAborted))
+ {
+ return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
+ title: "This request does not name a registered application and address.");
+ }
+
+ List<string> scopes = [];
+ foreach (string requested in (scope ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Distinct(StringComparer.Ordinal))
+ {
+ if (requested == Scopes.OpenId)
+ {
+ continue;
+ }
+
+ if (OAuthScopes.Find(requested) is null
+ || !await applications.HasPermissionAsync(application, Permissions.Prefixes.Scope + requested,
+ context.RequestAborted))
+ {
+ return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
+ title: "The application may not request this scope.", detail: requested);
+ }
+
+ scopes.Add(requested);
+ }
+
+ string applicationId = (await applications.GetIdAsync(application, context.RequestAborted))!;
+ Guid? ownerId = await registry.FindOwnerAsync(applicationId, context.RequestAborted);
+ AccountRecord? owner = ownerId is { } id ? await accounts.FindAsync(id, context.RequestAborted) : null;
+ RegisteredApplication? registered = ownerId is { } ownerAccount
+ ? await registry.FindOwnedAsync(ownerAccount, client_id!, context.RequestAborted)
+ : null;
+ string returnTo = redirect.Scheme is "http" or "https"
+ ? redirect.GetLeftPart(UriPartial.Authority)
+ : redirect.Scheme + ":";
+ return Results.Ok(new ConsentView(
+ client_id!,
+ await applications.GetDisplayNameAsync(application, context.RequestAborted) ?? client_id!,
+ registered?.Homepage ?? string.Empty,
+ owner?.AccountName,
+ returnTo,
+ scopes,
+ account.AccountName));
+ }
+}
diff --git a/apps/api/OAuth/Api/OAuthAccount.cs b/apps/api/OAuth/Api/OAuthAccount.cs
new file mode 100644
index 0000000..8a7940c
--- /dev/null
+++ b/apps/api/OAuth/Api/OAuthAccount.cs
@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// 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;
+
+/// <summary>The account signed in to Progmasoft OAuth, and the origin check of its requests.</summary>
+internal static class OAuthAccount
+{
+ /// <summary>Finds the signed-in account of a request.</summary>
+ /// <remarks>
+ /// The Progmasoft Console has no sign-in of its own: it knows the account of the Progmasoft account session, whose
+ /// cookie production shares with <c>progmasoft.com</c>. 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.
+ /// </remarks>
+ /// <param name="context">The request.</param>
+ /// <returns>The account, or <see langword="null"/> when nobody is signed in.</returns>
+ public static async Task<AccountRecord?> CurrentAsync(HttpContext context)
+ {
+ OAuthOptions options = context.RequestServices.GetRequiredService<IOptions<OAuthOptions>>().Value;
+ if (string.Equals(context.Request.Host.Host, options.ConsoleHost, StringComparison.OrdinalIgnoreCase))
+ {
+ return await context.RequestServices.GetRequiredService<SessionService>().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<AccountService>();
+ AccountRecord? account = await accounts.FindAsync(id, context.RequestAborted);
+ if (account is null)
+ {
+ await context.SignOutAsync(OAuthRegistration.CookieScheme);
+ }
+
+ return account;
+ }
+
+ /// <summary>Whether a request that changes something comes from a page of this host.</summary>
+ /// <remarks>
+ /// 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 <c>Origin</c> header naming this host. A request from another site is refused
+ /// even before the <c>SameSite=Lax</c> cookie would keep it from acting as the account.
+ /// </remarks>
+ /// <param name="context">The request.</param>
+ /// <returns><see langword="true"/> when the request may change state.</returns>
+ 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);
+ }
+
+ /// <summary>The answer f