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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/OAuth/Endpoints/AuthorizationEndpoints.cs
blob: a16a14c85fcebdfdfc4499cb309017f609b7841b (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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// 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 System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Validation.AspNetCore;
using Progmasoft.OAuth.Developers;
using Progmasoft.OAuth.Api;
using Progmasoft.Progmaweb.Api.Accounts;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace Progmasoft.OAuth.Endpoints;

/// <summary>The protocol endpoints OpenIddict passes through: authorization, token, user information, end session.</summary>
/// <remarks>
/// OpenIddict validates every request before it reaches these handlers: the client, its redirection address, its
/// permissions, PKCE and the requested scopes. The handlers decide who the account is and what it allowed.
/// </remarks>
internal static class AuthorizationEndpoints
{
    /// <summary>Maps the endpoints.</summary>
    /// <param name="app">The application.</param>
    public static void MapAuthorizationEndpoints(this WebApplication app)
    {
        app.MapMethods("/connect/authorize", ["GET", "POST"], AuthorizeAsync).RequireRateLimiting("oauth");
        app.MapPost("/connect/token", TokenAsync).RequireRateLimiting("oauth");
        app.MapMethods("/connect/userinfo", ["GET", "POST"], UserInfo)
            .RequireAuthorization(new AuthorizeAttribute
            {
                AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
            });
        app.MapMethods("/connect/logout", ["GET", "POST"], (Delegate)LogoutAsync);
    }

    /// <summary>Answers the authorization endpoint: sign-in, consent, and the authorization code.</summary>
    private static async Task<IResult> AuthorizeAsync(
        HttpContext context,
        IOpenIddictApplicationManager applications,
        IOpenIddictAuthorizationManager authorizations)
    {
        OpenIddictRequest request = context.GetOpenIddictServerRequest()
                                    ?? throw new InvalidOperationException("OpenIddict did not pass a request.");

        AccountRecord? account = await OAuthAccount.CurrentAsync(context);
        if (account is null || request.HasPromptValue(PromptValues.Login))
        {
            if (request.HasPromptValue(PromptValues.None))
            {
                return Forbid(Errors.LoginRequired, "The account holder is not signed in.");
            }

            if (account is not null)
            {
                await context.SignOutAsync(OAuthRegistration.CookieScheme);
            }

            // The parameters come back to this endpoint after the sign-in, without prompt=login, which would
            // otherwise send the account holder to the sign-in page for ever.
            IEnumerable<KeyValuePair<string, string?>> parameters = request.GetParameters()
                .Where(parameter => parameter.Key != Parameters.Prompt)
                .Select(parameter => new KeyValuePair<string, string?>(parameter.Key, parameter.Value.ToString()));
            return Results.Challenge(
                new AuthenticationProperties { RedirectUri = "/connect/authorize" + QueryString.Create(parameters) },
                [OAuthRegistration.CookieScheme]);
        }

        object application = await applications.FindByClientIdAsync(request.ClientId!, context.RequestAborted)
                             ?? throw new InvalidOperationException("OpenIddict passed an unknown client.");
        string applicationId = (await applications.GetIdAsync(application, context.RequestAborted))!;
        ImmutableArray<string> scopes = request.GetScopes();

        object? authorization = null;
        await foreach (object existing in authorizations.FindAsync(
                           account.Id.ToString(), applicationId, Statuses.Valid, AuthorizationTypes.Permanent, scopes,
                           context.RequestAborted))
        {
            authorization = existing;
            break;
        }

        string? decision = context.Request.HasFormContentType
            ? (await context.Request.ReadFormAsync(context.RequestAborted))["decision"].ToString()
            : null;
        if (!string.IsNullOrEmpty(decision))
        {
            // The decision comes from the consent page of this host; a form on another site cannot decide.
            if (!OAuthAccount.IsSameOrigin(context))
            {
                return OAuthAccount.ForeignOrigin();
            }

            if (decision != "allow")
            {
                return Forbid(Errors.AccessDenied, "The account holder did not allow the application.");
            }

            ClaimsIdentity identity = CreateIdentity(account, scopes);
            authorization = await authorizations.CreateAsync(
                identity, account.Id.ToString(), applicationId, AuthorizationTypes.Permanent, scopes,
                context.RequestAborted);
            return Issue(identity, await authorizations.GetIdAsync(authorization, context.RequestAborted));
        }

        if (authorization is not null && !request.HasPromptValue(PromptValues.Consent))
        {
            return Issue(CreateIdentity(account, scopes),
                await authorizations.GetIdAsync(authorization, context.RequestAborted));
        }

        if (request.HasPromptValue(PromptValues.None))
        {
            return Forbid(Errors.ConsentRequired, "The account holder has not allowed these scopes yet.");
        }

        // The consent page is a page of the Progmasoft site on this host. It reads what to show from
        // /api/v1/oauth/consent and posts the parameters back here with the decision.
        return Results.Redirect("/consent" + QueryString.Create(request.GetParameters()
            .Where(parameter => parameter.Key != Parameters.Prompt)
            .Select(parameter => new KeyValuePair<string, string?>(parameter.Key, parameter.Value.ToString()))));
    }

    /// <summary>Answers the token endpoint for the authorization code, refresh token and client credentials grants.</summary>
    private static async Task<IResult> TokenAsync(
        HttpContext context,
        IOpenIddictApplicationManager applications,
        AccountService accounts)
    {
        OpenIddictRequest request = context.GetOpenIddictServerRequest()
                                    ?? throw new InvalidOperationException("OpenIddict did not pass a request.");

        if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
        {
            AuthenticateResult result =
                await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
            string? subject = result.Principal?.GetClaim(Claims.Subject);
            AccountRecord? account = Guid.TryParse(subject, out Guid id)
                ? await accounts.FindAsync(id, context.RequestAborted)
                : null;
            if (account is null)
            {
                return Forbid(Errors.InvalidGrant, "The account no longer exists.");
            }

            // The claims are written again, so that a renamed account or a changed address reaches the client with
            // the next refresh, and a refresh never grants more than the original authorization.
            ClaimsIdentity identity = CreateIdentity(account, result.Principal!.GetScopes());
            return Issue(identity, result.Principal!.GetAuthorizationId());
        }

        if (request.IsClientCredentialsGrantType())
        {
            // A client credentials token names the application itself, not an account, so no account scope can be
            // granted with it.
            if (!request.GetScopes().IsEmpty)
            {
                return Forbid(Errors.InvalidScope,
                    "The client credentials grant acts as the application and takes no account scope.");
            }

            object application = await applications.FindByClientIdAsync(request.ClientId!, context.RequestAborted)
                                 ?? throw new InvalidOperationException("OpenIddict passed an unknown client.");
            ClaimsIdentity identity = new(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
            identity.SetClaim(Claims.Subject, request.ClientId);
            identity.SetClaim(Claims.Name, await applications.GetDisplayNameAsync(application, context.RequestAborted));
            identity.SetDestinations(_ => [Destinations.AccessToken]);
            return Results.SignIn(new ClaimsPrincipal(identity), null,
                OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }

        return Forbid(Errors.UnsupportedGrantType, "This grant type is not supported.");
    }

    /// <summary>Answers the user information endpoint with the claims the access token's scopes allow.</summary>
    private static IResult UserInfo(HttpContext context)
    {
        ClaimsPrincipal principal = context.User;
        Dictionary<string, object> claims = new(StringComparer.Ordinal)
        {
            [Claims.Subject] = principal.GetClaim(Claims.Subject)!
        };
        if (principal.HasScope(Scopes.Profile))
        {
            claims[Claims.Name] = principal.GetClaim(Claims.Name)!;
            claims[Claims.PreferredUsername] = principal.GetClaim(Claims.PreferredUsername)!;
        }

        if (principal.HasScope(Scopes.Email))
        {
            claims[Claims.Email] = principal.GetClaim(Claims.Email)!;
            claims[Claims.EmailVerified] = false;
        }

        return Results.Ok(claims);
    }

    /// <summary>Ends the sign-in and returns to the address the client registered, if any.</summary>
    private static async Task<IResult> LogoutAsync(HttpContext context)
    {
        await context.SignOutAsync(OAuthRegistration.CookieScheme);
        // OpenIddict sends the account holder to post_logout_redirect_uri when the client registered it.
        return Results.SignOut(new AuthenticationProperties { RedirectUri = "/" },
            [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]);
    }

    /// <summary>The identity of an account, with the claims the granted scopes allow.</summary>
    /// <param name="account">The account.</param>
    /// <param name="scopes">The granted scopes.</param>
    /// <returns>The identity.</returns>
    private static ClaimsIdentity CreateIdentity(AccountRecord account, ImmutableArray<string> scopes)
    {
        ClaimsIdentity identity = new(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
        identity.SetClaim(Claims.Subject, account.Id.ToString());
        if (scopes.Contains(Scopes.Profile))
        {
            identity.SetClaim(Claims.Name, account.AccountName);
            identity.SetClaim(Claims.PreferredUsername, account.AccountName);
        }

        if (scopes.Contains(Scopes.Email))
        {
            identity.SetClaim(Claims.Email, account.Email);
            // Registration does not verify the address; a client must not treat it as proven.
            identity.SetClaim(Claims.EmailVerified, false);
        }

        identity.SetScopes(scopes);
        identity.SetResources(OAuthScopes.ResourcesOf(scopes));
        identity.SetDestinations(claim => claim.Type switch
        {
            Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken],
            Claims.Name or Claims.PreferredUsername or Claims.Email or Claims.EmailVerified =>
                [Destinations.AccessToken, Destinations.IdentityToken],
            _ => [Destinations.AccessToken]
        });
        return identity;
    }

    /// <summary>Issues the tokens of an identity under an authorization.</summary>
    private static IResult Issue(ClaimsIdentity identity, string? authorizationId)
    {
        identity.SetAuthorizationId(authorizationId);
        return Results.SignIn(new ClaimsPrincipal(identity), null,
            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }

    /// <summary>Answers a protocol request with an OAuth error.</summary>
    private static IResult Forbid(string error, string description) => Results.Forbid(
        new AuthenticationProperties(new Dictionary<string, string?>
        {
            [OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
            [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
        }),
        [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]);
}