// 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.Pages; using Progmasoft.Progmaweb.Api.Accounts; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Progmasoft.OAuth.Developers; /// The developer portal: any account registers and manages its own applications here. internal static class DeveloperEndpoints { /// Maps the portal. /// The application. public static void MapDeveloperPortal(this WebApplication app) { app.MapGet("/developers", ListAsync); app.MapGet("/developers/new", (Delegate)NewAsync); app.MapPost("/developers/new", CreateAsync).RequireRateLimiting("oauth"); app.MapGet("/developers/{clientId}", ShowAsync); app.MapPost("/developers/{clientId}", UpdateAsync).RequireRateLimiting("oauth"); app.MapPost("/developers/{clientId}/secret", RotateAsync).RequireRateLimiting("oauth"); app.MapPost("/developers/{clientId}/delete", 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.SignInFirst(context); } IReadOnlyList owned = await registry.ListAsync(account.Id, context.RequestAborted); string list = owned.Count == 0 ? "

You have not registered an application yet.

" : ""; string body = "

Your applications

" + list + (owned.Count < options.Value.MaximumApplicationsPerAccount ? "

Register an application

" : $"

An account can register {options.Value.MaximumApplicationsPerAccount} applications.

") + "

Endpoints

" + "

Read the discovery document at " + "/.well-known/openid-configuration. Every client uses the authorization code flow with PKCE (S256); " + "confidential clients authenticate at the token endpoint with their secret.

" + "

Back

"; return HtmlPage.Render(context, "Your applications", body); } /// Shows the empty registration form. private static async Task NewAsync(HttpContext context) { if (await OAuthAccount.CurrentAsync(context) is null) { return OAuthAccount.SignInFirst(context); } return RenderForm(context, "Register an application", "/developers/new", new ApplicationInput(string.Empty, string.Empty, false, [], [], [Scopes.Profile], false), isNew: true, new Dictionary()); } /// Registers an application from the submitted form. private static async Task CreateAsync(HttpContext context, ApplicationRegistry registry) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInFirst(context); } if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } ApplicationInput input = await ReadAsync(context); RegistrationResult result = await registry.CreateAsync(account.Id, input, context.RequestAborted); if (result.Application is null) { return RenderForm(context, "Register an application", "/developers/new", input, isNew: true, result.Errors, StatusCodes.Status400BadRequest); } return RenderCredentials(context, "Application registered", result.Application, result.ClientSecret); } /// Shows an application of the signed-in account with its settings. private static async Task ShowAsync(HttpContext context, ApplicationRegistry registry, string clientId) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInFirst(context); } RegisteredApplication? application = await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted); return application is null ? NotFound(context) : RenderDetails(context, application, new Dictionary(), null); } /// Saves the submitted settings of an application. private static async Task UpdateAsync(HttpContext context, ApplicationRegistry registry, string clientId) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInFirst(context); } if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } ApplicationInput input = await ReadAsync(context); IReadOnlyDictionary? errors = await registry.UpdateAsync(account.Id, clientId, input, context.RequestAborted); RegisteredApplication? application = await registry.FindOwnedAsync(account.Id, clientId, context.RequestAborted); if (errors is null || application is null) { return NotFound(context); } return errors.Count == 0 ? RenderDetails(context, application, errors, "The application was saved.") : RenderForm(context, application.DisplayName, $"/developers/{Uri.EscapeDataString(clientId)}", input with { IsPublic = application.IsPublic }, isNew: false, errors, StatusCodes.Status400BadRequest); } /// Replaces the secret of an application and shows 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.SignInFirst(context); } if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } 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 ? NotFound(context) : RenderCredentials(context, "New client secret", application, secret); } /// Deletes an application of the signed-in account. private static async Task DeleteAsync(HttpContext context, ApplicationRegistry registry, string clientId) { if (await OAuthAccount.CurrentAsync(context) is not { } account) { return OAuthAccount.SignInFirst(context); } if (!await OAuthAccount.IsGenuineFormAsync(context)) { return Results.BadRequest(); } return await registry.DeleteAsync(account.Id, clientId, context.RequestAborted) ? Results.Redirect("/developers") : NotFound(context); } /// Reads the registration form into a submission. private static async Task ReadAsync(HttpContext context) { IFormCollection form = await context.Request.ReadFormAsync(context.RequestAborted); return new ApplicationInput( form["displayName"].ToString(), form["homepage"].ToString().Trim(), form["clientType"] == "public", ApplicationPolicy.Lines(form["redirectUris"]), ApplicationPolicy.Lines(form["postLogoutRedirectUris"]), form["scope"].Where(scope => scope is not null).Select(scope => scope!).Distinct(StringComparer.Ordinal).ToList(), form["allowClientCredentials"] == "on"); } /// Shows the client identifier and, once, the client secret. private static IResult RenderCredentials( HttpContext context, string title, RegisteredApplication application, string? secret) { string body = $"

