// SPDX-FileCopyrightText: 2026 Progmasoft // 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; /// What the developer portal submits to make or change a token. /// The type, as writes it; read only when a token is made. /// The name the consent page shows. /// The address of the application's site, or empty. /// The redirection addresses. /// The addresses after sign-out. /// The scopes the application may request. internal sealed record ApplicationRequest( string? Kind, string? DisplayName, string? Homepage, string[]? RedirectUris, string[]? PostLogoutRedirectUris, string[]? Scopes) { /// The submission of an application of a type. /// The type. /// The submission, with blank and repeated addresses and scopes left out. public ApplicationInput ToInput(ApplicationKind kind) => new( DisplayName ?? string.Empty, (Homepage ?? string.Empty).Trim(), kind, Clean(RedirectUris), Clean(PostLogoutRedirectUris), Clean(Scopes)); /// Each non-blank entry once, trimmed, in the submitted order. private static List Clean(string[]? values) => (values ?? []) .Select(value => value?.Trim() ?? string.Empty) .Where(value => value.Length != 0) .Distinct(StringComparer.Ordinal) .ToList(); } /// An application as the developer portal shows it. /// The client identifier. /// The name the consent page shows. /// The address of the application's site, or empty. /// The type, as writes it. /// Whether the application has no secret. /// The redirection addresses. /// The addresses after sign-out. /// The scopes the application may request. /// When the token was made. internal sealed record ApplicationView( string ClientId, string DisplayName, string Homepage, string Kind, bool IsPublic, IReadOnlyList RedirectUris, IReadOnlyList PostLogoutRedirectUris, IReadOnlyList Scopes, DateTimeOffset CreatedAt) { /// The view of a registered application. 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); } /// The applications of an account, with how many it may have. /// The applications, newest first. /// How many applications an account may have. internal sealed record ApplicationList(IReadOnlyList Applications, int Maximum); /// A token that was just made, or a secret that was just replaced. /// The application. /// The secret, shown this once; for a type without one. internal sealed record ApplicationCredentials(ApplicationView Application, string? ClientSecret); /// /// The Progmasoft Cloud Console: any account makes and manages the tokens of its own applications there. /// /// /// 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. /// internal static class ApplicationEndpoints { /// Maps the endpoints. /// The /api/v1/oauth group. 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"); } /// Lists the applications of the signed-in account. private static async Task ListAsync( HttpContext context, ApplicationRegistry registry, IOptions options) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInRequired(); } IReadOnlyList owned = await registry.ListAsync(account.Id, context.RequestAborted); return Results.Ok(new ApplicationList(owned.Select(ApplicationView.Of).ToList(), options.Value.MaximumApplicationsPerAccount)); } /// Makes a token of the submitted type. private static async Task 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 { ["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)); } /// Shows an application of the signed-in account. private static async Task 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(); } /// Saves the settings of an application. Its type, identifier and secret stay. private static async Task 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? 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)); } /// Replaces the secret of an application and answers the new one once. private static async Task 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)); } /// Deletes an application with its authorizations and tokens. private static async Task 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(); } }