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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/OAuth/Pages/AllowedApplicationsEndpoints.cs
blob: 19dec69b17428612369ab1fa586a3d17a6ba8243 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// 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.Pages;

/// <summary>The applications an account has allowed, and withdrawing an allowance.</summary>
internal static class AllowedApplicationsEndpoints
{
    /// <summary>Maps the pages.</summary>
    /// <param name="app">The application.</param>
    public static void MapAllowedApplications(this WebApplication app)
    {
        app.MapGet("/account/applications", ListAsync);
        app.MapPost("/account/applications/{authorizationId}/revoke", RevokeAsync);
    }

    /// <summary>Lists the applications the signed-in account has allowed.</summary>
    private static async Task<IResult> ListAsync(
        HttpContext context,
        IOpenIddictAuthorizationManager authorizations,
        IOpenIddictApplicationManager applications)
    {
        if (await OAuthAccount.CurrentAsync(context) is not { } account)
        {
            return OAuthAccount.SignInFirst(context);
        }

        List<string> rows = [];
        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);
            string name = application is null
                ? "A deleted application"
                : await applications.GetDisplayNameAsync(application, context.RequestAborted) ?? "An application";
            ImmutableArray<string> scopes = await authorizations.GetScopesAsync(authorization, context.RequestAborted);
            string id = (await authorizations.GetIdAsync(authorization, context.RequestAborted))!;
            DateTimeOffset? created = await authorizations.GetCreationDateAsync(authorization, context.RequestAborted);
            string grants = string.Join(", ", scopes.Select(scope => $"<code>{HtmlPage.Encode(scope)}</code>"));
            rows.Add(
                $"<li><strong>{HtmlPage.Encode(name)}</strong> " +
                $"<span class=\"note\">since {created:yyyy-MM-dd}</span><br>{grants}" +
                HtmlPage.Form(context, $"/account/applications/{Uri.EscapeDataString(id)}/revoke",
                    "<button class=\"secondary\" type=\"submit\">Withdraw</button>") + "</li>");
        }

        string body = "<h1>Applications you have allowed</h1>" +
                      (rows.Count == 0 ? "<p>You have not allowed any application.</p>" : $"<ul>{string.Concat(rows)}</ul>") +
                      "<p class=\"note\">Withdrawing ends the application's access tokens and refresh tokens at once; " +
                      "it will have to ask you again.</p><p><a href=\"/\">Back</a></p>";
        return HtmlPage.Render(context, "Allowed applications", body);
    }

    /// <summary>Withdraws an allowance and revokes the tokens issued under it.</summary>
    private static async Task<IResult> RevokeAsync(
        HttpContext context,
        IOpenIddictAuthorizationManager authorizations,
        IOpenIddictTokenManager tokens,
        string authorizationId)
    {
        if (await OAuthAccount.CurrentAsync(context) is not { } account)
        {
            return OAuthAccount.SignInFirst(context);
        }

        if (!await OAuthAccount.IsGenuineFormAsync(context))
        {
            return Results.BadRequest();
        }

        object? authorization = await authorizations.FindByIdAsync(authorizationId, context.RequestAborted);
        // Another account's authorization is treated as absent.
        if (authorization is not null
            && await authorizations.GetSubjectAsync(authorization, context.RequestAborted) == account.Id.ToString())
        {
            await authorizations.TryRevokeAsync(authorization, context.RequestAborted);
            await tokens.RevokeByAuthorizationIdAsync(authorizationId, context.RequestAborted);
        }

        return Results.Redirect("/account/applications");
    }
}