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 List() { var results = new List(); 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 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 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 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 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 Uris, string? Error) NormalizeRedirectUris(List? 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); } } }