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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/OAuth/Api/ConsentEndpoints.cs
blob: cc3bfb474314d6bd2499549b0765e30d5955c3e9 (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
96
97
98
99
100
101
102
103
104
105
106
107
108
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1

using OpenIddict.Abstractions;
using Progmasoft.OAuth.Developers;
using Progmasoft.Progmaweb.Api.Accounts;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace Progmasoft.OAuth.Api;

/// <summary>What the consent page shows about an authorization request.</summary>
/// <param name="ClientId">The client identifier.</param>
/// <param name="DisplayName">The name of the application.</param>
/// <param name="Homepage">The address of the application's site, or empty.</param>
/// <param name="Owner">The account that registered the application, or <see langword="null"/>.</param>
/// <param name="ReturnTo">The origin, or for a native application the scheme, that the account holder returns to.</param>
/// <param name="Scopes">The requested scopes the consent page lists, <c>openid</c> left out.</param>
/// <param name="AccountName">The account that is signed in.</param>
internal sealed record ConsentView(
    string ClientId,
    string DisplayName,
    string Homepage,
    string? Owner,
    string ReturnTo,
    IReadOnlyList<string> Scopes,
    string AccountName);

/// <summary>The data of the consent page.</summary>
/// <remarks>
/// <c>/connect/authorize</c> sends an account holder who has not allowed the request yet to the consent page with
/// the parameters of the request. The page reads what to show here and posts the decision back to
/// <c>/connect/authorize</c>, where OpenIddict checks the whole request again. The checks here only keep the page
/// from showing an application, an address or a scope that the request could not use.
/// </remarks>
internal static class ConsentEndpoints
{
    /// <summary>Maps the endpoint.</summary>
    /// <param name="api">The <c>/api/v1/oauth</c> group.</param>
    public static void MapConsentEndpoints(this RouteGroupBuilder api) => api.MapGet("/consent", DescribeAsync);

    /// <summary>Describes an authorization request for the consent page.</summary>
    private static async Task<IResult> DescribeAsync(
        HttpContext context,
        IOpenIddictApplicationManager applications,
        ApplicationRegistry registry,
        AccountService accounts,
        string? client_id,
        string? redirect_uri,
        string? scope)
    {
        if (await OAuthAccount.CurrentAsync(context) is not { } account)
        {
            return OAuthAccount.SignInRequired();
        }

        object? application = string.IsNullOrEmpty(client_id)
            ? null
            : await applications.FindByClientIdAsync(client_id, context.RequestAborted);
        if (application is null
            || !await applications.HasPermissionAsync(application, Permissions.Endpoints.Authorization,
                context.RequestAborted)
            || string.IsNullOrEmpty(redirect_uri)
            || !Uri.TryCreate(redirect_uri, UriKind.Absolute, out Uri? redirect)
            || !await applications.ValidateRedirectUriAsync(application, redirect_uri, context.RequestAborted))
        {
            return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
                title: "This request does not name a registered application and address.");
        }

        List<string> scopes = [];
        foreach (string requested in (scope ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries)
                     .Distinct(StringComparer.Ordinal))
        {
            if (requested == Scopes.OpenId)
            {
                continue;
            }

            if (OAuthScopes.Find(requested) is null
                || !await applications.HasPermissionAsync(application, Permissions.Prefixes.Scope + requested,
                    context.RequestAborted))
            {
                return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
                    title: "The application may not request this scope.", detail: requested);
            }

            scopes.Add(requested);
        }

        string applicationId = (await applications.GetIdAsync(application, context.RequestAborted))!;
        Guid? ownerId = await registry.FindOwnerAsync(applicationId, context.RequestAborted);
        AccountRecord? owner = ownerId is { } id ? await accounts.FindAsync(id, context.RequestAborted) : null;
        RegisteredApplication? registered = ownerId is { } ownerAccount
            ? await registry.FindOwnedAsync(ownerAccount, client_id!, context.RequestAborted)
            : null;
        string returnTo = redirect.Scheme is "http" or "https"
            ? redirect.GetLeftPart(UriPartial.Authority)
            : redirect.Scheme + ":";
        return Results.Ok(new ConsentView(
            client_id!,
            await applications.GetDisplayNameAsync(application, context.RequestAborted) ?? client_id!,
            registered?.Homepage ?? string.Empty,
            owner?.AccountName,
            returnTo,
            scopes,
            account.AccountName));
    }
}