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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf2026-10-23 08:01:02 +0300
committerGitHub2026-10-23 08:01:02 +0300
commit3481b5fe411fb3aa5898b0965e42cc852f3d690c (patch)
tree4e5f6caf530344fb13f2b892238d1e1b1f89c10c /apps/api
parenta68bb18e2d750fabcbcff985b5fe921d7c828d7e (diff)
parent7f1cb91eb16bc3d7fd78320465c32d5e2f47ec97 (diff)
downloadprogmaweb-3481b5fe411fb3aa5898b0965e42cc852f3d690c.tar.gz
Merge pull request #43 from Progmasoft/feature/progmasoft-credentials
Add Progmasoft OAuth, an OAuth 2.1 and OpenID Connect provider
Diffstat (limited to 'apps/api')
-rw-r--r--apps/api/Accounts/AccountService.cs40
-rw-r--r--apps/api/OAuth/Developers/ApplicationPolicy.cs152
-rw-r--r--apps/api/OAuth/Developers/ApplicationRegistry.cs330
-rw-r--r--apps/api/OAuth/Developers/DeveloperEndpoints.cs300
-rw-r--r--apps/api/OAuth/Endpoints/AuthorizationEndpoints.cs297
-rw-r--r--apps/api/OAuth/OAuthEndpoints.cs22
-rw-r--r--apps/api/OAuth/OAuthHostFilter.cs54
-rw-r--r--apps/api/OAuth/OAuthOptions.cs35
-rw-r--r--apps/api/OAuth/OAuthRegistration.cs254
-rw-r--r--apps/api/OAuth/OAuthScopes.cs61
-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/OAuth/Storage/OAuthDbContext.cs52
-rw-r--r--apps/api/OAuth/Storage/OAuthSchema.cs204
-rw-r--r--apps/api/Progmaweb.Api.csproj4
-rw-r--r--apps/api/Program.cs21
-rw-r--r--apps/api/appsettings.Development.json2
-rw-r--r--apps/api/appsettings.json2
-rw-r--r--apps/api/packages.lock.json440
20 files changed, 2615 insertions, 12 deletions
diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs
index 7634948..0deadb5 100644
--- a/apps/api/Accounts/AccountService.cs
+++ b/apps/api/Accounts/AccountService.cs
@@ -95,8 +95,26 @@ internal sealed class AccountService(
SessionService sessions,
CancellationToken cancellationToken)
{
- if (!EmailAddressPolicy.TryNormalize(request.Email, out _, out string normalizedEmail, out _) ||
- request.Password is null || request.Password.Length > PasswordPolicy.MaximumLength)
+ AccountRecord? account = await VerifyPasswordAsync(request.Email, request.Password, cancellationToken);
+ return account is null ? null : await sessions.CreateAsync(account, cancellationToken);
+ }
+
+ /// <summary>Checks an email address and password without opening a session.</summary>
+ /// <remarks>
+ /// Progmasoft Credentials signs the account in with its own cookie and must not open a session of the account
+ /// site. The verification is the one <see cref="LoginAsync"/> uses, with the same cost for an unknown address.
+ /// </remarks>
+ /// <param name="email">Email address as entered.</param>
+ /// <param name="password">Password as entered.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The account, or <see langword="null"/> when the credentials are refused.</returns>
+ public async ValueTask<AccountRecord?> VerifyPasswordAsync(
+ string? email,
+ string? password,
+ CancellationToken cancellationToken)
+ {
+ if (!EmailAddressPolicy.TryNormalize(email, out _, out string normalizedEmail, out _) ||
+ password is null || password.Length > PasswordPolicy.MaximumLength)
{
return null;
}
@@ -105,20 +123,22 @@ internal sealed class AccountService(
if (account is null || account.PasswordHash is null)
{
// Reuse one private hash so an unknown email follows one expensive verification, like a bad password.
- _ = passwordHasher.VerifyHashedPassword(DummyAccount, DummyPasswordHash, request.Password);
+ _ = passwordHasher.VerifyHashedPassword(DummyAccount, DummyPasswordHash, password);
return null;
}
PasswordVerificationResult verification =
- passwordHasher.VerifyHashedPassword(account, account.PasswordHash, request.Password);
- if (verification is PasswordVerificationResult.Failed)
- {
- return null;
- }
-
- return await sessions.CreateAsync(account, cancellationToken);
+ passwordHasher.VerifyHashedPassword(account, account.PasswordHash, password);
+ return verification is PasswordVerificationResult.Failed ? null : account;
}
+ /// <summary>Finds an account by its identifier, for a Credentials session that names it.</summary>
+ /// <param name="id">Identifier of the account.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The account, or <see langword="null"/> when it no longer exists.</returns>
+ public ValueTask<AccountRecord?> FindAsync(Guid id, CancellationToken cancellationToken) =>
+ store.FindByIdAsync(id, cancellationToken);
+
/// <summary>Signs in with a Google identity, creating the account on first use.</summary>
/// <remarks>
/// An identity that is already linked signs in to its account. A new identity creates an account under the
diff --git a/apps/api/OAuth/Developers/ApplicationPolicy.cs b/apps/api/OAuth/Developers/ApplicationPolicy.cs
new file mode 100644
index 0000000..6c5cb1e
--- /dev/null
+++ b/apps/api/OAuth/Developers/ApplicationPolicy.cs
@@ -0,0 +1,152 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+
+namespace Progmasoft.OAuth.Developers;
+
+/// <summary>What a developer submits to register or change an application.</summary>
+/// <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="IsPublic">
+/// Whether the application cannot keep a secret: a browser or native application. Such a client has no secret and
+/// may not use the client credentials grant.
+/// </param>
+/// <param name="RedirectUris">The addresses the authorization endpoint may send the account holder back to.</param>
+/// <param name="PostLogoutRedirectUris">The addresses the end-session endpoint may send the account holder back to.</param>
+/// <param name="Scopes">The scopes the application may request.</param>
+/// <param name="AllowClientCredentials">Whether the application may act as itself with the client credentials grant.</param>
+internal sealed record ApplicationInput(
+ string DisplayName,
+ string Homepage,
+ bool IsPublic,
+ IReadOnlyList<string> RedirectUris,
+ IReadOnlyList<string> PostLogoutRedirectUris,
+ IReadOnlyList<string> Scopes,
+ bool AllowClientCredentials);
+
+/// <summary>The rules an application registration must follow.</summary>
+/// <remarks>
+/// Redirection addresses are compared exactly by the authorization server, so the rules here only decide which
+/// addresses may be registered: HTTPS, an <c>http</c> loopback address for software on the developer's own
+/// machine, or, for public clients, a private-use scheme of a native application as RFC 8252 describes. A
+/// fragment, user information or a wildcard is never accepted.
+/// </remarks>
+internal static class ApplicationPolicy
+{
+ /// <summary>The most redirection addresses one application may register, of each kind.</summary>
+ public const int MaximumRedirectUris = 10;
+
+ /// <summary>The longest display name.</summary>
+ public const int MaximumDisplayNameLength = 64;
+
+ /// <summary>The longest address.</summary>
+ public const int MaximumUriLength = 2000;
+
+ /// <summary>Checks a submission.</summary>
+ /// <param name="input">The submission.</param>
+ /// <returns>The messages of each refused field, keyed by field name; empty when the submission is valid.</returns>
+ public static Dictionary<string, string> Validate(ApplicationInput input)
+ {
+ Dictionary<string, string> errors = [];
+ string name = input.DisplayName.Trim();
+ if (name.Length is < 3 or > MaximumDisplayNameLength || name.Any(char.IsControl))
+ {
+ errors["displayName"] =
+ $"Use 3 to {MaximumDisplayNameLength} characters, without control characters.";
+ }
+
+ if (input.Homepage.Length != 0 && !IsHttpsAddress(input.Homepage))
+ {
+ errors["homepage"] = "The homepage must be an https:// address.";
+ }
+
+ if (input.RedirectUris.Count is 0 or > MaximumRedirectUris)
+ {
+ errors["redirectUris"] = $"Register 1 to {MaximumRedirectUris} redirection addresses, one per line.";
+ }
+ else if (input.RedirectUris.FirstOrDefault(uri => !IsRedirectionAddress(uri, input.IsPublic)) is { } wrong)
+ {
+ errors["redirectUris"] = $"{wrong} cannot be registered. {AddressRule(input.IsPublic)}";
+ }
+
+ if (input.PostLogoutRedirectUris.Count > MaximumRedirectUris)
+ {
+ errors["postLogoutRedirectUris"] = $"Register at most {MaximumRedirectUris} addresses.";
+ }
+ else if (input.PostLogoutRedirectUris.FirstOrDefault(uri => !IsRedirectionAddress(uri, input.IsPublic)) is
+ { } wrongLogout)
+ {
+ errors["postLogoutRedirectUris"] = $"{wrongLogout} cannot be registered. {AddressRule(input.IsPublic)}";
+ }
+
+ if (input.Scopes.Any(scope => OAuthScopes.Find(scope) is null))
+ {
+ errors["scopes"] = "Choose scopes from the list.";
+ }
+
+ if (input.IsPublic && input.AllowClientCredentials)
+ {
+ errors["allowClientCredentials"] =
+ "A public client has no secret and cannot use the client credentials grant.";
+ }
+
+ return errors;
+ }
+
+ /// <summary>Splits a text area into its non-empty lines.</summary>
+ /// <param name="text">The submitted text.</param>
+ /// <returns>Each line once, trimmed, in the submitted order.</returns>
+ public static IReadOnlyList<string> Lines(string? text) => (text ?? string.Empty)
+ .Split('\n')
+ .Select(line => line.Trim())
+ .Where(line => line.Length != 0)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ /// <summary>The rule for redirection addresses, as the refusal message states it.</summary>
+ private static string AddressRule(bool isPublic) => isPublic
+ ? "Use https://, http:// with a loopback address, or a private-use scheme such as com.example.app:/callback."
+ : "Use https://, or http:// with a loopback address for development.";
+
+ /// <summary>Whether text is an absolute <c>https://</c> address without fragment or user information.</summary>
+ private static bool IsHttpsAddress(string text) =>
+ text.Length <= MaximumUriLength
+ && Uri.TryCreate(text, UriKind.Absolute, out Uri? uri)
+ && uri.Scheme == Uri.UriSchemeHttps
+ && IsClean(uri);
+
+ /// <summary>Whether an address may be registered for redirection.</summary>
+ /// <param name="text">The address.</param>
+ /// <param name="isPublic">Whether the application is a public client.</param>
+ /// <returns><see langword="true"/> when the address follows the rules above.</returns>
+ internal static bool IsRedirectionAddress(string text, bool isPublic)
+ {
+ if (text.Length > MaximumUriLength || text.Contains('*', StringComparison.Ordinal)
+ || !Uri.TryCreate(text, UriKind.Absolute, out Uri? uri) || !IsClean(uri))
+ {
+ return false;
+ }
+
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ return true;
+ }
+
+ if (uri.Scheme == Uri.UriSchemeHttp)
+ {
+ // RFC 8252 section 7.3: loopback, never a host name that DNS could point elsewhere, except localhost
+ // during development.
+ return uri.Host == "localhost" || (IPAddress.TryParse(uri.Host.Trim('[', ']'), out IPAddress? address)
+ && IPAddress.IsLoopback(address));
+ }
+
+ // RFC 8252 section 7.1: a private-use scheme names the application in reverse DNS order, so it contains a
+ // dot; javascript:, data: and the like are refused by that rule as well as by the explicit list.
+ string[] refused = ["javascript", "data", "file", "vbscript", "about", "blob"];
+ return isPublic && uri.Scheme.Contains('.', StringComparison.Ordinal) && !refused.Contains(uri.Scheme);
+ }
+
+ /// <summary>Whether an address has neither a fragment nor user information.</summary>
+ private static bool IsClean(Uri uri) => uri.Fragment.Length == 0 && uri.UserInfo.Length == 0;
+}
diff --git a/apps/api/OAuth/Developers/ApplicationRegistry.cs b/apps/api/OAuth/Developers/ApplicationRegistry.cs
new file mode 100644
index 0000000..f768b52
--- /dev/null
+++ b/apps/api/OAuth/Developers/ApplicationRegistry.cs
@@ -0,0 +1,330 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using OpenIddict.Abstractions;
+using Progmasoft.OAuth.Storage;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace Progmasoft.OAuth.Developers;
+
+/// <summary>An application as its owner sees it in the developer portal.</summary>
+/// <param name="ClientId">The public identifier of the client.</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="IsPublic">Whether the client has no secret.</param>
+/// <param name="RedirectUris">The registered redirection addresses.</param>
+/// <param name="PostLogoutRedirectUris">The registered end-session addresses.</param>
+/// <param name="Scopes">The scopes the application may request.</param>
+/// <param name="AllowClientCredentials">Whether the client credentials grant is allowed.</param>
+/// <param name="CreatedAt">When the application was registered.</param>
+internal sealed record RegisteredApplication(
+ string ClientId,
+ string DisplayName,
+ string Homepage,
+ bool IsPublic,
+ IReadOnlyList<string> RedirectUris,
+ IReadOnlyList<string> PostLogoutRedirectUris,
+ IReadOnlyList<string> Scopes,
+ bool AllowClientCredentials,
+ DateTimeOffset CreatedAt);
+
+/// <summary>What registering an application produced.</summary>
+/// <param name="Application">The application, or <see langword="null"/> when it was refused.</param>
+/// <param name="ClientSecret">The secret of a confidential client; shown once and never stored in clear.</param>
+/// <param name="Errors">The messages of each refused field.</param>
+internal sealed record RegistrationResult(
+ RegisteredApplication? Application,
+ string? ClientSecret,
+ IReadOnlyDictionary<string, string> Errors);
+
+/// <summary>Registers, changes and deletes the applications of an account.</summary>
+/// <remarks>
+/// Every operation takes the account that acts and touches only applications that account owns. An application of
+/// another account is reported as absent, so that client identifiers cannot be probed through the portal.
+/// </remarks>
+/// <param name="applications">OpenIddict's application manager.</param>
+/// <param name="database">The OAuth tables.</param>
+/// <param name="options">The settings of Progmasoft OAuth.</param>
+/// <param name="timeProvider">Clock of the registration times.</param>
+internal sealed class ApplicationRegistry(
+ IOpenIddictApplicationManager applications,
+ OAuthDbContext database,
+ IOptions<OAuthOptions> options,
+ TimeProvider timeProvider)
+{
+ /// <summary>The property of the application that keeps its homepage.</summary>
+ private const string HomepageProperty = "homepage";
+
+ /// <summary>Lists the applications of an account, newest first.</summary>
+ /// <param name="accountId">The owner.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The applications.</returns>
+ public async Task<IReadOnlyList<RegisteredApplication>> ListAsync(Guid accountId, CancellationToken cancellationToken)
+ {
+ List<ApplicationOwner> owned = await database.ApplicationOwners
+ .Where(owner => owner.AccountId == accountId)
+ .OrderByDescending(owner => owner.CreatedAt)
+ .ToListAsync(cancellationToken);
+ List<RegisteredApplication> result = [];
+ foreach (ApplicationOwner owner in owned)
+ {
+ if (await applications.FindByIdAsync(owner.ApplicationId, cancellationToken) is { } application)
+ {
+ result.Add(await DescribeAsync(application, owner.CreatedAt, cancellationToken));
+ }
+ }
+
+ return result;
+ }
+
+ /// <summary>Finds an application of an account by its client identifier.</summary>
+ /// <param name="accountId">The account that asks.</param>
+ /// <param name="clientId">The client identifier.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The application, or <see langword="null"/> when the account owns no such application.</returns>
+ public async Task<RegisteredApplication?> FindOwnedAsync(
+ Guid accountId,
+ string clientId,
+ CancellationToken cancellationToken)
+ {
+ (object application, ApplicationOwner owner)? found = await FindOwnedEntryAsync(accountId, clientId, cancellationToken);
+ return found is null ? null : await DescribeAsync(found.Value.application, found.Value.owner.CreatedAt, cancellationToken);
+ }
+
+ /// <summary>Finds the account that registered an application.</summary>
+ /// <param name="applicationId">The OpenIddict identifier of the application.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The owning account, or <see langword="null"/> for an application without an owner.</returns>
+ public async Task<Guid?> FindOwnerAsync(string applicationId, CancellationToken cancellationToken) =>
+ (await database.ApplicationOwners.FirstOrDefaultAsync(
+ owner => owner.ApplicationId == applicationId, cancellationToken))?.AccountId;
+
+ /// <summary>Registers an application for an account.</summary>
+ /// <param name="accountId">The owner.</param>
+ /// <param name="input">The submission.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The application and its secret, or the refused fields.</returns>
+ public async Task<RegistrationResult> CreateAsync(
+ Guid accountId,
+ ApplicationInput input,
+ CancellationToken cancellationToken)
+ {
+ Dictionary<string, string> errors = ApplicationPolicy.Validate(input);
+ int count = await database.ApplicationOwners.CountAsync(owner => owner.AccountId == accountId, cancellationToken);
+ if (count >= options.Value.MaximumApplicationsPerAccount)
+ {
+ errors["displayName"] =
+ $"An account can register {options.Value.MaximumApplicationsPerAccount} applications. Delete one first.";
+ }
+
+ if (errors.Count != 0)
+ {
+ return new RegistrationResult(null, null, errors);
+ }
+
+ string clientId = "pmo_" + RandomText(15);
+ string? secret = input.IsPublic ? null : RandomText(32);
+ OpenIddictApplicationDescriptor descriptor = new()
+ {
+ ClientId = clientId,
+ ClientSecret = secret,
+ };
+ Describe(descriptor, input);
+ object application = await applications.CreateAsync(descriptor, cancellationToken);
+
+ DateTimeOffset now = timeProvider.GetUtcNow();
+ database.ApplicationOwners.Add(new ApplicationOwner
+ {
+ ApplicationId = (await applications.GetIdAsync(application, cancellationToken))!,
+ AccountId = accountId,
+ CreatedAt = now
+ });
+ await database.SaveChangesAsync(cancellationToken);
+ return new RegistrationResult(await DescribeAsync(application, now, cancellationToken), secret,
+ new Dictionary<string, string>());
+ }
+
+ /// <summary>Changes an application of an account. The client identifier, type and secret stay.</summary>
+ /// <param name="accountId">The account that asks.</param>
+ /// <param name="clientId">The client identifier.</param>
+ /// <param name="input">The new settings; <see cref="ApplicationInput.IsPublic"/> is ignored.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The refused fields, empty on success, or <see langword="null"/> when the application is absent.</returns>
+ public async Task<IReadOnlyDictionary<string, string>?> UpdateAsync(
+ Guid accountId,
+ string clientId,
+ ApplicationInput input,
+ CancellationToken cancellationToken)
+ {
+ if (await FindOwnedEntryAsync(accountId, clientId, cancellationToken) is not { } found)
+ {
+ return null;
+ }
+
+ bool isPublic = await applications.HasClientTypeAsync(found.application, ClientTypes.Public, cancellationToken);
+ ApplicationInput effective = input with { IsPublic = isPublic };
+ Dictionary<string, string> errors = ApplicationPolicy.Validate(effective);
+ if (errors.Count != 0)
+ {
+ return errors;
+ }
+
+ OpenIddictApplicationDescriptor descriptor = new();
+ await applications.PopulateAsync(descriptor, found.application, cancellationToken);
+ Describe(descriptor, effective);
+ await applications.UpdateAsync(found.application, descriptor, cancellationToken);
+ return errors;
+ }
+
+ /// <summary>Replaces the secret of a confidential application.</summary>
+ /// <param name="accountId">The account that asks.</param>
+ /// <param name="clientId">The client identifier.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns>The new secret, or <see langword="null"/> for an absent or public application.</returns>
+ public async Task<string?> RotateSecretAsync(Guid accountId, string clientId, CancellationToken cancellationToken)
+ {
+ if (await FindOwnedEntryAsync(accountId, clientId, cancellationToken) is not { } found
+ || await applications.HasClientTypeAsync(found.application, ClientTypes.Public, cancellationToken))
+ {
+ return null;
+ }
+
+ string secret = RandomText(32);
+ OpenIddictApplicationDescriptor descriptor = new();
+ await applications.PopulateAsync(descriptor, found.application, cancellationToken);
+ descriptor.ClientSecret = secret;
+ await applications.UpdateAsync(found.application, descriptor, cancellationToken);
+ return secret;
+ }
+
+ /// <summary>Deletes an application with its authorizations and tokens.</summary>
+ /// <param name="accountId">The account that asks.</param>
+ /// <param name="clientId">The client identifier.</param>
+ /// <param name="cancellationToken">Cancels the operation.</param>
+ /// <returns><see langword="true"/> when the application was deleted.</returns>
+ public async Task<bool> DeleteAsync(Guid accountId, string clientId, CancellationToken cancellationToken)
+ {
+ if (await FindOwnedEntryAsync(accountId, clientId, cancellationToken) is not { } found)
+ {
+ return false;
+ }
+
+ database.ApplicationOwners.Remove(found.owner);
+ await database.SaveChangesAsync(cancellationToken);
+ await applications.DeleteAsync(found.application, cancellationToken);
+ return true;
+ }
+
+ /// <summary>Finds an application and its ownership row, when the account owns it.</summary>
+ private async Task<(object application, ApplicationOwner owner)?> FindOwnedEntryAsync(
+ Guid accountId,
+ string clientId,
+ CancellationToken cancellationToken)
+ {
+ if (await applications.FindByClientIdAsync(clientId, cancellationToken) is not { } application)
+ {
+ return null;
+ }
+
+ string? id = await applications.GetIdAsync(application, cancellationToken);
+ ApplicationOwner? owner = await database.ApplicationOwners.FirstOrDefaultAsync(
+ entry => entry.ApplicationId == id && entry.AccountId == accountId, cancellationToken);
+ return owner is null ? null : (application, owner);
+ }
+
+ /// <summary>Writes a submission into a descriptor: names, addresses and permissions.</summary>
+ /// <param name="descriptor">The descriptor to fill.</param>
+ /// <param name="input">The validated submission.</param>
+ private static void Describe(OpenIddictApplicationDescriptor descriptor, ApplicationInput input)
+ {
+ descriptor.DisplayName = input.DisplayName.Trim();
+ descriptor.ApplicationType = input.IsPublic ? ApplicationTypes.Native : ApplicationTypes.Web;
+ descriptor.ClientType = input.IsPublic ? ClientTypes.Public : ClientTypes.Confidential;
+ // Every client of Progmasoft OAuth shows the consent page: none is trusted by default.
+ descriptor.ConsentType = ConsentTypes.Explicit;
+ descriptor.Properties[HomepageProperty] = JsonSerializer.SerializeToElement(input.Homepage.Trim());
+
+ descriptor.RedirectUris.Clear();
+ foreach (string uri in input.RedirectUris)
+ {
+ descriptor.RedirectUris.Add(new Uri(uri));
+ }
+
+ descriptor.PostLogoutRedirectUris.Clear();
+ foreach (string uri in input.PostLogoutRedirectUris)
+ {
+ descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
+ }
+
+ descriptor.Permissions.Clear();
+ descriptor.Permissions.UnionWith(
+ [
+ Permissions.Endpoints.Authorization,
+ Permissions.Endpoints.Token,
+ Permissions.Endpoints.EndSession,
+ Permissions.Endpoints.Revocation,
+ Permissions.GrantTypes.AuthorizationCode,
+ Permissions.ResponseTypes.Code
+ ]);
+ if (!input.IsPublic)
+ {
+ descriptor.Permissions.Add(Permissions.Endpoints.Introspection);
+ }
+
+ if (input.Scopes.Contains(Scopes.OfflineAccess))
+ {
+ descriptor.Permissions.Add(Permissions.GrantTypes.RefreshToken);
+ }
+
+ if (input.AllowClientCredentials)
+ {
+ descriptor.Permissions.Add(Permissions.GrantTypes.ClientCredentials);
+ }
+
+ foreach (string scope in input.Scopes)
+ {
+ descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
+ }
+
+ descriptor.Requirements.Clear();
+ descriptor.Requirements.Add(Requirements.Features.ProofKeyForCodeExchange);
+ }
+
+ /// <summary>Reads an application into the view of the developer portal.</summary>
+ private async Task<RegisteredApplication> DescribeAsync(
+ object application,
+ DateTimeOffset createdAt,
+ CancellationToken cancellationToken)
+ {
+ var permissions = await applications.GetPermissionsAsync(application, cancellationToken);
+ var properties = await applications.GetPropertiesAsync(application, cancellationToken);
+ string homepage = properties.TryGetValue(HomepageProperty, out JsonElement value)
+ && value.ValueKind is JsonValueKind.String
+ ? value.GetString() ?? string.Empty
+ : string.Empty;
+ return new RegisteredApplication(
+ (await applications.GetClientIdAsync(application, cancellationToken))!,
+ await applications.GetDisplayNameAsync(application, cancellationToken) ?? string.Empty,
+ homepage,
+ await applications.HasClientTypeAsync(application, ClientTypes.Public, cancellationToken),
+ (await applications.GetRedirectUrisAsync(application, cancellationToken)).Order(StringComparer.Ordinal).ToList(),
+ (await applications.GetPostLogoutRedirectUrisAsync(application, cancellationToken))
+ .Order(StringComparer.Ordinal).ToList(),
+ OAuthScopes.All
+ .Where(scope => permissions.Contains(Permissions.Prefixes.Scope + scope.Name))
+ .Select(scope => scope.Name)
+ .ToList(),
+ permissions.Contains(Permissions.GrantTypes.ClientCredentials),
+ createdAt);
+ }
+
+ /// <summary>Random text of the given number of bytes, as unpadded URL-safe base64.</summary>
+ /// <param name="bytes">How many random bytes.</param>
+ /// <returns>The text.</returns>
+ private static string RandomText(int bytes) =>
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
diff --git a/apps/api/OAuth/Developers/DeveloperEndpoints.cs b/apps/api/OAuth/Developers/DeveloperEndpoints.cs
new file mode 100644
index 0000000..460d17b
--- /dev/null
+++ b/apps/api/OAuth/Developers/DeveloperEndpoints.cs
@@ -0,0 +1,300 @@
+// 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.Pages;
+using Progmasoft.Progmaweb.Api.Accounts;
+using static OpenIddict.Abstractions.OpenIddictConstants;
+
+namespace Progmasoft.OAuth.Developers;
+
+/// <summary>The developer portal: any account registers and manages its own applications here.</summary>
+internal static class DeveloperEndpoints
+{
+ /// <summary>Maps the portal.</summary>
+ /// <param name="app">The application.</param>
+ public static void MapDeveloperPortal(this WebApplication app)
+ {
+ app.MapGet("/developers", ListAsync);
+ app.MapGet("/developers/new", (Delegate)NewAsync);
+ app.MapPost("/developers/new", CreateAsync).RequireRateLimiting("oauth");
+ app.MapGet("/developers/{clientId}", ShowAsync);
+ app.MapPost("/developers/{clientId}", UpdateAsync).RequireRateLimiting("oauth");
+ app.MapPost("/developers/{clientId}/secret", RotateAsync).RequireRateLimiting("oauth");
+ app.MapPost("/developers/{clientId}/delete", 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.SignInFirst(context);
+ }
+
+ IReadOnlyList<RegisteredApplication> owned = await registry.ListAsync(account.Id, context.RequestAborted);
+ string list = owned.Count == 0
+ ? "<p>You have not registered an application yet.</p>"
+ : "<ul>" + string.Concat(owned.Select(application =>
+ $"<li><a href=\"/developers/{Uri.EscapeDataString(application.ClientId)}\">" +
+ $"{HtmlPage.Encode(application.DisplayName)}</a> " +
+ $"<span class=\"note\">{(application.IsPublic ? "public" : "confidential")} ยท " +
+ $"<code>{HtmlPage.Encode(application.ClientId)}</code></span></li>")) + "</ul>";
+ string body =
+ "<h1>Your applications</h1>" + list +
+ (owned.Count < options.Value.MaximumApplicationsPerAccount
+ ? "<p><a href=\"/developers/new\">Register an application</a></p>"
+ : $"<p class=\"note\">An account can register {options.Value.MaximumApplicationsPerAccount} applications.</p>") +
+ "<h2>Endpoints</h2>" +
+ "<p class=\"note\">Read the discovery document at <a href=\"/.well-known/openid-configuration\">" +
+ "/.well-known/openid-configuration</a>. Every client uses the authorization code flow with PKCE (S256); " +
+ "confidential clients authenticate at the token endpoint with their secret.</p>" +
+ "<p><a href=\"/\">Back</a></p>";
+ return HtmlPage.Render(context, "Your applications", body);
+ }
+
+ /// <summary>Shows the empty registration form.</summary>
+ private static async Task<IResult> NewAsync(HttpContext context)
+ {
+ if (await OAuthAccount.CurrentAsync(context) is null)
+ {
+ return OAuthAccount.S