member_center/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs
2026-07-03 15:42:49 +09:00

296 lines
11 KiB
C#

using MemberCenter.Api.Authorization;
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using System.Text.Json;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("admin/oauth-clients")]
[Authorize(Policy = "Admin")]
[AdminPermission(AdminPermissions.OAuthClients)]
public class AdminOAuthClientsController : ControllerBase
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
public AdminOAuthClientsController(
IOpenIddictApplicationManager applicationManager,
IAuthResourceRegistryService authResourceRegistry)
{
_applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
}
[HttpGet]
[AdminPermission(AdminPermissions.OAuthClientsIndex)]
public async Task<IActionResult> List()
{
var results = new List<object>();
await foreach (var application in _applicationManager.ListAsync())
{
results.Add(new
{
id = await _applicationManager.GetIdAsync(application),
name = await _applicationManager.GetDisplayNameAsync(application),
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
properties = await _applicationManager.GetPropertiesAsync(application)
});
}
return Ok(results);
}
[HttpPost]
[AdminPermission(AdminPermissions.OAuthClientsCreate)]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
return BadRequest(redirectUriError);
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
}
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Guid.NewGuid().ToString("N"),
DisplayName = request.Name,
ClientType = request.ClientType
};
await ApplyPermissionsAsync(descriptor, request.Usage);
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.CreateAsync(descriptor);
return Created("/admin/oauth-clients", new
{
descriptor.ClientId,
descriptor.DisplayName,
descriptor.ClientType,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
[HttpGet("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsIndex)]
public async Task<IActionResult> Get(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
return Ok(new
{
id,
name = await _applicationManager.GetDisplayNameAsync(app),
client_id = await _applicationManager.GetClientIdAsync(app),
client_type = await _applicationManager.GetClientTypeAsync(app),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(app),
properties = await _applicationManager.GetPropertiesAsync(app)
});
}
[HttpPut("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsEdit)]
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
{
if (!IsValidUsage(request.Usage))
{
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
}
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
{
return BadRequest("tenant_id is required for this usage.");
}
if (RequiresClientCredentials(request.Usage)
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("client_type must be confidential for this usage.");
}
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
if (!string.IsNullOrWhiteSpace(redirectUriError))
{
return BadRequest(redirectUriError);
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
}
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType;
if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
descriptor.ClientSecret = null;
}
await ApplyPermissionsAsync(descriptor, request.Usage);
descriptor.RedirectUris.Clear();
foreach (var uri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
}
else
{
descriptor.Properties.Remove("tenant_id");
}
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
return Ok(new
{
id,
descriptor.DisplayName,
descriptor.ClientType,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
[HttpDelete("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsDelete)]
public async Task<IActionResult> Delete(string id)
{
var app = await _applicationManager.FindByIdAsync(id);
if (app is null)
{
return NotFound();
}
await _applicationManager.DeleteAsync(app);
return NoContent();
}
private static bool IsValidUsage(string usage)
{
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTenantOptionalUsage(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresClientCredentials(string usage)
{
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
}
private static bool UsesAuthorizationCodeFlow(string usage)
{
return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
{
if (redirectUris is null || redirectUris.Count == 0)
{
return ([], null);
}
var values = redirectUris
.Where(uri => !string.IsNullOrWhiteSpace(uri))
.Select(uri => uri.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var uri in values)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out _))
{
return ([], "redirect_uris must contain absolute URIs only.");
}
}
return (values, null);
}
private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage)
{
descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (UsesAuthorizationCodeFlow(usage))
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
}
else
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
}
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
foreach (var scope in scopes)
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
}
}
}