diff options
| author | Leitwolf | 2026-10-23 08:01:02 +0300 |
|---|---|---|
| committer | GitHub | 2026-10-23 08:01:02 +0300 |
| commit | 3481b5fe411fb3aa5898b0965e42cc852f3d690c (patch) | |
| tree | 4e5f6caf530344fb13f2b892238d1e1b1f89c10c /apps/api | |
| parent | a68bb18e2d750fabcbcff985b5fe921d7c828d7e (diff) | |
| parent | 7f1cb91eb16bc3d7fd78320465c32d5e2f47ec97 (diff) | |
| download | progmaweb-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.cs | 40 | ||||
| -rw-r--r-- | apps/api/OAuth/Developers/ApplicationPolicy.cs | 152 | ||||
| -rw-r--r-- | apps/api/OAuth/Developers/ApplicationRegistry.cs | 330 | ||||
| -rw-r--r-- | apps/api/OAuth/Developers/DeveloperEndpoints.cs | 300 | ||||
| -rw-r--r-- | apps/api/OAuth/Endpoints/AuthorizationEndpoints.cs | 297 | ||||
| -rw-r--r-- | apps/api/OAuth/OAuthEndpoints.cs | 22 | ||||
| -rw-r--r-- | apps/api/OAuth/OAuthHostFilter.cs | 54 | ||||
| -rw-r--r-- | apps/api/OAuth/OAuthOptions.cs | 35 | ||||
| -rw-r--r-- | apps/api/OAuth/OAuthRegistration.cs | 254 | ||||
| -rw-r--r-- | apps/api/OAuth/OAuthScopes.cs | 61 | ||||
| -rw-r--r-- | apps/api/OAuth/Pages/AllowedApplicationsEndpoints.cs | 95 | ||||
| -rw-r--r-- | apps/api/OAuth/Pages/HtmlPage.cs | 95 | ||||
| -rw-r--r-- | apps/api/OAuth/Pages/SignInEndpoints.cs | 167 | ||||
| -rw-r--r-- | apps/api/OAuth/Storage/OAuthDbContext.cs | 52 | ||||
| -rw-r--r-- | apps/api/OAuth/Storage/OAuthSchema.cs | 204 | ||||
| -rw-r--r-- | apps/api/Progmaweb.Api.csproj | 4 | ||||
| -rw-r--r-- | apps/api/Program.cs | 21 | ||||
| -rw-r--r-- | apps/api/appsettings.Development.json | 2 | ||||
| -rw-r--r-- | apps/api/appsettings.json | 2 | ||||
| -rw-r--r-- | apps/api/packages.lock.json | 440 |
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 |