// SPDX-FileCopyrightText: 2026 Progmasoft // 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; /// An application the account has allowed. /// The identifier of the allowance, used to withdraw it. /// The name of the application, or when it was deleted. /// The scopes the account allowed. /// When the account allowed it. internal sealed record AllowedApplicationView( string AuthorizationId, string? DisplayName, IReadOnlyList Scopes, DateTimeOffset? CreatedAt); /// The applications an account has allowed, and withdrawing an allowance. internal static class AllowedApplicationEndpoints { /// Maps the endpoints. /// The /api/v1/oauth group. public static void MapAllowedApplicationEndpoints(this RouteGroupBuilder api) { api.MapGet("/allowed", ListAsync); api.MapDelete("/allowed/{authorizationId}", RevokeAsync).RequireRateLimiting("oauth"); } /// Lists the applications the signed-in account has allowed. private static async Task ListAsync( HttpContext context, IOpenIddictAuthorizationManager authorizations, IOpenIddictApplicationManager applications) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInRequired(); } List 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 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()); } /// Withdraws an allowance and revokes the tokens issued under it. private static async Task 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(); } // Only the account's own authorizations are searched, so another account's identifier is simply absent. await foreach (object authorization in authorizations.FindBySubjectAsync(account.Id.ToString(), context.RequestAborted)) { string? id = await authorizations.GetIdAsync(authorization, context.RequestAborted); if (id is null || !string.Equals(id, authorizationId, StringComparison.Ordinal)) { continue; } await authorizations.TryRevokeAsync(authorization, context.RequestAborted); await tokens.RevokeByAuthorizationIdAsync(id, context.RequestAborted); return Results.NoContent(); } return Results.NotFound(); } }