{HtmlPage.Encode(title)}

" + $"

{HtmlPage.Encode(application.DisplayName)}

" + $"

{HtmlPage.Encode(application.ClientId)}

" + (secret is null ? "

A public client has no secret: it proves itself with PKCE only.

" : $"

{HtmlPage.Encode(secret)}

" + "

Copy the secret now. It is stored only as a hash and is never shown again; " + "if it is lost, make a new one.

") + $"

Go to the application

"; return HtmlPage.Render(context, title, body); } /// Shows an application: its settings form, its secret and its deletion. private static IResult RenderDetails( HttpContext context, RegisteredApplication application, IReadOnlyDictionary errors, string? saved) { string path = $"/developers/{Uri.EscapeDataString(application.ClientId)}"; string secretForm = application.IsPublic ? string.Empty : HtmlPage.Form(context, path + "/secret", "") + "

The old secret stops working at once.

"; string body = (saved is null ? string.Empty : $"

{HtmlPage.Encode(saved)}

") + FormContent(context, application.DisplayName, path, new ApplicationInput(application.DisplayName, application.Homepage, application.IsPublic, application.RedirectUris, application.PostLogoutRedirectUris, application.Scopes, application.AllowClientCredentials), isNew: false, errors, application.ClientId) + "

Secret

" + secretForm + "

Delete

" + HtmlPage.Form(context, path + "/delete", "

Deleting the application ends every authorization and token it holds.

" + "") + "

Back

"; return HtmlPage.Render(context, application.DisplayName, body); } /// Shows the settings form as a page. private static IResult RenderForm( HttpContext context, string title, string action, ApplicationInput input, bool isNew, IReadOnlyDictionary errors, int statusCode = StatusCodes.Status200OK) => HtmlPage.Render(context, title, FormContent(context, title, action, input, isNew, errors, null) + "

Back

", statusCode); /// Builds the settings form with the messages of refused fields. private static string FormContent( HttpContext context, string title, string action, ApplicationInput input, bool isNew, IReadOnlyDictionary errors, string? clientId) { string Error(string field) => HtmlPage.Error(errors.TryGetValue(field, out string? message) ? message : null); string type = isNew ? "" + $"" + $"" : $"

{(input.IsPublic ? "Public" : "Confidential")} client · " + $"{HtmlPage.Encode(clientId)}

"; string scopes = string.Concat(OAuthScopes.All.Select(scope => $"")); string fields = type + "" + Error("displayName") + $"" + "" + Error("homepage") + $"" + "" + Error("redirectUris") + $"" + "" + Error("postLogoutRedirectUris") + $"" + "" + Error("scopes") + scopes + "

openid is always available.

" + "" + Error("allowClientCredentials") + $"" + $""; return $"

{HtmlPage.Encode(title)}

" + HtmlPage.Form(context, action, fields); } /// Answers for an application that is absent or belongs to another account. private static IResult NotFound(HttpContext context) => HtmlPage.Render(context, "Not found", "

Not found

No application of yours has this client identifier.

" + "

Back

", StatusCodes.Status404NotFound); }