// SPDX-FileCopyrightText: 2026 Progmasoft // 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; /// An application as its owner sees it in the developer portal. /// The public identifier of the client. /// The name the consent page shows. /// The address of the application's site, or empty. /// The type chosen when the token was made. /// The registered redirection addresses. /// The registered end-session addresses. /// The scopes the application may request. /// When the application was registered. internal sealed record RegisteredApplication( string ClientId, string DisplayName, string Homepage, ApplicationKind Kind, IReadOnlyList RedirectUris, IReadOnlyList PostLogoutRedirectUris, IReadOnlyList Scopes, DateTimeOffset CreatedAt) { /// Whether the client has no secret. public bool IsPublic => Kind.IsPublic(); } /// What registering an application produced. /// The application, or when it was refused. /// The secret of a confidential client; shown once and never stored in clear. /// The messages of each refused field. internal sealed record RegistrationResult( RegisteredApplication? Application, string? ClientSecret, IReadOnlyDictionary Errors); /// Registers, changes and deletes the applications of an account. /// /// 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. /// /// OpenIddict's application manager. /// The OAuth tables. /// The settings of Progmasoft OAuth. /// Clock of the registration times. internal sealed class ApplicationRegistry( IOpenIddictApplicationManager applications, OAuthDbContext database, IOptions options, TimeProvider timeProvider) { /// The property of the application that keeps its homepage. private const string HomepageProperty = "homepage"; /// The property of the application that keeps its . internal const string KindProperty = "kind"; /// Lists the applications of an account, newest first. /// The owner. /// Cancels the operation. /// The applications. public async Task> ListAsync(Guid accountId, CancellationToken cancellationToken) { List owned = await database.ApplicationOwners .Where(owner => owner.AccountId == accountId) .ToListAsync(cancellationToken); List result = []; // At most MaximumApplicationsPerAccount rows: ordered here, as SQLite cannot order by DateTimeOffset. foreach (ApplicationOwner owner in owned.OrderByDescending(owner => owner.CreatedAt)) { if (await applications.FindByIdAsync(owner.ApplicationId, cancellationToken) is { } application) { result.Add(await DescribeAsync(application, owner.CreatedAt, cancellationToken)); } } return result; } /// Finds an application of an account by its client identifier. /// The account that asks. /// The client identifier. /// Cancels the operation. /// The application, or when the account owns no such application. public async Task 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); } /// Finds the account that registered an application. /// The OpenIddict identifier of the application. /// Cancels the operation. /// The owning account, or for an application without an owner. public async Task FindOwnerAsync(string applicationId, CancellationToken cancellationToken) => (await database.ApplicationOwners.FirstOrDefaultAsync( owner => owner.ApplicationId == applicationId, cancellationToken))?.AccountId; /// Registers an application for an account. /// The owner. /// The submission. /// Cancels the operation. /// The application and its secret, or the refused fields. public async Task CreateAsync( Guid accountId, ApplicationInput input, CancellationToken cancellationToken) { Dictionary 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.Kind.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()); } /// Changes an application of an account. The client identifier, type and secret stay. /// The account that asks. /// The client identifier. /// The new settings; is ignored. /// Cancels the operation. /// The refused fields, empty on success, or when the application is absent. public async Task?> UpdateAsync( Guid accountId, string clientId, ApplicationInput input, CancellationToken cancellationToken) { if (await FindOwnedEntryAsync(accountId, clientId, cancellationToken) is not { } found) { return null; } ApplicationKind kind = await KindOfAsync(found.application, cancellationToken); ApplicationInput effective = input with { Kind = kind }; Dictionary 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; } /// Replaces the secret of a confidential application. /// The account that asks. /// The client identifier. /// Cancels the operation. /// The new secret, or for an absent or public application. public async Task 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; } /// Deletes an application with its authorizations and tokens. /// The account that asks. /// The client identifier. /// Cancels the operation. /// when the application was deleted. public async Task 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; } /// Finds an application and its ownership row, when the account owns it. 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); } /// Writes a submission into a descriptor: names, addresses and permissions. /// The descriptor to fill. /// The validated submission. private static void Describe(OpenIddictApplicationDescriptor descriptor, ApplicationInput input) { ApplicationKind kind = input.Kind; descriptor.DisplayName = input.DisplayName.Trim(); descriptor.ApplicationType = kind is ApplicationKind.Desktop or ApplicationKind.Mobile ? ApplicationTypes.Native : ApplicationTypes.Web; descriptor.ClientType = kind.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.Properties[KindProperty] = JsonSerializer.SerializeToElement(kind.Slug()); 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.Requirements.Clear(); if (!kind.SignsPeopleIn()) { // A service only asks the token endpoint for tokens that name itself; it never reaches the sign-in page. descriptor.Permissions.UnionWith( [ Permissions.Endpoints.Token, Permissions.Endpoints.Revocation, Permissions.Endpoints.Introspection, Permissions.GrantTypes.ClientCredentials ]); return; } descriptor.Permissions.UnionWith( [ Permissions.Endpoints.Authorization, Permissions.Endpoints.Token, Permissions.Endpoints.EndSession, Permissions.Endpoints.Revocation, Permissions.GrantTypes.AuthorizationCode, Permissions.ResponseTypes.Code ]); if (!kind.IsPublic()) { descriptor.Permissions.Add(Permissions.Endpoints.Introspection); } if (input.Scopes.Contains(Scopes.OfflineAccess)) { descriptor.Permissions.Add(Permissions.GrantTypes.RefreshToken); } foreach (string scope in input.Scopes) { descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope); } descriptor.Requirements.Add(Requirements.Features.ProofKeyForCodeExchange); } /// Reads the type of a stored application. /// The application. /// Cancels the operation. /// The type it was registered with. internal async Task KindOfAsync(object application, CancellationToken cancellationToken) { var properties = await applications.GetPropertiesAsync(application, cancellationToken); if (properties.TryGetValue(KindProperty, out JsonElement value) && value.ValueKind is JsonValueKind.String && ApplicationKinds.Parse(value.GetString()) is { } kind) { return kind; } // An application stored before types existed: a public client was a native application, a confidential one // a web application. return await applications.HasClientTypeAsync(application, ClientTypes.Public, cancellationToken) ? ApplicationKind.Desktop : ApplicationKind.Web; } /// Reads an application into the view of the developer portal. private async Task 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 KindOfAsync(application, 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(), createdAt); } /// Random text of the given number of bytes, as unpadded URL-safe base64. /// How many random bytes. /// The text. private static string RandomText(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }